Initial commit: lang + logr libraries with SvelteKit scaffold

lang — type-safe i18n engine (zero-deps):
- Pure engine (createEngineLang) with explicit locale per call
- Svelte 5 reactive wrapper (createActiveLang) via $state
- Fallback chain, Intl.PluralRules, cross-key references, JSON round-trip
- Type-safe paths with inference from schema

logr — structured logger (zero-deps, decoupled from i18n):
- 6 levels (TRACE..FATAL) aligned with pino/Log4j/OpenTelemetry/Sentry
- Auto-generated entry.id (UUID v4 / hex fallback)
- globalContext, child(), time/timeEnd, lazy messages, source capture
- Dynamic transports with per-sink minLevel/filter, buffer + writeBatch
- Hybrid dispatch: sync loop, transports may be async
- Failure routing with deniedFor and cascade guard
- Adapters: console, http, callback, sentry, datadog, logtail, loki, otel

Test pages at /test/lang and /test/logr. 180 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 6 months ago
commit a6077dd357

36
.gitignore vendored

@ -0,0 +1,36 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# IDE / tool state (personal, not shared)
.claude/settings.local.json
.idea/workspace.xml
.idea/shelf/
.idea/tasks.xml
.idea/usage.statistics.xml
.idea/dictionaries/
.idea/libraries/
.idea/dataSources/
.idea/dataSources.local.xml
.idea/dynamic.xml
.idea/uiDesigner.xml

8
.idea/.gitignore vendored

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,63 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/active.iml" filepath="$PROJECT_DIR$/.idea/active.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
</component>
</project>

@ -0,0 +1 @@
engine-strict=true

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}

@ -0,0 +1,44 @@
import prettier from 'eslint-config-prettier';
import path from 'node:path';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
ts.configs.recommended,
svelte.configs.recommended,
prettier,
svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
},
{
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'
rules: {}
}
);

3487
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,44 @@
{
"name": "active",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/compat": "^2.0.4",
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.1.3",
"eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.0",
"globals": "^17.4.0",
"playwright": "^1.59.1",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.1",
"vite": "^8.0.7",
"vitest": "^4.1.3",
"vitest-browser-svelte": "^2.1.0"
},
"dependencies": {
"@sentry/browser": "^10.50.0"
}
}

13
src/app.d.ts vendored

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

@ -0,0 +1,351 @@
# lang
Type-safe i18n library for SvelteKit. **Zero external dependencies.** Reactive
translation resolution with fallback chain, pluralization via
`Intl.PluralRules`, cross-key references, and full JSON serialization.
---
## Architecture
```
lang/
├── index.ts Barrel exports
├── types.ts LangRecord, LangNode, EngineLangInstance, ActiveLangInstance, ...
├── engine-lang.ts Pure factory: createEngineLang()
├── active-lang.svelte.ts Svelte 5 reactive wrapper: createActiveLang()
├── plural.ts p() helper, WeakMap-based config storage
├── plural_rules.ts Intl.PluralRules wrapper
├── guards.ts Type guards: isLangRef, isLangRecord, isLangString
├── helpers.ts resolvePath, interpolateTemplate, resolveRecordFallback, deepMerge
├── json.ts langNodeToJSON / JSONToLangNode (round-trip)
├── consts.ts ID_PREFIX (#?), separators, limits
├── errors.ts Centralized error messages
└── test/
└── lang.test.ts Unit tests (vitest, node env)
```
### Design principles
1. **Pure engine** — `createEngineLang()` has no reactive state of its own.
`t()` and `ts()` **require** an explicit `locale`. No hidden mutation.
2. **Reactive wrapper** — `createActiveLang()` (in `.svelte.ts`) owns the
locale via `$state`, injecting it into each engine call. The engine stays
framework-agnostic so non-Svelte consumers can build their own adapter.
3. **Fallback chain** — `[currentLocale, ...fallbackChain, defaultLocale]`.
4. **Type-safe paths** — `t('common.ok')` autocompletes and validates types.
A dynamic `string` overload is available for runtime paths.
5. **Decoupled plural data** — `p()` lives in `plural.ts`, not in the engine;
translation data never imports from the engine.
---
## Alias
Configured in `svelte.config.js`:
```js
alias: { $lang: 'src/arts/lang' }
```
Imports:
```ts
import { createEngineLang } from '$lang';
import { createActiveLang } from '$lang/active-lang.svelte';
```
---
## Schema
A schema is a tree of `LangNode` with four leaf types:
### LangRecord — static translations
```ts
{ es: 'Aceptar', en: 'OK', ar: 'حسناً' }
```
All locale keys are optional — no locale is hardcoded.
### LangFn — parametric interpolation
```ts
(params: { name: string }) => ({
es: `Hola, ${params.name}`,
en: `Hello, ${params.name}`
});
```
At serialization time (`langNodeToJSON`), a proxy captures the tokens so the
JSON contains `"Hola, {{name}}"` regardless of whether the function uses
`${...}` or `{{...}}`.
### LangPluralFn — pluralization with `p()`
```ts
import { p } from '$lang';
p({
es: { one: '{{count}} mensaje', other: '{{count}} mensajes' },
en: { one: '{{count}} message', other: '{{count}} messages' },
ar: {
zero: 'لا توجد رسائل',
one: 'رسالة واحدة',
two: 'رسالتان',
few: '{{count}} رسائل',
many: '{{count}} رسالة',
other: '{{count}} رسالة'
}
});
```
Internally uses `Intl.PluralRules`. Forms: `zero`, `one`, `two`, `few`,
`many`, `other`. Only `other` is required; missing forms fall back to
`other`.
### LangRef — alias to another key
```ts
'#?common.ok'; // resolves the value at `common.ok`
'#?common.ok|fallback'; // literal fallback if the key does not exist
```
Prefix `#?`, separator `|` for the fallback literal. Up to 3 levels of
indirection; cycles throw.
### Full example
```ts
import { p } from '$lang';
import type { LangNode } from '$lang';
export const translations = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' }
},
greet: (params: { name: string }) => ({
es: `Hola, ${params.name}`,
en: `Hello, ${params.name}`
}),
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' }
})
},
ref: '#?common.ok'
} satisfies LangNode;
export type TranslationSchema = typeof translations;
```
---
## Usage
### Pure engine (non-Svelte)
```ts
import { createEngineLang } from '$lang';
import { translations } from './translations';
const lang = createEngineLang(translations, 'es');
// `t()` and `ts()` require an explicit locale on the pure engine.
lang.t('common.ok', undefined, 'es'); // 'Aceptar'
lang.t('greet', { name: 'Ana' }, 'en'); // 'Hello, Ana'
lang.t('messages.unread', { count: 5 }, 'en'); // '5 unread messages'
lang.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello'
lang.ts('#?common.ok', 'es'); // 'Aceptar'
```
### Reactive wrapper for Svelte 5
```svelte
<script lang="ts">
import { createActiveLang } from '$lang/active-lang.svelte';
import { translations } from './translations';
const lang = createActiveLang(translations, 'es');
</script>
<h1>{lang.t('common.ok')}</h1>
<button onclick={() => lang.setLocale('en')}>EN</button>
<button onclick={() => lang.setLocale('ar')}>AR</button>
```
`createActiveLang` holds `locale` in `$state`. When you call
`setLocale(x)`, every `t()` / `ts()` / `getLocale()` consumed in a
component (or inside `$derived` / `$effect`) re-evaluates.
Signatures on the reactive wrapper make `locale` **optional** — it defaults
to the active reactive locale:
```ts
lang.t('common.ok'); // uses active locale
lang.t('common.ok', undefined, 'en'); // explicit locale override
```
### Fallback chain
```ts
// Resolution order: currentLocale → en → es (default)
const lang = createActiveLang(translations, 'es', ['en']);
lang.setLocale('fr');
lang.t('common.ok'); // fr missing → tries en → 'OK'
```
### Dynamic modules — `extend()`
Adds translations to the schema at runtime. Supports dotted namespaces and
deep-merges existing keys:
```ts
lang.extend('shop', {
product: { es: 'Producto', en: 'Product' }
});
lang.t('shop.product'); // 'Producto'
// Dotted namespace — creates nested structure
lang.extend('app.settings', {
title: { es: 'Ajustes', en: 'Settings' }
});
// Subscribe to schema changes (triggers re-render in Svelte automatically)
const unsub = lang.onSchemaChange(() => {
// schema changed
});
```
### Child instances — `register()`
Creates a new instance with the module merged. In the reactive wrapper the
child shares locale state with the parent:
```ts
const child = lang.register('admin', {
dashboard: { es: 'Panel', en: 'Dashboard' }
});
child.t('admin.dashboard'); // 'Panel'
child.t('common.ok'); // 'Aceptar' (inherits parent schema)
lang.setLocale('en');
child.getLocale(); // 'en' (synced)
child.dispose(); // detach from parent
lang.setLocale('es');
child.getLocale(); // 'en' (stopped syncing)
```
---
## API — EngineLangInstance
| Method | Description |
| ------------------------------------ | ----------------------------------------------------------- |
| `t(path, params, locale)` | Translate by schema path. Params for interpolation/plural. |
| `ts(value, locale)` | Translate a direct `LangString` (record, string or ref). |
| `onSchemaChange(fn)` | Subscribe to schema mutations (`extend`). Returns unsub. |
| `extend(namespace, module)` | Add a module at runtime via deep-merge. Dotted namespaces. |
| `register(namespace, module)` | Return a child instance with the merged module. |
| `setLogger(logger)` | Inject a custom logger. Set-once. |
| `getDefaultLocale()` | Return the default locale of the instance. |
| `getFallbackChain()` | Return the configured fallback chain (or `undefined`). |
## API — ActiveLangInstance (Svelte)
In addition to the above (with `locale` made optional on `t` / `ts`):
| Method | Description |
| ------------------------------------ | ----------------------------------------------------------- |
| `getLocale()` | Return the active locale (reactive). |
| `setLocale(locale)` | Change the active locale. |
| `onLocaleChange(fn)` | Subscribe to locale changes. Returns unsub. |
| `dispose()` | Detach a child from its parent locale stream. |
---
## Types
| Type | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------- |
| `SupportedLocale` | Union: `es \| en \| ar \| de \| fr \| it \| pt \| ca \| eu \| gl` |
| `LangRecord` | `{ [K in SupportedLocale]?: string }` — all optional |
| `LangString` | `string \| LangRecord \| LangRef` |
| `LangNode` | Recursive: `LangValue \| LangBranch` |
| `LangValue<P>` | `LangRecord \| LangFn<P> \| LangPluralFn<P> \| LangRef` |
| `LangBranch` | `{ [key: string]: LangNode }` — named branch |
| `LangRef` | Template literal: `` `#?${string}` `` |
| `LangFn<P>` | `(params: P) => LangRecord` |
| `LangPluralFn<P>` | `(params: { count: number } & P) => LangRecord` |
| `LangParams` | `Record<string, unknown>` — canonical params type |
| `PluralConfig` | `{ [K in SupportedLocale]?: PluralForms }` |
| `PluralForms` | `{ other: string } & Partial<Record<PluralCategory, string>>` |
| `EngineLangInstance<S>` | Pure engine public interface |
| `ActiveLangInstance<S>` | Reactive wrapper public interface |
| `LangLogger` | `{ warn, error }` — custom logger interface |
---
## Fallback behavior
When a key is translated, locales are tried in this order:
```
1. currentLocale
2. fallbackChain[0]
3. fallbackChain[1]
4. ...
5. defaultLocale
```
Example with `createEngineLang(schema, 'es', ['en'])` and
`setLocale('fr')`:
```
key: common.ok → { es: 'Aceptar', en: 'OK' }
1. fr → missing
2. en → 'OK' ✓
```
If every locale is missing, `t()` returns the path (or the `|fallback`
literal if present) and `ts()` returns the empty string. In DEV a warning
is emitted when a fallback is used.
---
## JSON round-trip
```ts
import { langNodeToJSON, JSONToLangNode } from '$lang';
const json = langNodeToJSON(translations);
// LangFn → { es: 'Hola {{name}}', en: 'Hello {{name}}' }
// LangPluralFn → { __type: 'plural', config: { ... } }
// LangRef → '#?common.ok' (plain string)
// LangRecord → { es: 'Aceptar', en: 'OK' } (unchanged)
const restored = JSONToLangNode(json);
// Interpolation and plural functions are reconstructed.
// References stay as strings (isLangRef() recognises them).
```
---
## Tests
```bash
npx vitest run src/arts/lang/test/lang.test.ts
```
Covers: guards, helpers, plural rules, `p()` with WeakMap, engine (t/ts,
locale param, fallback chain, extend, register, setLogger), JSON
round-trip, and confirmation that no locale is hardcoded.

@ -0,0 +1,103 @@
import { SvelteSet } from 'svelte/reactivity';
import {
type ActiveLangInstance,
createEngineLang,
type EngineLangInstance,
type LangLogger,
type LangNode,
type LangParams,
type LangString,
type SupportedLocale
} from './index';
export type { LangNode, LangParams, LangString, SupportedLocale, ActiveLangInstance };
/**
* Reactive wrapper around the pure lang engine for Svelte 5.
*
* The engine is stateless regarding locale — this wrapper owns the
* `_locale` `$state` and injects it into every `t()`/`ts()` call.
* Any `$derived` / `$effect` touching `getLocale()`, `t()` or `ts()`
* re-evaluates when the locale changes.
*/
export function createActiveLang<S extends LangNode>(
schema: S,
defaultLocale: SupportedLocale = 'es',
fallbackChain?: SupportedLocale[]
): ActiveLangInstance<S> {
const engine = createEngineLang(schema, defaultLocale, fallbackChain);
return wrapEngine(engine, defaultLocale);
}
function wrapEngine<S extends LangNode>(
engine: EngineLangInstance<S>,
initialLocale: SupportedLocale,
parentOnLocaleChange?: (fn: (locale: SupportedLocale) => void) => () => void
): ActiveLangInstance<S> {
let _locale = $state<SupportedLocale>(initialLocale);
let _schemaVersion = $state(0);
const localeListeners = new SvelteSet<(locale: SupportedLocale) => void>();
const unsubSchema = engine.onSchemaChange(() => {
_schemaVersion++;
});
const unsubParent = parentOnLocaleChange?.((locale) => {
setLocale(locale);
});
function setLocale(locale: SupportedLocale): void {
if (_locale === locale) return;
_locale = locale;
localeListeners.forEach((fn) => fn(locale));
}
function onLocaleChange(fn: (locale: SupportedLocale) => void): () => void {
localeListeners.add(fn);
return () => localeListeners.delete(fn);
}
return {
t(path: string, params?: LangParams, locale?: SupportedLocale): string {
void _schemaVersion;
return engine.t(path, params, locale ?? _locale);
},
ts(value: LangString, locale?: SupportedLocale): string {
void _schemaVersion;
return engine.ts(value, locale ?? _locale);
},
getLocale(): SupportedLocale {
return _locale;
},
setLocale,
onLocaleChange,
onSchemaChange(fn: () => void): () => void {
return engine.onSchemaChange(fn);
},
extend(namespace: string, module: LangNode): void {
engine.extend(namespace, module);
},
register<NS extends string, M extends LangNode>(namespace: NS, module: M) {
const childEngine = engine.register(namespace, module);
return wrapEngine(childEngine, _locale, onLocaleChange);
},
setLogger(logger: LangLogger): void {
engine.setLogger(logger);
},
dispose(): void {
unsubSchema();
unsubParent?.();
localeListeners.clear();
}
} as ActiveLangInstance<S>;
}

@ -0,0 +1,5 @@
export const ID_PREFIX = '#?';
export const ID_FALLBACK_SEPARATOR = '|';
export const MAX_RESOLVE_DEEP = 3;
export const LOGGER_CATEGORY = 'lang';

@ -0,0 +1,240 @@
import type {
SupportedLocale,
LangBranch,
LangFn,
LangParams,
LangRecord,
LangString,
EngineLangInstance,
LangLogger,
LangNode
} from './types.ts';
import { LANG_ERRORS } from './errors.ts';
import { ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP } from './consts.ts';
import { isLangRef, isLangRecord } from './guards.ts';
import {
interpolateTemplate,
resolvePath,
parseLangRef,
parsePathFallback,
deepMerge
} from './helpers.ts';
const DEV: boolean =
typeof import.meta !== 'undefined' && import.meta.env != null
? import.meta.env.DEV
: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
export function createEngineLang<S extends LangNode>(
schema: S,
defaultLocale: SupportedLocale = 'es',
fallbackChain?: SupportedLocale[]
): EngineLangInstance<S> {
let currentSchema: LangNode = schema;
let logger: LangLogger = consoleLogger;
let loggerSet: boolean = false;
const schemaListeners = new Set<() => void>();
function setLogger(external: LangLogger): void {
if (loggerSet) {
if (DEV) console.warn(LANG_ERRORS.LOGGER_ALREADY_SET);
return;
}
logger = external;
loggerSet = true;
}
function buildChain(locale: SupportedLocale): SupportedLocale[] {
if (locale === defaultLocale) return [defaultLocale];
const chain: SupportedLocale[] = [locale];
for (const loc of fallbackChain ?? []) {
if (loc !== locale && loc !== defaultLocale) chain.push(loc);
}
chain.push(defaultLocale);
return chain;
}
function tsRecord(
record: LangRecord,
locale: SupportedLocale,
path?: string,
params?: LangParams
): string {
const chain = buildChain(locale);
let translation: string | undefined;
let usedLocale: SupportedLocale | undefined;
for (const loc of chain) {
const candidate = record[loc];
if (candidate !== undefined) {
translation = candidate;
usedLocale = loc;
break;
}
}
if (translation === undefined) translation = path ?? '';
translation = interpolateTemplate(translation, params);
if (DEV && usedLocale !== undefined && usedLocale !== locale) {
const msg = path
? LANG_ERRORS.MISSING_TRANSLATION(path, locale, usedLocale)
: LANG_ERRORS.MISSING_TRANSLATION_RECORD(locale, usedLocale);
logger.warn(LOGGER_CATEGORY, msg);
}
return translation;
}
type ResolvedLeaf = string | LangRecord | undefined;
function resolveValue(
value: LangNode | string | undefined,
params: unknown,
locale: SupportedLocale,
depth: number,
visited?: Set<string>
): ResolvedLeaf {
if (value === undefined) return undefined;
if (depth > MAX_RESOLVE_DEEP) {
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(String(value)));
throw new Error('Circular reference in lang');
}
if (isLangRef(value)) {
const parsed = parseLangRef(value);
const path = parsed?.path ?? value.substring(ID_PREFIX.length);
const seen = visited ?? new Set<string>();
if (seen.has(path)) {
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(path));
throw new Error('Circular reference in lang');
}
seen.add(path);
const resolved = resolvePath(currentSchema, path);
if (resolved === undefined) {
if (parsed?.fallback !== undefined) {
if (DEV)
logger.warn(
LOGGER_CATEGORY,
`${LANG_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".`
);
return parsed.fallback;
}
return undefined;
}
return resolveValue(resolved, params, locale, depth + 1, seen);
}
if (typeof value === 'function') {
return (value as LangFn<unknown>)(params);
}
if (isLangRecord(value)) return value;
// Plain string or unexpected shape (e.g. raw LangBranch) — no valid translation.
return typeof value === 'string' ? value : undefined;
}
const t: EngineLangInstance<S>['t'] = (
path: string,
params: LangParams | undefined,
locale: SupportedLocale
): string => {
const { path: cleanPath, fallback: pathFallback } = parsePathFallback(path);
const rawValue = resolvePath(currentSchema, cleanPath);
if (rawValue === undefined) {
if (pathFallback !== undefined) {
return interpolateTemplate(pathFallback, params);
}
if (DEV) logger.error(LOGGER_CATEGORY, LANG_ERRORS.KEY_NOT_FOUND(cleanPath));
return cleanPath;
}
const finalValue = resolveValue(rawValue, params, locale, 0);
if (isLangRecord(finalValue)) {
return tsRecord(finalValue, locale, cleanPath, params);
}
return finalValue ?? cleanPath;
};
function ts(value: LangString, locale: SupportedLocale): string {
if (value == null) return '';
const finalValue = resolveValue(value, undefined, locale, 0);
if (isLangRecord(finalValue)) return tsRecord(finalValue, locale, undefined, undefined);
return typeof finalValue === 'string' ? finalValue : '';
}
function extend(namespace: string, module: LangNode): void {
const parts = namespace.split('.');
let nested: LangNode = module;
for (let i = parts.length - 1; i > 0; i--) {
nested = { [parts[i]]: nested };
}
const root = parts[0];
const schemaBranch = currentSchema as LangBranch;
const existing = schemaBranch[root];
const merged: LangNode = isPlainBranch(existing) && isPlainBranch(nested)
? deepMerge(existing, nested)
: nested;
currentSchema = { ...schemaBranch, [root]: merged };
schemaListeners.forEach((fn) => fn());
}
function register<NS extends string, M extends LangNode>(
namespace: NS,
module: M
): EngineLangInstance<S & { [K in NS]: M }> {
const newSchema = {
...(currentSchema as LangBranch),
[namespace]: module
} as S & { [K in NS]: M };
return createEngineLang(newSchema, defaultLocale, fallbackChain);
}
function onSchemaChange(fn: () => void): () => void {
schemaListeners.add(fn);
return () => schemaListeners.delete(fn);
}
function getDefaultLocale(): SupportedLocale {
return defaultLocale;
}
function getFallbackChain(): SupportedLocale[] | undefined {
return fallbackChain;
}
return {
t,
ts,
onSchemaChange,
extend,
register,
setLogger,
getDefaultLocale,
getFallbackChain
};
}
function isPlainBranch(value: unknown): value is LangBranch {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
typeof value !== 'function'
);
}
const consoleLogger: LangLogger = {
warn: (_category: string, message: string) => DEV && console.warn(message),
error: (_category: string, message: string) => DEV && console.error(message)
};

@ -0,0 +1,13 @@
export const LANG_ERRORS = {
KEY_NOT_FOUND: (path: string): string => `[lang] Translation key not found: "${path}"`,
CIRCULAR_REFERENCE: (path: string): string => `[lang] Circular reference in "${path}".`,
MISSING_TRANSLATION: (path: string, locale: string, usedLocale: string): string =>
`[lang] Missing translation for "${path}" in "${locale}". Falling back to "${usedLocale}".`,
MISSING_TRANSLATION_RECORD: (locale: string, usedLocale: string): string =>
`[lang] Missing translation in "${locale}". Falling back to "${usedLocale}".`,
LOGGER_ALREADY_SET: '[lang] Logger already set. setLogger() can only be called once.'
} as const;

@ -0,0 +1,19 @@
import type { LangRef, LangRecord, LangString } from './types.ts';
import { ID_PREFIX } from './consts.ts';
export function isLangRef(value: unknown): value is LangRef {
return typeof value === 'string' && value.startsWith(ID_PREFIX) && value.length > 2;
}
export function isLangRecord(value: unknown): value is LangRecord {
if (typeof value !== 'object' || value === null) return false;
if (Array.isArray(value)) return false;
if (typeof value === 'function') return false;
const vals = Object.values(value as Record<string, unknown>);
if (vals.length === 0) return false;
return vals.every((v) => typeof v === 'string' || v === undefined);
}
export function isLangString(value: unknown): value is LangString {
return isLangRef(value) || isLangRecord(value) || typeof value === 'string';
}

@ -0,0 +1,95 @@
import type { LangBranch, LangNode, LangParams, LangRecord, LangString, SupportedLocale } from './types.ts';
import { ID_FALLBACK_SEPARATOR, ID_PREFIX } from './consts.ts';
import { isLangString } from './guards.ts';
export function resolvePath(obj: LangNode | undefined, path: string): LangNode | undefined {
if (!path || obj == null) return undefined;
let current: unknown = obj;
for (const key of path.split('.')) {
if (current == null || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[key];
}
return current as LangNode | undefined;
}
export function parseLangRef(value: string): { path: string; fallback?: string } | null {
if (!value.startsWith(ID_PREFIX) || value.length <= ID_PREFIX.length) return null;
const raw = value.slice(ID_PREFIX.length);
const separatorIndex = raw.indexOf(ID_FALLBACK_SEPARATOR);
if (separatorIndex === -1) {
return { path: raw };
}
return {
path: raw.slice(0, separatorIndex),
fallback: raw.slice(separatorIndex + 1)
};
}
export function parsePathFallback(path: string): { path: string; fallback?: string } {
const separatorIndex = path.indexOf(ID_FALLBACK_SEPARATOR);
if (separatorIndex === -1) {
return { path };
}
return {
path: path.slice(0, separatorIndex),
fallback: path.slice(separatorIndex + 1)
};
}
export function makeLangRecord(text: string, locale: SupportedLocale): LangRecord {
return { [locale]: text } as LangRecord;
}
export function asLangString(value: unknown): LangString {
if (value == null) return '';
if (isLangString(value)) return value;
return String(value);
}
export function interpolateTemplate(text: string, params?: LangParams): string {
if (!params) return text;
let result = text;
for (const [key, val] of Object.entries(params)) {
result = result.replaceAll(`{{${key}}}`, String(val));
}
return result;
}
export function resolveRecordFallback(
record: Partial<Record<SupportedLocale, string>>,
chain: SupportedLocale[]
): string | undefined {
for (const locale of chain) {
const val = record[locale];
if (val !== undefined) return val;
}
return Object.values(record).find((v) => typeof v === 'string');
}
export function deepMerge<T extends LangBranch, S extends LangBranch>(target: T, source: S): T & S {
const result: LangBranch = { ...target };
for (const key of Object.keys(source)) {
const srcVal = source[key];
const tgtVal = result[key];
if (isPlainBranch(srcVal) && isPlainBranch(tgtVal)) {
result[key] = deepMerge(tgtVal, srcVal);
} else {
result[key] = srcVal;
}
}
return result as T & S;
}
function isPlainBranch(value: unknown): value is LangBranch {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
typeof value !== 'function'
);
}

@ -0,0 +1,9 @@
export * from './consts.ts';
export * from './guards.ts';
export * from './types.ts';
export * from './plural_rules.ts';
export * from './plural.ts';
export * from './helpers.ts';
export * from './engine-lang.ts';
export * from './json.ts';
export * from './errors.ts';

@ -0,0 +1,71 @@
import type { LangBranch, LangFn, LangNode, LangParams, PluralConfig } from './types.ts';
import { p, getPluralConfig } from './plural.ts';
export type LangJSONValue =
| string
| number
| boolean
| null
| LangJSONObject
| LangJSONValue[];
export interface LangJSONObject {
[key: string]: LangJSONValue;
}
type PluralJSON = LangJSONObject & {
__type: 'plural';
config: LangJSONObject;
};
function isPluralJSON(value: LangJSONObject): value is PluralJSON {
return value.__type === 'plural' && typeof value.config === 'object' && value.config !== null;
}
export function langNodeToJSON(node: LangNode): LangJSONValue {
if (typeof node === 'string') return node;
if (typeof node === 'function') {
const config = getPluralConfig(node);
if (config) {
return {
__type: 'plural',
config: config as unknown as LangJSONObject
};
}
try {
const proxyParams = new Proxy({} as LangParams, {
get: (_, prop) => `{{${String(prop)}}}`
});
return langNodeToJSON((node as LangFn)(proxyParams));
} catch {
return {};
}
}
const result: LangJSONObject = {};
const source = node as LangBranch;
for (const key in source) {
result[key] = langNodeToJSON(source[key]);
}
return result;
}
export function JSONToLangNode(json: LangJSONValue): LangNode {
if (typeof json === 'string') return json as LangNode;
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
throw new TypeError('[lang] JSONToLangNode: invalid JSON shape for LangNode');
}
if (isPluralJSON(json)) {
return p(json.config as unknown as PluralConfig);
}
const result: LangBranch = {};
for (const key in json) {
result[key] = JSONToLangNode(json[key]);
}
return result;
}

@ -0,0 +1,37 @@
import type { LangParams, LangPluralFn, LangRecord, PluralConfig, PluralForms } from './types.ts';
import type { PluralCategory } from './plural_rules.ts';
import { pluralRule } from './plural_rules.ts';
type AnyLangFn = (params: LangParams) => LangRecord;
const pluralConfigs = new WeakMap<AnyLangFn, PluralConfig>();
export const p = <P extends LangParams = LangParams>(config: PluralConfig): LangPluralFn<P> => {
const cache = new Map<string, LangRecord>();
const fn: LangPluralFn<P> = (params) => {
const key = String(params.count);
const cached = cache.get(key);
if (cached) return cached;
const result: Partial<LangRecord> = {};
for (const [locale, forms] of Object.entries(config)) {
if (!forms) continue;
const rule: PluralCategory = pluralRule(locale, params.count);
const typedForms = forms as PluralForms;
result[locale as keyof LangRecord] = typedForms[rule] || typedForms.other;
}
const record = result as LangRecord;
cache.set(key, record);
return record;
};
pluralConfigs.set(fn as unknown as AnyLangFn, config);
return fn;
};
export function getPluralConfig(fn: AnyLangFn | LangPluralFn | unknown): PluralConfig | undefined {
if (typeof fn !== 'function') return undefined;
return pluralConfigs.get(fn as AnyLangFn);
}

@ -0,0 +1,12 @@
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
export function pluralRule(locale: string, n: number): PluralCategory {
if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') {
try {
return new Intl.PluralRules(locale).select(n) as PluralCategory;
} catch {
return 'other';
}
}
return 'other';
}

@ -0,0 +1,700 @@
/**
* lang — test suite
*
* Covers:
* - guards (isLangRef, isLangRecord, isLangString)
* - helpers (resolvePath, parseLangRef, parsePathFallback, makeLangRecord,
* interpolateTemplate, resolveRecordFallback, deepMerge)
* - plural_rules (pluralRule via Intl.PluralRules)
* - plural (p, getPluralConfig)
* - engine (createEngineLang: t, ts, fallback chain, locale param,
* extend, register, setLogger)
* - json (langNodeToJSON, JSONToLangNode, round-trip)
*/
import { describe, it, expect, vi } from 'vitest';
import { isLangRef, isLangRecord, isLangString } from '../guards.ts';
import {
parseLangRef,
parsePathFallback,
resolvePath,
makeLangRecord,
interpolateTemplate,
resolveRecordFallback,
deepMerge
} from '../helpers.ts';
import { pluralRule } from '../plural_rules.ts';
import { p, getPluralConfig } from '../plural.ts';
import { createEngineLang } from '../engine-lang.ts';
import { langNodeToJSON, JSONToLangNode } from '../json.ts';
import type { LangNode, LangBranch } from '../types.ts';
// ============================================================================
// GUARDS
// ============================================================================
describe('isLangRef', () => {
it('returns true for a valid reference', () => {
expect(isLangRef('#?common.ok')).toBe(true);
});
it('returns false for prefix only without path', () => {
expect(isLangRef('#?')).toBe(false);
});
it('returns false for normal strings', () => {
expect(isLangRef('hola')).toBe(false);
expect(isLangRef('')).toBe(false);
});
it('returns false for non-strings', () => {
expect(isLangRef(null)).toBe(false);
expect(isLangRef(42)).toBe(false);
expect(isLangRef({})).toBe(false);
expect(isLangRef(undefined)).toBe(false);
});
it('returns true for references with nested paths', () => {
expect(isLangRef('#?a.b.c')).toBe(true);
});
});
describe('isLangRecord', () => {
it('returns true for a record with es and en', () => {
expect(isLangRecord({ es: 'Aceptar', en: 'OK' })).toBe(true);
});
it('returns true with only en — no hardcoded locale required', () => {
expect(isLangRecord({ en: 'Hello' })).toBe(true);
});
it('returns false for empty objects', () => {
expect(isLangRecord({})).toBe(false);
});
it('returns false if values are not strings', () => {
expect(isLangRecord({ es: 42 })).toBe(false);
expect(isLangRecord({ es: null })).toBe(false);
});
it('returns false for arrays', () => {
expect(isLangRecord(['es', 'Hola'])).toBe(false);
});
it('returns false for functions', () => {
expect(isLangRecord(() => ({ es: 'x' }))).toBe(false);
});
it('returns false for primitives', () => {
expect(isLangRecord(null)).toBe(false);
expect(isLangRecord('hola')).toBe(false);
expect(isLangRecord(42)).toBe(false);
});
});
describe('isLangString', () => {
it('accepts plain strings', () => {
expect(isLangString('hola')).toBe(true);
expect(isLangString('')).toBe(true);
});
it('accepts LangRecord', () => {
expect(isLangString({ en: 'Hello' })).toBe(true);
});
it('accepts LangRef', () => {
expect(isLangString('#?common.ok')).toBe(true);
});
it('rejects null and undefined', () => {
expect(isLangString(null)).toBe(false);
expect(isLangString(undefined)).toBe(false);
});
it('rejects numbers', () => {
expect(isLangString(42)).toBe(false);
});
});
// ============================================================================
// HELPERS
// ============================================================================
describe('parseLangRef', () => {
it('extracts path without fallback', () => {
expect(parseLangRef('#?common.ok')).toEqual({ path: 'common.ok' });
});
it('extracts path and fallback literal', () => {
expect(parseLangRef('#?terra.calendar.month|month')).toEqual({
path: 'terra.calendar.month',
fallback: 'month'
});
});
it('returns null for non-ref strings', () => {
expect(parseLangRef('terra.calendar.month')).toBeNull();
expect(parseLangRef('#?')).toBeNull();
});
});
describe('parsePathFallback', () => {
it('extracts path without fallback', () => {
expect(parsePathFallback('common.ok')).toEqual({ path: 'common.ok' });
});
it('extracts path and fallback', () => {
expect(parsePathFallback('home.options|Options')).toEqual({
path: 'home.options',
fallback: 'Options'
});
});
it('returns full string as path when no separator', () => {
expect(parsePathFallback('noseparator')).toEqual({ path: 'noseparator' });
});
it('handles empty fallback', () => {
expect(parsePathFallback('path|')).toEqual({ path: 'path', fallback: '' });
});
});
describe('resolvePath', () => {
const obj: LangBranch = {
a: { b: { c: { en: 'value' } } }
};
it('resolves nested paths', () => {
expect(resolvePath(obj, 'a.b.c')).toEqual({ en: 'value' });
});
it('resolves one level', () => {
expect(resolvePath(obj, 'a')).toEqual({ b: { c: { en: 'value' } } });
});
it('returns undefined for non-existent paths', () => {
expect(resolvePath(obj, 'a.x.y')).toBeUndefined();
});
it('returns undefined for empty path', () => {
expect(resolvePath(obj, '')).toBeUndefined();
});
});
describe('makeLangRecord', () => {
it('creates a record with the specified locale', () => {
const record = makeLangRecord('Hola', 'es');
expect(record.es).toBe('Hola');
});
it('creates a record with en locale', () => {
const record = makeLangRecord('Hello', 'en');
expect(record.en).toBe('Hello');
expect((record as Record<string, unknown>).es).toBeUndefined();
});
});
describe('interpolateTemplate', () => {
it('replaces {{key}} with value', () => {
expect(interpolateTemplate('Hello {{name}}', { name: 'World' })).toBe('Hello World');
});
it('replaces multiple keys', () => {
expect(
interpolateTemplate('{{greeting}} {{name}}', { greeting: 'Hi', name: 'Ana' })
).toBe('Hi Ana');
});
it('returns text unchanged if no params', () => {
expect(interpolateTemplate('Hello')).toBe('Hello');
});
it('handles special regex chars in values', () => {
expect(interpolateTemplate('Code: {{code}}', { code: 'A+B' })).toBe('Code: A+B');
});
});
describe('resolveRecordFallback', () => {
it('returns value for first locale in chain', () => {
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['en', 'es'])).toBe('Hello');
});
it('falls back through the chain', () => {
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'en', 'es'])).toBe('Hello');
});
it('uses the last available entry', () => {
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'de', 'es'])).toBe('Hola');
});
it('returns undefined for empty record', () => {
expect(resolveRecordFallback({}, ['es'])).toBeUndefined();
});
it('uses Object.values fallback when chain misses', () => {
expect(resolveRecordFallback({ fr: 'Bonjour' }, ['de'])).toBe('Bonjour');
});
});
describe('deepMerge', () => {
it('merges flat branches', () => {
expect(deepMerge({ a: { en: '1' } }, { b: { en: '2' } })).toEqual({
a: { en: '1' },
b: { en: '2' }
});
});
it('deep merges nested branches', () => {
expect(
deepMerge({ a: { x: { en: '1' }, y: { en: '2' } } }, { a: { y: { en: '3' }, z: { en: '4' } } })
).toEqual({
a: { x: { en: '1' }, y: { en: '3' }, z: { en: '4' } }
});
});
it('overwrites leaves with source', () => {
expect(deepMerge({ a: { en: '1' } }, { a: { en: '2' } })).toEqual({
a: { en: '2' }
});
});
});
// ============================================================================
// PLURAL RULES (Intl.PluralRules)
// ============================================================================
describe('pluralRule', () => {
describe('Spanish (es) — one/other', () => {
it('1 → one', () => expect(pluralRule('es', 1)).toBe('one'));
it('0 → other', () => expect(pluralRule('es', 0)).toBe('other'));
it('2 → other', () => expect(pluralRule('es', 2)).toBe('other'));
});
describe('English (en) — one/other', () => {
it('1 → one', () => expect(pluralRule('en', 1)).toBe('one'));
it('2 → other', () => expect(pluralRule('en', 2)).toBe('other'));
});
describe('Arabic (ar) — 6 forms', () => {
it('0 → zero', () => expect(pluralRule('ar', 0)).toBe('zero'));
it('1 → one', () => expect(pluralRule('ar', 1)).toBe('one'));
it('2 → two', () => expect(pluralRule('ar', 2)).toBe('two'));
it('5 → few', () => expect(pluralRule('ar', 5)).toBe('few'));
it('15 → many', () => expect(pluralRule('ar', 15)).toBe('many'));
it('100 → other', () => expect(pluralRule('ar', 100)).toBe('other'));
});
describe('region-normalized locales', () => {
it('es-ES → one for 1', () => expect(pluralRule('es-ES', 1)).toBe('one'));
it('en-US → one for 1', () => expect(pluralRule('en-US', 1)).toBe('one'));
});
describe('unknown locale — falls back to Intl root', () => {
it('xx, 5 → other', () => expect(pluralRule('xx', 5)).toBe('other'));
});
});
// ============================================================================
// PLURAL — p() with WeakMap
// ============================================================================
describe('p()', () => {
const plural = p({
es: { one: '{{count}} elemento', other: '{{count}} elementos' },
en: { one: '{{count}} item', other: '{{count}} items' }
});
it('selects singular form for 1', () => {
const record = plural({ count: 1 });
expect(record.es).toBe('{{count}} elemento');
});
it('selects plural form for 2', () => {
const record = plural({ count: 2 });
expect(record.es).toBe('{{count}} elementos');
});
it('caches by count', () => {
const r1 = plural({ count: 5 });
const r2 = plural({ count: 5 });
expect(r1).toBe(r2);
});
it('uses other as fallback when form missing', () => {
const fallback = p({ es: { other: 'varios' } });
const record = fallback({ count: 1 });
expect(record.es).toBe('varios');
});
it('getPluralConfig retrieves config via WeakMap', () => {
expect(getPluralConfig(plural)).toBeDefined();
expect(getPluralConfig(plural)!.es).toBeDefined();
});
it('getPluralConfig returns undefined for non-plural functions', () => {
const regular = () => ({ es: 'x' });
expect(getPluralConfig(regular)).toBeUndefined();
});
it('stores config via WeakMap, not on the function itself', () => {
expect((plural as unknown as Record<string, unknown>).__pluralConfig).toBeUndefined();
});
});
// ============================================================================
// ENGINE — createEngineLang
// ============================================================================
const schema = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' }
},
greet: (params: { name: string }) => ({
es: `Hola, ${params.name}`,
en: `Hello, ${params.name}`
}),
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' }
})
},
ref: '#?common.ok',
nested: {
deep: {
value: { es: 'Profundo', en: 'Deep' }
}
},
onlyEn: { en: 'English only' },
onlyFr: { fr: 'Francais seulement' }
} as const satisfies LangNode;
describe('createEngineLang — t()', () => {
it('translates a simple key to the requested locale', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.t('common.ok', undefined, 'es')).toBe('Aceptar');
});
it('translates to alternative locale', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.t('common.ok', undefined, 'en')).toBe('OK');
});
it('returns path when key does not exist', () => {
const lang = createEngineLang(schema, 'es');
expect((lang.t as (p: string, params: undefined, locale: 'es') => string)('no.existe', undefined, 'es')).toBe(
'no.existe'
);
});
it('interpolates params', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.t('greet', { name: 'Ana' }, 'es')).toBe('Hola, Ana');
});
it('resolves LangRef references (#?)', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.t('ref', undefined, 'es')).toBe('Aceptar');
});
it('resolves deep nested paths', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.t('nested.deep.value', undefined, 'es')).toBe('Profundo');
});
it('resolves plural — one', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.t('messages.unread', { count: 1 }, 'es')).toBe('1 mensaje sin leer');
});
it('resolves plural — other', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.t('messages.unread', { count: 5 }, 'es')).toBe('5 mensajes sin leer');
});
it('returns fallback literal when key missing and | present', () => {
const lang = createEngineLang(schema, 'es');
expect(
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
'missing.key|Alternate',
undefined,
'es'
)
).toBe('Alternate');
});
it('interpolates params in the fallback literal', () => {
const lang = createEngineLang(schema, 'es');
expect(
(lang.t as (p: string, params: Record<string, unknown>, locale: 'es') => string)(
'missing.greet|Hola {{name}}',
{ name: 'Ana' },
'es'
)
).toBe('Hola Ana');
});
});
describe('createEngineLang — ts()', () => {
it('translates a LangRecord directly', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.ts({ es: 'Directo', en: 'Direct' }, 'es')).toBe('Directo');
});
it('translates a LangRecord in explicit locale', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.ts({ es: 'Directo', en: 'Direct' }, 'en')).toBe('Direct');
});
it('returns empty string for null', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.ts(null as unknown as string, 'es')).toBe('');
});
it('resolves LangRef references', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.ts('#?common.ok', 'es')).toBe('Aceptar');
});
it('uses fallback literal when ref key does not exist', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.ts('#?terra.calendar.month|month', 'es')).toBe('month');
});
it('keeps plain strings as literals', () => {
const lang = createEngineLang(schema, 'es');
expect(lang.ts('plain text', 'es')).toBe('plain text');
});
});
describe('createEngineLang — fallback chain', () => {
it('falls back through the chain: current → chain → default', () => {
const lang = createEngineLang(schema, 'es', ['en']);
expect(lang.t('common.ok', undefined, 'fr')).toBe('OK');
});
it('falls back to default when chain misses', () => {
const lang = createEngineLang(schema, 'es', ['de']);
expect(lang.t('common.ok', undefined, 'fr')).toBe('Aceptar');
});
it('skips chain locales equal to current or default', () => {
const lang = createEngineLang(schema, 'es', ['es']);
expect(lang.t('common.ok', undefined, 'en')).toBe('OK');
});
});
describe('createEngineLang — extend()', () => {
it('adds a simple namespace', () => {
const lang = createEngineLang(schema, 'es');
lang.extend('shop', { product: { es: 'Producto', en: 'Product' } });
expect(
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
'shop.product',
undefined,
'es'
)
).toBe('Producto');
});
it('supports dotted namespaces', () => {
const lang = createEngineLang(schema, 'es');
lang.extend('app.settings', { title: { es: 'Ajustes', en: 'Settings' } });
expect(
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
'app.settings.title',
undefined,
'es'
)
).toBe('Ajustes');
});
it('deep-merges without overwriting existing keys', () => {
const lang = createEngineLang(schema, 'es');
lang.extend('common', { extra: { es: 'Extra', en: 'Extra' } });
expect(lang.t('common.ok', undefined, 'es')).toBe('Aceptar');
expect(
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
'common.extra',
undefined,
'es'
)
).toBe('Extra');
});
it('notifies schema listeners', () => {
const lang = createEngineLang(schema, 'es');
const spy = vi.fn();
lang.onSchemaChange(spy);
lang.extend('shop', { product: { es: 'Producto' } });
expect(spy).toHaveBeenCalledTimes(1);
});
it('unsubscribe stops schema notifications', () => {
const lang = createEngineLang(schema, 'es');
const spy = vi.fn();
const unsub = lang.onSchemaChange(spy);
unsub();
lang.extend('shop', { product: { es: 'Producto' } });
expect(spy).not.toHaveBeenCalled();
});
});
describe('createEngineLang — register()', () => {
it('returns a new instance with the namespace merged', () => {
const lang = createEngineLang(schema, 'es');
const extended = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
expect(
(extended.t as (p: string, params: undefined, locale: 'es') => string)(
'shop.product',
undefined,
'es'
)
).toBe('Producto');
});
it('leaves the original instance unaffected', () => {
const lang = createEngineLang(schema, 'es');
lang.register('shop', { product: { es: 'Producto' } });
expect(
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
'shop.product',
undefined,
'es'
)
).toBe('shop.product');
});
it('child inherits fallback chain from the parent', () => {
const lang = createEngineLang(schema, 'es', ['en']);
const child = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
expect(
(child.t as (p: string, params: undefined, locale: 'fr') => string)(
'shop.product',
undefined,
'fr'
)
).toBe('Product');
});
});
describe('createEngineLang — setLogger()', () => {
it('replaces the default logger', () => {
const lang = createEngineLang(schema, 'es');
const warn = vi.fn();
const error = vi.fn();
lang.setLogger({ warn, error });
expect(lang.getDefaultLocale()).toBe('es');
});
it('ignores subsequent setLogger calls (set-once)', () => {
const lang = createEngineLang(schema, 'es');
const warn1 = vi.fn();
const warn2 = vi.fn();
lang.setLogger({ warn: warn1, error: vi.fn() });
lang.setLogger({ warn: warn2, error: vi.fn() });
expect(lang.getDefaultLocale()).toBe('es');
});
});
describe('createEngineLang — non-Spanish defaults', () => {
it('works with en as default locale', () => {
const enSchema = { greeting: { en: 'Hello' } } as const;
const lang = createEngineLang(enSchema, 'en');
expect(lang.t('greeting', undefined, 'en')).toBe('Hello');
});
it('works with fr as default locale', () => {
const frSchema = { greeting: { fr: 'Bonjour' } } as const;
const lang = createEngineLang(frSchema, 'fr');
expect(lang.t('greeting', undefined, 'fr')).toBe('Bonjour');
});
});
// ============================================================================
// JSON — langNodeToJSON / JSONToLangNode
// ============================================================================
describe('langNodeToJSON', () => {
it('serializes LangRecord as plain object', () => {
expect(langNodeToJSON({ en: 'Hello' })).toEqual({ en: 'Hello' });
});
it('serializes namespaces recursively', () => {
const node = { common: { ok: { es: 'Aceptar', en: 'OK' } } } satisfies LangNode;
const result = langNodeToJSON(node) as { common: { ok: Record<string, string> } };
expect(result.common.ok).toEqual({ es: 'Aceptar', en: 'OK' });
});
it('serializes interpolation functions with {{tokens}}', () => {
const fn = (params: { name: string }) => ({ es: `Hola ${params.name}`, en: `Hello ${params.name}` });
const result = langNodeToJSON(fn) as { es: string; en: string };
expect(result.es).toBe('Hola {{name}}');
expect(result.en).toBe('Hello {{name}}');
});
it('serializes plural via WeakMap as { __type: "plural", config }', () => {
const plural = p({ en: { one: '1 item', other: '{{count}} items' } });
const result = langNodeToJSON(plural) as { __type: string; config: unknown };
expect(result.__type).toBe('plural');
expect(result.config).toBeDefined();
});
});
describe('JSONToLangNode', () => {
it('reconstructs LangRecord', () => {
const result = JSONToLangNode({ en: 'Hello' });
expect(result).toEqual({ en: 'Hello' });
});
it('reconstructs plurals as callable functions', () => {
const config = { en: { one: '{{count}} item', other: '{{count}} items' } };
const restored = JSONToLangNode({ __type: 'plural', config }) as unknown as (p: {
count: number;
}) => Record<string, string>;
expect(typeof restored).toBe('function');
expect(restored({ count: 1 }).en).toBe('{{count}} item');
});
it('reconstructs namespaces recursively', () => {
const json = { common: { ok: { en: 'OK' } } };
const restored = JSONToLangNode(json) as { common: { ok: { en: string } } };
expect(restored.common.ok.en).toBe('OK');
});
it('throws on invalid JSON shape', () => {
expect(() => JSONToLangNode(42 as never)).toThrow(TypeError);
expect(() => JSONToLangNode(true as never)).toThrow(TypeError);
});
});
describe('round-trip langNodeToJSON → JSONToLangNode', () => {
it('preserves LangRecord', () => {
const original: LangNode = { en: 'Hello' };
const json = langNodeToJSON(original);
const restored = JSONToLangNode(json) as { en: string };
expect(restored.en).toBe('Hello');
});
it('preserves plurals and keeps them callable', () => {
const original = p({ en: { one: '1 item', other: '{{count}} items' } });
const json = langNodeToJSON(original);
const restored = JSONToLangNode(json) as unknown as (p: { count: number }) => Record<string, string>;
expect(typeof restored).toBe('function');
expect(restored({ count: 1 }).en).toBe('1 item');
expect(restored({ count: 5 }).en).toBe('{{count}} items');
});
it('preserves a full tree', () => {
const node: LangNode = {
common: { ok: { es: 'Aceptar', en: 'OK' } },
items: p({ en: { one: '1 item', other: '{{count}} items' } })
};
const json = langNodeToJSON(node);
const restored = JSONToLangNode(json) as LangBranch;
const common = restored.common as LangBranch;
expect((common.ok as Record<string, string>).en).toBe('OK');
expect(typeof restored.items).toBe('function');
});
});

@ -0,0 +1,120 @@
import type { ID_PREFIX } from './consts.ts';
import type { PluralCategory } from './plural_rules.ts';
export type SupportedLocale = 'es' | 'en' | 'ar' | 'de' | 'fr' | 'it' | 'pt' | 'ca' | 'eu' | 'gl';
export type LangRef = `${typeof ID_PREFIX}${string}`;
export type LangRecord = {
[K in SupportedLocale]?: string;
};
export type LangParams = Record<string, unknown>;
// NOTE: default is `any` (not `LangParams`) to preserve contravariant
// parameter variance: a schema `(params: { name: string }) => ...` must be
// assignable to the default type without forcing the user to widen their
// signature. Public type-safety lives in `t()` / `ts()`, which infer the
// concrete `P` via `ParamsFor<TType>`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type LangFn<P = any> = (params: P) => LangRecord;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type LangPluralFn<P = any> = (params: { count: number } & P) => LangRecord;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type LangValue<P = any> = LangRecord | LangFn<P> | LangPluralFn<P> | LangRef;
export type LangBranch = { [key: string]: LangNode };
export type LangNode = LangValue | LangBranch;
export type LangString = string | LangRecord | LangRef;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
export type LeafPaths<T, D extends number = 6> = [D] extends [never]
? never
: T extends LangValue
? ''
: T extends object
? {
[K in keyof T & string]: T[K] extends LangValue
? K
: `${K}.${LeafPaths<T[K], Prev[D]> & string}`;
}[keyof T & string]
: never;
export type GetTypeAtPath<Root, Current, P extends string> = P extends `${infer K}.${infer Rest}`
? K extends keyof Current
? GetTypeAtPath<Root, Current[K], Rest>
: never
: P extends keyof Current
? Current[P] extends `#?${infer AliasPath}`
? GetTypeAtPath<Root, Root, AliasPath>
: Current[P]
: never;
export type ParamsFor<T> = T extends (params: infer P) => LangRecord ? P : never;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HasParams<T> = T extends (params: any) => LangRecord ? true : false;
export type PluralForms = Partial<Record<PluralCategory, string>> & { other: string };
export type PluralConfig = {
[K in SupportedLocale]?: PluralForms;
};
export type EngineLangInstance<S extends LangNode = LangNode> = {
t: {
<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P,
params: HasParams<TType> extends true ? ParamsFor<TType> : LangParams | undefined,
locale: SupportedLocale
): string;
(path: string, params: LangParams | undefined, locale: SupportedLocale): string;
};
ts: (value: LangString, locale: SupportedLocale) => string;
onSchemaChange: (fn: () => void) => () => void;
extend: (namespace: string, module: LangNode) => void;
register: <NS extends string, M extends LangNode>(
namespace: NS,
module: M
) => EngineLangInstance<S & { [K in NS]: M }>;
setLogger: (logger: LangLogger) => void;
getDefaultLocale: () => SupportedLocale;
getFallbackChain: () => SupportedLocale[] | undefined;
};
export type ActiveLangInstance<S extends LangNode = LangNode> = {
t: {
<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P,
params?: HasParams<TType> extends true ? ParamsFor<TType> : LangParams,
locale?: SupportedLocale
): string;
(path: string, params?: LangParams, locale?: SupportedLocale): string;
};
ts: (value: LangString, locale?: SupportedLocale) => string;
getLocale: () => SupportedLocale;
setLocale: (locale: SupportedLocale) => void;
onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void;
onSchemaChange: (fn: () => void) => () => void;
extend: (namespace: string, module: LangNode) => void;
register: <NS extends string, M extends LangNode>(
namespace: NS,
module: M
) => ActiveLangInstance<S & { [K in NS]: M }>;
setLogger: (logger: LangLogger) => void;
dispose: () => void;
};
export interface LangLogger {
warn: (category: string, message: string) => void;
error: (category: string, message: string) => void;
}

@ -0,0 +1,445 @@
# logr
Professional structured logger. **Zero external dependencies.** Decoupled from
i18n. Dynamic transports with per-sink filtering. Async-aware dispatch.
Failure routing with `deniedFor`. Built-in adapters for Sentry, Datadog,
Logtail (Better Stack), Grafana Loki and OpenTelemetry.
---
## Features
- **6 severity levels** aligned with pino / Log4j / OpenTelemetry / Sentry:
`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`.
- **Structured entries** — category, context, tags, traceId, error, durationMs,
source (auto-captured from stack in DEV).
- **Lazy messages** — pass a thunk, evaluated only if the entry passes the
global level filter.
- **globalContext** merged into every entry (appVersion, env, sessionId, ...).
- **`child(ctx)`** — logger with extended context; shares history and transports.
- **`time(label)` / `timeEnd(label)`** — duration measurement with `timer` tag.
- **Dynamic transports** — `addTransport()`, `subscribe()`, `removeAllTransports()`.
- **Per-transport filters** — `minLevel` and `filter` at the transport level.
- **Hybrid dispatch** — sync loop, transports may return `Promise<void>` for
async work. Caller never awaits.
- **Failure routing** — when a transport throws (sync) or rejects (async), a
synthetic ERROR entry is dispatched to the remaining transports with
`deniedFor: [failedName]`. Guarantees no cascade loop.
- **Built-in adapters** — `sentryTransport`, `datadogTransport`,
`logtailTransport`, `lokiTransport`, `otelTransport`. All accept the SDK as
an injected parameter so logr stays dep-free.
---
## Architecture
```
logr/
├── index.ts Barrel exports
├── types.ts LogLevel, LogEntry, LogInput, Transport, EngineLogger, ...
├── engine-logger.ts Factory createEngineLogger()
├── transports.ts consoleTransport, httpTransport, callbackTransport
├── source.ts captureSource(), extractError()
├── consts.ts runtime identifier (`engine_logger`)
├── adapters/
│ ├── sentry.ts sentryTransport(sentrySDK, options)
│ ├── datadog.ts datadogTransport(ddLogger, options)
│ ├── logtail.ts logtailTransport(logtailInstance, options)
│ ├── loki.ts lokiTransport({ url, labels, ... })
│ └── otel.ts otelTransport(otelLogger, options)
└── test/
└── logr.test.ts Unit tests (vitest, node env)
```
## Alias
Configured in `svelte.config.js`:
```js
alias: { $logr: 'src/arts/logr' }
```
Imports:
```ts
import { createEngineLogger, LogLevel, consoleTransport } from '$logr';
import { sentryTransport } from '$logr/adapters/sentry';
import { datadogTransport } from '$logr/adapters/datadog';
import { logtailTransport } from '$logr/adapters/logtail';
import { lokiTransport } from '$logr/adapters/loki';
import { otelTransport } from '$logr/adapters/otel';
```
---
## Quick start
```ts
import { createEngineLogger, LogLevel, consoleTransport } from '$logr';
const logger = createEngineLogger({
level: LogLevel.DEBUG,
globalContext: { appVersion: '1.2.3', env: 'prod' },
transports: [consoleTransport()]
});
logger.info('auth', 'Login succeeded', {
context: { userId: 42 },
tags: ['auth', 'security'],
traceId: 'req-abc-123'
});
try { ... } catch (err) {
logger.error('db', 'Query failed', { error: err, traceId: 'req-abc-123' });
}
// Duration measurement
logger.time('fetch-user');
await fetchUser();
logger.timeEnd('fetch-user', 'perf'); // emits INFO with durationMs and tag 'timer'
// Lazy message — only evaluated if the entry passes the level filter
logger.debug('sql', () => `Query: ${expensiveFormat(query)}`);
```
---
## Log levels
```ts
enum LogLevel {
TRACE = 0,
DEBUG = 1,
INFO = 2,
WARN = 3,
ERROR = 4,
FATAL = 5,
NONE = 999 // disables all logs
}
```
| Level | Intended use |
| ----- | --------------------------------------------------------------------- |
| TRACE | Very verbose tracing (spans, frame-by-frame) |
| DEBUG | Development diagnostics |
| INFO | Normal flow events worth noting |
| WARN | Unexpected but non-interrupting situations |
| ERROR | Recoverable errors that need attention |
| FATAL | Unrecoverable failures (route to immediate paging) |
---
## API
### `createEngineLogger(options)`
```ts
interface LoggerOptions {
level?: LogLevel; // @default LogLevel.WARN
maxLogs?: number; // @default 1000
transports?: Transport[]; // @default [consoleTransport()]
globalContext?: Record<string, unknown>;
captureSource?: boolean; // @default true in DEV, false in PROD
}
```
### Level methods
```ts
logger.trace(category, message, input?)
logger.debug(category, message, input?)
logger.info (category, message, input?)
logger.warn (category, message, input?)
logger.error(category, message, input?)
logger.fatal(category, message, input?)
```
Where:
```ts
type LogMessage = string | (() => string); // lazy thunks supported
interface LogInput {
context?: Record<string, unknown>; // merged with globalContext
error?: unknown; // extracted to LogError { name, message, stack, cause }
tags?: string[]; // finer-grained filtering than category
traceId?: string; // correlate across async flows
durationMs?: number; // auto-set by timeEnd()
source?: LogSource; // manual override of auto-captured file:line
}
```
### History
```ts
logger.getLogs(filters?) // defensive copy, filtered by level/minLevel/category/tag/traceId/since
logger.clear()
logger.serialize() // JSON string (circular-safe)
```
### Runtime controls
```ts
logger.setLevel(LogLevel.WARN)
logger.setMaxLogs(500)
logger.setGlobalContext({ appVersion: '1.3.0' })
```
### Dynamic transports
```ts
const remove = logger.addTransport(transport); // returns a detach fn
const unsub = logger.subscribe((entry) => ...); // sugar over addTransport
logger.removeAllTransports();
logger.transports(); // read-only snapshot
```
### Child loggers
```ts
const reqLog = logger.child({ requestId: 'req-1', userId: 42 });
reqLog.info('auth', 'authenticated'); // context merges parent + child
```
Children **share** history, transports and level with the parent. They only
extend `globalContext`.
### Timers
```ts
logger.time('fetch');
await doWork();
logger.timeEnd('fetch', 'perf', 'operation done'); // emits INFO with durationMs
```
---
## Transport interface
```ts
interface Transport {
write(entry: LogEntry): void | Promise<void>;
minLevel?: LogLevel;
filter?(entry: LogEntry): boolean;
name?: string;
}
```
- `minLevel` and `filter` are applied **after** the logger's global level
filter, for fine-grained per-sink routing.
- `name` appears in introspection and in `deniedFor` on failure entries.
- Sync throws and promise rejections both route to the failure handler.
---
## Failure routing (`deniedFor`)
If `transport.write()` throws or rejects:
1. A synthetic `ERROR` entry is created with:
- `category: 'engine_logger'`
- `message: 'Transport "X" failed: ...'`
- `error`: structured `LogError` from the thrown value
- `tags: ['engine_logger', 'transport-failure']`
- `deniedFor: ['X']`
2. The failure entry is dispatched to every **other** transport, skipping the
failed one by reference.
3. If a second transport throws while handling the failure entry, the
secondary failure is **swallowed** (console-only) — this guard guarantees
no cascade loop.
This is why you can attach Sentry (or any other transport) safely: if Sentry
is down, the failure gets logged to console/Datadog/Loki/etc., but never
back to Sentry.
---
## Built-in transports
### `consoleTransport(options?)`
```ts
interface ConsoleTransportOptions {
timestamp?: boolean; // @default true
prefix?: boolean; // @default true
source?: boolean; // @default true — shows "(file.ts:42)"
minLevel?: LogLevel;
}
```
Browser/Node console with the appropriate method per level (TRACE → debug,
FATAL → error).
### `httpTransport({ url, headers?, minLevel? })`
Fire-and-forget POST to an arbitrary endpoint. No retry/batching built in —
use it for prototypes or wrap it for production.
### `callbackTransport(fn, options?)`
Factory that builds a `Transport` out of a callback. Structurally equivalent
to `logger.subscribe(fn)` but lets you pre-register it in
`LoggerOptions.transports` and configure `minLevel`, `filter`, `name`.
---
## Adapters
All adapters follow the same pattern: **receive the third-party SDK as an
injected parameter**. logr itself never imports `@sentry/*`, `@datadog/*`,
etc. — callers install those packages and pass the already-initialized SDK.
This keeps logr zero-dep and makes tests trivial (mock the SDK with a plain
object).
### Sentry — `sentryTransport(SentrySDK, options?)`
```ts
import * as Sentry from '@sentry/browser';
import { sentryTransport } from '$logr/adapters/sentry';
Sentry.init({ dsn: '<your-dsn>', environment: 'prod' });
logger.addTransport(sentryTransport(Sentry, {
minLevel: LogLevel.ERROR, // @default ERROR
breadcrumbLevel: LogLevel.INFO // @default INFO
}));
```
- `ERROR+` → `captureException(entry.error)` if present, otherwise
`captureMessage(entry.message, severity)`.
- `[breadcrumbLevel, minLevel)` → `addBreadcrumb(...)` attached to the next
captured event.
- `< breadcrumbLevel` → ignored.
- Every event passes through `Sentry.withScope()` to attach tags
(`category`, `traceId`, `tag:x`), contexts (`log.context`, `log.source`,
`log.timing`) without polluting the global scope.
### Datadog — `datadogTransport(ddLogger, options?)`
```ts
import { datadogLogs } from '@datadog/browser-logs';
import { datadogTransport } from '$logr/adapters/datadog';
datadogLogs.init({
clientToken: 'pubXXXXXXXXXX',
site: 'datadoghq.eu',
service: 'my-app',
env: 'prod',
version: '1.0.0'
});
logger.addTransport(datadogTransport(datadogLogs.logger, { minLevel: LogLevel.WARN }));
```
Level mapping: TRACE/DEBUG → `debug`, INFO → `info`, WARN → `warn`,
ERROR/FATAL → `error`. Original level preserved in `messageContext.logLevel`.
Uses reserved-safe names (`logTags`, `sourceLocation`, `durationMs`) to avoid
Datadog's reserved attributes (`source`, `duration`, `tags`).
### Logtail (Better Stack) — `logtailTransport(logtailInstance, options?)`
```ts
import { Logtail } from '@logtail/browser';
import { logtailTransport } from '$logr/adapters/logtail';
const lt = new Logtail('<source-token>');
logger.addTransport(logtailTransport(lt, { minLevel: LogLevel.INFO }));
```
### Grafana Loki — `lokiTransport(options)`
No SDK required — pure HTTP POST to the Loki push endpoint.
```ts
import { lokiTransport } from '$logr/adapters/loki';
logger.addTransport(lokiTransport({
url: 'https://logs-prod-006.grafana.net/loki/api/v1/push',
labels: { app: 'my-app', env: 'prod' }, // low-cardinality only!
basicAuthUser: '123456',
basicAuthPassword: 'eyJhbG...',
tenantId: 'my-tenant' // X-Scope-OrgID header
}));
```
The **level** goes into the stream labels (low cardinality, safe). Everything
else (context, tags, traceId, error) is JSON-encoded in the log line so you
can query with LogQL:
`{app="my-app"} | json | traceId="req-1"`.
### OpenTelemetry — `otelTransport(otelLogger, options?)`
```ts
import { logs } from '@opentelemetry/api-logs';
import { LoggerProvider } from '@opentelemetry/sdk-logs';
import { otelTransport } from '$logr/adapters/otel';
const provider = new LoggerProvider({ /* ... */ });
logs.setGlobalLoggerProvider(provider);
const otelLogger = logs.getLogger('my-app');
logger.addTransport(otelTransport(otelLogger));
```
Maps `LogLevel` to OTel severityNumber: TRACE=1, DEBUG=5, INFO=9, WARN=13,
ERROR=17, FATAL=21 (base of each 4-number band per the OTel spec).
Structured context, tags, source and error flatten into OTel attributes
using dotted keys (`log.context.userId`, `code.filepath`, `exception.type`,
etc.).
---
## Patterns
### Sentry + breadcrumbs + metrics
```ts
// Events in Sentry (ERROR+) and breadcrumbs (INFO/WARN)
logger.addTransport(sentryTransport(Sentry));
// Counter of errors
logger.subscribe((entry) => {
if (entry.level >= LogLevel.ERROR) metrics.errorsPerMinute.inc();
});
```
### Scoped child for a request
```ts
export async function handle(request: Request) {
const reqLog = logger.child({
requestId: crypto.randomUUID(),
path: new URL(request.url).pathname
});
reqLog.info('http', 'incoming request');
try {
return await handler(request, reqLog);
} catch (err) {
reqLog.error('http', 'handler threw', { error: err });
throw err;
}
}
```
### Feature-flagged transport
```ts
if (flags.enableRemoteLogs) {
const detach = logger.addTransport(httpTransport({ url: '...' }));
onFlagDisable(() => detach());
}
```
---
## Tests
```bash
npx vitest run --project server
```
Covers 171 cases: level filtering, lazy messages, globalContext merging,
error extraction, tags/traceId, time/timeEnd, child semantics, getLogs
filters, dynamic add/remove, per-transport minLevel/filter, sync and async
failure routing with `deniedFor`, cascade guard, serialize, plus adapter
unit tests (Sentry, Datadog, Logtail, Loki, OpenTelemetry) using mocked SDKs.

@ -0,0 +1,444 @@
/**
* Adapter test suite — sentry, datadog, logtail, loki, otel.
*
* All adapters use duck-typed SDK injection so the tests mock the SDK with
* plain objects/spies. No real network calls are made.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LogLevel, createEngineLogger, type LogEntry } from '../index.ts';
import { sentryTransport, type SentryLike, type SentryScopeLike } from './sentry.ts';
import { datadogTransport, type DatadogLoggerLike } from './datadog.ts';
import { logtailTransport, type LogtailLike } from './logtail.ts';
import { lokiTransport } from './loki.ts';
import { otelTransport, type OtelLoggerLike, type OtelLogRecord } from './otel.ts';
// ============================================================================
// SENTRY
// ============================================================================
function mockSentry(): {
sentry: SentryLike;
calls: {
captureException: unknown[];
captureMessage: { msg: string; level?: string }[];
breadcrumbs: { message?: string; level?: string; category?: string }[];
tags: Record<string, string>;
contexts: Record<string, Record<string, unknown> | null>;
};
} {
const calls = {
captureException: [] as unknown[],
captureMessage: [] as { msg: string; level?: string }[],
breadcrumbs: [] as { message?: string; level?: string; category?: string }[],
tags: {} as Record<string, string>,
contexts: {} as Record<string, Record<string, unknown> | null>
};
const scope: SentryScopeLike = {
setTag(key, value) {
calls.tags[key] = value;
},
setContext(name, context) {
calls.contexts[name] = context;
}
};
const sentry: SentryLike = {
captureException(err) {
calls.captureException.push(err);
},
captureMessage(msg, level) {
calls.captureMessage.push({ msg, level });
},
addBreadcrumb(bc) {
calls.breadcrumbs.push(bc);
},
withScope(cb) {
cb(scope);
}
};
return { sentry, calls };
}
describe('sentryTransport', () => {
it('routes INFO to breadcrumb', () => {
const { sentry, calls } = mockSentry();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(sentryTransport(sentry));
log.info('auth', 'Login attempt');
expect(calls.breadcrumbs).toHaveLength(1);
expect(calls.breadcrumbs[0].message).toBe('Login attempt');
expect(calls.breadcrumbs[0].level).toBe('info');
expect(calls.captureMessage).toHaveLength(0);
});
it('routes ERROR without error to captureMessage', () => {
const { sentry, calls } = mockSentry();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(sentryTransport(sentry));
log.error('auth', 'Login failed');
expect(calls.captureMessage).toHaveLength(1);
expect(calls.captureMessage[0]).toEqual({ msg: 'Login failed', level: 'error' });
});
it('routes ERROR with error to captureException', () => {
const { sentry, calls } = mockSentry();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(sentryTransport(sentry));
const err = new Error('DB down');
log.error('db', 'Query failed', { error: err });
expect(calls.captureException).toHaveLength(1);
const captured = calls.captureException[0] as Error;
expect(captured.message).toBe('DB down');
expect(captured.name).toBe('Error');
});
it('routes FATAL to captureMessage with "fatal" severity', () => {
const { sentry, calls } = mockSentry();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(sentryTransport(sentry));
log.fatal('kernel', 'Unrecoverable');
expect(calls.captureMessage[0].level).toBe('fatal');
});
it('attaches category/traceId/tags as scope tags', () => {
const { sentry, calls } = mockSentry();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(sentryTransport(sentry));
log.error('auth', 'fail', { traceId: 'req-1', tags: ['login', 'oauth'] });
expect(calls.tags.category).toBe('auth');
expect(calls.tags.traceId).toBe('req-1');
expect(calls.tags['tag:login']).toBe('true');
expect(calls.tags['tag:oauth']).toBe('true');
});
it('attaches context and source as scope contexts', () => {
const { sentry, calls } = mockSentry();
const log = createEngineLogger({
level: LogLevel.TRACE,
globalContext: { appVersion: '1.0' },
transports: []
});
log.addTransport(sentryTransport(sentry));
log.error('t', 'fail', { context: { userId: 42 } });
expect(calls.contexts['log.context']).toEqual({ appVersion: '1.0', userId: 42 });
});
it('does not emit below breadcrumbLevel', () => {
const { sentry, calls } = mockSentry();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(sentryTransport(sentry, { breadcrumbLevel: LogLevel.INFO }));
log.debug('t', 'skipped');
log.trace('t', 'skipped');
expect(calls.breadcrumbs).toHaveLength(0);
expect(calls.captureMessage).toHaveLength(0);
});
});
// ============================================================================
// DATADOG
// ============================================================================
function mockDatadog(): {
dd: DatadogLoggerLike;
calls: { level: string; message: string; context: unknown; error?: Error }[];
} {
const calls: { level: string; message: string; context: unknown; error?: Error }[] = [];
const dd: DatadogLoggerLike = {
debug(msg, ctx) {
calls.push({ level: 'debug', message: msg, context: ctx });
},
info(msg, ctx) {
calls.push({ level: 'info', message: msg, context: ctx });
},
warn(msg, ctx) {
calls.push({ level: 'warn', message: msg, context: ctx });
},
error(msg, ctx, err) {
calls.push({ level: 'error', message: msg, context: ctx, error: err });
}
};
return { dd, calls };
}
describe('datadogTransport', () => {
it('maps TRACE/DEBUG to debug, FATAL/ERROR to error', () => {
const { dd, calls } = mockDatadog();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
log.trace('t', 'a');
log.debug('t', 'b');
log.info('t', 'c');
log.warn('t', 'd');
log.error('t', 'e');
log.fatal('t', 'f');
expect(calls.map((c) => c.level)).toEqual(['debug', 'debug', 'info', 'warn', 'error', 'error']);
});
it('preserves original level in messageContext.logLevel', () => {
const { dd, calls } = mockDatadog();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
log.fatal('t', 'boom');
const ctx = calls[0].context as Record<string, unknown>;
expect(ctx.logLevel).toBe('FATAL');
});
it('uses reserved-safe names (logTags, sourceLocation, durationMs)', () => {
const { dd, calls } = mockDatadog();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
log.info('t', 'hi', {
tags: ['x'],
traceId: 'r1',
durationMs: 42
});
const ctx = calls[0].context as Record<string, unknown>;
expect(ctx.logTags).toEqual(['x']);
expect(ctx.traceId).toBe('r1');
expect(ctx.durationMs).toBe(42);
});
it('passes reconstructed Error to dd.error', () => {
const { dd, calls } = mockDatadog();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
log.error('t', 'fail', { error: new Error('boom') });
expect(calls[0].error).toBeInstanceOf(Error);
expect(calls[0].error?.message).toBe('boom');
});
});
// ============================================================================
// LOGTAIL
// ============================================================================
function mockLogtail(): {
lt: LogtailLike;
calls: { level: string; message: string; context: unknown }[];
} {
const calls: { level: string; message: string; context: unknown }[] = [];
const lt: LogtailLike = {
debug(msg, ctx) {
calls.push({ level: 'debug', message: msg, context: ctx });
},
info(msg, ctx) {
calls.push({ level: 'info', message: msg, context: ctx });
},
warn(msg, ctx) {
calls.push({ level: 'warn', message: msg, context: ctx });
},
error(msg, ctx) {
calls.push({ level: 'error', message: msg, context: ctx });
}
};
return { lt, calls };
}
describe('logtailTransport', () => {
it('maps levels correctly', () => {
const { lt, calls } = mockLogtail();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE }));
log.trace('t', 'a');
log.debug('t', 'b');
log.info('t', 'c');
log.warn('t', 'd');
log.error('t', 'e');
log.fatal('t', 'f');
expect(calls.map((c) => c.level)).toEqual(['debug', 'debug', 'info', 'warn', 'error', 'error']);
});
it('includes logLevel, category and error in context', () => {
const { lt, calls } = mockLogtail();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE }));
log.error('auth', 'fail', { error: new Error('boom') });
const ctx = calls[0].context as Record<string, unknown>;
expect(ctx.category).toBe('auth');
expect(ctx.logLevel).toBe('ERROR');
expect((ctx.error as { message: string }).message).toBe('boom');
});
});
// ============================================================================
// LOKI
// ============================================================================
describe('lokiTransport', () => {
let originalFetch: typeof global.fetch;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
originalFetch = global.fetch;
fetchMock = vi.fn(async () => new Response('', { status: 204 }));
global.fetch = fetchMock as unknown as typeof global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
it('POSTs to /loki/api/v1/push with stream labels and JSON line', async () => {
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(
lokiTransport({
url: 'https://loki.example.com/loki/api/v1/push',
labels: { app: 'test' },
minLevel: LogLevel.TRACE
})
);
log.info('auth', 'hi');
await new Promise((r) => setTimeout(r, 10));
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://loki.example.com/loki/api/v1/push');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body as string) as {
streams: { stream: Record<string, string>; values: [string, string][] }[];
};
expect(body.streams[0].stream).toEqual({ app: 'test', level: 'info', category: 'auth' });
const line = JSON.parse(body.streams[0].values[0][1]) as { message: string; level: string };
expect(line.message).toBe('hi');
expect(line.level).toBe('INFO');
});
it('builds basic auth header from user/password', async () => {
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(
lokiTransport({
url: 'https://loki.example.com/push',
basicAuthUser: 'user',
basicAuthPassword: 'pass',
minLevel: LogLevel.TRACE
})
);
log.info('t', 'x');
await new Promise((r) => setTimeout(r, 10));
const init = fetchMock.mock.calls[0][1] as RequestInit;
const headers = init.headers as Record<string, string>;
const expected = `Basic ${btoa('user:pass')}`;
expect(headers.Authorization).toBe(expected);
});
it('sends X-Scope-OrgID header when tenantId is provided', async () => {
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(
lokiTransport({
url: 'https://loki.example.com/push',
tenantId: 'tenant-1',
minLevel: LogLevel.TRACE
})
);
log.info('t', 'x');
await new Promise((r) => setTimeout(r, 10));
const init = fetchMock.mock.calls[0][1] as RequestInit;
const headers = init.headers as Record<string, string>;
expect(headers['X-Scope-OrgID']).toBe('tenant-1');
});
it('rejects on non-2xx response (routed to deniedFor)', async () => {
fetchMock.mockResolvedValueOnce(new Response('bad', { status: 500 }));
const seen: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({ name: 'capture', write: (e) => seen.push(e) });
log.addTransport(
lokiTransport({ url: 'https://loki.example.com/push', minLevel: LogLevel.TRACE })
);
log.info('t', 'hi');
await new Promise((r) => setTimeout(r, 30));
const failure = seen.find((e) => e.deniedFor !== undefined);
expect(failure).toBeDefined();
expect(failure?.deniedFor).toEqual(['loki']);
});
});
// ============================================================================
// OPENTELEMETRY
// ============================================================================
function mockOtelLogger(): {
otelLogger: OtelLoggerLike;
emitted: OtelLogRecord[];
} {
const emitted: OtelLogRecord[] = [];
const otelLogger: OtelLoggerLike = {
emit(record) {
emitted.push(record);
}
};
return { otelLogger, emitted };
}
describe('otelTransport', () => {
it('maps levels to OTel severityNumber (1/5/9/13/17/21)', () => {
const { otelLogger, emitted } = mockOtelLogger();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
log.trace('t', 'a');
log.debug('t', 'b');
log.info('t', 'c');
log.warn('t', 'd');
log.error('t', 'e');
log.fatal('t', 'f');
expect(emitted.map((r) => r.severityNumber)).toEqual([1, 5, 9, 13, 17, 21]);
});
it('sets severityText to the level name', () => {
const { otelLogger, emitted } = mockOtelLogger();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
log.info('t', 'hi');
expect(emitted[0].severityText).toBe('INFO');
});
it('flattens context as log.context.* attributes', () => {
const { otelLogger, emitted } = mockOtelLogger();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
log.info('auth', 'hi', { context: { userId: 42, region: 'eu' } });
const attrs = emitted[0].attributes ?? {};
expect(attrs['log.category']).toBe('auth');
expect(attrs['log.context.userId']).toBe(42);
expect(attrs['log.context.region']).toBe('eu');
});
it('maps error fields to exception.* attributes', () => {
const { otelLogger, emitted } = mockOtelLogger();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
log.error('t', 'fail', { error: new Error('boom') });
const attrs = emitted[0].attributes ?? {};
expect(attrs['exception.type']).toBe('Error');
expect(attrs['exception.message']).toBe('boom');
expect(attrs['exception.stacktrace']).toBeDefined();
});
});

@ -0,0 +1,100 @@
import type { LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
/**
* Duck-typed subset of the `@datadog/browser-logs` SDK used by the transport.
* The caller injects the already-initialized SDK (normally `datadogLogs.logger`).
*
* @example
* import { datadogLogs } from '@datadog/browser-logs';
* datadogLogs.init({ clientToken: '...', site: 'datadoghq.eu' });
* logger.addTransport(datadogTransport(datadogLogs.logger));
*/
export interface DatadogLoggerLike {
debug(message: string, messageContext?: Record<string, unknown>): void;
info(message: string, messageContext?: Record<string, unknown>): void;
warn(message: string, messageContext?: Record<string, unknown>): void;
error(message: string, messageContext?: Record<string, unknown>, error?: Error): void;
}
export interface DatadogTransportOptions {
/** @default LogLevel.INFO — INFO/WARN/ERROR/FATAL reach the backend */
minLevel?: LogLevel;
/** @default 'datadog' */
name?: string;
filter?: (entry: LogEntry) => boolean;
}
/**
* Transport that forwards entries to the `@datadog/browser-logs` SDK.
*
* The Datadog SDK handles batching, retries and throttling internally. This
* adapter maps levels to the SDK shortcuts and serializes the structured
* context into `messageContext`. Level mapping:
* - TRACE, DEBUG → `debug`
* - INFO → `info`
* - WARN → `warn`
* - ERROR, FATAL → `error`
*
* The original level is preserved in `messageContext.logLevel` for
* downstream routing/alerts.
*
* @example
* import { datadogLogs } from '@datadog/browser-logs';
* datadogLogs.init({
* clientToken: 'pubXXXXXXXXXX',
* site: 'datadoghq.eu',
* service: 'my-app',
* env: 'prod',
* version: '1.0.0'
* });
*
* logger.addTransport(datadogTransport(datadogLogs.logger, { minLevel: LogLevel.WARN }));
*/
export function datadogTransport(
dd: DatadogLoggerLike,
options: DatadogTransportOptions = {}
): Transport {
return {
name: options.name ?? 'datadog',
minLevel: options.minLevel ?? LogLevel.INFO,
filter: options.filter,
write(entry: LogEntry): void {
// `source`, `duration`, `tags` are Datadog-reserved attributes — we
// use suffixed names to avoid collisions.
const messageContext: Record<string, unknown> = {
category: entry.category,
logLevel: LogLevel[entry.level],
...(entry.context ?? {})
};
if (entry.tags?.length) messageContext.logTags = entry.tags;
if (entry.traceId) messageContext.traceId = entry.traceId;
if (entry.durationMs !== undefined) messageContext.durationMs = entry.durationMs;
if (entry.source) messageContext.sourceLocation = entry.source;
switch (entry.level) {
case LogLevel.TRACE:
case LogLevel.DEBUG:
dd.debug(entry.message, messageContext);
break;
case LogLevel.INFO:
dd.info(entry.message, messageContext);
break;
case LogLevel.WARN:
dd.warn(entry.message, messageContext);
break;
case LogLevel.ERROR:
case LogLevel.FATAL:
if (entry.error) {
const reconstructed = new Error(entry.error.message);
reconstructed.name = entry.error.name;
if (entry.error.stack) reconstructed.stack = entry.error.stack;
dd.error(entry.message, messageContext, reconstructed);
} else {
dd.error(entry.message, messageContext);
}
break;
}
}
};
}

@ -0,0 +1,77 @@
import type { LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
/**
* Duck-typed subset of the `@logtail/browser` SDK (Better Stack).
*
* @example
* import { Logtail } from '@logtail/browser';
* const lt = new Logtail('source-token');
* logger.addTransport(logtailTransport(lt));
*/
export interface LogtailLike {
debug(message: string, context?: Record<string, unknown>): void;
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
}
export interface LogtailTransportOptions {
/** @default LogLevel.INFO */
minLevel?: LogLevel;
/** @default 'logtail' */
name?: string;
filter?: (entry: LogEntry) => boolean;
}
/**
* Transport that forwards entries to Logtail (Better Stack).
*
* The Logtail SDK handles batching, retries and fingerprinting internally.
* Level mapping:
* - TRACE, DEBUG → `debug`
* - INFO → `info`
* - WARN → `warn`
* - ERROR, FATAL → `error`
*
* The original level is preserved in `context.logLevel` for downstream routing.
*/
export function logtailTransport(
lt: LogtailLike,
options: LogtailTransportOptions = {}
): Transport {
return {
name: options.name ?? 'logtail',
minLevel: options.minLevel ?? LogLevel.INFO,
filter: options.filter,
write(entry: LogEntry): void {
const context: Record<string, unknown> = {
category: entry.category,
logLevel: LogLevel[entry.level],
...(entry.context ?? {})
};
if (entry.tags?.length) context.logTags = entry.tags;
if (entry.traceId) context.traceId = entry.traceId;
if (entry.durationMs !== undefined) context.durationMs = entry.durationMs;
if (entry.source) context.sourceLocation = entry.source;
if (entry.error) context.error = entry.error;
switch (entry.level) {
case LogLevel.TRACE:
case LogLevel.DEBUG:
lt.debug(entry.message, context);
break;
case LogLevel.INFO:
lt.info(entry.message, context);
break;
case LogLevel.WARN:
lt.warn(entry.message, context);
break;
case LogLevel.ERROR:
case LogLevel.FATAL:
lt.error(entry.message, context);
break;
}
}
};
}

@ -0,0 +1,153 @@
import type { LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
export interface LokiTransportOptions {
/** Absolute URL of the push endpoint. Typically `https://<host>/loki/api/v1/push`. */
url: string;
/**
* Labels that identify the stream in Loki. Labels drive indexing and
* filtering — keep their cardinality low (never user ids, trace ids, etc.;
* those belong in the log line as JSON).
*
* @example { app: 'my-app', env: 'prod', source: 'browser' }
*/
labels?: Record<string, string>;
/** Extra headers — useful for auth. */
headers?: Record<string, string>;
/** Basic auth user. Equivalent to `Authorization: Basic base64(user:pass)`. */
basicAuthUser?: string;
basicAuthPassword?: string;
/** @default LogLevel.INFO */
minLevel?: LogLevel;
/** @default 'loki' */
name?: string;
filter?: (entry: LogEntry) => boolean;
/**
* Grafana Cloud / multi-tenant Loki tenant id. Sent as
* `X-Scope-OrgID` header.
*/
tenantId?: string;
}
/**
* Transport that POSTs entries to the Grafana Loki push endpoint.
*
* - No SDK — plain `fetch` to `/loki/api/v1/push`.
* - The level is attached as a `level` label in the stream (low cardinality,
* safe).
* - The rest of the structured context is serialized as JSON in the log line,
* allowing LogQL queries like `{app="my-app"} | json | traceId="req-1"`.
* - Fire-and-forget from the caller's perspective — network failures bubble
* up through the promise, which the logger routes via `deniedFor`.
*
* @example
* logger.addTransport(lokiTransport({
* url: 'https://logs-prod-006.grafana.net/loki/api/v1/push',
* labels: { app: 'my-app', env: 'prod' },
* basicAuthUser: '123456',
* basicAuthPassword: 'eyJhbG...',
* tenantId: 'fake'
* }));
*/
export function lokiTransport(options: LokiTransportOptions): Transport {
const baseLabels = options.labels ?? {};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers
};
if (options.basicAuthUser !== undefined) {
const credentials = btoa(`${options.basicAuthUser}:${options.basicAuthPassword ?? ''}`);
headers.Authorization = `Basic ${credentials}`;
}
if (options.tenantId) {
headers['X-Scope-OrgID'] = options.tenantId;
}
function serialize(entry: LogEntry): [string, string] {
const line = JSON.stringify({
id: entry.id,
message: entry.message,
level: LogLevel[entry.level],
...(entry.context ?? {}),
tags: entry.tags,
traceId: entry.traceId,
durationMs: entry.durationMs,
sourceLocation: entry.source,
error: entry.error
});
const ns = String(entry.timestamp.getTime()) + '000000';
return [ns, line];
}
async function push(streams: { stream: Record<string, string>; values: [string, string][] }[]): Promise<void> {
const res = await fetch(options.url, {
method: 'POST',
headers,
body: JSON.stringify({ streams })
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Loki push failed: ${res.status} ${res.statusText} ${body}`);
}
}
function streamKey(labels: Record<string, string>): string {
// Stable JSON: keys sorted so identical label sets collapse into one stream.
return JSON.stringify(labels, Object.keys(labels).sort());
}
return {
name: options.name ?? 'loki',
minLevel: options.minLevel ?? LogLevel.INFO,
filter: options.filter,
async write(entry: LogEntry): Promise<void> {
const streamLabels: Record<string, string> = {
...baseLabels,
level: levelLabel(entry.level),
category: entry.category
};
await push([{ stream: streamLabels, values: [serialize(entry)] }]);
},
async writeBatch(entries: LogEntry[]): Promise<void> {
// Group entries by their stream labels — Loki stores timeseries by
// label set, so batching is cheapest when entries sharing labels
// collapse into a single stream.
const grouped = new Map<string, { stream: Record<string, string>; values: [string, string][] }>();
for (const entry of entries) {
const labels: Record<string, string> = {
...baseLabels,
level: levelLabel(entry.level),
category: entry.category
};
const key = streamKey(labels);
let bucket = grouped.get(key);
if (!bucket) {
bucket = { stream: labels, values: [] };
grouped.set(key, bucket);
}
bucket.values.push(serialize(entry));
}
await push([...grouped.values()]);
}
};
}
function levelLabel(level: LogLevel): string {
switch (level) {
case LogLevel.TRACE:
return 'trace';
case LogLevel.DEBUG:
return 'debug';
case LogLevel.INFO:
return 'info';
case LogLevel.WARN:
return 'warn';
case LogLevel.ERROR:
return 'error';
case LogLevel.FATAL:
return 'fatal';
default:
return 'info';
}
}

@ -0,0 +1,114 @@
import type { LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
/**
* Duck-typed subset of the OpenTelemetry Logs API. The caller injects an
* already-configured OTel `Logger` (usually obtained from a `LoggerProvider`).
*
* @example
* import { logs } from '@opentelemetry/api-logs';
* import { LoggerProvider } from '@opentelemetry/sdk-logs';
* const provider = new LoggerProvider({ ... });
* logs.setGlobalLoggerProvider(provider);
* const otelLogger = logs.getLogger('my-app');
* logger.addTransport(otelTransport(otelLogger));
*/
export interface OtelLoggerLike {
emit(record: OtelLogRecord): void;
}
export interface OtelLogRecord {
/** OTel severityNumber. See spec: 1-4 trace, 5-8 debug, 9-12 info, 13-16 warn, 17-20 error, 21-24 fatal. */
severityNumber?: number;
/** Human-readable severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL). */
severityText?: string;
body: string;
attributes?: Record<string, unknown>;
/** Unix time in nanoseconds (or Date). */
timestamp?: number | Date;
}
export interface OtelTransportOptions {
/** @default LogLevel.INFO */
minLevel?: LogLevel;
/** @default 'otel' */
name?: string;
filter?: (entry: LogEntry) => boolean;
}
/**
* Transport that forwards entries to an OpenTelemetry `Logger`.
*
* Maps our `LogLevel` to the OTel severityNumber space defined in the spec
* (each level has 4 numbers available; we use the base value of each band):
* - TRACE → 1
* - DEBUG → 5
* - INFO → 9
* - WARN → 13
* - ERROR → 17
* - FATAL → 21
*
* Structured context, tags, traceId and error are flattened into OTel
* attributes using dotted keys (`log.context.userId`, `log.tags`, ...).
*/
export function otelTransport(
otelLogger: OtelLoggerLike,
options: OtelTransportOptions = {}
): Transport {
return {
name: options.name ?? 'otel',
minLevel: options.minLevel ?? LogLevel.INFO,
filter: options.filter,
write(entry: LogEntry): void {
const attributes: Record<string, unknown> = {
'log.category': entry.category
};
if (entry.context) {
for (const [key, value] of Object.entries(entry.context)) {
attributes[`log.context.${key}`] = value;
}
}
if (entry.tags?.length) attributes['log.tags'] = entry.tags;
if (entry.traceId) attributes['log.traceId'] = entry.traceId;
if (entry.durationMs !== undefined) attributes['log.durationMs'] = entry.durationMs;
if (entry.source) {
if (entry.source.file) attributes['code.filepath'] = entry.source.file;
if (entry.source.line !== undefined) attributes['code.lineno'] = entry.source.line;
if (entry.source.function) attributes['code.function'] = entry.source.function;
}
if (entry.error) {
attributes['exception.type'] = entry.error.name;
attributes['exception.message'] = entry.error.message;
if (entry.error.stack) attributes['exception.stacktrace'] = entry.error.stack;
}
otelLogger.emit({
severityNumber: toOtelSeverityNumber(entry.level),
severityText: LogLevel[entry.level],
body: entry.message,
attributes,
timestamp: entry.timestamp
});
}
};
}
function toOtelSeverityNumber(level: LogLevel): number {
switch (level) {
case LogLevel.TRACE:
return 1;
case LogLevel.DEBUG:
return 5;
case LogLevel.INFO:
return 9;
case LogLevel.WARN:
return 13;
case LogLevel.ERROR:
return 17;
case LogLevel.FATAL:
return 21;
default:
return 9;
}
}

@ -0,0 +1,149 @@
import type { LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
/**
* Duck-typed subset of the Sentry SDK used by the transport. The `engine_logger`
* library does not import `@sentry/browser` directly — the caller injects an
* already-initialized SDK. This keeps the logger zero-deps and makes testing
* with trivial mocks easy.
*/
export interface SentryLike {
captureException(err: unknown): void;
captureMessage(msg: string, level?: SentrySeverity): void;
addBreadcrumb(breadcrumb: {
message?: string;
level?: SentrySeverity;
category?: string;
data?: Record<string, unknown>;
timestamp?: number;
}): void;
withScope(cb: (scope: SentryScopeLike) => void): void;
}
export interface SentryScopeLike {
setTag(key: string, value: string): void;
setContext(name: string, context: Record<string, unknown> | null): void;
setFingerprint?(fingerprint: string[]): void;
}
export type SentrySeverity = 'debug' | 'info' | 'warning' | 'error' | 'fatal';
export interface SentryTransportOptions {
/**
* Entries at or above `minLevel` become Sentry events (captureException/
* captureMessage).
* @default LogLevel.ERROR
*/
minLevel?: LogLevel;
/**
* Entries in `[breadcrumbLevel, minLevel)` become breadcrumbs. Breadcrumbs
* are context attached to the next captured event. Pass `LogLevel.NONE` to
* disable them entirely.
* @default LogLevel.INFO
*/
breadcrumbLevel?: LogLevel;
/** Transport name shown in introspection and `deniedFor`. @default 'sentry' */
name?: string;
/** Extra predicate. When it returns `false`, the entry is skipped entirely. */
filter?: (entry: LogEntry) => boolean;
}
/**
* Transport that forwards entries to the Sentry SDK.
*
* - ERROR+ → `captureException` when `entry.error` is set, `captureMessage`
* otherwise.
* - INFO..WARN → `addBreadcrumb` (attached to the next captured event).
* - DEBUG/TRACE → `addBreadcrumb` with severity 'debug' (only if
* `breadcrumbLevel` is low enough).
*
* Every event passes through `withScope` so that tags, traceId and context
* are attached without polluting Sentry's global scope.
*
* @example
* import * as Sentry from '@sentry/browser';
* Sentry.init({ dsn: '...' });
*
* const logger = createEngineLogger({ level: LogLevel.DEBUG });
* logger.addTransport(sentryTransport(Sentry));
*
* logger.info('auth', 'Login attempt'); // breadcrumb
* logger.error('auth', 'Login failed', { error: err }); // event + breadcrumbs
*/
export function sentryTransport(
sentry: SentryLike,
options: SentryTransportOptions = {}
): Transport {
const minLevel = options.minLevel ?? LogLevel.ERROR;
const breadcrumbLevel = options.breadcrumbLevel ?? LogLevel.INFO;
const name = options.name ?? 'sentry';
return {
name,
// Intentionally no `transport.minLevel` here — we need to receive lower
// levels too so they can become breadcrumbs.
filter: options.filter,
write(entry: LogEntry): void {
if (entry.level < breadcrumbLevel) return;
if (entry.level < minLevel) {
sentry.addBreadcrumb({
message: entry.message,
level: toSentrySeverity(entry.level),
category: entry.category,
data: entry.context,
timestamp: entry.timestamp.getTime() / 1000
});
return;
}
sentry.withScope((scope) => {
scope.setTag('category', entry.category);
if (entry.traceId) scope.setTag('traceId', entry.traceId);
if (entry.tags?.length) {
for (const t of entry.tags) scope.setTag(`tag:${t}`, 'true');
}
if (entry.context) scope.setContext('log.context', entry.context);
if (entry.source) {
scope.setContext(
'log.source',
entry.source as unknown as Record<string, unknown>
);
}
if (entry.durationMs !== undefined) {
scope.setContext('log.timing', { durationMs: entry.durationMs });
}
if (entry.error) {
const reconstructed = new Error(entry.error.message);
reconstructed.name = entry.error.name;
if (entry.error.stack) reconstructed.stack = entry.error.stack;
if (entry.error.cause !== undefined) {
(reconstructed as { cause?: unknown }).cause = entry.error.cause;
}
sentry.captureException(reconstructed);
} else {
sentry.captureMessage(entry.message, toSentrySeverity(entry.level));
}
});
}
};
}
function toSentrySeverity(level: LogLevel): SentrySeverity {
switch (level) {
case LogLevel.TRACE:
case LogLevel.DEBUG:
return 'debug';
case LogLevel.INFO:
return 'info';
case LogLevel.WARN:
return 'warning';
case LogLevel.ERROR:
return 'error';
case LogLevel.FATAL:
return 'fatal';
default:
return 'info';
}
}

@ -0,0 +1,10 @@
/**
* Runtime identifier of the logging engine. Used for:
* - `[engine_logger]` prefix in console output
* - Category of internal entries (transport failures, etc.)
* - `engine_logger` tag on synthetic entries
*
* Independent of the folder/alias name (`logr`, `$logr`): the directory is a
* filesystem detail; this identifier describes the engine itself.
*/
export const logger = 'engine_logger';

@ -0,0 +1,466 @@
import type {
LoggerOptions,
LogEntry,
LogInput,
LogMessage,
MessageCategory,
LogFilters,
EngineLogger,
Transport,
SubscriberFn,
LogSource
} from './types.ts';
import { LogLevel } from './types.ts';
import { logger } from './consts.ts';
import { consoleTransport } from './transports.ts';
import { captureSource, extractError } from './source.ts';
/**
* Unique entry id. Uses `crypto.randomUUID()` when available (modern browsers,
* Node 16+); falls back to a compact `timestamp-random` hex in older hosts.
*/
function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
const DEV: boolean =
typeof import.meta !== 'undefined' && import.meta.env != null
? import.meta.env.DEV === true
: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
// ============================================================================
// ENGINE
// ============================================================================
/**
* Create an `EngineLogger` instance.
*
* **Decoupled from i18n** — messages are plain strings. If you need
* translated log lines, resolve them at the call site:
* `logger.info('auth', lang.t('login.ok'))`
*
* Features:
* - `globalContext` merged into every entry (appVersion, sessionId, userId…).
* - `source` auto-captured from the stack in DEV (`file:line:col`).
* - `error` normalized to a serializable `LogError` when passed in `input.error`.
* - `traceId` and `tags` for correlation and fine-grained filtering.
* - `child(ctx)` — child logger with extended context, shares history/transports.
* - `time(label)` / `timeEnd(label)` — duration measurement.
* - `addTransport(t)` / `subscribe(fn)` — dynamic bus (Sentry, metrics, etc.).
* - Per-transport `filter` and `minLevel` — e.g. Sentry only ERROR+.
* - Lazy messages via thunks — not evaluated if filtered by global level.
*
* @example
* const log = createEngineLogger({
* level: LogLevel.DEBUG,
* globalContext: { appVersion: '1.2.3', env: 'prod' },
* transports: [consoleTransport()]
* });
*
* // Sentry as a dynamic transport
* const detachSentry = log.addTransport({
* name: 'sentry',
* minLevel: LogLevel.ERROR,
* write: (entry) => {
* if (entry.error) Sentry.captureException(entry.error);
* else Sentry.captureMessage(entry.message);
* }
* });
*
* // Pure observer via subscribe
* log.subscribe((entry) => {
* if (entry.level < LogLevel.ERROR) Sentry.addBreadcrumb({ message: entry.message });
* });
*
* try { ... } catch (err) {
* log.error('db', 'Query failed', { error: err, traceId: 'req-1' });
* }
*/
export function createEngineLogger(options: LoggerOptions = {}): EngineLogger {
const captureSourceEnabled: boolean = options.captureSource ?? DEV;
const timers = new Map<string, number>();
// Per-transport buffers and interval timer ids — indexed by reference.
const buffers = new WeakMap<Transport, LogEntry[]>();
const flushTimers = new WeakMap<Transport, ReturnType<typeof setTimeout>>();
// Mutable state shared between root logger and its children.
const state = {
level: options.level ?? LogLevel.WARN,
maxLogs: options.maxLogs ?? 1000,
entries: [] as LogEntry[],
transports: [...(options.transports ?? [consoleTransport()])]
};
const built = buildLogger(
captureSourceEnabled,
timers,
buffers,
flushTimers,
state,
{ ...options.globalContext }
);
// Browser: flush everything before the page unloads so buffered entries
// get a chance to reach their destinations.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('beforeunload', () => built.flush());
}
return built;
}
interface LoggerState {
level: LogLevel;
maxLogs: number;
entries: LogEntry[];
transports: Transport[];
}
function buildLogger(
captureSourceEnabled: boolean,
timers: Map<string, number>,
buffers: WeakMap<Transport, LogEntry[]>,
flushTimers: WeakMap<Transport, ReturnType<typeof setTimeout>>,
state: LoggerState,
globalContext: Record<string, unknown>
): EngineLogger {
function mergeContext(input?: LogInput): Record<string, unknown> | undefined {
const inputCtx = input?.context;
const hasGlobal = Object.keys(globalContext).length > 0;
if (!inputCtx && !hasGlobal) return undefined;
return { ...globalContext, ...inputCtx };
}
function resolveMessage(message: LogMessage): string {
if (typeof message === 'function') {
try {
return message();
} catch (err) {
return `[${logger}] thunk threw: ${err instanceof Error ? err.message : String(err)}`;
}
}
return message;
}
function log(
lvl: LogLevel,
category: MessageCategory,
message: LogMessage,
input: LogInput | undefined,
skipFrames: number
): void {
// Global level check first: if dropped, skip thunk evaluation and source capture.
if (lvl < state.level) return;
const resolved = resolveMessage(message);
const source: LogSource | undefined =
input?.source ?? (captureSourceEnabled ? captureSource(skipFrames) : undefined);
const entry: LogEntry = {
id: generateId(),
timestamp: new Date(),
level: lvl,
category,
message: resolved,
context: mergeContext(input),
error: input?.error !== undefined ? extractError(input.error) : undefined,
tags: input?.tags,
traceId: input?.traceId,
durationMs: input?.durationMs,
source
};
pushEntry(entry);
dispatch(entry, undefined);
}
function pushEntry(entry: LogEntry): void {
state.entries.push(entry);
if (state.entries.length > state.maxLogs) {
state.entries = state.entries.slice(-state.maxLogs);
}
}
/**
* Dispatch an entry to the active transports.
*
* - `skipTransport` excludes a specific transport by reference (used when
* re-injecting a failure entry so the transport that failed does not
* receive the report of its own error — prevents cascades).
* - Sync throws and promise rejections are routed to the same failure handler,
* except when the entry is already a failure report (`deniedFor` set):
* a second-level failure is swallowed to guarantee no cascade loop.
* - If `transport.buffer > 0`, entries are queued and flushed when the
* buffer is full or the interval timer fires.
*/
function dispatch(entry: LogEntry, skipTransport: Transport | undefined): void {
const isFailureReport = entry.deniedFor !== undefined;
for (const transport of state.transports) {
if (transport === skipTransport) continue;
if (transport.minLevel !== undefined && entry.level < transport.minLevel) continue;
if (transport.filter && !transport.filter(entry)) continue;
const bufferSize = transport.buffer ?? 0;
if (bufferSize <= 0) {
writeOne(transport, entry, isFailureReport);
} else {
enqueue(transport, entry, bufferSize, isFailureReport);
}
}
}
function writeOne(transport: Transport, entry: LogEntry, isFailureReport: boolean): void {
try {
const ret = transport.write(entry);
if (ret && typeof ret.then === 'function') {
ret.catch((err: unknown) => handleFailure(transport, err, isFailureReport));
}
} catch (err) {
handleFailure(transport, err, isFailureReport);
}
}
function enqueue(
transport: Transport,
entry: LogEntry,
maxSize: number,
isFailureReport: boolean
): void {
let buf = buffers.get(transport);
if (!buf) {
buf = [];
buffers.set(transport, buf);
}
buf.push(entry);
// Arm the time-based flush timer on the first enqueue of the cycle.
if (
transport.flushIntervalMs !== undefined &&
transport.flushIntervalMs > 0 &&
!flushTimers.has(transport)
) {
const id = setTimeout(() => flushTransport(transport, isFailureReport), transport.flushIntervalMs);
flushTimers.set(transport, id);
}
if (buf.length >= maxSize) {
flushTransport(transport, isFailureReport);
}
}
function flushTransport(transport: Transport, isFailureReport: boolean): void {
const buf = buffers.get(transport);
if (!buf || buf.length === 0) {
clearFlushTimer(transport);
return;
}
// Take ownership of the buffer and clear the pending timer.
buffers.delete(transport);
clearFlushTimer(transport);
if (transport.writeBatch) {
try {
const ret = transport.writeBatch(buf);
if (ret && typeof ret.then === 'function') {
ret.catch((err: unknown) => handleFailure(transport, err, isFailureReport));
}
} catch (err) {
handleFailure(transport, err, isFailureReport);
}
} else {
for (const entry of buf) writeOne(transport, entry, isFailureReport);
}
}
function clearFlushTimer(transport: Transport): void {
const id = flushTimers.get(transport);
if (id !== undefined) {
clearTimeout(id);
flushTimers.delete(transport);
}
}
function flushAll(): void {
for (const transport of state.transports) {
flushTransport(transport, false);
}
}
function handleFailure(
failed: Transport,
err: unknown,
suppressFailureReport: boolean
): void {
const failedName = failed.name ?? 'anonymous';
console.error(`[${logger}] Transport "${failedName}" error:`, err);
// Cascade guard: if the failure happens while re-dispatching a failure
// entry, do not synthesize a second report. Console-only.
if (suppressFailureReport) return;
const errorObj = extractError(err);
const failureEntry: LogEntry = {
id: generateId(),
timestamp: new Date(),
level: LogLevel.ERROR,
category: logger,
message: `Transport "${failedName}" failed: ${errorObj.message}`,
context: mergeContext(undefined),
error: errorObj,
tags: [logger, 'transport-failure'],
deniedFor: [failedName]
};
pushEntry(failureEntry);
dispatch(failureEntry, failed);
}
return {
trace(category, message, input) {
log(LogLevel.TRACE, category, message, input, 3);
},
debug(category, message, input) {
log(LogLevel.DEBUG, category, message, input, 3);
},
info(category, message, input) {
log(LogLevel.INFO, category, message, input, 3);
},
warn(category, message, input) {
log(LogLevel.WARN, category, message, input, 3);
},
error(category, message, input) {
log(LogLevel.ERROR, category, message, input, 3);
},
fatal(category, message, input) {
log(LogLevel.FATAL, category, message, input, 3);
},
getLogs(filters?: LogFilters): LogEntry[] {
let result = state.entries.map((e) => ({ ...e }));
if (filters?.level !== undefined) result = result.filter((e) => e.level === filters.level);
if (filters?.minLevel !== undefined)
result = result.filter((e) => e.level >= filters.minLevel!);
if (filters?.category !== undefined)
result = result.filter((e) => e.category === filters.category);
if (filters?.since !== undefined)
result = result.filter((e) => e.timestamp >= filters.since!);
if (filters?.tag !== undefined) {
const tag = filters.tag;
result = result.filter((e) => e.tags?.includes(tag) ?? false);
}
if (filters?.traceId !== undefined)
result = result.filter((e) => e.traceId === filters.traceId);
return result;
},
clear(): void {
state.entries = [];
},
serialize(): string {
const seen = new WeakSet<object>();
const safeReplacer = (_key: string, value: unknown) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
};
return JSON.stringify(
state.entries.map((e) => ({
...e,
timestamp: e.timestamp.toISOString()
})),
safeReplacer,
2
);
},
setLevel(l: LogLevel): void {
state.level = l;
},
setMaxLogs(max: number): void {
const normalized = !Number.isFinite(max) || max < 1 ? 1 : Math.floor(max);
state.maxLogs = normalized;
if (state.entries.length > normalized) {
state.entries = state.entries.slice(-normalized);
}
},
setGlobalContext(ctx: Record<string, unknown>): void {
globalContext = { ...ctx };
},
addTransport(transport: Transport): () => void {
state.transports.push(transport);
return () => {
// Flush anything buffered so data isn't lost on detach.
flushTransport(transport, false);
const idx = state.transports.indexOf(transport);
if (idx !== -1) state.transports.splice(idx, 1);
};
},
subscribe(fn: SubscriberFn): () => void {
const transport: Transport = { write: fn, name: 'subscriber' };
state.transports.push(transport);
return () => {
flushTransport(transport, false);
const idx = state.transports.indexOf(transport);
if (idx !== -1) state.transports.splice(idx, 1);
};
},
removeAllTransports(): void {
flushAll();
state.transports = [];
},
transports(): readonly Transport[] {
return state.transports;
},
child(ctx: Record<string, unknown>): EngineLogger {
return buildLogger(captureSourceEnabled, timers, buffers, flushTimers, state, {
...globalContext,
...ctx
});
},
time(label: string): void {
timers.set(label, performance.now());
},
timeEnd(label: string, category: MessageCategory = 'timer', message?: LogMessage): void {
const start = timers.get(label);
if (start === undefined) {
log(
LogLevel.WARN,
'timer',
`[${logger}] timeEnd("${label}") called without matching time()`,
undefined,
3
);
return;
}
timers.delete(label);
const durationMs = performance.now() - start;
const msg = message ?? label;
log(LogLevel.INFO, category, msg, { durationMs, tags: ['timer'] }, 3);
},
flush(): void {
flushAll();
}
};
}

@ -0,0 +1,3 @@
export * from './engine-logger.ts';
export * from './transports.ts';
export * from './types.ts';

@ -0,0 +1,99 @@
import type { LogSource } from './types.ts';
/**
* Capture the caller location (file:line:column) by parsing `new Error().stack`.
*
* @param skip Number of internal frames to skip before the actual caller.
* Depends on the call depth inside the logger itself.
*
* V8 format (Chrome/Node/Edge):
* " at fnName (file:///path:10:15)"
* " at file:///path:10:15"
*
* SpiderMonkey/JSC format (Firefox/Safari):
* "fnName@file:///path:10:15"
*
* In minified production builds the function names are mangled — the caller
* can opt out via `captureSource: false` in LoggerOptions.
*/
export function captureSource(skip: number): LogSource | undefined {
const err = new Error();
const stack = err.stack;
if (!stack) return undefined;
const lines = stack.split('\n');
// First line is either "Error" (V8) or the first frame (SpiderMonkey).
const startIdx = lines[0]?.trim().startsWith('at ') || lines[0]?.includes('@') ? 0 : 1;
const frameLine = lines[startIdx + skip];
if (!frameLine) return undefined;
return parseFrame(frameLine);
}
function parseFrame(frame: string): LogSource | undefined {
const trimmed = frame.trim();
// V8: "at fnName (file:line:col)" or "at file:line:col"
const v8Match =
trimmed.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/) ??
trimmed.match(/^at\s+(.+):(\d+):(\d+)$/);
if (v8Match) {
if (v8Match.length === 5) {
return {
function: v8Match[1],
file: v8Match[2],
line: Number(v8Match[3]),
column: Number(v8Match[4])
};
}
return {
file: v8Match[1],
line: Number(v8Match[2]),
column: Number(v8Match[3])
};
}
// SpiderMonkey/JSC: "fnName@file:line:col" or "@file:line:col"
const mozMatch = trimmed.match(/^(.*?)@(.+):(\d+):(\d+)$/);
if (mozMatch) {
const fn = mozMatch[1];
return {
function: fn || undefined,
file: mozMatch[2],
line: Number(mozMatch[3]),
column: Number(mozMatch[4])
};
}
return undefined;
}
/**
* Extract an `Error` (or any value caught in a try/catch) into the
* serializable `LogError` shape. Accepts `unknown` because thrown values are
* not always `Error` instances — strings, objects, etc.
*/
export function extractError(value: unknown): import('./types.ts').LogError {
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack,
cause: value.cause
};
}
if (typeof value === 'object' && value !== null) {
const obj = value as { name?: unknown; message?: unknown; stack?: unknown; cause?: unknown };
return {
name: typeof obj.name === 'string' ? obj.name : 'Unknown',
message: typeof obj.message === 'string' ? obj.message : String(value),
stack: typeof obj.stack === 'string' ? obj.stack : undefined,
cause: obj.cause
};
}
return {
name: 'Unknown',
message: String(value)
};
}

@ -0,0 +1,733 @@
/**
* logr — test suite
*
* Covers:
* - Level filtering (global)
* - All six level methods (trace..fatal)
* - globalContext merge
* - Lazy messages (thunks)
* - source capture
* - error extraction
* - tags, traceId
* - time / timeEnd
* - child()
* - getLogs filters
* - setLevel, setMaxLogs, setGlobalContext
* - addTransport, subscribe, removeAllTransports, transports()
* - Per-transport minLevel + filter
* - Transport failure → deniedFor (sync + async)
* - Cascade guard
* - serialize()
*/
import { describe, it, expect, vi } from 'vitest';
import {
LogLevel,
createEngineLogger,
callbackTransport,
type LogEntry,
type Transport
} from '../index.ts';
import { extractError } from '../source.ts';
import { logger as LOGGER_NAME } from '../consts.ts';
function capture(): { transport: Transport; entries: LogEntry[] } {
const entries: LogEntry[] = [];
const transport: Transport = {
name: 'capture',
write(entry) {
entries.push(entry);
}
};
return { transport, entries };
}
describe('createEngineLogger — level methods', () => {
it('emits each of the 6 levels', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.trace('t', 'trace');
log.debug('t', 'debug');
log.info('t', 'info');
log.warn('t', 'warn');
log.error('t', 'error');
log.fatal('t', 'fatal');
expect(entries.map((e) => e.level)).toEqual([
LogLevel.TRACE,
LogLevel.DEBUG,
LogLevel.INFO,
LogLevel.WARN,
LogLevel.ERROR,
LogLevel.FATAL
]);
});
it('drops entries below the global level', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.WARN, transports: [transport] });
log.trace('t', 'dropped');
log.debug('t', 'dropped');
log.info('t', 'dropped');
log.warn('t', 'kept');
log.error('t', 'kept');
log.fatal('t', 'kept');
expect(entries).toHaveLength(3);
expect(entries.map((e) => e.level)).toEqual([LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]);
});
it('setLevel raises/lowers the filter at runtime', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'one');
log.setLevel(LogLevel.ERROR);
log.info('t', 'dropped');
log.error('t', 'two');
expect(entries.map((e) => e.message)).toEqual(['one', 'two']);
});
});
describe('createEngineLogger — lazy messages', () => {
it('does not evaluate the thunk when filtered by level', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.ERROR, transports: [transport] });
const thunk = vi.fn(() => 'expensive');
log.debug('t', thunk);
expect(thunk).not.toHaveBeenCalled();
});
it('evaluates the thunk when the entry passes', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', () => 'expensive');
expect(entries[0].message).toBe('expensive');
});
it('captures thunk errors as the resolved message', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', () => {
throw new Error('boom');
});
expect(entries[0].message).toContain('thunk threw');
expect(entries[0].message).toContain('boom');
});
});
describe('createEngineLogger — globalContext', () => {
it('merges globalContext into every entry context', () => {
const { transport, entries } = capture();
const log = createEngineLogger({
level: LogLevel.TRACE,
globalContext: { appVersion: '1.0.0' },
transports: [transport]
});
log.info('t', 'hello', { context: { userId: 42 } });
expect(entries[0].context).toEqual({ appVersion: '1.0.0', userId: 42 });
});
it('call-site context wins on key collision', () => {
const { transport, entries } = capture();
const log = createEngineLogger({
level: LogLevel.TRACE,
globalContext: { env: 'dev' },
transports: [transport]
});
log.info('t', 'hello', { context: { env: 'prod' } });
expect(entries[0].context).toEqual({ env: 'prod' });
});
it('setGlobalContext replaces the existing context', () => {
const { transport, entries } = capture();
const log = createEngineLogger({
level: LogLevel.TRACE,
globalContext: { a: 1 },
transports: [transport]
});
log.setGlobalContext({ b: 2 });
log.info('t', 'hello');
expect(entries[0].context).toEqual({ b: 2 });
});
it('entry has undefined context when no global and no local context exist', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'hello');
expect(entries[0].context).toBeUndefined();
});
});
describe('createEngineLogger — error / traceId / tags / durationMs', () => {
it('extracts an Error to LogError shape', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
const err = new Error('boom');
log.error('t', 'failed', { error: err });
expect(entries[0].error).toMatchObject({ name: 'Error', message: 'boom' });
expect(entries[0].error?.stack).toBeDefined();
});
it('accepts non-Error values as error', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.error('t', 'failed', { error: 'string-err' });
expect(entries[0].error?.message).toBe('string-err');
});
it('preserves traceId, tags and durationMs on the entry', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'hello', {
traceId: 'req-1',
tags: ['auth', 'critical'],
durationMs: 123
});
expect(entries[0].traceId).toBe('req-1');
expect(entries[0].tags).toEqual(['auth', 'critical']);
expect(entries[0].durationMs).toBe(123);
});
});
describe('extractError', () => {
it('handles Error instances', () => {
const err = new Error('boom');
const result = extractError(err);
expect(result.name).toBe('Error');
expect(result.message).toBe('boom');
expect(result.stack).toBeDefined();
});
it('handles strings', () => {
expect(extractError('oops')).toMatchObject({ name: 'Unknown', message: 'oops' });
});
it('handles plain objects with message', () => {
expect(extractError({ message: 'obj-fail' })).toMatchObject({ message: 'obj-fail' });
});
});
describe('createEngineLogger — time / timeEnd', () => {
it('emits an INFO entry with durationMs and timer tag', async () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.time('op');
await new Promise((r) => setTimeout(r, 10));
log.timeEnd('op', 'perf', 'operation done');
const last = entries[entries.length - 1];
expect(last.level).toBe(LogLevel.INFO);
expect(last.category).toBe('perf');
expect(last.message).toBe('operation done');
expect(last.durationMs).toBeGreaterThan(0);
expect(last.tags).toEqual(['timer']);
});
it('warns when timeEnd has no matching time', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.timeEnd('missing');
expect(entries[0].level).toBe(LogLevel.WARN);
expect(entries[0].message).toContain('timeEnd');
});
});
describe('createEngineLogger — child()', () => {
it('extends globalContext and shares history', () => {
const { transport, entries } = capture();
const log = createEngineLogger({
level: LogLevel.TRACE,
globalContext: { app: 'X' },
transports: [transport]
});
const child = log.child({ requestId: 'r1' });
child.info('t', 'via child');
log.info('t', 'via parent');
expect(entries).toHaveLength(2);
expect(entries[0].context).toEqual({ app: 'X', requestId: 'r1' });
expect(entries[1].context).toEqual({ app: 'X' });
});
it('children share the transports list with the parent', () => {
const log = createEngineLogger({ level: LogLevel.TRACE });
const child = log.child({});
const parentCount = log.transports().length;
child.addTransport({ name: 'extra', write: () => {} });
expect(log.transports().length).toBe(parentCount + 1);
});
});
describe('createEngineLogger — getLogs filters', () => {
it('filters by exact level', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.debug('t', 'a');
log.info('t', 'b');
log.debug('t', 'c');
expect(log.getLogs({ level: LogLevel.DEBUG })).toHaveLength(2);
});
it('filters by minLevel', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'a');
log.warn('t', 'b');
log.error('t', 'c');
expect(log.getLogs({ minLevel: LogLevel.WARN })).toHaveLength(2);
});
it('filters by category', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('auth', 'a');
log.info('db', 'b');
expect(log.getLogs({ category: 'auth' })).toHaveLength(1);
});
it('filters by tag', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'a', { tags: ['x', 'y'] });
log.info('t', 'b', { tags: ['y', 'z'] });
expect(log.getLogs({ tag: 'x' })).toHaveLength(1);
expect(log.getLogs({ tag: 'y' })).toHaveLength(2);
});
it('filters by traceId', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'a', { traceId: '1' });
log.info('t', 'b', { traceId: '2' });
expect(log.getLogs({ traceId: '1' })).toHaveLength(1);
});
it('filters by since', async () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'a');
await new Promise((r) => setTimeout(r, 2));
const now = new Date();
await new Promise((r) => setTimeout(r, 2));
log.info('t', 'b');
expect(log.getLogs({ since: now })).toHaveLength(1);
});
it('combines filters with AND', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('auth', 'a');
log.warn('auth', 'b');
log.error('db', 'c');
expect(log.getLogs({ category: 'auth', minLevel: LogLevel.WARN })).toHaveLength(1);
});
it('returns a defensive copy', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'hi');
const logs = log.getLogs();
logs[0].message = 'modified';
expect(log.getLogs()[0].message).toBe('hi');
});
});
describe('createEngineLogger — maxLogs', () => {
it('trims history to maxLogs', () => {
const { transport } = capture();
const log = createEngineLogger({
level: LogLevel.TRACE,
maxLogs: 3,
transports: [transport]
});
for (let i = 0; i < 5; i++) log.info('t', String(i));
expect(log.getLogs()).toHaveLength(3);
expect(log.getLogs().map((l) => l.message)).toEqual(['2', '3', '4']);
});
it('setMaxLogs trims immediately when shrinking', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
for (let i = 0; i < 4; i++) log.info('t', String(i));
log.setMaxLogs(2);
expect(log.getLogs()).toHaveLength(2);
expect(log.getLogs().map((l) => l.message)).toEqual(['2', '3']);
});
});
describe('createEngineLogger — dynamic transports', () => {
it('addTransport receives entries emitted afterwards', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.info('t', 'before');
log.addTransport(transport);
log.info('t', 'after');
expect(entries.map((e) => e.message)).toEqual(['after']);
});
it('addTransport remove function detaches it', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
const remove = log.addTransport(transport);
log.info('t', 'one');
remove();
log.info('t', 'two');
expect(entries.map((e) => e.message)).toEqual(['one']);
});
it('subscribe is sugar over addTransport', () => {
const received: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
const unsub = log.subscribe((e) => received.push(e));
log.info('t', 'x');
unsub();
log.info('t', 'y');
expect(received.map((e) => e.message)).toEqual(['x']);
});
it('removeAllTransports detaches everything', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'a');
log.removeAllTransports();
log.info('t', 'b');
expect(entries.map((e) => e.message)).toEqual(['a']);
});
it('transports() returns a read-only view', () => {
const { transport } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
expect(log.transports().length).toBe(1);
expect(log.transports()[0].name).toBe('capture');
});
});
describe('createEngineLogger — per-transport minLevel and filter', () => {
it('drops entries below transport.minLevel', () => {
const entries: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE });
log.removeAllTransports();
log.addTransport({ name: 't', minLevel: LogLevel.WARN, write: (e) => entries.push(e) });
log.info('t', 'dropped');
log.warn('t', 'kept');
expect(entries.map((e) => e.message)).toEqual(['kept']);
});
it('drops entries when predicate returns false', () => {
const entries: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE });
log.removeAllTransports();
log.addTransport({
name: 't',
filter: (e) => e.category === 'kept',
write: (e) => entries.push(e)
});
log.info('dropped', 'a');
log.info('kept', 'b');
expect(entries.map((e) => e.category)).toEqual(['kept']);
});
});
describe('createEngineLogger — transport failure and deniedFor', () => {
it('sync throw routes a failure entry to the rest', () => {
const seenByOther: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
const failing: Transport = {
name: 'bad',
write() {
throw new Error('sync boom');
}
};
const other: Transport = { name: 'good', write: (e) => seenByOther.push(e) };
log.addTransport(failing);
log.addTransport(other);
log.error('t', 'original');
// `other` saw both: the failure report (dispatched inside handleFailure,
// which runs before the outer dispatch loop continues) and then the
// original entry.
expect(seenByOther).toHaveLength(2);
const failureEntry = seenByOther[0];
const originalEntry = seenByOther[1];
expect(originalEntry.message).toBe('original');
expect(failureEntry.category).toBe(LOGGER_NAME);
expect(failureEntry.deniedFor).toEqual(['bad']);
expect(failureEntry.tags).toContain('transport-failure');
expect(failureEntry.error?.message).toContain('sync boom');
});
it('async rejection triggers the same failure flow', async () => {
const seenByOther: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
const failing: Transport = {
name: 'bad-async',
async write() {
throw new Error('async boom');
}
};
const other: Transport = { name: 'good', write: (e) => seenByOther.push(e) };
log.addTransport(failing);
log.addTransport(other);
log.error('t', 'original');
await new Promise((r) => setTimeout(r, 10));
// Async case: original is dispatched first (sync loop), then the
// promise rejects and the failure entry is dispatched to `other`.
expect(seenByOther).toHaveLength(2);
expect(seenByOther[0].message).toBe('original');
expect(seenByOther[1].deniedFor).toEqual(['bad-async']);
expect(seenByOther[1].error?.message).toContain('async boom');
});
it('failed transport does NOT receive the failure report (no loop)', () => {
let throwCount = 0;
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
const failing: Transport = {
name: 'bad',
write() {
throwCount++;
throw new Error('boom');
}
};
const other: Transport = { name: 'good', write: () => {} };
log.addTransport(failing);
log.addTransport(other);
log.error('t', 'original');
// One throw only — the failed transport did not receive the failure report.
expect(throwCount).toBe(1);
});
it('cascade guard: a second-level failure is swallowed', () => {
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({
name: 'a',
write() {
throw new Error('a failed');
}
});
log.addTransport({
name: 'b',
write() {
throw new Error('b failed');
}
});
expect(() => log.error('t', 'original')).not.toThrow();
});
});
describe('callbackTransport', () => {
it('invokes the callback on matching entries', () => {
const hits: LogEntry[] = [];
const cb = callbackTransport((e) => hits.push(e));
const log = createEngineLogger({
level: LogLevel.TRACE,
transports: [cb]
});
log.info('t', 'a');
expect(hits).toHaveLength(1);
});
it('respects minLevel option', () => {
const hits: LogEntry[] = [];
const cb = callbackTransport((e) => hits.push(e), { minLevel: LogLevel.ERROR });
const log = createEngineLogger({
level: LogLevel.TRACE,
transports: [cb]
});
log.warn('t', 'dropped');
log.error('t', 'kept');
expect(hits.map((e) => e.message)).toEqual(['kept']);
});
});
describe('createEngineLogger — entry id', () => {
it('auto-generates a unique id per entry', () => {
const { transport, entries } = capture();
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
log.info('t', 'a');
log.info('t', 'b');
expect(typeof entries[0].id).toBe('string');
expect(entries[0].id.length).toBeGreaterThan(0);
expect(entries[0].id).not.toBe(entries[1].id);
});
it('failure entries also carry an id', () => {
const seen: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({ name: 'bad', write: () => { throw new Error('x'); } });
log.addTransport({ name: 'good', write: (e) => seen.push(e) });
log.error('t', 'msg');
const failureEntry = seen.find((e) => e.deniedFor !== undefined);
expect(failureEntry?.id).toBeDefined();
expect(typeof failureEntry?.id).toBe('string');
});
});
describe('createEngineLogger — buffering', () => {
it('default buffer=0 delivers immediately', () => {
const calls: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({ name: 'nobuf', write: (e) => calls.push(e) });
log.info('t', 'a');
expect(calls).toHaveLength(1);
});
it('holds entries until the buffer fills, then flushes', () => {
const writeCalls: LogEntry[] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({
name: 'buf',
buffer: 3,
write: (e) => writeCalls.push(e)
});
log.info('t', 'a');
log.info('t', 'b');
expect(writeCalls).toHaveLength(0);
log.info('t', 'c'); // reaches buffer size → flush
expect(writeCalls).toHaveLength(3);
expect(writeCalls.map((e) => e.message)).toEqual(['a', 'b', 'c']);
});
it('uses writeBatch when available (single call with all entries)', () => {
const batchCalls: LogEntry[][] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({
name: 'batch',
buffer: 3,
write: () => { throw new Error('write should not be called when writeBatch exists'); },
writeBatch: (entries) => { batchCalls.push(entries); }
});
log.info('t', 'a');
log.info('t', 'b');
log.info('t', 'c');
expect(batchCalls).toHaveLength(1);
expect(batchCalls[0]).toHaveLength(3);
});
it('logger.flush() drains everything immediately', () => {
const batchCalls: LogEntry[][] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({
name: 'batch',
buffer: 100,
write: () => {},
writeBatch: (entries) => { batchCalls.push(entries); }
});
log.info('t', 'a');
log.info('t', 'b');
expect(batchCalls).toHaveLength(0);
log.flush();
expect(batchCalls).toHaveLength(1);
expect(batchCalls[0].map((e) => e.message)).toEqual(['a', 'b']);
});
it('flushes on transport detach', () => {
const batchCalls: LogEntry[][] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
const remove = log.addTransport({
name: 'batch',
buffer: 10,
write: () => {},
writeBatch: (entries) => { batchCalls.push(entries); }
});
log.info('t', 'a');
log.info('t', 'b');
remove();
expect(batchCalls).toHaveLength(1);
expect(batchCalls[0]).toHaveLength(2);
});
it('removeAllTransports flushes everything before detach', () => {
const batchCalls: LogEntry[][] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({
name: 'batch',
buffer: 10,
write: () => {},
writeBatch: (entries) => { batchCalls.push(entries); }
});
log.info('t', 'a');
log.removeAllTransports();
expect(batchCalls).toHaveLength(1);
});
it('time-based flush via flushIntervalMs', async () => {
const batchCalls: LogEntry[][] = [];
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.addTransport({
name: 'batch',
buffer: 100,
flushIntervalMs: 20,
write: () => {},
writeBatch: (entries) => { batchCalls.push(entries); }
});
log.info('t', 'a');
expect(batchCalls).toHaveLength(0);
await new Promise((r) => setTimeout(r, 40));
expect(batchCalls).toHaveLength(1);
});
});
describe('createEngineLogger — serialize()', () => {
it('produces valid JSON including structured fields', () => {
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
log.info('auth', 'login', {
context: { userId: 1 },
tags: ['auth'],
traceId: 'abc'
});
const json = log.serialize();
const parsed = JSON.parse(json) as Array<{
message: string;
context: Record<string, unknown>;
tags: string[];
traceId: string;
timestamp: string;
}>;
expect(parsed).toHaveLength(1);
expect(parsed[0].message).toBe('login');
expect(parsed[0].context).toEqual({ userId: 1 });
expect(parsed[0].tags).toEqual(['auth']);
expect(parsed[0].traceId).toBe('abc');
expect(typeof parsed[0].timestamp).toBe('string');
});
it('handles circular references gracefully', () => {
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
const circular: Record<string, unknown> = {};
circular.self = circular;
log.info('t', 'hi', { context: circular });
expect(() => log.serialize()).not.toThrow();
});
});

@ -0,0 +1,169 @@
import type {
Transport,
ConsoleTransportOptions,
HttpTransportOptions,
LogEntry
} from './types.ts';
import { LogLevel } from './types.ts';
import { logger } from './consts.ts';
// ============================================================================
// CONSOLE TRANSPORT
// ============================================================================
/**
* Emit log entries to the browser/Node console using the method that matches
* the level: DEBUG → console.debug, INFO → console.info, WARN → console.warn,
* ERROR → console.error. TRACE maps to `console.debug`, FATAL to `console.error`
* since consoles don't expose those levels natively.
*
* @example
* consoleTransport()
* consoleTransport({ timestamp: false, prefix: false })
* consoleTransport({ minLevel: LogLevel.INFO })
*/
export function consoleTransport(options: ConsoleTransportOptions = {}): Transport {
const {
timestamp: showTimestamp = true,
prefix: showPrefix = true,
source: showSource = true,
minLevel
} = options;
return {
name: 'console',
minLevel,
write(entry: LogEntry): void {
const args: unknown[] = [];
if (showTimestamp) args.push(entry.timestamp.toISOString());
if (showPrefix) args.push(`[${entry.category}]`);
if (showSource && entry.source?.file) {
const file = entry.source.file;
const afterSlash = file.slice(file.lastIndexOf('/') + 1);
const clean = afterSlash.split('?')[0];
args.push(`(${clean}:${entry.source.line})`);
}
args.push(entry.message);
// Structured payload — only appended when it has content.
const meta: Record<string, unknown> = {};
if (entry.context) meta.context = entry.context;
if (entry.tags?.length) meta.tags = entry.tags;
if (entry.traceId) meta.traceId = entry.traceId;
if (entry.durationMs !== undefined) meta.durationMs = entry.durationMs;
if (entry.error) meta.error = entry.error;
if (Object.keys(meta).length > 0) args.push(meta);
// Browser/Node console has no trace/fatal native —
// TRACE → debug, FATAL → error.
switch (entry.level) {
case LogLevel.TRACE:
case LogLevel.DEBUG:
console.debug(...(args as [unknown, ...unknown[]]));
break;
case LogLevel.INFO:
console.info(...(args as [unknown, ...unknown[]]));
break;
case LogLevel.WARN:
console.warn(...(args as [unknown, ...unknown[]]));
break;
case LogLevel.ERROR:
case LogLevel.FATAL:
console.error(...(args as [unknown, ...unknown[]]));
break;
}
}
};
}
// ============================================================================
// HTTP TRANSPORT
// ============================================================================
/**
* POST log entries to an HTTP endpoint. Fire-and-forget — network errors are
* reported via `console.error` but do not interrupt the application flow.
*
* No built-in retry or batching. For high-volume needs use a custom transport
* with a queue/retry layer.
*/
export function httpTransport(options: HttpTransportOptions): Transport {
function serialize(entry: LogEntry): Record<string, unknown> {
return {
timestamp: entry.timestamp.toISOString(),
level: entry.level,
category: entry.category,
message: entry.message,
context: entry.context,
error: entry.error,
tags: entry.tags,
traceId: entry.traceId,
durationMs: entry.durationMs,
source: entry.source
};
}
async function post(payload: unknown): Promise<void> {
const res = await fetch(options.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options.headers
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`http transport push failed: ${res.status} ${res.statusText} ${body}`);
}
}
return {
name: 'http',
minLevel: options.minLevel ?? LogLevel.ERROR,
async write(entry: LogEntry): Promise<void> {
await post(serialize(entry));
},
async writeBatch(entries: LogEntry[]): Promise<void> {
// Batched delivery — single request with an array of entries.
await post(entries.map(serialize));
}
};
}
// ============================================================================
// CALLBACK TRANSPORT
// ============================================================================
/**
* Transport that invokes a callback for each reaching entry. Structurally
* equivalent to `logger.subscribe(fn)` — it exists as a factory so it can be
* pre-registered via `LoggerOptions.transports` or composed with
* `minLevel`/`filter`/`name`.
*
* @example
* callbackTransport((entry) => captured.push(entry))
*
* @example
* // Sentry integration: ERROR+ only, using the caller-provided SDK
* callbackTransport(
* (entry) => {
* if (entry.error) Sentry.captureException(entry.error);
* else Sentry.captureMessage(entry.message);
* },
* { minLevel: LogLevel.ERROR, name: 'sentry' }
* )
*/
export function callbackTransport(
fn: (entry: LogEntry) => void,
options: { minLevel?: LogLevel; filter?: (entry: LogEntry) => boolean; name?: string } = {}
): Transport {
return {
write: fn,
minLevel: options.minLevel,
filter: options.filter,
name: options.name ?? 'callback'
};
}

@ -0,0 +1,302 @@
// ============================================================================
// LOG LEVEL
// ============================================================================
/**
* Severity levels, ordered low-to-high. A logger configured with level X
* processes only entries whose level is >= X.
*
* Aligned with pino, Log4j, OpenTelemetry and Sentry's `Severity` enum.
*
* - **TRACE** — very verbose tracing (spans, frame-by-frame).
* - **DEBUG** — development diagnostics.
* - **INFO** — normal flow events worth noting.
* - **WARN** — unexpected but non-interrupting situations.
* - **ERROR** — recoverable errors that need attention.
* - **FATAL** — unrecoverable failures (route to immediate paging).
*/
export enum LogLevel {
TRACE = 0,
DEBUG = 1,
INFO = 2,
WARN = 3,
ERROR = 4,
FATAL = 5,
/** Disables every log. Deliberately high so any future level stays below. */
NONE = 999
}
// ============================================================================
// CORE TYPES
// ============================================================================
/**
* Functional domain of a log. Used as `[auth]` prefix in the console and as
* a filter in `getLogs({ category })`.
*/
export type MessageCategory = string;
/**
* Log message. Accepts a literal string or a thunk — the thunk is only
* invoked if the entry passes the global level filter, avoiding formatting
* cost for dropped logs.
*
* @example
* logger.info('auth', 'Login succeeded');
* logger.debug('db', () => `Query took ${performance.now() - t0}ms`);
*/
export type LogMessage = string | (() => string);
/**
* Serializable shape of a captured error.
*/
export interface LogError {
name: string;
message: string;
stack?: string;
cause?: unknown;
}
/**
* Source location where the log was emitted. Parsed from the native `Error`
* stack. In minified production the names are mangled — disable
* `captureSource` in that case.
*/
export interface LogSource {
file?: string;
line?: number;
column?: number;
function?: string;
}
/**
* Optional caller-provided data for a single log call.
*/
export interface LogInput {
/** Structured diagnostic context. Merged with `globalContext` on the entry. */
context?: Record<string, unknown>;
/** Captured error. Extracted to `entry.error` as a structured `LogError`. */
error?: unknown;
/** Free-form tags for filtering — finer than `category`. */
tags?: string[];
/** Correlation id to trace async flows (fetch → handler → service). */
traceId?: string;
/** Duration in ms — auto-populated by `timeEnd()`. */
durationMs?: number;
/** Manual `source` override — useful for wrappers that re-emit logs. */
source?: LogSource;
}
/**
* A single history entry. `message` is already resolved to a string — the
* logger knows nothing about i18n. For translated strings, resolve at the
* call site: `logger.info('auth', lang.t('login.ok'))`.
*/
export interface LogEntry {
/**
* Unique identifier (UUID v4 where available, hex fallback otherwise).
* Survives serialization, enabling backend deduplication, partial-retry
* in batched delivery, and cross-transport correlation.
*/
id: string;
timestamp: Date;
level: LogLevel;
category: MessageCategory;
message: string;
/** Logger `globalContext` merged with `input.context`. */
context?: Record<string, unknown>;
error?: LogError;
tags?: string[];
traceId?: string;
durationMs?: number;
source?: LogSource;
/**
* Names of transports that were skipped when dispatching this entry.
* Auto-populated when a transport throws: the synthetic failure entry is
* dispatched to the remaining transports with the failed one's name here,
* for audit trail and to prevent loops (e.g. if Sentry fails, don't send
* the Sentry-failure report to Sentry).
*/
deniedFor?: string[];
}
/**
* `getLogs()` filters. All optional — combined with AND.
*/
export interface LogFilters {
level?: LogLevel;
minLevel?: LogLevel;
category?: MessageCategory;
since?: Date;
/** Matches when the entry's `tags` includes this value. */
tag?: string;
traceId?: string;
}
// ============================================================================
// TRANSPORTS / SUBSCRIBERS
// ============================================================================
/**
* Output destination for log entries.
*
* Can be registered at logger creation (`options.transports`) or dynamically
* via `logger.addTransport()` / `logger.subscribe()`.
*
* Each transport decides whether to process each entry via `minLevel` and
* `filter` — both applied after the logger's global level filter.
*/
export interface Transport {
/**
* Process a single entry. Sync or async.
*
* - If it throws synchronously: caught and a failure entry is dispatched
* to the remaining transports with `deniedFor: [this.name]`.
* - If it returns a rejecting `Promise`: same handler via `.catch`.
* - The logger never awaits — dispatch is sync; async work inside the
* transport is fire-and-forget from its own perspective.
*/
write(entry: LogEntry): void | Promise<void>;
/**
* Optional batch delivery. When the logger flushes a buffered transport
* and `writeBatch` is defined, it is called once with all the queued
* entries (instead of N separate `write` calls). Ideal for HTTP-style
* sinks where one batched request is dramatically cheaper than many.
*
* If undefined, the logger falls back to `write` per entry.
*/
writeBatch?(entries: LogEntry[]): void | Promise<void>;
/**
* Number of entries to buffer before flushing. `0` (default) means
* immediate delivery — every entry triggers a `write` call right away.
*
* When `buffer > 0`, entries are queued per-transport; the queue flushes
* when it reaches `buffer` entries, or when `flushIntervalMs` elapses,
* or when `logger.flush()` is called manually. In the browser, a
* `beforeunload` handler flushes everything before the page is torn down.
*
* @default 0
*/
buffer?: number;
/**
* Flush the queue every N milliseconds even if `buffer` is not yet full.
* Applies only when `buffer > 0`. @default 0 (no time-based flush).
*/
flushIntervalMs?: number;
/**
* Transport-level minimum severity. If undefined, only the logger's global
* level applies. Useful to send only ERROR+ to Sentry while keeping the
* logger at DEBUG.
*/
minLevel?: LogLevel;
/**
* Arbitrary predicate. If it returns `false`, the entry is skipped.
* Useful to filter by category, tag, traceId, etc.
*/
filter?(entry: LogEntry): boolean;
/**
* Optional identifier for introspection and debugging.
* Does not influence behavior.
*/
name?: string;
}
/**
* Shorthand form of a subscriber — no filters, just an observer on the stream.
* Wrapped as a `Transport` internally on registration.
*/
export type SubscriberFn = (entry: LogEntry) => void;
export interface ConsoleTransportOptions {
timestamp?: boolean;
prefix?: boolean;
/** Include the `source` in parentheses. @default true */
source?: boolean;
minLevel?: LogLevel;
}
export interface HttpTransportOptions {
url: string;
headers?: Record<string, string>;
/** @default LogLevel.ERROR */
minLevel?: LogLevel;
}
// ============================================================================
// OPTIONS
// ============================================================================
export interface LoggerOptions {
/** @default LogLevel.WARN */
level?: LogLevel;
/** @default 1000 */
maxLogs?: number;
/** Transports registered at creation time. More can be added dynamically. */
transports?: Transport[];
/** Context merged into every entry. */
globalContext?: Record<string, unknown>;
/**
* Capture `file:line` by parsing `new Error().stack` on every log.
* Cost: ~0.1ms/log. Disable in minified production (names are mangled).
* @default DEV
*/
captureSource?: boolean;
}
// ============================================================================
// INSTANCE TYPE
// ============================================================================
export type LogFn = (
category: MessageCategory,
message: LogMessage,
input?: LogInput
) => void;
/**
* Public `EngineLogger` interface.
* Children created via `child()` share history, transports and level with
* the parent — they only extend the `globalContext`.
*/
export interface EngineLogger {
trace: LogFn;
debug: LogFn;
info: LogFn;
warn: LogFn;
error: LogFn;
fatal: LogFn;
/** Defensive copy of the history, optionally filtered. */
getLogs: (filters?: LogFilters) => LogEntry[];
/** Empty the history. Shared with children. */
clear: () => void;
/** Export the history as JSON. */
serialize: () => string;
setLevel: (level: LogLevel) => void;
setMaxLogs: (max: number) => void;
/** Replace the logger's `globalContext`. Does not affect previously created children. */
setGlobalContext: (ctx: Record<string, unknown>) => void;
/** Register a transport at runtime. Returns a function that detaches it. */
addTransport: (transport: Transport) => () => void;
/** Sugar: equivalent to `addTransport({ write: fn })`. */
subscribe: (fn: SubscriberFn) => () => void;
/** Remove every transport. Handy in tests. */
removeAllTransports: () => void;
/** Read-only snapshot of active transports. */
transports: () => readonly Transport[];
/** Child logger with extended `globalContext`. Shares history and transports. */
child: (ctx: Record<string, unknown>) => EngineLogger;
time: (label: string) => void;
timeEnd: (label: string, category?: MessageCategory, message?: LogMessage) => void;
/**
* Flush every buffered transport synchronously. Use before handing control
* back to the host (page unload, test teardown, app exit) to make sure
* queued entries are delivered.
*/
flush: () => void;
}

@ -0,0 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
{@render children()}

@ -0,0 +1,23 @@
<script lang="ts">
import { resolve } from '$app/paths';
</script>
<svelte:head>
<title>active</title>
</svelte:head>
<main>
<h1>active</h1>
<p>
<a href={resolve('/test')}>→ páginas de prueba</a>
</p>
</main>
<style>
main {
max-width: 600px;
margin: 4rem auto;
padding: 0 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
</style>

@ -0,0 +1,53 @@
<script lang="ts">
import { resolve } from '$app/paths';
</script>
<svelte:head>
<title>Tests — arts</title>
</svelte:head>
<main>
<h1>Páginas de prueba</h1>
<p>Demostraciones interactivas de los módulos de <code>src/arts</code>.</p>
<ul>
<li>
<a href={resolve('/test/lang')}>/test/lang</a> — cambio de idioma reactivo (es/en/ar) sobre
distintos elementos UI: títulos, navegación, formularios, plurales, interpolación y alias.
</li>
<li>
<a href={resolve('/test/logr')}>/test/logr</a> — logger acoplado a lang: entradas en distintos
niveles, historial con locale capturado al registrar, serialización y cambio de nivel.
</li>
</ul>
</main>
<style>
main {
max-width: 720px;
margin: 3rem auto;
padding: 0 1.5rem;
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
}
h1 {
margin-bottom: 0.5rem;
}
ul {
padding-left: 1.2rem;
}
li {
margin-bottom: 0.75rem;
}
a {
color: #0366d6;
}
code {
background: #f4f4f4;
padding: 0.1em 0.3em;
border-radius: 3px;
}
</style>

@ -0,0 +1,290 @@
<script lang="ts">
import type { SupportedLocale } from '$lang';
import { createActiveLang } from '$lang/active-lang.svelte';
import { translations } from './schema';
const lang = createActiveLang(translations, 'es');
const LOCALES: { code: SupportedLocale; label: string; flag: string }[] = [
{ code: 'es', label: 'Español', flag: '🇪🇸' },
{ code: 'en', label: 'English', flag: '🇬🇧' },
{ code: 'ar', label: 'العربية', flag: '🇸🇦' }
];
let userName = $state('Ana');
let cartCount = $state(1);
const isRTL = $derived(lang.getLocale() === 'ar');
</script>
<svelte:head>
<title>/test/lang</title>
</svelte:head>
<main dir={isRTL ? 'rtl' : 'ltr'} class:rtl={isRTL}>
<header>
<h1>{lang.t('meta.title')}</h1>
<p class="subtitle">{lang.t('meta.subtitle')}</p>
<div class="locale-switcher" dir="ltr">
<strong>Locale:</strong>
{#each LOCALES as { code, label, flag } (code)}
<button
type="button"
class:active={lang.getLocale() === code}
onclick={() => lang.setLocale(code)}
>
<span aria-hidden="true">{flag}</span>
{label}
<code>({code})</code>
</button>
{/each}
</div>
</header>
<section>
<h2>Navegación</h2>
<nav>
<a href="#home">{lang.t('nav.home')}</a>
<a href="#products">{lang.t('nav.products')}</a>
<a href="#about">{lang.t('nav.about')}</a>
<a href="#contact">{lang.t('nav.contact')}</a>
</nav>
</section>
<section>
<h2>Interpolación</h2>
<label>
<span>{lang.t('form.name')}:</span>
<input type="text" bind:value={userName} placeholder={lang.t('form.placeholder')} />
</label>
<p class="greeting">{lang.t('greet', { name: userName })}</p>
</section>
<section>
<h2>Pluralización (Intl.PluralRules)</h2>
<label>
<span>Count:</span>
<input type="number" min="0" max="99" bind:value={cartCount} />
<input type="range" min="0" max="20" bind:value={cartCount} />
</label>
<p class="plural">{lang.t('cart.itemsInCart', { count: cartCount })}</p>
<p class="hint">
En árabe se aplican las 6 formas plurales de <code>Intl.PluralRules</code>: zero, one, two,
few, many, other.
</p>
</section>
<section>
<h2>Formulario</h2>
<form onsubmit={(e) => e.preventDefault()}>
<label>
<span>{lang.t('form.email')}</span>
<input type="email" placeholder={lang.t('form.placeholder')} />
</label>
<label>
<span>{lang.t('form.message')}</span>
<textarea rows="3" placeholder={lang.t('form.placeholder')}></textarea>
</label>
<div class="buttons">
<button type="submit" class="primary">{lang.t('form.send')}</button>
<button type="button">{lang.t('actions.save')}</button>
<button type="button">{lang.t('actions.confirm')}</button>
<button type="button" class="danger">{lang.t('actions.delete')}</button>
<button type="reset">{lang.t('actions.cancel')}</button>
</div>
</form>
</section>
<section>
<h2>Alias (LangRef)</h2>
<p>
<code>actions.acceptAlias</code> es un alias <code>#?actions.confirm</code>:
<strong>{lang.t('actions.acceptAlias')}</strong>
</p>
</section>
<section>
<h2>Mensajes de error</h2>
<div class="errors">
<div class="error">{lang.t('errors.notFound')}</div>
<div class="error">{lang.t('errors.network')}</div>
</div>
</section>
<section>
<h2>Uso directo con <code>ts()</code></h2>
<p>
Record inline: <em>{lang.ts({ es: 'Valor directo', en: 'Direct value', ar: 'قيمة مباشرة' })}</em>
</p>
<p>
LangRef con fallback literal:
<em>{lang.ts('#?no.existe|Fallback text')}</em>
</p>
</section>
</main>
<style>
main {
max-width: 820px;
margin: 2rem auto;
padding: 0 1.5rem;
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
color: #222;
}
main.rtl {
font-family: 'Segoe UI', 'Tahoma', system-ui, sans-serif;
}
header {
border-bottom: 1px solid #e1e4e8;
padding-bottom: 1rem;
margin-bottom: 2rem;
}
h1 {
margin: 0 0 0.5rem;
font-size: 2rem;
}
h2 {
border-bottom: 1px solid #eee;
padding-bottom: 0.3rem;
margin-top: 2rem;
font-size: 1.2rem;
}
.subtitle {
color: #666;
margin-top: 0;
}
.locale-switcher {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-top: 1rem;
}
.locale-switcher button {
background: #fff;
border: 1px solid #d1d5da;
padding: 0.4rem 0.8rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.locale-switcher button.active {
background: #0366d6;
color: #fff;
border-color: #0366d6;
}
.locale-switcher button:hover:not(.active) {
background: #f6f8fa;
}
nav {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
nav a {
color: #0366d6;
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: #f1f8ff;
}
nav a:hover {
text-decoration: underline;
}
section {
margin-bottom: 2rem;
}
label {
display: block;
margin: 0.5rem 0;
}
label span {
display: inline-block;
min-width: 160px;
font-weight: 500;
}
input[type='text'],
input[type='email'],
input[type='number'],
textarea {
padding: 0.4rem 0.6rem;
border: 1px solid #d1d5da;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
input[type='range'] {
vertical-align: middle;
margin-left: 0.5rem;
}
textarea {
width: 100%;
max-width: 400px;
resize: vertical;
}
.greeting,
.plural {
background: #f6f8fa;
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 1.1rem;
}
.hint {
font-size: 0.85rem;
color: #666;
}
.buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
button[type='button'],
button[type='submit'],
button[type='reset'] {
padding: 0.5rem 1rem;
border-radius: 5px;
border: 1px solid #d1d5da;
background: #fafbfc;
cursor: pointer;
font-size: 0.95rem;
font-family: inherit;
}
button.primary {
background: #2ea44f;
color: #fff;
border-color: #2a8644;
}
button.danger {
background: #d73a49;
color: #fff;
border-color: #b82d3a;
}
.errors {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.error {
background: #ffeef0;
color: #86181d;
padding: 0.5rem 0.75rem;
border-radius: 4px;
border-left: 3px solid #d73a49;
}
main.rtl .error {
border-left: none;
border-right: 3px solid #d73a49;
}
code {
background: #f4f4f4;
padding: 0.1em 0.3em;
border-radius: 3px;
font-size: 0.9em;
}
</style>

@ -0,0 +1,79 @@
import { p, type LangNode } from '$lang';
export const translations = {
meta: {
title: {
es: 'Demostración del módulo lang',
en: 'lang module demo',
ar: 'عرض توضيحي لوحدة lang'
},
subtitle: {
es: 'Traducciones reactivas con español, inglés y árabe',
en: 'Reactive translations in Spanish, English and Arabic',
ar: 'ترجمات تفاعلية بالإسبانية والإنجليزية والعربية'
}
},
nav: {
home: { es: 'Inicio', en: 'Home', ar: 'الرئيسية' },
products: { es: 'Productos', en: 'Products', ar: 'المنتجات' },
about: { es: 'Acerca de', en: 'About', ar: 'حول' },
contact: { es: 'Contacto', en: 'Contact', ar: 'اتصل بنا' }
},
actions: {
save: { es: 'Guardar', en: 'Save', ar: 'حفظ' },
cancel: { es: 'Cancelar', en: 'Cancel', ar: 'إلغاء' },
delete: { es: 'Eliminar', en: 'Delete', ar: 'حذف' },
confirm: { es: 'Confirmar', en: 'Confirm', ar: 'تأكيد' },
acceptAlias: '#?actions.confirm'
},
form: {
name: { es: 'Nombre', en: 'Name', ar: 'الاسم' },
email: { es: 'Correo electrónico', en: 'Email', ar: 'البريد الإلكتروني' },
message: { es: 'Mensaje', en: 'Message', ar: 'الرسالة' },
send: { es: 'Enviar', en: 'Send', ar: 'إرسال' },
placeholder: {
es: 'Escribe aquí...',
en: 'Type here...',
ar: 'اكتب هنا...'
}
},
greet: (params: { name: string }) => ({
es: `¡Hola, ${params.name}! Bienvenido.`,
en: `Hello, ${params.name}! Welcome.`,
ar: `مرحباً، ${params.name}! أهلاً وسهلاً.`
}),
cart: {
itemsInCart: p({
es: {
one: 'Tienes {{count}} producto en el carrito',
other: 'Tienes {{count}} productos en el carrito'
},
en: {
one: 'You have {{count}} item in your cart',
other: 'You have {{count}} items in your cart'
},
ar: {
zero: 'لا توجد منتجات في سلتك',
one: 'لديك منتج واحد في سلتك',
two: 'لديك منتجان في سلتك',
few: 'لديك {{count}} منتجات في سلتك',
many: 'لديك {{count}} منتجاً في سلتك',
other: 'لديك {{count}} منتج في سلتك'
}
})
},
errors: {
notFound: {
es: 'Página no encontrada',
en: 'Page not found',
ar: 'الصفحة غير موجودة'
},
network: {
es: 'Error de conexión',
en: 'Network error',
ar: 'خطأ في الشبكة'
}
}
} satisfies LangNode;
export type TranslationSchema = typeof translations;

@ -0,0 +1,917 @@
<script lang="ts">
import type { LogEntry, Transport } from '$logr';
import {
LogLevel,
callbackTransport,
consoleTransport,
createEngineLogger
} from '$logr';
import { sentryTransport } from '$logr/adapters/sentry';
import { browser } from '$app/environment';
// Capturamos las entries localmente para mostrarlas en una tabla.
const captured: LogEntry[] = $state([]);
const captureTransport: Transport = {
name: 'ui-capture',
write(entry) {
captured.push(entry);
}
};
const logger = createEngineLogger({
level: LogLevel.DEBUG,
captureSource: true,
globalContext: {
appVersion: '1.0.0',
env: 'test',
sessionId: 'sess-' + Math.random().toString(36).slice(2, 8)
},
transports: [consoleTransport({ minLevel: LogLevel.DEBUG }), captureTransport]
});
const LEVELS = [
{ name: 'TRACE', level: LogLevel.TRACE, color: '#8a8f96' },
{ name: 'DEBUG', level: LogLevel.DEBUG, color: '#6a737d' },
{ name: 'INFO', level: LogLevel.INFO, color: '#0366d6' },
{ name: 'WARN', level: LogLevel.WARN, color: '#b08800' },
{ name: 'ERROR', level: LogLevel.ERROR, color: '#d73a49' },
{ name: 'FATAL', level: LogLevel.FATAL, color: '#6f42c1' }
] as const;
let currentMinLevel = $state(LogLevel.TRACE);
let counter = $state(1);
let traceIdFilter = $state('');
// Referencias a transports dinámicos para poder mostrarlos / removerlos.
const attached: { name: string; remove: () => void }[] = $state([]);
// Forzamos un contador para refrescar la lista de transports (readonly array).
let transportsVersion = $state(0);
function refreshTransports(): void {
transportsVersion++;
}
function levelName(l: LogLevel): string {
return LogLevel[l] ?? String(l);
}
function logSample(level: LogLevel): void {
const message = `Test event #${counter}`;
const fns = {
[LogLevel.TRACE]: logger.trace,
[LogLevel.DEBUG]: logger.debug,
[LogLevel.INFO]: logger.info,
[LogLevel.WARN]: logger.warn,
[LogLevel.ERROR]: logger.error,
[LogLevel.FATAL]: logger.fatal
} as const;
fns[level as keyof typeof fns]?.('demo', message, {
context: { n: counter },
tags: ['demo', `counter-${counter % 3}`],
traceId: `trace-${Math.floor(counter / 3)}`
});
counter++;
}
function logWithError(): void {
try {
JSON.parse('{ not valid json }');
} catch (err) {
logger.error('parse', 'JSON parse error', {
error: err,
tags: ['parse', 'critical'],
context: { input: '{ not valid json }' }
});
}
}
function logWithChildContext(): void {
const requestLogger = logger.child({
requestId: 'req-' + Math.random().toString(36).slice(2, 6),
userId: 42
});
requestLogger.info('auth', 'Authenticated request');
requestLogger.warn('auth', 'Token near expiry');
}
function logWithLazyMessage(): void {
// El thunk solo se evalúa si supera el filtro de nivel global.
logger.debug('perf', () => {
const t0 = performance.now();
// Simular formatting caro
const data = Array.from({ length: 1000 }, (_, i) => i).reduce((a, b) => a + b, 0);
return `Lazy-computed sum=${data} in ${(performance.now() - t0).toFixed(2)}ms`;
});
}
async function logWithTimer(): Promise<void> {
logger.time('fake-request');
await new Promise((resolve) => setTimeout(resolve, 150 + Math.random() * 250));
logger.timeEnd('fake-request', 'perf', 'Simulated request completed');
}
// -------------------------------------------------------------------------
// Demo: transports dinámicos
// -------------------------------------------------------------------------
function attachFakeSentry(): void {
const fakeSentry: Transport = {
name: 'fake-sentry',
minLevel: LogLevel.ERROR,
write(entry) {
console.log('[fake-sentry] captureException/Message', {
message: entry.message,
error: entry.error?.message,
tags: entry.tags
});
}
};
const remove = logger.addTransport(fakeSentry);
attached.push({ name: fakeSentry.name!, remove });
refreshTransports();
}
function attachMetricsSubscriber(): void {
let errorCount = 0;
const unsub = logger.subscribe((entry) => {
if (entry.level >= LogLevel.ERROR) {
errorCount++;
console.log(`[metrics] errorCount=${errorCount}`, { category: entry.category });
}
});
attached.push({ name: 'metrics (subscriber)', remove: unsub });
refreshTransports();
}
function attachSyncFailingTransport(): void {
const failing: Transport = {
name: 'sync-fail',
write() {
throw new Error('Simulated sync transport failure');
}
};
const remove = logger.addTransport(failing);
attached.push({ name: failing.name!, remove });
refreshTransports();
}
function attachAsyncFailingTransport(): void {
const failing: Transport = {
name: 'async-fail',
async write() {
await new Promise((r) => setTimeout(r, 50));
throw new Error('Simulated async transport rejection');
}
};
const remove = logger.addTransport(failing);
attached.push({ name: failing.name!, remove });
refreshTransports();
}
function detach(idx: number): void {
attached[idx].remove();
attached.splice(idx, 1);
refreshTransports();
}
// -------------------------------------------------------------------------
// Sentry integration
// -------------------------------------------------------------------------
const DSN_KEY = 'logr-test-sentry-dsn';
let sentryDsn = $state(browser ? sessionStorage.getItem(DSN_KEY) ?? '' : '');
let sentryStatus = $state<'idle' | 'initializing' | 'ready' | 'error'>('idle');
let sentryErrorMsg = $state('');
let sentryAttached = $state(false);
let sentryRemove: (() => void) | null = null;
function persistDsn(): void {
if (browser) sessionStorage.setItem(DSN_KEY, sentryDsn);
}
async function initSentry(): Promise<void> {
if (!sentryDsn.trim()) {
sentryStatus = 'error';
sentryErrorMsg = 'Pegá una DSN válida';
return;
}
sentryStatus = 'initializing';
try {
const Sentry = await import('@sentry/browser');
Sentry.init({
dsn: sentryDsn.trim(),
environment: 'logr-test',
release: 'logr-test@1.0.0',
sampleRate: 1.0,
tracesSampleRate: 0
});
persistDsn();
sentryStatus = 'ready';
sentryErrorMsg = '';
} catch (err) {
sentryStatus = 'error';
sentryErrorMsg = err instanceof Error ? err.message : String(err);
}
}
async function attachSentry(): Promise<void> {
if (sentryStatus !== 'ready') return;
const Sentry = await import('@sentry/browser');
sentryRemove = logger.addTransport(
sentryTransport(Sentry, {
minLevel: LogLevel.ERROR,
breadcrumbLevel: LogLevel.INFO,
name: 'sentry'
})
);
sentryAttached = true;
refreshTransports();
}
function detachSentry(): void {
sentryRemove?.();
sentryRemove = null;
sentryAttached = false;
refreshTransports();
}
async function sendTestToSentry(): Promise<void> {
// Preparamos breadcrumbs
logger.info('sentry-test', 'Preparing Sentry test', {
context: { step: 1 }
});
logger.warn('sentry-test', 'About to trigger test error', {
context: { step: 2 },
tags: ['pre-flight']
});
// Evento real
try {
throw new Error('Logr → Sentry test: ' + new Date().toISOString());
} catch (err) {
logger.error('sentry-test', 'Test error captured', {
error: err,
traceId: 'sentry-test-' + Math.random().toString(36).slice(2, 8),
tags: ['sentry-test', 'demo'],
context: { intentional: true }
});
}
}
// -------------------------------------------------------------------------
function changeLevel(l: LogLevel): void {
currentMinLevel = l;
logger.setLevel(l);
}
function clearHistory(): void {
logger.clear();
captured.splice(0, captured.length);
}
function downloadSerialized(): void {
const json = logger.serialize();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `logr-${new Date().toISOString()}.json`;
a.click();
URL.revokeObjectURL(url);
}
const filtered = $derived(
traceIdFilter ? captured.filter((e) => e.traceId === traceIdFilter) : captured
);
const stats = $derived({
total: captured.length,
byLevel: LEVELS.map((l) => ({
...l,
count: captured.filter((e) => e.level === l.level).length
}))
});
const activeTransports = $derived.by(() => {
void transportsVersion; // fuerza re-evaluación al cambiar lista
return logger.transports();
});
// Usado solo en el efecto callback de captureSubscriber (no aplica aquí).
// Removemos callbackTransport del import si no se usa.
void callbackTransport;
function fileShort(file?: string): string {
if (!file) return '';
const idx = file.lastIndexOf('/');
const short = idx >= 0 ? file.slice(idx + 1) : file;
return short.split('?')[0];
}
</script>
<svelte:head>
<title>/test/logr</title>
</svelte:head>
<main>
<header>
<h1>Prueba del módulo logr</h1>
<p class="subtitle">
Logger profesional, <strong>desacoplado de i18n</strong>. Transports dinámicos con filtros
per-sink, dispatch sync con soporte async, <code>deniedFor</code> en fallos,
<code>globalContext</code>, <code>child()</code>, <code>time()</code>, mensajes lazy,
<code>source</code> auto-capturado, error estructurado, <code>traceId</code>,
<code>tags</code>.
</p>
</header>
<section class="controls">
<div class="group">
<h3>Nivel mínimo</h3>
<div class="btn-row">
{#each LEVELS as { name, level } (level)}
<button
type="button"
class:active={currentMinLevel === level}
onclick={() => changeLevel(level)}
>
{name}
</button>
{/each}
</div>
<p class="hint">Actual: <strong>{levelName(currentMinLevel)}</strong></p>
</div>
<div class="group">
<h3>Emitir básico</h3>
<div class="btn-row">
{#each LEVELS as { name, level, color } (level)}
<button
type="button"
style:background={color}
style:color="#fff"
onclick={() => logSample(level)}
>
{name}
</button>
{/each}
</div>
<p class="hint">Cada 3 logs comparten <code>traceId</code>.</p>
</div>
<div class="group">
<h3>Features</h3>
<div class="btn-row">
<button type="button" onclick={logWithError}>error + Error</button>
<button type="button" onclick={logWithChildContext}>child(ctx)</button>
<button type="button" onclick={logWithTimer}>time/timeEnd</button>
<button type="button" onclick={logWithLazyMessage}>lazy msg</button>
</div>
</div>
<div class="group">
<h3>Historial</h3>
<div class="btn-row">
<button type="button" onclick={clearHistory}>Vaciar</button>
<button type="button" onclick={downloadSerialized}>Descargar JSON</button>
</div>
</div>
</section>
<section class="sentry">
<h2>Integración con Sentry</h2>
<p class="hint">
Pegá la DSN de tu proyecto Sentry (se guarda en <code>sessionStorage</code>). El SDK se
importa lazy para no cargarlo si no se usa.
</p>
<div class="sentry-form">
<label>
<span>DSN:</span>
<input
type="text"
bind:value={sentryDsn}
placeholder="https://xxxx@xxxx.ingest.sentry.io/xxxx"
spellcheck="false"
autocomplete="off"
/>
</label>
<div class="btn-row">
<button
type="button"
onclick={initSentry}
disabled={sentryStatus === 'initializing' || sentryStatus === 'ready'}
>
{sentryStatus === 'ready' ? '✓ Sentry inicializado' : 'Inicializar Sentry'}
</button>
{#if sentryStatus === 'ready' && !sentryAttached}
<button type="button" onclick={attachSentry} class="primary">
Attach sentryTransport (ERROR+ event, INFO/WARN breadcrumb)
</button>
{/if}
{#if sentryAttached}
<button type="button" onclick={detachSentry} class="remove-btn">Detach Sentry</button>
<button type="button" onclick={sendTestToSentry} class="primary">
Enviar test (2 breadcrumbs + 1 event)
</button>
{/if}
</div>
{#if sentryStatus === 'error'}
<p class="error-msg">Error: {sentryErrorMsg}</p>
{/if}
{#if sentryAttached}
<p class="hint success">
✓ Sentry attached como transport. Ahora cualquier log ≥ ERROR irá al dashboard de Sentry;
INFO y WARN como breadcrumbs del próximo error.
</p>
{/if}
</div>
</section>
<section>
<h2>Transports activos ({activeTransports.length})</h2>
<div class="transports">
{#each activeTransports as t, i (i)}
<div class="transport">
<strong>{t.name ?? 'anonymous'}</strong>
{#if t.minLevel !== undefined}
<span class="badge">minLevel: {levelName(t.minLevel)}</span>
{/if}
{#if t.filter}
<span class="badge">filter</span>
{/if}
</div>
{/each}
</div>
<h3 class="attach-title">Añadir dinámicamente:</h3>
<div class="btn-row">
<button type="button" onclick={attachFakeSentry}>+ fake-sentry (ERROR+)</button>
<button type="button" onclick={attachMetricsSubscriber}>+ metrics (subscriber)</button>
<button type="button" onclick={attachSyncFailingTransport}>+ sync-fail</button>
<button type="button" onclick={attachAsyncFailingTransport}>+ async-fail</button>
</div>
{#if attached.length > 0}
<h3 class="attach-title">Attached runtime:</h3>
<div class="attached">
{#each attached as a, i (i)}
<div class="attached-item">
<code>{a.name}</code>
<button type="button" class="remove" onclick={() => detach(i)}>Quitar</button>
</div>
{/each}
</div>
{/if}
<p class="hint">
Emite un log de nivel &ge; ERROR con el <code>sync-fail</code> o <code>async-fail</code>
adjunto y observá cómo aparece una entry <code>[logr]</code> con
<code>deniedFor: [&quot;sync-fail&quot;]</code> — el reporte del fallo NO se vuelve a enviar
al transport que falló.
</p>
</section>
<section>
<h2>Estadísticas</h2>
<div class="stats">
<div class="stat"><strong>{stats.total}</strong><span>Total</span></div>
{#each stats.byLevel as { name, count, color } (name)}
<div class="stat" style:border-color={color}>
<strong style:color>{count}</strong><span>{name}</span>
</div>
{/each}
</div>
</section>
<section>
<h2>Filtro por traceId</h2>
<label class="filter">
<span>traceId:</span>
<input type="text" bind:value={traceIdFilter} placeholder="trace-0" />
{#if traceIdFilter}
<button type="button" onclick={() => (traceIdFilter = '')}>Limpiar</button>
{/if}
<span class="hint-inline">
({filtered.length} de {captured.length} coinciden)
</span>
</label>
</section>
<section>
<h2>Historial ({filtered.length})</h2>
{#if filtered.length === 0}
<p class="empty">Sin entradas. Usá los botones de arriba.</p>
{:else}
<table>
<thead>
<tr>
<th>#</th>
<th>Nivel</th>
<th>Cat</th>
<th>Mensaje</th>
<th>traceId</th>
<th>tags</th>
<th>deniedFor</th>
<th>source</th>
<th>dur</th>
<th>context</th>
<th>error</th>
</tr>
</thead>
<tbody>
{#each filtered as entry, i (i)}
<tr class:failure={entry.deniedFor !== undefined}>
<td>{i + 1}</td>
<td>
<span
class="level"
style:background={LEVELS.find((l) => l.level === entry.level)?.color}
>
{levelName(entry.level)}
</span>
</td>
<td><code>{entry.category}</code></td>
<td>{entry.message}</td>
<td>
{#if entry.traceId}
<code class="trace">{entry.traceId}</code>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.tags?.length}
{#each entry.tags as t (t)}
<span class="tag">{t}</span>
{/each}
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.deniedFor?.length}
{#each entry.deniedFor as d (d)}
<span class="denied">{d}</span>
{/each}
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.source}
<code class="source" title={entry.source.file}
>{fileShort(entry.source.file)}:{entry.source.line}</code
>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.durationMs !== undefined}
<code>{entry.durationMs.toFixed(1)}ms</code>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.context}
<code class="ctx">{JSON.stringify(entry.context)}</code>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if entry.error}
<details>
<summary><strong>{entry.error.name}</strong>: {entry.error.message}</summary>
{#if entry.error.stack}
<pre class="stack">{entry.error.stack}</pre>
{/if}
</details>
{:else}
<span class="muted">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
</main>
<style>
main {
max-width: 1280px;
margin: 2rem auto;
padding: 0 1.5rem;
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.5;
color: #222;
}
h1 {
margin: 0 0 0.5rem;
}
h2 {
border-bottom: 1px solid #eee;
padding-bottom: 0.3rem;
margin-top: 2rem;
font-size: 1.15rem;
}
h3 {
margin: 0 0 0.5rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #586069;
}
.subtitle {
color: #666;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
background: #f6f8fa;
padding: 1rem;
border-radius: 8px;
margin: 1.5rem 0;
}
.group {
display: flex;
flex-direction: column;
}
.btn-row {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.btn-row button {
padding: 0.4rem 0.8rem;
border-radius: 5px;
border: 1px solid #d1d5da;
background: #fafbfc;
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
}
.btn-row button.active {
background: #0366d6;
color: #fff;
border-color: #0366d6;
}
.hint {
font-size: 0.8rem;
color: #666;
margin: 0.5rem 0 0;
}
.hint-inline {
font-size: 0.8rem;
color: #666;
margin-left: 0.5rem;
}
.transports {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.transport {
background: #f0fff4;
border: 1px solid #bef5cb;
padding: 0.4rem 0.7rem;
border-radius: 6px;
font-size: 0.85rem;
}
.badge {
display: inline-block;
background: #e1ecf4;
color: #39739d;
padding: 0.05em 0.4em;
margin-left: 0.4em;
border-radius: 3px;
font-size: 0.72rem;
}
.attach-title {
margin-top: 1rem;
}
.attached {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.5rem;
}
.attached-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.remove {
padding: 0.2rem 0.5rem;
border-radius: 3px;
border: 1px solid #d73a49;
background: #fff;
color: #d73a49;
cursor: pointer;
font-size: 0.75rem;
}
.sentry {
background: #fff5f7;
border: 1px solid #f0b3c0;
padding: 1rem 1.25rem;
border-radius: 8px;
margin: 1.5rem 0;
}
.sentry h2 {
margin-top: 0;
}
.sentry-form {
margin-top: 0.75rem;
}
.sentry-form label {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.sentry-form label span {
font-weight: 600;
min-width: 3rem;
}
.sentry-form input {
flex: 1;
padding: 0.5rem 0.7rem;
border: 1px solid #d1d5da;
border-radius: 4px;
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 0.85rem;
}
.sentry-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sentry-form button.primary {
background: #6f42c1;
color: #fff;
border-color: #553098;
}
.sentry-form button.remove-btn {
background: #fff;
color: #d73a49;
border-color: #d73a49;
}
.error-msg {
background: #ffeef0;
color: #86181d;
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.5rem;
font-size: 0.85rem;
}
.hint.success {
color: #22863a;
}
.stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.stat {
background: #fff;
border: 2px solid #e1e4e8;
border-radius: 8px;
padding: 0.75rem 1.2rem;
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
}
.stat strong {
font-size: 1.5rem;
}
.stat span {
font-size: 0.7rem;
color: #666;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.empty {
color: #666;
font-style: italic;
}
.filter {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter input {
padding: 0.3rem 0.5rem;
border: 1px solid #d1d5da;
border-radius: 4px;
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 0.85rem;
}
.filter button {
padding: 0.3rem 0.6rem;
border-radius: 4px;
border: 1px solid #d1d5da;
background: #fafbfc;
cursor: pointer;
font-size: 0.8rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
th,
td {
padding: 0.4rem 0.5rem;
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: top;
}
th {
background: #f6f8fa;
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #586069;
white-space: nowrap;
}
tr.failure {
background: #fff5f5;
}
.level {
display: inline-block;
padding: 0.1em 0.5em;
border-radius: 3px;
color: #fff;
font-size: 0.7rem;
font-weight: 600;
}
.muted {
color: #bbb;
}
.tag {
display: inline-block;
background: #e1ecf4;
color: #39739d;
padding: 0.05em 0.4em;
margin: 0 0.15em 0.15em 0;
border-radius: 3px;
font-size: 0.72rem;
}
.denied {
display: inline-block;
background: #ffeef0;
color: #86181d;
padding: 0.05em 0.4em;
margin: 0 0.15em 0.15em 0;
border-radius: 3px;
font-size: 0.72rem;
font-weight: 600;
}
.trace {
color: #6f42c1;
background: #f5f0ff;
}
.source {
color: #22863a;
background: #f0fff4;
font-size: 0.75rem;
}
.ctx {
font-size: 0.72rem;
max-width: 260px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
details summary {
cursor: pointer;
font-size: 0.82rem;
}
.stack {
font-size: 0.7rem;
background: #fff5f5;
color: #86181d;
padding: 0.4rem;
margin-top: 0.3rem;
border-radius: 3px;
overflow-x: auto;
max-width: 300px;
}
code {
background: #f4f4f4;
padding: 0.05em 0.3em;
border-radius: 3px;
font-size: 0.78rem;
}
</style>

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
adapter: adapter(),
files : {
routes: 'src/web/routes',
},
alias: {
$lang: 'src/arts/lang',
$logr: 'src/arts/logr',
}
}
};
export default config;

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

@ -0,0 +1,35 @@
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'client',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']
}
},
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
});
Loading…
Cancel
Save

Powered by TurnKey Linux.