dev 7 months ago
parent 828abd3000
commit 1db6022233

@ -8,7 +8,7 @@
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/vcen/editor/main.ts"></script> <script type="module" src="/src/mtas/svelte/demo/main.ts"></script>
</body> </body>
</html> </html>

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { ling } from '@/ling'; import { ling } from '@/ling/lib';
import { createGlobState } from '@/glob/adapters/svelte/glob.svelte'; import { createGlobState } from '@/glob/adapters/svelte/glob.svelte';
import { p } from '@/ling/engine'; import { p } from '@/ling/lib/engine.ts';
import type { CurrDefinition } from '@/glob/curr_types'; import type { CurrDefinition } from '@/glob/lib/curr_types.ts';
import type { UnitSystem } from '@/glob/unit_types'; import type { UnitSystem } from '@/glob/lib/unit_types.ts';
import type { DatiTimeFormat } from '@/glob/dati_types'; import type { DatiTimeFormat } from '@/glob/lib/dati_types.ts';
// ── Definiciones de monedas ─────────────────────────────────────────────── // ── Definiciones de monedas ───────────────────────────────────────────────
const currencies: Record<string, CurrDefinition> = { const currencies: Record<string, CurrDefinition> = {

@ -1,13 +0,0 @@
export * from './comn_types';
export * from './curr_types';
export * from './dati_types';
export * from './glob_types';
export * from './numr_types';
export * from './unit_types';
export * from './comn_engine';
export * from './curr_engine';
export * from './dati_engine';
export * from './glob_engine';
export * from './numr_engine';
export * from './unit_engine';

@ -2,7 +2,7 @@
// HELPERS // HELPERS
// ============================== // ==============================
import type { GlobLocale, LocaleResolver } from "./comn_types"; import type { GlobLocale, LocaleResolver } from "./comn_types.ts";
/** Normaliza un `LocaleResolver` a un string de locale listo para `Intl.*`. */ /** Normaliza un `LocaleResolver` a un string de locale listo para `Intl.*`. */
export function resolveLocale(l: LocaleResolver): GlobLocale { export function resolveLocale(l: LocaleResolver): GlobLocale {

@ -1,4 +1,4 @@
import type { LingInstance, LingNode, LingRecord, LingPluralFn, SupportedLocale } from '@/ling/types'; import type { LingInstance, LingNode, LingRecord, LingPluralFn, SupportedLocale } from '@/ling/lib/types.ts';
// ============================== // ==============================
// LOCALE // LOCALE

@ -1,8 +1,8 @@
import type { CurrInstance, CurrDefinition, CurrDisplay, CurrConfig, ConversionValue } from './curr_types'; import type { CurrInstance, CurrDefinition, CurrDisplay, CurrConfig, ConversionValue } from './curr_types.ts';
import type { LingInstance, LingNode } from '@/ling/types'; import type { LingInstance, LingNode } from '@/ling/lib/types.ts';
import type { LocaleResolver, GlobLocale } from './comn_types'; import type { LocaleResolver, GlobLocale } from './comn_types.ts';
import { resolveLocale } from './comn_engine'; import { resolveLocale } from './comn_engine.ts';
import { defaultISOLocale } from '@/ling/consts'; import { defaultISOLocale } from '@/ling/lib/consts.ts';
/** /**

@ -2,8 +2,8 @@
// CURR TYPES // CURR TYPES
// ============================== // ==============================
import type { LingPluralFn } from "../ling"; import type { LingPluralFn } from "@/ling/lib";
import type { LocaleResolver } from "./comn_types"; import type { LocaleResolver } from "./comn_types.ts";
/** /**

@ -1,7 +1,7 @@
import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder } from './dati_types'; import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder } from './dati_types.ts';
import type { LocaleResolver, GlobLocale } from './comn_types'; import type { LocaleResolver, GlobLocale } from './comn_types.ts';
import { resolveLocale } from './comn_engine'; import { resolveLocale } from './comn_engine.ts';
import { defaultISOLocale } from '@/ling/consts'; import { defaultISOLocale } from '@/ling/lib/consts.ts';
// ============================== // ==============================

@ -3,7 +3,7 @@
// DATI // DATI
// ============================== // ==============================
import type { LocaleResolver } from "./comn_types"; import type { LocaleResolver } from "./comn_types.ts";
/** Nivel de detalle en el formateo de fecha u hora. */ /** Nivel de detalle en el formateo de fecha u hora. */
export type DatiStyleType = 'short' | 'long'; export type DatiStyleType = 'short' | 'long';

@ -1,10 +1,10 @@
import type { GlobConfig, GlobInstance } from './glob_types'; import type { GlobConfig, GlobInstance } from './glob_types.ts';
import type { LingNode } from '@/ling/types'; import type { LingNode } from '@/ling/lib/types.ts';
import { createNumr } from './numr_engine'; import { createNumr } from './numr_engine.ts';
import { createCurr } from './curr_engine'; import { createCurr } from './curr_engine.ts';
import { createDati } from './dati_engine'; import { createDati } from './dati_engine.ts';
import { createUnit } from './unit_engine'; import { createUnit } from './unit_engine.ts';
import type { GlobLocale } from './comn_types'; import type { GlobLocale } from './comn_types.ts';
/** /**
* Crea una instancia integrada de globalización. * Crea una instancia integrada de globalización.

@ -2,12 +2,12 @@
// GLOB // GLOB
// ============================== // ==============================
import type { LingInstance, LingNode } from "../ling"; import type { LingInstance, LingNode } from "@/ling/lib";
import type { CurrConfig, CurrInstance } from "./curr_types"; import type { CurrConfig, CurrInstance } from "./curr_types.ts";
import type { DatiConfig, DatiInstance } from "./dati_types"; import type { DatiConfig, DatiInstance } from "./dati_types.ts";
import type { NumrConfig, NumrInstance } from "./numr_types"; import type { NumrConfig, NumrInstance } from "./numr_types.ts";
import type { UnitConfig, UnitInstance } from "./unit_types"; import type { UnitConfig, UnitInstance } from "./unit_types.ts";
import type { GlobLocale } from "./comn_types"; import type { GlobLocale } from "./comn_types.ts";
/** Configuración de `createGlob`. Todos los módulos son opcionales. */ /** Configuración de `createGlob`. Todos los módulos son opcionales. */

@ -0,0 +1,13 @@
export * from './comn_types.ts';
export * from './curr_types.ts';
export * from './dati_types.ts';
export * from './glob_types.ts';
export * from './numr_types.ts';
export * from './unit_types.ts';
export * from './comn_engine.ts';
export * from './curr_engine.ts';
export * from './dati_engine.ts';
export * from './glob_engine.ts';
export * from './numr_engine.ts';
export * from './unit_engine.ts';

@ -1,7 +1,7 @@
import { defaultISOLocale } from '@/ling/consts'; import { defaultISOLocale } from '@/ling/lib/consts.ts';
import { resolveLocale } from './comn_engine'; import { resolveLocale } from './comn_engine.ts';
import type { LocaleResolver } from './comn_types'; import type { LocaleResolver } from './comn_types.ts';
import type { NumrInstance } from './numr_types'; import type { NumrInstance } from './numr_types.ts';
// ============================== // ==============================

@ -2,7 +2,7 @@
// NUMR TYPES // NUMR TYPES
// ============================== // ==============================
import type { LocaleResolver } from "./comn_types"; import type { LocaleResolver } from "./comn_types.ts";
export interface NumrInstance { export interface NumrInstance {
/** /**

@ -1,4 +1,4 @@
import type { UnitCategoryKey, UnitCategoryData } from './unit_types'; import type { UnitCategoryKey, UnitCategoryData } from './unit_types.ts';
/** /**
* Datos numéricos de las unidades — factores de conversión y sistemas. * Datos numéricos de las unidades — factores de conversión y sistemas.

@ -1,11 +1,11 @@
import type { LocaleResolver, GlobLocale } from "./comn_types"; import type { LocaleResolver, GlobLocale } from "./comn_types.ts";
import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types"; import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types.ts";
import { resolveLocale } from "./comn_engine"; import { resolveLocale } from "./comn_engine.ts";
import { defaultISOLocale } from "@/ling/consts"; import { defaultISOLocale } from "@/ling/lib/consts.ts";
import { type LingNode, type LingInstance, ling } from "../ling"; import { type LingNode, type LingInstance, ling } from "@/ling/lib";
import { unitData } from "./unit_data"; import { unitData } from "./unit_data.ts";
import { unitsLingNode } from "./unit_langs"; import { unitsLingNode } from "./unit_langs.ts";

@ -1,5 +1,5 @@
import { p } from '@/ling/engine'; import { p } from '@/ling/lib/engine.ts';
import type { LingNode } from '@/ling/types'; import type { LingNode } from '@/ling/lib/types.ts';
/** /**
* Traducciones de unidades de medida. * Traducciones de unidades de medida.

@ -1,7 +1,7 @@
// ============================== // ==============================
// UNIT TYPES // UNIT TYPES
// ============================== // ==============================
import type { LocaleResolver } from "./comn_types"; import type { LocaleResolver } from "./comn_types.ts";

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createCurr } from '@/glob/curr_engine'; import { createCurr } from '@/glob/lib/curr_engine.ts';
import { createLing, p } from '@/ling/engine'; import { createLing, p } from '@/ling/lib/engine.ts';
import type { CurrDefinition } from '../curr_types'; import type { CurrDefinition } from '../lib/curr_types.ts';
// ============================================================================= // =============================================================================
// FIXTURES // FIXTURES

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createDati } from '@/glob/dati_engine'; import { createDati } from '@/glob/lib/dati_engine.ts';
const DATE = new Date(2026, 1, 28, 10, 30, 0); // 28 feb 2026, 10:30:00 const DATE = new Date(2026, 1, 28, 10, 30, 0); // 28 feb 2026, 10:30:00

@ -1,8 +1,8 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { createGlob } from '@/glob/glob_engine'; import { createGlob } from '@/glob/lib/glob_engine.ts';
import { createLing, p } from '@/ling/engine'; import { createLing, p } from '@/ling/lib/engine.ts';
import type { CurrDefinition } from '../curr_types'; import type { CurrDefinition } from '../lib/curr_types.ts';
import { unitsLingNode } from '../unit_langs'; import { unitsLingNode } from '../lib/unit_langs.ts';
// ============================================================================= // =============================================================================
// FIXTURES // FIXTURES

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createNumr } from '../numr_engine'; import { createNumr } from '../lib/numr_engine.ts';
// ============================================================================= // =============================================================================
// NUMR — Formato de números // NUMR — Formato de números

@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createUnit } from '@/glob/unit_engine'; import { createUnit } from '@/glob/lib/unit_engine.ts';
import { createLing } from '@/ling/engine'; import { createLing } from '@/ling/lib/engine.ts';
function makeLing(locale = 'es') { function makeLing(locale = 'es') {
return createLing({} as any, locale as any); return createLing({} as any, locale as any);
} }

@ -1,4 +1,4 @@
import type {LingLogger} from "@/ling/types.ts"; import type {LingLogger} from "@/ling/lib/types.ts";
export const idPrefix = '#?'; export const idPrefix = '#?';

@ -8,10 +8,10 @@ import type {
LingRecord, LingRecord,
LingString, LingInstance, LingLogger, LingNode, PluralConfig, LingString, LingInstance, LingLogger, LingNode, PluralConfig,
} from './types.ts'; } from './types.ts';
import {LING_ERRORS} from "@/ling/errors.ts"; import {LING_ERRORS} from "@/ling/lib/errors.ts";
import {idPrefix, loggerCategory, maxResolveDeep} from "@/ling/consts.ts"; import {idPrefix, loggerCategory, maxResolveDeep} from "@/ling/lib/consts.ts";
import {isIDLing, isLingRecord} from "@/ling/guards.ts"; import {isIDLing, isLingRecord} from "@/ling/lib/guards.ts";
import {pluralRule} from "@/ling/plural-rules.ts"; import {pluralRule} from "@/ling/lib/plural-rules.ts";
// ============================== // ==============================
@ -186,7 +186,7 @@ export function createLing<S extends LingNode>(
/** /**
* Registra un módulo y devuelve una nueva instancia con el tipo actualizado. * Registra un módulo y devuelve una nueva instancia con el tipo actualizado.
* Útil para contextos aislados, tests, o cuando necesitas el tipo inferido * Útil para contextos aislados, testsass, o cuando necesitas el tipo inferido
* sin declarar FullSchema de antemano. * sin declarar FullSchema de antemano.
* *
* @example * @example

@ -1,5 +1,5 @@
import type {DefaultLocale, IDLing, LingRecord} from "@/ling/types.ts"; import type {DefaultLocale, IDLing, LingRecord, LingString} from "@/ling/lib/types.ts";
import {defaultISOLocale, idPrefix} from "@/ling/consts.ts"; import {defaultISOLocale, idPrefix} from "@/ling/lib/consts.ts";
/** /**
* Guard para identificar referencias. * Guard para identificar referencias.
@ -27,3 +27,16 @@ export function isLingRecord(value: unknown): value is LingRecord {
defaultISOLocale in value // obligatorio según DefaultLocale defaultISOLocale in value // obligatorio según DefaultLocale
); );
} }
/**
* Comprueba si un valor es un LingString.
* Un LingString puede ser:
* - string → texto plano
* - LingRecord → objeto multiidioma { es: '...', en: '...' }
* - IDLing → referencia con prefijo '#?...'
*/
export function isLingString(value: unknown): value is LingString {
return isIDLing(value) || isLingRecord(value) || typeof value === 'string';
}

@ -1,8 +1,8 @@
export { translations } from './translations'; export { translations } from './translations.ts';
export * from './types.ts'; export * from './types.ts';
export * from './engine.ts'; export * from './engine.ts';
export * from './instance.ts'; export * from './instance.ts';
export * from './translations'; export * from './translations.ts';

@ -1,7 +1,7 @@
import { createLing } from './engine.ts'; import { createLing } from './engine.ts';
import type { TranslationSchema } from './translations'; import type { TranslationSchema } from './translations.ts';
import {translations} from './translations'; import {translations} from './translations.ts';
// ─── CASO EAGER (esquema conocido en build time) ──────────────────────────────── // ─── CASO EAGER (esquema conocido en build time) ────────────────────────────────
export const ling = createLing<TranslationSchema>(translations, 'es'); export const ling = createLing<TranslationSchema>(translations, 'es');

@ -2,8 +2,8 @@
// LOCALES // LOCALES
// ============================== // ==============================
import type {idPrefix} from "@/ling/consts.ts"; import type {idPrefix} from "@/ling/lib/consts.ts";
import type {PluralCategory} from "@/ling/plural-rules.ts"; import type {PluralCategory} from "@/ling/lib/plural-rules.ts";
/** /**
* Referencia interna: #?path.del.schema * Referencia interna: #?path.del.schema
@ -168,7 +168,7 @@ export type LingInstance<S extends LingNode = LingNode> = {
onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void; onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void;
/** Muta la instancia actual añadiendo un módulo lazy. El type-safety viene de FullSchema. */ /** Muta la instancia actual añadiendo un módulo lazy. El type-safety viene de FullSchema. */
extend : (namespace: string, module: LingNode) => void; extend : (namespace: string, module: LingNode) => void;
/** Devuelve una nueva instancia con el tipo actualizado. Útil para tests o contextos aislados. */ /** Devuelve una nueva instancia con el tipo actualizado. Útil para testsass o contextos aislados. */
register : <NS extends string, M extends LingNode>( register : <NS extends string, M extends LingNode>(
namespace: NS, namespace: NS,
module: M module: M

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { LingRecord, SupportedLocale } from "@/ling"; import type { LingRecord, SupportedLocale } from "@/ling/lib";
type AsType = 'input' | 'textarea'; type AsType = 'input' | 'textarea';

@ -0,0 +1,185 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LingRecord, SupportedLocale } from "@/ling/lib";
type AsType = 'input' | 'textarea';
let {
value,
locales = ['es'],
defaultLocale,
label = '',
placeholder = '',
required = false,
invalid = false,
rows = 3,
as: asType = 'textarea' as AsType,
localeTab,
onUpdate,
}: {
value: LingRecord;
locales?: string[];
defaultLocale: string;
label?: string;
placeholder?: string;
required?: boolean;
invalid?: boolean;
rows?: number;
as?: AsType;
localeTab?: Snippet<[{ locale: string; active: boolean; hasContent: boolean; activate: () => void }]>;
onUpdate: (value: LingRecord) => void;
} = $props();
const get = (locale: string): string => value[locale as SupportedLocale] ?? '';
let currentLocale = $state(defaultLocale);
let inputText = $state(get(defaultLocale));
let previousLocale = $state(defaultLocale);
let focused = $state(false);
$effect(() => {
if (currentLocale !== previousLocale) {
previousLocale = currentLocale;
inputText = get(currentLocale);
}
});
let isDefault = $derived(currentLocale === defaultLocale);
let charCount = $derived(inputText.length);
let filledCount = $derived(locales.filter(l => get(l).trim().length > 0).length);
let dynamicPH = $derived(placeholder || `Escribe en ${currentLocale.toUpperCase()}…`);
function activateLocale(locale: string) { currentLocale = locale; }
function handleInput() {
const key = currentLocale as SupportedLocale;
const defKey = defaultLocale as SupportedLocale;
let next: LingRecord;
if (isDefault) {
next = { ...value, [defKey]: inputText };
} else {
if (inputText.trim() === '') {
const { [key]: _, ...rest } = value;
next = rest as LingRecord;
} else {
next = { ...value, [key]: inputText };
}
}
onUpdate(next);
}
function hasContent(locale: string): boolean {
if (locale === currentLocale) return !!(inputText.trim());
return !!(get(locale).trim());
}
</script>
<div class="flex w-full flex-col gap-2">
{#if label}
<div class="flex items-center justify-between">
<label class="flex items-center gap-1.5">
<span class={['text-[13px] font-semibold', invalid ? 'text-red-500' : 'text-zinc-700 dark:text-zinc-200'].join(' ')}>
{label}
</span>
{#if required}<span class="text-red-400">*</span>{/if}
</label>
<span class="text-[12px] text-zinc-400 dark:text-zinc-500">
{filledCount}/{locales.length} idiomas
</span>
</div>
{/if}
<div class={[
'overflow-hidden rounded-xl border-2 transition-all duration-200 bg-white dark:bg-zinc-900',
focused && !invalid ? 'border-zinc-900 dark:border-zinc-100'
: invalid ? 'border-red-400'
: 'border-zinc-200 dark:border-zinc-700',
].join(' ')}>
<!-- Locale tabs -->
<div class="flex items-center border-b border-zinc-100 dark:border-zinc-800">
<div role="tablist" class="flex flex-1">
{#each locales as locale (locale)}
{#if localeTab}
{@render localeTab({ locale, active: currentLocale === locale, hasContent: hasContent(locale), activate: () => activateLocale(locale) })}
{:else}
{@const active = currentLocale === locale}
{@const filled = hasContent(locale)}
{@const isDef = locale === defaultLocale}
<button
type="button" role="tab" aria-selected={active}
onclick={() => activateLocale(locale)}
class={[
'relative flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-medium transition-colors duration-150 focus:outline-none',
active
? invalid ? 'text-red-500' : 'text-zinc-900 dark:text-zinc-100'
: filled ? 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
: 'text-zinc-300 hover:text-zinc-500 dark:text-zinc-600 dark:hover:text-zinc-400',
].join(' ')}
>
{locale.toUpperCase()}
{#if isDef && !active}
<span class={['h-1.5 w-1.5 rounded-full', filled ? 'bg-emerald-400' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}></span>
{:else if filled && !active && !isDef}
<span class="h-1.5 w-1.5 rounded-full bg-violet-400"></span>
{/if}
{#if active}
<span class={['absolute bottom-0 left-0 right-0 h-0.5', invalid ? 'bg-red-400' : 'bg-zinc-900 dark:bg-zinc-100'].join(' ')}></span>
{/if}
</button>
{/if}
{/each}
</div>
{#if charCount > 0}
<span class="px-4 text-[12px] tabular-nums text-zinc-300 dark:text-zinc-600">{charCount}</span>
{/if}
</div>
{#if asType === 'textarea'}
<textarea
aria-label={`Texto en ${currentLocale}`}
aria-required={isDefault && required}
aria-invalid={invalid}
required={isDefault && required}
{rows}
placeholder={dynamicPH}
bind:value={inputText}
oninput={handleInput}
onfocus={() => focused = true}
onblur={() => focused = false}
style="height: calc({rows} * 1.65rem + 1.75rem)"
class="w-full resize-none bg-transparent px-4 py-3 text-[14px] leading-relaxed
text-zinc-800 placeholder:text-zinc-300 focus:outline-none
dark:text-zinc-200 dark:placeholder:text-zinc-600"
></textarea>
{:else}
<input
type="text"
aria-label={`Texto en ${currentLocale}`}
aria-required={isDefault && required}
aria-invalid={invalid}
required={isDefault && required}
placeholder={dynamicPH}
bind:value={inputText}
oninput={handleInput}
onfocus={() => focused = true}
onblur={() => focused = false}
class="h-11 w-full bg-transparent px-4 text-[14px] text-zinc-800
placeholder:text-zinc-300 focus:outline-none
dark:text-zinc-200 dark:placeholder:text-zinc-600"
/>
{/if}
{#if invalid}
<div class="flex items-center gap-2 border-t border-red-100 bg-red-50 px-4 py-2.5 dark:border-red-900/30 dark:bg-red-900/10">
<svg class="h-4 w-4 shrink-0 text-red-400" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<span class="text-[13px] text-red-500">
El campo <strong>{defaultLocale.toUpperCase()}</strong> es obligatorio
</span>
</div>
{/if}
</div>
</div>

@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { createLing } from '@/ling/engine'; import { createLing } from '@/ling/lib/engine.ts';
import type { LingNode } from '@/ling/types'; import type { LingNode } from '@/ling/lib/types.ts';
// ─── SCHEMAS DE TEST ───────────────────────────────────────────────────────── // ─── SCHEMAS DE TEST ─────────────────────────────────────────────────────────
@ -94,7 +94,7 @@ describe('Carga Eager', () => {
// ============================================================================= // =============================================================================
// LAZY — extend() sobre instancia global // LAZY — extend() sobre instancia global
// Los tests de lazy verifican comportamiento en runtime, no tipos. // Los testsass de lazy verifican comportamiento en runtime, no tipos.
// El type-safety del lazy se verifica en instance.ts con FullSchema + import type. // El type-safety del lazy se verifica en instance.ts con FullSchema + import type.
// Aquí usamos createLing sin genérico — t() acepta cualquier string. // Aquí usamos createLing sin genérico — t() acepta cualquier string.
// ============================================================================= // =============================================================================

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import {createLing, ling, type LingString} from '@/ling'; import {createLing, ling, type LingString} from '@/ling/lib';
import type { LingNode } from '@/ling'; import type { LingNode } from '@/ling/lib';
// ============================== // ==============================

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLing } from '@/ling'; import { createLing } from '@/ling/lib';
import type { LingNode, LingString } from '@/ling'; import type { LingNode, LingString } from '@/ling/lib';
// ============================== // ==============================
// SCHEMA DE TEST ACTUALIZADO // SCHEMA DE TEST ACTUALIZADO
@ -27,7 +27,7 @@ const testSchema = {
invoice: '#?checkout.total' invoice: '#?checkout.total'
}, },
danger: { danger: {
// Referencia circular para test de seguridad // Referencia circular para testsass de seguridad
loopA: '#?danger.loopB', loopA: '#?danger.loopB',
loopB: '#?danger.loopA' loopB: '#?danger.loopA'
} }
@ -128,5 +128,5 @@ describe('Ling Library', () => {
expect(extended.t('admin.saveBtn')).toBe('Guardar'); expect(extended.t('admin.saveBtn')).toBe('Guardar');
}); });
}); });
});// ling2.test.ts });// ling2.testsass.ts

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createLing, p } from '@/ling'; import { createLing, p } from '@/ling/lib';
describe('Pluralización con helper p()', () => { describe('Pluralización con helper p()', () => {

@ -1,8 +1,8 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { lingNodeToJSON, JSONToLingNode } from '../json'; import { lingNodeToJSON, JSONToLingNode } from '../lib/json.ts';
import { p } from '../engine'; import { p } from '../lib/engine.ts';
import { createLing } from '../engine'; import { createLing } from '../lib/engine.ts';
import type { LingNode } from '../types'; import type { LingNode } from '../lib/types.ts';
describe('Ling Serialization System', () => { describe('Ling Serialization System', () => {
@ -107,5 +107,5 @@ describe('Ling Serialization System', () => {
expect(ling.t("user.items" as any, { count: 5 })).toBe("5 items"); expect(ling.t("user.items" as any, { count: 5 })).toBe("5 items");
}); });
}); });
});// serializer.test.ts });// serializer.testsass.ts

@ -97,12 +97,12 @@ logr.getLogs({ category: 'auth', level: LogLevel.WARN }) // combinados
> Los mensajes se devuelven como `I18nString` sin resolver. > Los mensajes se devuelven como `I18nString` sin resolver.
### `serialize()` ### `serialization()`
Exporta el historial como JSON. Los mensajes se resuelven al locale activo **en el momento de la llamada**. Exporta el historial como JSON. Los mensajes se resuelven al locale activo **en el momento de la llamada**.
```ts ```ts
const json = logr.serialize(); const json = logr.serialization();
// [ // [
// { // {
// "timestamp": "2024-01-01T00:00:00.000Z", // "timestamp": "2024-01-01T00:00:00.000Z",
@ -125,7 +125,7 @@ logr.getLogs(); // → []
```ts ```ts
logr.setLevel(LogLevel.DEBUG) // activa todos los niveles logr.setLevel(LogLevel.DEBUG) // activa todos los niveles
logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en tests logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en testsass
``` ```
### `setMaxLogs(max)` ### `setMaxLogs(max)`
@ -199,7 +199,7 @@ callbackTransport((entry, message) => {
} }
}) })
// Captura en tests — sin output a consola // Captura en testsass — sin output a consola
const captured: string[] = []; const captured: string[] = [];
const logr = createLogr(i18n, { const logr = createLogr(i18n, {
level : LogLevel.DEBUG, level : LogLevel.DEBUG,
@ -255,7 +255,7 @@ logr.warn('auth', { es: 'Sesión expirada', en: 'Session expired' });
El mensaje se resuelve **una sola vez** por entrada y se pasa ya resuelto a todos los transports. El mensaje se resuelve **una sola vez** por entrada y se pasa ya resuelto a todos los transports.
> El mensaje se almacena como `I18nString` sin resolver. La resolución al locale ocurre en el momento del output, y en `serialize()` en el momento de la llamada. > El mensaje se almacena como `I18nString` sin resolver. La resolución al locale ocurre en el momento del output, y en `serialization()` en el momento de la llamada.
--- ---

@ -1,7 +1,7 @@
import type { import type {
LingInstance, LingInstance,
LingString LingString
} from '@/ling'; } from '@/ling/lib';
import type { import type {
LogrOptions, LogrOptions,
@ -129,7 +129,7 @@ export function createLogr(ling: LingInstance, options: LogrOptions = {}): Logr
/** /**
* Los mensajes se resuelven al locale activo en el momento * Los mensajes se resuelven al locale activo en el momento
* de llamar a serialize(), no al momento en que se registraron. * de llamar a serialization(), no al momento en que se registraron.
*/ */
function serialize(): string { function serialize(): string {
return JSON.stringify( return JSON.stringify(

@ -99,7 +99,7 @@ export function httpTransport(options: HttpTransportOptions): Transport {
/** /**
* Transport que invoca una función callback por cada entrada de log. * Transport que invoca una función callback por cada entrada de log.
* Es el transport más flexible — ideal para integraciones custom, * Es el transport más flexible — ideal para integraciones custom,
* tests, o cuando necesitas lógica de routing entre destinos. * testsass, o cuando necesitas lógica de routing entre destinos.
* *
* @example * @example
* // Integración con Sentry * // Integración con Sentry
@ -110,7 +110,7 @@ export function httpTransport(options: HttpTransportOptions): Transport {
* }) * })
* *
* @example * @example
* // En tests — captura los logs sin output a consola * // En testsass — captura los logs sin output a consola
* const captured: string[] = []; * const captured: string[] = [];
* callbackTransport((entry, message) => captured.push(message)) * callbackTransport((entry, message) => captured.push(message))
*/ */

@ -1,4 +1,4 @@
import type { LingString } from '@/ling'; import type { LingString } from '@/ling/lib';
// ============================================================================ // ============================================================================
@ -24,7 +24,7 @@ export enum LogLevel {
WARN = 2, WARN = 2,
/** Errores que requieren atención inmediata */ /** Errores que requieren atención inmediata */
ERROR = 3, ERROR = 3,
/** Desactiva todos los logs — útil en tests */ /** Desactiva todos los logs — útil en testsass */
NONE = 4 NONE = 4
} }

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLogr, LogLevel } from '@/logr'; import { createLogr, LogLevel } from '@/logr/lib';
import { createLing } from '@/ling'; import { createLing } from '@/ling/lib';
import type { LingNode, SupportedLocale } from '@/ling'; import type { LingNode, SupportedLocale } from '@/ling/lib';
@ -272,7 +272,7 @@ describe('createLogger', () => {
}); });
}); });
describe('serialize()', () => { describe('serialization()', () => {
it('exporta los logs como JSON con mensajes resueltos', () => { it('exporta los logs como JSON con mensajes resueltos', () => {
const i18n = makeI18n('en'); const i18n = makeI18n('en');
const logger = createLogr(i18n, { level: LogLevel.DEBUG }); const logger = createLogr(i18n, { level: LogLevel.DEBUG });

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import {callbackTransport, consoleTransport, createLogr, httpTransport, type LogEntry, LogLevel} from '@/logr'; import {callbackTransport, consoleTransport, createLogr, httpTransport, type LogEntry, LogLevel} from '@/logr/lib';
import { createLing } from '@/ling'; import { createLing } from '@/ling/lib';
import type { LingNode, SupportedLocale } from '@/ling'; import type { LingNode, SupportedLocale } from '@/ling/lib';
// ============================================================================ // ============================================================================
// HELPERS // HELPERS
@ -182,7 +182,7 @@ describe('callbackTransport', () => {
expect(fn).toHaveBeenCalledWith(entry, 'Login fallido'); expect(fn).toHaveBeenCalledWith(entry, 'Login fallido');
}); });
it('se puede usar para capturar logs en tests', () => { it('se puede usar para capturar logs en testsass', () => {
const i18n = makeI18n('en'); const i18n = makeI18n('en');
const captured: string[] = []; const captured: string[] = [];
const logr = createLogr(i18n, { const logr = createLogr(i18n, {
@ -246,7 +246,7 @@ describe('múltiples transports', () => {
}); });
// El segundo transport sí debería ejecutarse aunque el primero falle // El segundo transport sí debería ejecutarse aunque el primero falle
// Este test documenta el comportamiento actual — si se quiere // Este testsass documenta el comportamiento actual — si se quiere
// aislamiento habría que añadir try/catch en el engine // aislamiento habría que añadir try/catch en el engine
expect(() => logr.warn('auth', { es: 'msg' })).toThrow(); expect(() => logr.warn('auth', { es: 'msg' })).toThrow();
}); });

@ -0,0 +1,139 @@
import type {LingString} from "@/ling/lib";
import type {
Metadata, MetadataField,
CurrencyValue, MediaRef,
StringField, NumberField, BooleanField, LingStringField,
DateField, DateTimeField, UrlField, CurrencyField,
ImageField, VideoField, AudioField, PdfField, FileField, GroupField,
} from './types.ts';
// ─────────────────────────────────────────────
// Builders de campo (devuelven MetadataField)
// ─────────────────────────────────────────────
export const string = (value: string): StringField => ({ type: 'string', value });
export const number = (value: number): NumberField => ({ type: 'number', value });
export const boolean = (value: boolean): BooleanField => ({ type: 'boolean', value });
export const lingString = (value: LingString): LingStringField => ({ type: 'lingstring', value });
export const date = (value: string): DateField => ({ type: 'date', value });
export const datetime = (value: string): DateTimeField => ({ type: 'datetime', value });
export const url = (value: string): UrlField => ({ type: 'url', value });
export const currency = (value: CurrencyValue): CurrencyField => ({ type: 'currency', value });
export const image = (value: MediaRef): ImageField => ({ type: 'image', value });
export const video = (value: MediaRef): VideoField => ({ type: 'video', value });
export const audio = (value: MediaRef): AudioField => ({ type: 'audio', value });
export const pdf = (value: MediaRef): PdfField => ({ type: 'pdf', value });
export const file = (value: MediaRef): FileField => ({ type: 'file', value });
export const group = (value: Metadata, order?: string[]): GroupField => ({ type: 'group', value, ...(order !== undefined && { order }) });
/** Namespace agrupado para importar como `field.string(...)`, `field.group(...)`, etc. */
export const field = {
string, number, boolean, lingstring: lingString, date, datetime,
url, currency, image, video, audio, pdf, file, group,
} as const;
// ─────────────────────────────────────────────
// Operaciones sobre Metadata (inmutables)
// ─────────────────────────────────────────────
/**
* Obtiene el MetadataField asociado a una clave (solo nivel raíz).
* Devuelve undefined si la clave no existe o el valor no es un MetadataField.
*/
export function get(metadata: Metadata, key: string): MetadataField | undefined {
const value = metadata[key];
if (typeof value !== 'object' || value === null) return undefined;
const v = value as Record<string, unknown>;
if (typeof v['type'] !== 'string') return undefined;
return value as MetadataField;
}
/**
* Búsqueda recursiva por clave en toda la jerarquía de grupos.
* Devuelve el primer MetadataField cuya clave coincida.
*/
export function find(metadata: Metadata, key: string): MetadataField | undefined {
for (const [k, value] of Object.entries(metadata)) {
if (k === key) return value as MetadataField;
// Si es un grupo, buscar dentro recursivamente
if (
typeof value === 'object' && value !== null &&
(value as Record<string, unknown>)['type'] === 'group'
) {
const groupValue = (value as GroupField).value;
const found = find(groupValue, key);
if (found) return found;
}
}
return undefined;
}
/**
* Añade una clave al Metadata. Lanza error si la clave ya existe.
* Devuelve un nuevo Metadata (inmutable).
*/
export function add(metadata: Metadata, key: string, value: MetadataField): Metadata {
if (key in metadata) throw new Error(`Key "${key}" already exists in Metadata`);
return { ...metadata, [key]: value };
}
/**
* Establece (crea o reemplaza) una clave en el Metadata.
* Devuelve un nuevo Metadata (inmutable).
*/
export function set(metadata: Metadata, key: string, value: MetadataField): Metadata {
return { ...metadata, [key]: value };
}
/**
* Elimina una clave del Metadata (no-op si no existe).
* Devuelve un nuevo Metadata (inmutable).
*/
export function remove(metadata: Metadata, key: string): Metadata {
const { [key]: _, ...rest } = metadata;
return rest;
}
/**
* Convierte un Metadata a un objeto plano con los valores resueltos.
* Los grupos se convierten en objetos anidados.
*
* @example
* toPlain({ brand: { type: 'string', value: 'Toyota' } })
* // → { brand: 'Toyota' }
*/
export function toPlain(metadata: Metadata): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(metadata)) {
if (typeof value !== 'object' || value === null) { result[key] = value; continue; }
const field = value as MetadataField;
result[key] = field.type === 'group' ? toPlain(field.value) : field.value;
}
return result;
}
// ─────────────────────────────────────────────
// Resolución de orden en GroupField
// ─────────────────────────────────────────────
/**
* Devuelve las claves de un GroupField en el orden correcto.
*
* - Si `order` está definido: primero las claves de `order` (ignorando las que
* no existan en `value`), luego las claves restantes en orden natural.
* - Si `order` es undefined: orden natural del Record.
*
* @example
* resolveOrder({ type: 'group', value: { hp: ..., fuel: ... }, order: ['fuel', 'hp'] })
* // → ['fuel', 'hp']
*/
export function resolveOrder(groupField: GroupField): string[] {
const allKeys = Object.keys(groupField.value);
if (!groupField.order || groupField.order.length === 0) return allKeys;
const ordered = groupField.order.filter(k => k in groupField.value);
const rest = allKeys.filter(k => !ordered.includes(k));
return [...ordered, ...rest];
}

@ -0,0 +1,37 @@
// ─────────────────────────────────────────────
// Type guards de primitivos
// ─────────────────────────────────────────────
import type {CurrencyValue, MediaRef, MetadataField, MetadataFieldType} from "@/mtas/lib/types.ts";
export function isCurrencyValue(v: unknown): v is CurrencyValue {
if (typeof v !== 'object' || v === null) return false;
const c = v as Record<string, unknown>;
return typeof c['amount'] === 'number' && typeof c['currency'] === 'string' && c['currency'].length > 0;
}
export function isMediaRef(v: unknown): v is MediaRef {
if (typeof v !== 'object' || v === null) return false;
const m = v as Record<string, unknown>;
return typeof m['url'] === 'string' && m['url'].length > 0;
}
export function isValidUrl(value: string): boolean {
try { new URL(value); return true; } catch { return false; }
}
const KNOWN_TYPES: MetadataFieldType[] = [
'string','number','boolean','lingstring','date','datetime',
'url','currency','image','video','audio','pdf','file','group',
];
export function isMetadataField(v: unknown): v is MetadataField {
if (typeof v !== 'object' || v === null) return false;
const f = v as Record<string, unknown>;
return KNOWN_TYPES.includes(f['type'] as MetadataFieldType);
}

@ -0,0 +1,9 @@
export * from './builders.ts'
export * from './guards.ts'
export * from './validation.ts'
export * from './builders.ts'
export * from './serialization.ts'
export * from './types.ts'
export * from './version.ts'

@ -0,0 +1,99 @@
import type {DeserializeResult, Metadata, SerializedMetadata} from './types';
import { validateMetadata } from './validation.ts';
import {CURRENT_VERSION} from "@/mtas/lib/version.ts";
// ─────────────────────────────────────────────
// Migraciones por versión
// Añadir aquí cuando __v cambie en el futuro.
// ─────────────────────────────────────────────
type Migrator = (data: unknown) => Metadata;
const migrations: Record<number, Migrator> = {
1: (data) => data as Metadata, // v1 → v1: sin transformación
};
function migrate(version: number, data: unknown): Metadata | undefined {
const migrator = migrations[version];
if (!migrator) return undefined;
return migrator(data);
}
// ─────────────────────────────────────────────
// serialization
// ─────────────────────────────────────────────
/**
* Serializa un Metadata a string JSON versionado.
*
* @example
* const json = serialization({ brand: { type: 'string', value: 'Toyota' } });
* // '{"__v":1,"data":{"brand":{"type":"string","value":"Toyota"}}}'
*/
export function serialization(metadata: Metadata): string {
const payload: SerializedMetadata = {
__v: CURRENT_VERSION,
data: metadata,
};
return JSON.stringify(payload);
}
// ─────────────────────────────────────────────
// deserialize
// ─────────────────────────────────────────────
/**
* Deserializa un string JSON versionado a Metadata.
* Valida el resultado antes de devolverlo.
* Devuelve DeserializeError si el JSON es inválido o la validación falla.
*
* @example
* const result = deserialize('{"__v":1,"data":{...}}');
* if (result.ok) {
* const metadata = result.metadata;
* } else {
* console.error(result.error, result.validation?.errors);
* }
*/
export function deserialize(json: string): DeserializeResult {
// 1. Parse JSON
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return { ok: false, error: 'Invalid JSON string' };
}
// 2. Comprobar estructura mínima
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return { ok: false, error: 'Serialized payload must be a plain object' };
}
const payload = parsed as Record<string, unknown>;
if (typeof payload['__v'] !== 'number') {
return { ok: false, error: 'Missing or invalid "__v" version field' };
}
if (!('data' in payload)) {
return { ok: false, error: 'Missing "data" field' };
}
// 3. Migrar según versión
const version = payload['__v'] as number;
const migrated = migrate(version, payload['data']);
if (!migrated) {
return { ok: false, error: `Unsupported version: ${version}` };
}
// 4. Validar el Metadata resultante
const validation = validateMetadata(migrated);
if (!validation.valid) {
return { ok: false, error: 'Metadata validation failed', validation };
}
return { ok: true, metadata: migrated };
}

@ -0,0 +1,183 @@
import type {LingString} from "@/ling/lib";
import type {CURRENT_VERSION} from "@/mtas/lib/version.ts";
// ─────────────────────────────────────────────
// Tipos base — tal como fueron definidos
// ─────────────────────────────────────────────
/**
* Metadata arbitraria — pares clave/valor para datos adicionales.
* Los valores son MetadataField (discriminated union por 'type').
* Un valor puede ser otro Metadata (grupo anidado via GroupField).
*/
export type Metadata = Record<string, unknown>;
// ─────────────────────────────────────────────
// Primitivos de soporte
// ─────────────────────────────────────────────
/** Valor monetario con código ISO 4217 */
export interface CurrencyValue {
amount : number;
currency: string; // "EUR", "USD"…
}
/** Referencia a un recurso externo (imagen, vídeo, audio, pdf, fichero) */
export interface MediaRef {
url : string;
mimeType?: string;
size? : number; // bytes
filename?: string;
alt? : LingString;
}
// ─────────────────────────────────────────────
// Campos tipados — Discriminated Union
// Los valores del Record<string, unknown> de Metadata son de este tipo.
// ─────────────────────────────────────────────
interface FieldBase {
type : MetadataFieldType;
hidden? : boolean;
// futuras: required?, disabled?, label?...
}
export interface StringField extends FieldBase { type: 'string'; value: string }
export interface NumberField extends FieldBase { type: 'number'; value: number }
export interface BooleanField extends FieldBase { type: 'boolean'; value: boolean }
export interface LingStringField extends FieldBase { type: 'lingstring'; value: LingString }
export interface DateField extends FieldBase { type: 'date'; value: string } // ISO 8601: "YYYY-MM-DD"
export interface DateTimeField extends FieldBase { type: 'datetime'; value: string } // ISO 8601: "YYYY-MM-DDThh:mm:ssZ"
export interface UrlField extends FieldBase { type: 'url'; value: string }
export interface CurrencyField extends FieldBase { type: 'currency'; value: CurrencyValue }
export interface ImageField extends FieldBase { type: 'image'; value: MediaRef }
export interface VideoField extends FieldBase { type: 'video'; value: MediaRef }
export interface AudioField extends FieldBase { type: 'audio'; value: MediaRef }
export interface PdfField extends FieldBase { type: 'pdf'; value: MediaRef }
export interface FileField extends FieldBase { type: 'file'; value: MediaRef }
/**
* Grupo anidado — su value ES otro Metadata.
* Permite construir estructuras jerárquicas de profundidad arbitraria.
*
* @example
* const field: GroupField = {
* type: 'group',
* value: {
* hp: { type: 'number', value: 120 },
* fuel: { type: 'string', value: 'diesel' },
* }
* }
*/
export interface GroupField {
type: 'group';
order?: string[]; // claves en el orden deseado
value: Metadata;
}
/** Unión discriminada de todos los tipos de campo */
export type MetadataField =
| StringField
| NumberField
| BooleanField
| LingStringField
| DateField
| DateTimeField
| UrlField
| CurrencyField
| ImageField
| VideoField
| AudioField
| PdfField
| FileField
| GroupField;
/** Todos los discriminantes posibles */
export type MetadataFieldType = MetadataField['type'];
// ─────────────────────────────────────────────
// Mixin — adjunta Metadata a cualquier entidad
// ─────────────────────────────────────────────
/**
* @example
* interface Car extends WithMetadata {
* id: string;
* model: string;
* }
*
* const car: Car = {
* id: '1',
* model: 'Corolla',
* metadata: {
* brand: { type: 'string', value: 'Toyota' },
* price: { type: 'currency', value: { amount: 25000, currency: 'EUR' } },
* specs: { type: 'group', value: {
* hp: { type: 'number', value: 120 },
* }},
* }
* };
*/
export type WithMetadata<T extends object = object> = T & {
metadata?: Metadata;
};
// ─────────────────────────────────────────────
// Resultado de validación
// ─────────────────────────────────────────────
export interface ValidationOk { valid: true }
export interface ValidationError { valid: false; errors: string[] }
export type ValidationResult = ValidationOk | ValidationError;
// ─────────────────────────────────────────────
// Formato serializado con versión
// ─────────────────────────────────────────────
export type SerializedVersion = typeof CURRENT_VERSION;
/**
* Formato wire de un Metadata serializado.
* El campo `__v` permite migrar el schema en el futuro
* sin romper consumidores que manejen versiones anteriores.
*/
export interface SerializedMetadata {
__v: SerializedVersion;
data: Metadata;
}
// ─────────────────────────────────────────────
// Resultado de deserialización
// ─────────────────────────────────────────────
export interface DeserializeOk {
ok: true;
metadata: Metadata;
}
export interface DeserializeError {
ok: false;
error: string;
validation?: ValidationError;
}
export type DeserializeResult = DeserializeOk | DeserializeError;

@ -0,0 +1,117 @@
import type {
Metadata, MetadataField, ValidationError, ValidationOk, ValidationResult
} from './types.ts';
import {isLingString} from "@/ling/lib/guards.ts";
import {isCurrencyValue, isMediaRef, isMetadataField, isValidUrl} from "@/mtas/lib/guards.ts";
const ok = (): ValidationOk => ({ valid: true });
const fail = (...errors: string[]): ValidationError => ({ valid: false, errors });
function mergeResults(...results: ValidationResult[]): ValidationResult {
const errors = results.flatMap(r => r.valid ? [] : r.errors);
return errors.length > 0 ? fail(...errors) : ok();
}
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
// ─────────────────────────────────────────────
// Validadores por tipo
// ─────────────────────────────────────────────
function validateByType(key: string, field: MetadataField): ValidationResult {
switch (field.type) {
case 'string':
return ok();
case 'number':
return !isNaN(field.value)
? ok() : fail(`[${key}] value must be a valid number`);
case 'boolean':
return ok();
case 'lingstring':
return isLingString(field.value)
? ok() : fail(`[${key}] value must be a LingString (string | LingRecord | IDLing)`);
case 'date':
return ISO_DATE_RE.test(field.value)
? ok() : fail(`[${key}] value must be an ISO 8601 date (YYYY-MM-DD)`);
case 'datetime':
return ISO_DATETIME_RE.test(field.value)
? ok() : fail(`[${key}] value must be an ISO 8601 datetime (YYYY-MM-DDThh:mm:ssZ)`);
case 'url':
return isValidUrl(field.value)
? ok() : fail(`[${key}] value must be a valid URL`);
case 'currency': {
if (!isCurrencyValue(field.value)) return fail(`[${key}] value must be { amount: number, currency: string }`);
if (field.value.currency.length !== 3) return fail(`[${key}] currency must be a 3-letter ISO 4217 code`);
return ok();
}
case 'image':
case 'video':
case 'audio':
case 'pdf':
case 'file': {
if (!isMediaRef(field.value)) return fail(`[${key}] value must be a MediaRef with a non-empty 'url'`);
if (!isValidUrl(field.value.url)) return fail(`[${key}] MediaRef.url must be a valid URL`);
return ok();
}
case 'group':
return validateMetadata(field.value, key);
default: {
const exhaustive: never = field;
return fail(`Unknown field type: ${(exhaustive as MetadataField).type}`);
}
}
}
// ─────────────────────────────────────────────
// Validadores públicos
// ─────────────────────────────────────────────
/**
* Valida un único valor extraído de un Metadata.
* @param key - clave dentro del Metadata (para mensajes de error)
* @param value - el valor en bruto (unknown) almacenado en el Record
*/
export function validateField(key: string, value: unknown): ValidationResult {
if (typeof key !== 'string' || key.trim() === '')
return fail('Field key must be a non-empty string');
if (!isMetadataField(value))
return fail(`[${key}] value must be a MetadataField with a known 'type'`);
return validateByType(key, value);
}
/**
* Valida un objeto Metadata completo (todos sus pares clave/valor).
* @param metadata - el Record<string, unknown> a validar
* @param prefix - prefijo para los mensajes de error en grupos anidados
*/
export function validateMetadata(metadata: Metadata, prefix = ''): ValidationResult {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata))
return fail(`${prefix ? `[${prefix}] ` : ''}Metadata must be a plain object`);
const results = Object.entries(metadata).map(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
return validateField(fullKey, value);
});
return mergeResults(...results);
}

@ -0,0 +1,3 @@
export const CURRENT_VERSION = 1 as const;

@ -0,0 +1,168 @@
<script lang="ts">
import type { MetadataField, MetadataFieldType } from '../../lib';
import LingInput from '@/ling/svelte/LingInput2.svelte';
let {
fieldKey, field, locales, defaultLocale, onKeyChange, onFieldChange,
}: {
fieldKey: string;
field: MetadataField;
locales: string[];
defaultLocale: string;
onKeyChange: (newKey: string) => void;
onFieldChange: (field: MetadataField) => void;
} = $props();
let editKey = $state(fieldKey);
function handleKeyBlur() {
const trimmed = editKey.trim();
if (trimmed && trimmed !== fieldKey) onKeyChange(trimmed);
else editKey = fieldKey;
}
function update(partial: Partial<MetadataField>) {
onFieldChange({ ...field, ...partial } as MetadataField);
}
function updateMediaUrl(url: string) {
onFieldChange({ ...field, value: { ...(field as any).value, url } } as MetadataField);
}
// Clases comunes para inputs
const inputCls = 'w-full rounded-lg border-2 border-zinc-200 bg-white px-3.5 py-2.5 text-[14px] text-zinc-800 transition-colors focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-zinc-100';
const labelCls = 'mb-1.5 block text-[12px] font-semibold uppercase tracking-wide text-zinc-400 dark:text-zinc-500';
</script>
<div class="flex flex-col gap-4 px-5 py-4">
<!-- Key -->
<div>
<label class={labelCls}>Clave del campo</label>
<input
bind:value={editKey}
onblur={handleKeyBlur}
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
class="{inputCls} font-mono"
placeholder="nombre_campo"
/>
</div>
<!-- Value per type -->
{#if field.type === 'string'}
<div>
<label class={labelCls}>Valor</label>
<input type="text" value={field.value}
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
class={inputCls} placeholder="Texto…" />
</div>
{:else if field.type === 'number'}
<div>
<label class={labelCls}>Valor numérico</label>
<input type="number" value={field.value}
oninput={(e) => update({ value: parseFloat((e.currentTarget as HTMLInputElement).value) })}
class={inputCls} />
</div>
{:else if field.type === 'boolean'}
<div>
<label class={labelCls}>Valor</label>
<div class="flex items-center gap-3">
<button type="button" onclick={() => update({ value: !field.value })}
class={['relative h-7 w-12 rounded-full transition-colors duration-200',
field.value ? 'bg-zinc-900 dark:bg-zinc-100' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}>
<span class={['absolute top-1 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
field.value ? 'translate-x-6 dark:bg-zinc-900' : 'translate-x-1'].join(' ')}></span>
</button>
<span class="text-[14px] font-medium text-zinc-700 dark:text-zinc-300">
{field.value ? 'Verdadero' : 'Falso'}
</span>
</div>
</div>
{:else if field.type === 'lingstring'}
<LingInput
value={field.value as any}
{locales}
{defaultLocale}
label="Valor multiidioma"
as="input"
onUpdate={(val) => update({ value: val })}
/>
{:else if field.type === 'date'}
<div>
<label class={labelCls}>Fecha</label>
<input type="date" value={field.value}
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
class={inputCls} />
</div>
{:else if field.type === 'datetime'}
<div>
<label class={labelCls}>Fecha y hora</label>
<input type="datetime-local" value={field.value?.replace('Z', '')}
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value + 'Z' })}
class={inputCls} />
</div>
{:else if field.type === 'url'}
<div>
<label class={labelCls}>URL</label>
<input type="text" value={field.value}
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
class={inputCls} placeholder="https://… o /ruta/relativa" />
</div>
{:else if field.type === 'currency'}
<div class="flex gap-3">
<div class="flex-1">
<label class={labelCls}>Cantidad</label>
<input type="number" value={field.value.amount}
oninput={(e) => update({ value: { ...field.value, amount: parseFloat((e.currentTarget as HTMLInputElement).value) } })}
class={inputCls} />
</div>
<div class="w-28">
<label class={labelCls}>Moneda (ISO)</label>
<input type="text" maxlength="3" value={field.value.currency}
oninput={(e) => update({ value: { ...field.value, currency: (e.currentTarget as HTMLInputElement).value.toUpperCase() } })}
class="{inputCls} font-mono uppercase" placeholder="EUR" />
</div>
</div>
{:else if field.type === 'image' || field.type === 'video' || field.type === 'audio' || field.type === 'pdf' || field.type === 'file'}
<div>
<label class={labelCls}>Ruta o URL</label>
<input type="text" value={field.value.url}
oninput={(e) => updateMediaUrl((e.currentTarget as HTMLInputElement).value)}
class={inputCls} placeholder="/ruta/relativa o https://…" />
</div>
{#if field.type === 'image' && field.value.url}
<div class="overflow-hidden rounded-xl border-2 border-zinc-100 dark:border-zinc-800">
<img src={field.value.url} alt={fieldKey}
class="max-h-52 w-full object-cover" />
</div>
{/if}
{:else if field.type === 'group'}
<p class="text-[13px] text-zinc-400">
Edita los campos del grupo expandiéndolos en la lista.
</p>
{/if}
<!-- Hidden toggle -->
<div class="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800">
<div>
<p class="text-[13px] font-semibold text-zinc-700 dark:text-zinc-300">Ocultar campo</p>
<p class="text-[12px] text-zinc-400">El campo no se mostrará en la UI</p>
</div>
<button type="button" onclick={() => update({ hidden: !field.hidden })}
class={['relative h-7 w-12 rounded-full transition-colors duration-200',
field.hidden ? 'bg-zinc-900 dark:bg-zinc-100' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}>
<span class={['absolute top-1 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
field.hidden ? 'translate-x-6 dark:bg-zinc-900' : 'translate-x-1'].join(' ')}></span>
</button>
</div>
</div>

@ -0,0 +1,300 @@
<script lang="ts">
import type { MetadataField, MetadataFieldType } from '../../lib';
import { validateField, field as f } from '../../lib';
import TypeBadge from './TypeBadge.svelte';
import FieldEditor from './FieldEditor.svelte';
import FieldRowR from './FieldRow.svelte';
let {
fieldKey, field, locales, defaultLocale,
depth = 0,
onKeyChange, onFieldChange, onDelete,
onDragStart, onDragOver, onDrop,
}: {
fieldKey: string;
field: MetadataField;
locales: string[];
defaultLocale: string;
depth?: number;
onKeyChange: (newKey: string) => void;
onFieldChange: (field: MetadataField) => void;
onDelete: () => void;
onDragStart: (e: DragEvent) => void;
onDragOver: (e: DragEvent) => void;
onDrop: (e: DragEvent) => void;
} = $props();
let expanded = $state(false);
let dragOver = $state(false);
let groupOpen = $state(true);
let childDragSrc = $state<string | null>(null);
let childDragOver = $state<string | null>(null);
let validation = $derived(validateField(fieldKey, field));
// ── Summary value for collapsed row ─────────────────────────────
let summary = $derived((() => {
switch (field.type) {
case 'string': return field.value || '—';
case 'number': return String(field.value);
case 'boolean': return field.value ? 'Verdadero' : 'Falso';
case 'lingstring': return typeof field.value === 'string'
? field.value
: (Object.values(field.value as Record<string, string>)[0] ?? '—');
case 'date':
case 'datetime': return field.value || '—';
case 'url': return field.value || '—';
case 'currency': return `${field.value.amount} ${field.value.currency}`;
case 'image':
case 'video':
case 'audio':
case 'pdf':
case 'file': return (field.value as { url: string }).url || '—';
case 'group': return `${Object.keys(field.value).length} campos`;
}
})());
let thumbUrl = $derived(
field.type === 'image' && (field.value as { url: string }).url
? (field.value as { url: string }).url : null
);
// ── Child group operations ───────────────────────────────────────
function defaultChild(type: MetadataFieldType): MetadataField {
switch (type) {
case 'string': return f.string('');
case 'number': return f.number(0);
case 'boolean': return f.boolean(false);
case 'lingstring': return f.lingstring({ [defaultLocale]: '' } as any);
case 'date': return f.date('');
case 'datetime': return f.datetime('');
case 'url': return f.url('');
case 'currency': return f.currency({ amount: 0, currency: 'EUR' });
case 'image': return f.image({ url: '' });
case 'video': return f.video({ url: '' });
case 'audio': return f.audio({ url: '' });
case 'pdf': return f.pdf({ url: '' });
case 'file': return f.file({ url: '' });
case 'group': return f.group({});
}
}
function uniqueChildKey(type: MetadataFieldType): string {
if (field.type !== 'group') return type;
let n = 1;
while (`${type}_${n}` in field.value) n++;
return `${type}_${n}`;
}
function addChildField(type: MetadataFieldType) {
if (field.type !== 'group') return;
const key = uniqueChildKey(type);
onFieldChange({ ...field, value: { ...field.value, [key]: defaultChild(type) } } as MetadataField);
groupOpen = true;
}
function updateChildField(childKey: string, childField: MetadataField) {
if (field.type !== 'group') return;
onFieldChange({ ...field, value: { ...field.value, [childKey]: childField } } as MetadataField);
}
function renameChildField(oldKey: string, newKey: string) {
if (field.type !== 'group' || newKey === oldKey || newKey in field.value) return;
const entries = Object.entries(field.value).map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]);
onFieldChange({ ...field, value: Object.fromEntries(entries) } as MetadataField);
}
function deleteChildField(childKey: string) {
if (field.type !== 'group') return;
const { [childKey]: _, ...rest } = field.value;
onFieldChange({ ...field, value: rest } as MetadataField);
}
function dropChildField(targetKey: string) {
if (field.type !== 'group' || !childDragSrc || childDragSrc === targetKey) return;
const entries = Object.entries(field.value);
const srcIdx = entries.findIndex(([k]) => k === childDragSrc);
const tgtIdx = entries.findIndex(([k]) => k === targetKey);
if (srcIdx === -1 || tgtIdx === -1) return;
const reordered = [...entries];
const [moved] = reordered.splice(srcIdx, 1);
reordered.splice(tgtIdx, 0, moved);
childDragSrc = null; childDragOver = null;
onFieldChange({ ...field, value: Object.fromEntries(reordered) } as MetadataField);
}
</script>
<div
draggable="true"
ondragstart={onDragStart}
ondragover={(e) => { e.preventDefault(); dragOver = true; onDragOver(e); }}
ondragleave={() => dragOver = false}
ondrop={(e) => { dragOver = false; onDrop(e); }}
class={[
'group/row overflow-hidden rounded-xl border-2 bg-white transition-all duration-150 dark:bg-zinc-900',
depth > 0 ? 'ml-5' : '',
dragOver
? 'border-zinc-400 dark:border-zinc-500'
: validation.valid
? expanded
? 'border-zinc-900 dark:border-zinc-100'
: 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600'
: 'border-red-300 dark:border-red-700',
].join(' ')}
>
<!-- ── Row header ─────────────────────────────────────────────── -->
<div
role="button" tabindex="0"
onclick={() => expanded = !expanded}
onkeydown={(e) => e.key === 'Enter' && (expanded = !expanded)}
class={[
'flex cursor-pointer items-center gap-3 px-4 transition-colors duration-100',
thumbUrl ? 'py-2' : 'py-3',
expanded ? 'bg-zinc-50 dark:bg-zinc-800/50' : '',
].join(' ')}
>
<!-- Drag handle -->
<div class="shrink-0 cursor-grab opacity-0 transition-opacity group-hover/row:opacity-30 active:cursor-grabbing"
onclick={(e) => e.stopPropagation()}>
<svg class="h-4 w-4 text-zinc-400" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="4" r="1.3"/><circle cx="5" cy="8" r="1.3"/><circle cx="5" cy="12" r="1.3"/>
<circle cx="11" cy="4" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="11" cy="12" r="1.3"/>
</svg>
</div>
<!-- Image thumb -->
{#if thumbUrl}
<div class="h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-zinc-100 dark:border-zinc-800">
<img src={thumbUrl} alt={fieldKey} class="h-full w-full object-cover" />
</div>
{/if}
<!-- Type badge -->
<TypeBadge type={field.type} />
<!-- Key -->
<span class="shrink-0 text-[14px] font-semibold text-zinc-800 dark:text-zinc-200">
{fieldKey}
</span>
<!-- Summary -->
<span class="min-w-0 flex-1 truncate text-[13px] text-zinc-400 dark:text-zinc-500">
{summary}
</span>
<!-- Hidden badge -->
{#if field.hidden}
<span class="shrink-0 rounded-md bg-zinc-100 px-2 py-0.5 text-[12px] text-zinc-400 dark:bg-zinc-800 dark:text-zinc-500">
oculto
</span>
{/if}
<!-- Validation indicator -->
<div class={[
'h-2 w-2 shrink-0 rounded-full',
validation.valid ? 'bg-emerald-400' : 'bg-red-400',
].join(' ')}></div>
<!-- Actions -->
<div class="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover/row:opacity-100"
onclick={(e) => e.stopPropagation()}>
<button type="button" onclick={() => expanded = !expanded}
class="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
title="Editar">
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11.5 2.5l2 2-9 9H2.5v-2l9-9z"/>
</svg>
</button>
<button type="button" onclick={onDelete}
class="rounded-lg p-1.5 text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
title="Eliminar">
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/>
</svg>
</button>
</div>
<!-- Chevron -->
<svg class={['h-4 w-4 shrink-0 text-zinc-300 transition-transform duration-200 dark:text-zinc-600', expanded ? 'rotate-180' : ''].join(' ')}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6l4 4 4-4"/>
</svg>
</div>
<!-- ── Validation errors ──────────────────────────────────────── -->
{#if !validation.valid && (validation as any).errors?.length}
<div class="flex items-center gap-2 border-t border-red-100 bg-red-50/80 px-4 py-2.5 dark:border-red-900/30 dark:bg-red-900/10">
<svg class="h-4 w-4 shrink-0 text-red-400" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<span class="text-[13px] text-red-500">{(validation as any).errors[0]}</span>
</div>
{/if}
<!-- ── Inline editor ──────────────────────────────────────────── -->
{#if expanded}
<div class="border-t border-zinc-100 dark:border-zinc-800">
<FieldEditor
{fieldKey} {field} {locales} {defaultLocale}
{onKeyChange}
onFieldChange={(f) => onFieldChange(f)}
/>
</div>
{/if}
<!-- ── Group children ────────────────────────────────────────── -->
{#if field.type === 'group'}
<div class="border-t border-zinc-100 dark:border-zinc-800">
<!-- Group toolbar: toggle + add type buttons -->
<div class="flex flex-wrap items-center gap-2 bg-zinc-50/70 px-4 py-2.5 dark:bg-zinc-800/30">
<button type="button" onclick={() => groupOpen = !groupOpen}
class="flex items-center gap-2 text-[13px] font-medium text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200">
<svg class={['h-4 w-4 transition-transform duration-150', groupOpen ? 'rotate-90' : ''].join(' ')}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 4l4 4-4 4"/>
</svg>
{Object.keys(field.value).length} campos anidados
</button>
<div class="h-4 w-px bg-zinc-200 dark:bg-zinc-700"></div>
<!-- Add type buttons -->
{#each (['string','number','boolean','lingstring','date','datetime','url','currency','image','video','audio','pdf','file','group'] as MetadataFieldType[]) as type}
<button type="button" title="Añadir {type}" onclick={() => addChildField(type)}
class="rounded-lg p-0.5 transition-colors hover:bg-zinc-200 dark:hover:bg-zinc-700">
<TypeBadge {type} />
</button>
{/each}
</div>
<!-- Child rows -->
{#if groupOpen}
<div class="flex flex-col gap-2 p-3">
{#if Object.keys(field.value).length === 0}
<div class="flex items-center justify-center rounded-xl border-2 border-dashed border-zinc-200 py-6 dark:border-zinc-700">
<span class="text-[13px] text-zinc-300 dark:text-zinc-600">
Grupo vacío — añade campos con los botones de arriba
</span>
</div>
{:else}
{#each Object.keys(field.value) as childKey (childKey)}
<FieldRowR
fieldKey={childKey}
field={field.value[childKey] as MetadataField}
{locales} {defaultLocale}
depth={depth + 1}
onKeyChange={(newKey) => renameChildField(childKey, newKey)}
onFieldChange={(f) => updateChildField(childKey, f)}
onDelete={() => deleteChildField(childKey)}
onDragStart={(e) => { e.dataTransfer?.setData('text/plain', `${fieldKey}::${childKey}`); childDragSrc = childKey; }}
onDragOver={() => { childDragOver = childKey; }}
onDrop={() => dropChildField(childKey)}
/>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
</div>

@ -0,0 +1,191 @@
<script lang="ts">
import type { Metadata, MetadataField, MetadataFieldType } from '../../lib';
import { validateMetadata, field as f } from '../../lib';
import FieldRow from './FieldRow.svelte';
import TypeBadge from './TypeBadge.svelte';
let {
value = {},
locales = ['es'],
defaultLocale = 'es',
dark = false,
onUpdate,
}: {
value?: Metadata;
locales?: string[];
defaultLocale?: string;
dark?: boolean;
onUpdate?: (metadata: Metadata) => void;
} = $props();
let dragSrcKey = $state<string | null>(null);
let validation = $derived(validateMetadata(value));
let fieldKeys = $derived(Object.keys(value));
const typeGroups: { label: string; types: MetadataFieldType[] }[] = [
{ label: 'Texto', types: ['string', 'lingstring', 'url'] },
{ label: 'Número', types: ['number', 'currency'] },
{ label: 'Fecha', types: ['date', 'datetime'] },
{ label: 'Lógico', types: ['boolean'] },
{ label: 'Media', types: ['image', 'video', 'audio', 'pdf', 'file'] },
{ label: 'Grupo', types: ['group'] },
];
function defaultField(type: MetadataFieldType): MetadataField {
switch (type) {
case 'string': return f.string('');
case 'number': return f.number(0);
case 'boolean': return f.boolean(false);
case 'lingstring': return f.lingstring({ [defaultLocale]: '' } as any);
case 'date': return f.date('');
case 'datetime': return f.datetime('');
case 'url': return f.url('');
case 'currency': return f.currency({ amount: 0, currency: 'EUR' });
case 'image': return f.image({ url: '' });
case 'video': return f.video({ url: '' });
case 'audio': return f.audio({ url: '' });
case 'pdf': return f.pdf({ url: '' });
case 'file': return f.file({ url: '' });
case 'group': return f.group({});
}
}
function uniqueKey(type: MetadataFieldType): string {
let n = 1;
while (`${type}_${n}` in value) n++;
return `${type}_${n}`;
}
function addField(type: MetadataFieldType) {
emit({ ...value, [uniqueKey(type)]: defaultField(type) });
}
function updateFieldKey(oldKey: string, newKey: string) {
if (newKey === oldKey || newKey in value) return;
const entries = Object.entries(value).map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]);
emit(Object.fromEntries(entries));
}
function updateField(key: string, field: MetadataField) {
emit({ ...value, [key]: field });
}
function deleteField(key: string) {
const { [key]: _, ...rest } = value;
emit(rest);
}
function emit(next: Metadata) {
value = next;
onUpdate?.(next);
}
function handleDrop(targetKey: string) {
if (!dragSrcKey || dragSrcKey === targetKey) return;
const entries = Object.entries(value);
const srcIdx = entries.findIndex(([k]) => k === dragSrcKey);
const tgtIdx = entries.findIndex(([k]) => k === targetKey);
if (srcIdx === -1 || tgtIdx === -1) return;
const reordered = [...entries];
const [moved] = reordered.splice(srcIdx, 1);
reordered.splice(tgtIdx, 0, moved);
dragSrcKey = null;
emit(Object.fromEntries(reordered));
}
</script>
<div data-theme={dark ? 'dark' : 'light'}>
<div class="flex w-full flex-col overflow-hidden rounded-2xl border-2 border-zinc-200 bg-white
dark:border-zinc-700 dark:bg-zinc-950">
<!-- ── Header ──────────────────────────────────────────────────── -->
<div class="border-b border-zinc-100 bg-white px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900">
<!-- Title row -->
<div class="mb-4 flex items-center gap-3">
<h2 class="text-[15px] font-bold text-zinc-900 dark:text-zinc-100">
Metadata
</h2>
<span class="text-[13px] text-zinc-400 dark:text-zinc-500">
{fieldKeys.length} {fieldKeys.length === 1 ? 'campo' : 'campos'}
</span>
<!-- Validation pill -->
<div class={[
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px] font-medium',
validation.valid
? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-400'
: 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400',
].join(' ')}>
<div class={['h-1.5 w-1.5 rounded-full', validation.valid ? 'bg-emerald-500' : 'bg-red-500'].join(' ')}></div>
{validation.valid ? 'Válido' : `${(validation as any).errors?.length} errores`}
</div>
</div>
<!-- Add field buttons grouped by category -->
<div class="flex flex-wrap items-center gap-3">
{#each typeGroups as group}
<div class="flex flex-col gap-1">
<span class="text-[11px] font-semibold uppercase tracking-wide text-zinc-300 dark:text-zinc-600">
{group.label}
</span>
<div class="flex items-center gap-1">
{#each group.types as type}
<button type="button" onclick={() => addField(type)} title="Añadir {type}"
class="rounded-lg border-2 border-transparent p-0.5 transition-all
hover:border-zinc-200 hover:bg-zinc-50
dark:hover:border-zinc-700 dark:hover:bg-zinc-800">
<TypeBadge {type} />
</button>
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- ── Field list ────────────────────────────────────────────────── -->
<div class="flex flex-col gap-2 p-4">
{#if fieldKeys.length === 0}
<div class="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-zinc-200 py-16 dark:border-zinc-800">
<svg class="h-10 w-10 text-zinc-200 dark:text-zinc-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="4"/>
<path d="M12 8v8M8 12h8"/>
</svg>
<p class="text-[14px] font-medium text-zinc-300 dark:text-zinc-600">
Añade campos usando los botones de arriba
</p>
</div>
{:else}
{#each fieldKeys as key (key)}
<FieldRow
fieldKey={key}
field={value[key] as MetadataField}
{locales} {defaultLocale}
onKeyChange={(newKey) => updateFieldKey(key, newKey)}
onFieldChange={(field) => updateField(key, field)}
onDelete={() => deleteField(key)}
onDragStart={(e) => { e.dataTransfer?.setData('text/plain', key); dragSrcKey = key; }}
onDragOver={() => {}}
onDrop={() => handleDrop(key)}
/>
{/each}
{/if}
</div>
<!-- ── Global validation errors ──────────────────────────────────── -->
{#if !validation.valid && (validation as any).errors?.length}
<div class="border-t border-red-100 bg-red-50/60 px-5 py-4 dark:border-red-900/30 dark:bg-red-900/10">
<p class="mb-2 text-[13px] font-semibold text-red-600 dark:text-red-400">
Errores de validación
</p>
<ul class="flex flex-col gap-1">
{#each (validation as any).errors as error}
<li class="text-[13px] text-red-500">· {error}</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>

@ -0,0 +1,32 @@
<script lang="ts">
import type { MetadataFieldType } from '../../lib';
let { type }: { type: MetadataFieldType } = $props();
const config: Record<MetadataFieldType, { label: string; classes: string }> = {
string: { label: 'STR', classes: 'bg-sky-500/10 text-sky-400 border-sky-500/20' },
number: { label: 'NUM', classes: 'bg-violet-500/10 text-violet-400 border-violet-500/20' },
boolean: { label: 'BOOL', classes: 'bg-amber-500/10 text-amber-400 border-amber-500/20' },
lingstring: { label: 'LING', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
date: { label: 'DATE', classes: 'bg-orange-500/10 text-orange-400 border-orange-500/20' },
datetime: { label: 'DTIME', classes: 'bg-rose-500/10 text-rose-400 border-rose-500/20' },
url: { label: 'URL', classes: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20' },
currency: { label: 'CUR', classes: 'bg-lime-500/10 text-lime-400 border-lime-500/20' },
image: { label: 'IMG', classes: 'bg-pink-500/10 text-pink-400 border-pink-500/20' },
video: { label: 'VID', classes: 'bg-red-500/10 text-red-400 border-red-500/20' },
audio: { label: 'AUD', classes: 'bg-purple-500/10 text-purple-400 border-purple-500/20' },
pdf: { label: 'PDF', classes: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20' },
file: { label: 'FILE', classes: 'bg-teal-500/10 text-teal-400 border-teal-500/20' },
group: { label: 'GROUP', classes: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20' },
};
let cfg = $derived(config[type]);
</script>
<span class={[
'inline-flex items-center rounded border px-1.5 py-0.5',
'font-mono text-[9px] font-bold uppercase tracking-widest',
cfg.classes,
].join(' ')}>
{cfg.label}
</span>

@ -0,0 +1,285 @@
<script lang="ts">
import './app.css';
import MetadataEditor from '../components/MetadataEditor.svelte';
import LingInput from '@/ling/svelte/LingInput.svelte';
import type { Metadata } from '../../lib';
import type { LingRecord } from '@/ling/lib';
// ── Demo metadata con todos los tipos ───────────────────────────
let metadata = $state<Metadata>({
nombre: { type: 'string', value: 'Toyota Corolla' },
año: { type: 'number', value: 2024 },
disponible: { type: 'boolean', value: true },
descripcion: { type: 'lingstring', value: { es: 'Sedán compacto fiable', en: 'Reliable compact sedan' } },
lanzamiento: { type: 'date', value: '2024-01-15' },
ultima_mod: { type: 'datetime', value: '2024-03-09T10:00:00Z' },
web: { type: 'url', value: 'https://toyota.es' },
precio: { type: 'currency', value: { amount: 24990, currency: 'EUR' } },
foto: { type: 'image', value: { url: 'https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?w=400&h=300&fit=crop', mimeType: 'image/jpeg' } },
manual: { type: 'pdf', value: { url: 'https://example.com/manual.pdf', filename: 'manual.pdf' } },
specs: { type: 'group', value: {
motor: { type: 'string', value: '1.8 VVT-i' },
cv: { type: 'number', value: 122 },
traccion: { type: 'string', value: 'FWD' },
}, order: ['cv', 'motor', 'traccion'] },
});
// ── LingInput demo ───────────────────────────────────────────────
let lingValue = $state<LingRecord>({ es: 'Hola mundo', en: '' });
let lingInvalid = $state(false);
// ── JSON output ─────────────────────────────────────────────────
let showJson = $state(false);
let darkMode = $state(false);
const locales = ['es', 'en', 'fr'];
const defaultLocale = 'es';
</script>
<svelte:head>
<title>meta-content · Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet" />
</svelte:head>
<div class={['min-h-screen transition-colors duration-300', darkMode ? 'dark bg-zinc-950' : 'bg-zinc-50'].join(' ')}
style="font-family: 'Syne', sans-serif;">
<!-- ── Background grid ────────────────────────────────────────── -->
<!--
<div class="pointer-events-none fixed inset-0 opacity-[0.03] dark:opacity-[0.06]"
style="background-image: linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px); background-size: 32px 32px;">
</div>
-->
<!-- ── Header ─────────────────────────────────────────────────── -->
<header class="relative border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80">
<div class="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 dark:bg-white">
<svg class="h-4 w-4 text-white dark:text-zinc-900" viewBox="0 0 16 16" fill="currentColor">
<rect x="1" y="1" width="6" height="6" rx="1"/>
<rect x="9" y="1" width="6" height="6" rx="1"/>
<rect x="1" y="9" width="6" height="6" rx="1"/>
<rect x="9" y="9" width="6" height="6" rx="1" opacity="0.4"/>
</svg>
</div>
<div>
<span class="font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-zinc-800 dark:text-zinc-200">
meta-content
</span>
<span class="ml-2 rounded border border-zinc-200 px-1.5 py-0.5 font-mono text-[9px] text-zinc-400 dark:border-zinc-700 dark:text-zinc-500">
v0.1.0
</span>
</div>
</div>
<div class="flex items-center gap-3">
<button
onclick={() => showJson = !showJson}
class={[
'rounded-lg border px-3 py-1.5 font-mono text-[11px] font-medium transition-all duration-150',
showJson
? 'border-zinc-800 bg-zinc-900 text-white dark:border-zinc-200 dark:bg-white dark:text-zinc-900'
: 'border-zinc-200 text-zinc-500 hover:border-zinc-300 dark:border-zinc-700 dark:text-zinc-400',
].join(' ')}
>
{showJson ? '← editor' : 'JSON →'}
</button>
<button
onclick={() => darkMode = !darkMode}
class="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200
text-zinc-400 transition-colors hover:border-zinc-300 hover:text-zinc-600
dark:border-zinc-700 dark:text-zinc-500 dark:hover:border-zinc-600 dark:hover:text-zinc-300"
>
{#if darkMode}
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a.75.75 0 0 1 .75.75v1a.75.75 0 0 1-1.5 0v-1A.75.75 0 0 1 8 1zM8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm5.657-8.157a.75.75 0 0 1 0 1.06l-.707.708a.75.75 0 0 1-1.06-1.06l.707-.708a.75.75 0 0 1 1.06 0zM3.05 4.904a.75.75 0 0 1-1.06-1.06l.707-.708a.75.75 0 0 1 1.06 1.06l-.707.708zM15 8a.75.75 0 0 1-.75.75h-1a.75.75 0 0 1 0-1.5h1A.75.75 0 0 1 15 8zM2.75 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5H2A.75.75 0 0 1 2.75 8zM12.95 11.096a.75.75 0 0 1 1.06 1.06l-.707.708a.75.75 0 0 1-1.06-1.06l.707-.708zm-9.9 0l.707.708a.75.75 0 0 1-1.06 1.06l-.707-.707a.75.75 0 0 1 1.06-1.061z"/>
</svg>
{:else}
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 .278a.75.75 0 0 1 .18.83A7 7 0 1 0 14.85 9.5a.75.75 0 0 1 .83.18A8.5 8.5 0 1 1 6 .278z"/>
</svg>
{/if}
</button>
</div>
</div>
</header>
<!-- ── Main ───────────────────────────────────────────────────── -->
<main class="mx-auto max-w-5xl px-6 py-10">
<!-- Hero -->
<div class="mb-10">
<h1 class="text-[42px] font-extrabold leading-none tracking-tight text-zinc-900 dark:text-zinc-100">
Metadata<br/>
<span class="text-zinc-300 dark:text-zinc-700">Editor</span>
</h1>
<p class="mt-3 font-mono text-[12px] text-zinc-400 dark:text-zinc-500">
Campos tipados · Validación en tiempo real · Drag & drop · Dark mode
</p>
</div>
{#if showJson}
<!-- ── JSON view ─────────────────────────────────────── -->
<div class="rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<span class="font-mono text-[10px] font-bold uppercase tracking-widest text-zinc-400">
metadata · serializado
</span>
<button
onclick={() => navigator.clipboard.writeText(JSON.stringify(metadata, null, 2))}
class="rounded border border-zinc-200 px-2 py-1 font-mono text-[10px] text-zinc-400
hover:border-zinc-300 hover:text-zinc-600 dark:border-zinc-700 dark:hover:border-zinc-600"
>
copiar
</button>
</div>
<pre class="overflow-auto p-4 font-mono text-[11px] leading-relaxed text-zinc-600 dark:text-zinc-400"
>{JSON.stringify(metadata, null, 2)}</pre>
</div>
{:else}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_320px]">
<!-- ── Left: MetadataEditor ──────────────────────── -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-0.5">
<span class="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
MetadataEditor
</span>
<div class="h-px flex-1 bg-zinc-100 dark:bg-zinc-800"></div>
</div>
<MetadataEditor
bind:value={metadata}
{locales}
{defaultLocale}
dark={darkMode}
onUpdate={(m) => metadata = m}
/>
</div>
<!-- ── Right: LingInput demo + stats ─────────────── -->
<div class="flex flex-col gap-6">
<!-- LingInput showcase -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-0.5">
<span class="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
LingInput
</span>
<div class="h-px flex-1 bg-zinc-100 dark:bg-zinc-800"></div>
</div>
<div class="flex flex-col gap-4 rounded-xl border border-zinc-200 bg-white p-4
dark:border-zinc-800 dark:bg-zinc-900">
<!-- textarea mode -->
<LingInput
value={lingValue}
{locales}
{defaultLocale}
label="Descripción"
placeholder="Escribe aquí…"
required={true}
invalid={lingInvalid}
as="textarea"
rows={3}
onUpdate={(v) => lingValue = v}
/>
<!-- input mode -->
<LingInput
value={lingValue}
{locales}
{defaultLocale}
label="Título"
as="input"
onUpdate={(v) => lingValue = v}
/>
<!-- Invalid toggle -->
<div class="flex items-center gap-2 border-t border-zinc-100 pt-3 dark:border-zinc-800">
<span class="font-mono text-[10px] text-zinc-400">Simular inválido</span>
<button
onclick={() => lingInvalid = !lingInvalid}
class={[
'relative h-5 w-9 rounded-full transition-colors duration-200',
lingInvalid ? 'bg-red-400' : 'bg-zinc-200 dark:bg-zinc-700',
].join(' ')}
>
<span class={[
'absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200',
lingInvalid ? 'translate-x-4' : 'translate-x-0.5',
].join(' ')}></span>
</button>
</div>
</div>
</div>
<!-- Stats card -->
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<p class="mb-3 font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
Estado actual
</p>
<div class="flex flex-col gap-2">
{#each [
{ label: 'campos', value: Object.keys(metadata).length },
{ label: 'tipos únicos', value: new Set(Object.values(metadata).map((f: any) => f.type)).size },
{ label: 'con hidden', value: Object.values(metadata).filter((f: any) => f.hidden).length },
] as stat}
<div class="flex items-center justify-between">
<span class="font-mono text-[11px] text-zinc-400 dark:text-zinc-500">{stat.label}</span>
<span class="font-mono text-[13px] font-bold text-zinc-700 dark:text-zinc-300">{stat.value}</span>
</div>
{/each}
</div>
</div>
<!-- Type legend -->
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<p class="mb-3 font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
Tipos disponibles
</p>
<div class="flex flex-wrap gap-1.5">
{#each ['string','number','boolean','lingstring','date','datetime','url','currency','image','video','audio','pdf','file','group'] as type}
{@const colors: Record<string, string> = {
string: 'bg-sky-50 text-sky-500 border-sky-200 dark:bg-sky-900/20 dark:text-sky-400 dark:border-sky-800',
number: 'bg-violet-50 text-violet-500 border-violet-200 dark:bg-violet-900/20 dark:text-violet-400 dark:border-violet-800',
boolean: 'bg-amber-50 text-amber-500 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800',
lingstring: 'bg-emerald-50 text-emerald-500 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800',
date: 'bg-orange-50 text-orange-500 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800',
datetime: 'bg-rose-50 text-rose-500 border-rose-200 dark:bg-rose-900/20 dark:text-rose-400 dark:border-rose-800',
url: 'bg-cyan-50 text-cyan-500 border-cyan-200 dark:bg-cyan-900/20 dark:text-cyan-400 dark:border-cyan-800',
currency: 'bg-lime-50 text-lime-500 border-lime-200 dark:bg-lime-900/20 dark:text-lime-400 dark:border-lime-800',
image: 'bg-pink-50 text-pink-500 border-pink-200 dark:bg-pink-900/20 dark:text-pink-400 dark:border-pink-800',
video: 'bg-red-50 text-red-500 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800',
audio: 'bg-purple-50 text-purple-500 border-purple-200 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800',
pdf: 'bg-zinc-100 text-zinc-500 border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700',
file: 'bg-teal-50 text-teal-500 border-teal-200 dark:bg-teal-900/20 dark:text-teal-400 dark:border-teal-800',
group: 'bg-indigo-50 text-indigo-500 border-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-400 dark:border-indigo-800',
}}
<span class={[
'rounded border px-1.5 py-0.5 font-mono text-[9px] font-bold uppercase tracking-widest',
colors[type] ?? '',
].join(' ')}>
{type}
</span>
{/each}
</div>
</div>
</div>
</div>
{/if}
</main>
<!-- ── Footer ─────────────────────────────────────────────────── -->
<footer class="border-t border-zinc-100 py-6 dark:border-zinc-800">
<p class="text-center font-mono text-[10px] text-zinc-300 dark:text-zinc-700">
meta-content · TypeScript · Svelte 5 · Tailwind CSS
</p>
</footer>
</div>

@ -0,0 +1,10 @@
@import "tailwindcss";
/* Smooth scroll para navegación */
html {
scroll-behavior: smooth;
}
@variant dark (&:where([data-theme=dark], [data-theme=dark] *));

@ -0,0 +1,178 @@
import { describe, it, expect } from 'vitest';
import {deserialize, serialization} from '../lib';
import { field } from '../lib';
import type { Metadata } from '../lib';
// ─────────────────────────────────────────────
// serialization
// ─────────────────────────────────────────────
describe('serialization', () => {
it('produce un JSON con __v y data', () => {
const meta: Metadata = { brand: field.string('Toyota') };
const json = serialization(meta);
const parsed = JSON.parse(json);
expect(parsed.__v).toBe(1);
expect(parsed.data).toEqual(meta);
});
it('serializa metadata vacía', () => {
const json = serialization({});
const parsed = JSON.parse(json);
expect(parsed.data).toEqual({});
});
it('serializa grupos anidados', () => {
const meta: Metadata = {
specs: field.group({
hp: field.number(120),
fuel: field.string('diesel'),
}),
};
const json = serialization(meta);
const parsed = JSON.parse(json);
expect(parsed.data.specs.type).toBe('group');
expect(parsed.data.specs.value.hp.value).toBe(120);
});
it('serializa todos los tipos de campo', () => {
const meta: Metadata = {
a: field.string('hello'),
b: field.number(42),
c: field.boolean(true),
d: field.date('2024-01-15'),
e: field.datetime('2024-01-15T10:00:00Z'),
f: field.url('https://example.com'),
g: field.currency({ amount: 99, currency: 'EUR' }),
h: field.image({ url: 'https://cdn.example.com/img.jpg' }),
i: field.video({ url: 'https://cdn.example.com/v.mp4' }),
j: field.audio({ url: 'https://cdn.example.com/s.mp3' }),
k: field.pdf({ url: 'https://cdn.example.com/d.pdf' }),
l: field.file({ url: 'https://cdn.example.com/d.zip' }),
};
const json = serialization(meta);
const result = deserialize(json);
expect(result.ok).toBe(true);
});
});
// ─────────────────────────────────────────────
// deserialize — casos de éxito
// ─────────────────────────────────────────────
describe('deserialize — éxito', () => {
it('recupera exactamente el Metadata original', () => {
const meta: Metadata = {
brand: field.string('Toyota'),
price: field.currency({ amount: 25000, currency: 'EUR' }),
};
const result = deserialize(serialization(meta));
expect(result.ok).toBe(true);
if (result.ok) expect(result.metadata).toEqual(meta);
});
it('roundtrip con grupo anidado y order', () => {
const meta: Metadata = {
specs: field.group(
{ hp: field.number(120), fuel: field.string('diesel') },
['fuel', 'hp']
),
};
const result = deserialize(serialization(meta));
expect(result.ok).toBe(true);
if (result.ok) expect(result.metadata).toEqual(meta);
});
it('roundtrip con hidden: true', () => {
const meta: Metadata = {
internal: { ...field.string('secret'), hidden: true },
};
const result = deserialize(serialization(meta));
expect(result.ok).toBe(true);
if (result.ok) {
const f = result.metadata['internal'] as { hidden: boolean };
expect(f.hidden).toBe(true);
}
});
it('roundtrip con metadata vacía', () => {
const result = deserialize(serialization({}));
expect(result.ok).toBe(true);
if (result.ok) expect(result.metadata).toEqual({});
});
});
// ─────────────────────────────────────────────
// deserialize — casos de error
// ─────────────────────────────────────────────
describe('deserialize — errores', () => {
it('falla con JSON malformado', () => {
const result = deserialize('{invalid json}');
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe('Invalid JSON string');
});
it('falla si el payload es un array', () => {
const result = deserialize('[]');
expect(result.ok).toBe(false);
});
it('falla si falta __v', () => {
const result = deserialize(JSON.stringify({ data: {} }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('__v');
});
it('falla si falta data', () => {
const result = deserialize(JSON.stringify({ __v: 1 }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('data');
});
it('falla con versión desconocida', () => {
const result = deserialize(JSON.stringify({ __v: 999, data: {} }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('999');
});
it('falla y devuelve ValidationError si data es Metadata inválido', () => {
const invalid = JSON.stringify({
__v: 1,
data: { price: { type: 'currency', value: { amount: 100, currency: 'EU' } } },
});
const result = deserialize(invalid);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.validation).toBeDefined();
expect(result.validation?.errors.length).toBeGreaterThan(0);
}
});
it('falla si un campo tiene type desconocido', () => {
const invalid = JSON.stringify({
__v: 1,
data: { x: { type: 'unknown', value: 'test' } },
});
const result = deserialize(invalid);
expect(result.ok).toBe(false);
});
});
// ─────────────────────────────────────────────
// Propiedades de roundtrip
// ─────────────────────────────────────────────
describe('roundtrip serialization → deserialize', () => {
it('es idempotente: serializar dos veces produce el mismo resultado', () => {
const meta: Metadata = { x: field.number(1) };
expect(serialization(meta)).toBe(serialization(meta));
});
it('deserializar dos veces el mismo JSON produce el mismo Metadata', () => {
const json = serialization({ x: field.string('a') });
const r1 = deserialize(json);
const r2 = deserialize(json);
expect(r1).toEqual(r2);
});
});

@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import { validateField, validateMetadata } from '../lib';
import { field } from '../lib';
import type { Metadata } from '../lib';
// ─────────────────────────────────────────────
// validateField — edge cases
// ─────────────────────────────────────────────
describe('validateField — key edge cases', () => {
it('falla con key de solo espacios', () => {
expect(validateField(' ', field.string('x')).valid).toBe(false);
});
it('acepta key con caracteres especiales', () => {
expect(validateField('my-field_1', field.string('x'))).toEqual({ valid: true });
});
it('falla si el valor no tiene type', () => {
expect(validateField('x', { value: 'hello' } as unknown as never).valid).toBe(false);
});
it('falla si el valor es null', () => {
expect(validateField('x', null).valid).toBe(false);
});
it('falla si el valor es un array', () => {
expect(validateField('x', []).valid).toBe(false);
});
it('falla si el valor es un primitivo', () => {
expect(validateField('x', 42).valid).toBe(false);
expect(validateField('x', 'string').valid).toBe(false);
});
});
describe('validateField — number edge cases', () => {
it('acepta 0', () => expect(validateField('n', field.number(0))).toEqual({ valid: true }));
it('acepta negativos', () => expect(validateField('n', field.number(-99))).toEqual({ valid: true }));
it('acepta Infinity', () => expect(validateField('n', field.number(Infinity))).toEqual({ valid: true }));
it('falla con NaN', () => expect(validateField('n', field.number(NaN)).valid).toBe(false));
});
describe('validateField — date edge cases', () => {
it('falla con fecha inválida que cumple el formato', () => {
// ISO_DATE_RE solo comprueba formato, no si la fecha existe
expect(validateField('d', field.date('9999-99-99')).valid).toBe(true); // formato ok
});
it('falla con datetime en campo date', () => {
expect(validateField('d', field.date('2024-01-15T10:00:00Z')).valid).toBe(false);
});
});
describe('validateField — url edge cases', () => {
it('acepta URL con path y query', () => expect(validateField('u', field.url('https://example.com/path?q=1'))).toEqual({ valid: true }));
it('acepta URL con puerto', () => expect(validateField('u', field.url('http://localhost:3000'))).toEqual({ valid: true }));
it('falla con URL relativa', () => expect(validateField('u', field.url('/relative/path')).valid).toBe(false));
it('falla con string vacío', () => expect(validateField('u', field.url('')).valid).toBe(false));
});
describe('validateField — currency edge cases', () => {
it('acepta amount 0', () => expect(validateField('p', field.currency({ amount: 0, currency: 'EUR' }))).toEqual({ valid: true }));
it('acepta amount negativo', () => expect(validateField('p', field.currency({ amount: -10, currency: 'EUR' }))).toEqual({ valid: true }));
it('falla con currency vacío', () => expect(validateField('p', field.currency({ amount: 10, currency: '' })).valid).toBe(false));
it('falla con currency 2 letras', () => expect(validateField('p', field.currency({ amount: 10, currency: 'EU' })).valid).toBe(false));
it('falla con currency 4 letras', () => expect(validateField('p', field.currency({ amount: 10, currency: 'EURO' })).valid).toBe(false));
});
describe('validateField — media edge cases', () => {
it('acepta MediaRef con campos opcionales', () => {
expect(validateField('img', field.image({
url: 'https://cdn.example.com/img.jpg',
mimeType: 'image/jpeg',
size: 1024,
filename: 'img.jpg',
}))).toEqual({ valid: true });
});
it('falla con url relativa en MediaRef', () => {
expect(validateField('img', field.image({ url: '/images/photo.jpg' })).valid).toBe(false);
});
});
describe('validateField — group edge cases', () => {
it('acepta grupo vacío', () => expect(validateField('g', field.group({}))).toEqual({ valid: true }));
it('acumula errores de múltiples campos internos', () => {
const g = field.group({
hp: field.number(NaN),
price: field.currency({ amount: 10, currency: 'EU' }),
});
const result = validateField('specs', g);
expect(result.valid).toBe(false);
if (!result.valid) expect(result.errors.length).toBeGreaterThanOrEqual(2);
});
it('los errores anidados incluyen el prefijo de la clave padre', () => {
const g = field.group({ hp: field.number(NaN) });
const result = validateField('specs', g);
expect(result.valid).toBe(false);
if (!result.valid) expect(result.errors[0]).toContain('specs');
});
it('soporta triple anidamiento', () => {
const deep = field.group({
l1: field.group({
l2: field.group({
value: field.boolean(false),
}),
}),
});
expect(validateField('root', deep)).toEqual({ valid: true });
});
});
// ─────────────────────────────────────────────
// validateMetadata — edge cases
// ─────────────────────────────────────────────
describe('validateMetadata — edge cases', () => {
it('acepta metadata vacía', () => expect(validateMetadata({})).toEqual({ valid: true }));
it('falla si metadata es null', () => expect(validateMetadata(null as unknown as Metadata).valid).toBe(false));
it('falla si metadata es un array', () => expect(validateMetadata([] as unknown as Metadata).valid).toBe(false));
it('falla si un valor no es MetadataField', () => {
const meta: Metadata = { x: 'not a field' };
expect(validateMetadata(meta).valid).toBe(false);
});
it('acumula todos los errores sin parar en el primero', () => {
const meta: Metadata = {
a: field.number(NaN),
b: field.currency({ amount: 10, currency: 'EU' }),
c: field.url('bad-url'),
};
const result = validateMetadata(meta);
expect(result.valid).toBe(false);
if (!result.valid) expect(result.errors.length).toBeGreaterThanOrEqual(3);
});
it('los mensajes de error identifican la clave afectada', () => {
const meta: Metadata = { myKey: field.number(NaN) };
const result = validateMetadata(meta);
expect(result.valid).toBe(false);
if (!result.valid) expect(result.errors[0]).toContain('myKey');
});
});

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import './app.css'; import './app.css';
import ConditionBuilderDemo from "@/vcen/editor/demos/ConditionBuilderDemo.svelte"; import ConditionBuilderDemo from "@/vcen-edit/demos/ConditionBuilderDemo.svelte";
import CatalogDemo from "@/vcen/editor/demos/CatalogDemo.svelte"; import CatalogDemo from "@/vcen-edit/demos/CatalogDemo.svelte";
import Editor from "@/vcen/editor/canvas/Editor.svelte"; import Editor from "@/vcen-edit/canvas/Editor.svelte";
import LingInput from "@/ling/svelte/LingInput.svelte"; import LingInput from "@/ling/svelte/LingInput2.svelte";
import type {LingRecord, SupportedLocale} from "@/ling"; import type {LingRecord, SupportedLocale} from "@/ling/lib";
const locales4 = ['es', 'en', 'fr', 'de']; const locales4 = ['es', 'en', 'fr', 'de'];

@ -7,9 +7,3 @@ html {
} }
@layer components {
}

@ -2,7 +2,7 @@
import {type Cat, toCatID} from "@/vcen/lib"; import {type Cat, toCatID} from "@/vcen/lib";
import CatalogEditor from "@/vcen/editor/comps/CatalogEditor.svelte"; import CatalogEditor from "@/vcen-edit/comps/CatalogEditor.svelte";
const cat = { const cat = {
id: toCatID('test'), id: toCatID('test'),

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import type { AttDomain } from '@/vcen/lib' import type { AttDomain } from '@/vcen/lib'
import RuleList from './RuleList.svelte' import RuleList from './RuleList.svelte'

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import AttDetail from './AttDetail.svelte' import AttDetail from './AttDetail.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat) const cat = $derived(editor.cat)

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { setContext } from 'svelte' import { setContext } from 'svelte'
import { createCatalogEditorStore } from './catalog-editor.svelte' import { createCatalogEditorStore } from './catalog-editor.svelte.js'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import type { Cat } from '@/vcen/lib' import type { Cat } from '@/vcen/lib'
import CatalogLayout from './CatalogLayout.svelte' import CatalogLayout from './CatalogLayout.svelte'

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Cat } from '@/vcen/lib' import type { Cat } from '@/vcen/lib'
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import CatalogSidebar from './CatalogSidebar.svelte' import CatalogSidebar from './CatalogSidebar.svelte'
import CatMetaPanel from './CatMetaPanel.svelte' import CatMetaPanel from './CatMetaPanel.svelte'
import ObjsPanel from './ObjsPanel.svelte' import ObjsPanel from './ObjsPanel.svelte'

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')

@ -4,8 +4,8 @@
* Trabaja directamente con la API del ConditionBuilderStore (árbol de nodos). * Trabaja directamente con la API del ConditionBuilderStore (árbol de nodos).
* Sigue el patrón visual del ConditionBuilder.svelte de referencia. * Sigue el patrón visual del ConditionBuilder.svelte de referencia.
*/ */
import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts' import type { ConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts'
import type { ConditionNode, LogicOp, CompareOp, Operand } from '../lib/condition-editor' import type { ConditionNode, LogicOp, CompareOp, Operand } from '@/vcen-edit/lib/condition-editor.ts'
let { let {
store, store,

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat) const cat = $derived(editor.cat)

@ -6,7 +6,7 @@
* Col 3: AttList + AttDetail (si hay sec o att activo) * Col 3: AttList + AttDetail (si hay sec o att activo)
*/ */
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from './catalog-editor.svelte.ts'
import ObjList from './ObjList.svelte' import ObjList from './ObjList.svelte'
import SecList from './SecList.svelte' import SecList from './SecList.svelte'
import AttList from './AttList.svelte' import AttList from './AttList.svelte'

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import OptDetail from './OptDetail.svelte' import OptDetail from './OptDetail.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')

@ -8,12 +8,12 @@
* - Mensaje (label/descripción) * - Mensaje (label/descripción)
*/ */
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { import type {
RuleSet, Rule, RuleSet, Rule,
RuleAction, Obj, Sec, Opt, AttState RuleAction, Obj, Sec, Opt, AttState
} from '@/vcen/lib' } from '@/vcen/lib'
import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts' import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts'
import ConditionBuilder from './ConditionBuilder.svelte' import ConditionBuilder from './ConditionBuilder.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')

@ -4,7 +4,7 @@
* Sigue exactamente el patrón de RuleList.svelte + RuleEditor.svelte de referencia * Sigue exactamente el patrón de RuleList.svelte + RuleEditor.svelte de referencia
*/ */
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import type { RuleSet , Obj, Sec } from '@/vcen/lib' import type { RuleSet , Obj, Sec } from '@/vcen/lib'
import RuleEditor from './RuleEditor.svelte' import RuleEditor from './RuleEditor.svelte'

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte' import type { CatalogEditorStore } from './catalog-editor.svelte.js'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection) const sel = $derived(editor.selection)

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { DemoStore, DomainKind } from './demo.store.svelte.js'; import type { DemoStore, DomainKind } from './demo.store.svelte.ts';
import type {Att, AttDomain} from '@/vcen/lib/types'; import type {Att, AttDomain} from '@/vcen/lib/types';
import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts'; import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts';
import ConditionBuilder from '../comps/ConditionBuilder.svelte'; import ConditionBuilder from '@/vcen-edit/comps/ConditionBuilder.svelte';
interface Props { store: DemoStore; } interface Props { store: DemoStore; }
let { store }: Props = $props(); let { store }: Props = $props();

@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { createDemoStore } from './demo.store.svelte.js'; import { createDemoStore } from './demo.store.svelte.ts';
import AttEditor from './AttEditor.svelte'; import AttEditor from './AttEditor.svelte';
import RuntimePanel from './RuntimePanel.svelte'; import RuntimePanel from './RuntimePanel.svelte';
import GraphPanel from './GraphPanel.svelte'; import GraphPanel from './GraphPanel.svelte';
import type { DomainKind } from './demo.store.svelte.js'; import type { DomainKind } from './demo.store.svelte.ts';
const store = createDemoStore(); const store = createDemoStore();

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts'; import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts';
import ConditionBuilder from '../comps/ConditionBuilder.svelte'; import ConditionBuilder from '@/vcen-edit/comps/ConditionBuilder.svelte';
import type { Obj } from '@/vcen/lib/types'; import type { Obj } from '@/vcen/lib/types';
import type { JsonLogicValue } from '@/jslg'; import type { JsonLogicValue } from '@/jslg/lib';
// ── Mock Obj con atributos de ejemplo ──────────────────────────────────── // ── Mock Obj con atributos de ejemplo ────────────────────────────────────

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { DemoStore } from './demo.store.svelte.js'; import type { DemoStore } from './demo.store.svelte.ts';
import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph.ts'; import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph.ts';

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { DemoStore } from './demo.store.svelte.js'; import type { DemoStore } from './demo.store.svelte.ts';
interface Props { store: DemoStore; } interface Props { store: DemoStore; }
let { store }: Props = $props(); let { store }: Props = $props();

@ -1,4 +1,4 @@
import type { JsonLogicValue } from '@/jslg'; import type { JsonLogicValue } from '@/jslg/lib';
import type {Att, Sec, Obj, AttDomain, OptID, Opt, AttOptionDomain} from '@/vcen/lib/types'; import type {Att, Sec, Obj, AttDomain, OptID, Opt, AttOptionDomain} from '@/vcen/lib/types';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

@ -0,0 +1,11 @@
import { mount } from 'svelte';
import App from './App.svelte';
const app = mount(App, {
target: document.getElementById('app')!
});
export default app;

@ -1,4 +1,4 @@
import type { JsonLogicValue } from '@/jslg'; import type { JsonLogicValue } from '@/jslg/lib';
import type {Obj, Opt, Sec} from '@/vcen/lib/types'; import type {Obj, Opt, Sec} from '@/vcen/lib/types';
import type { import type {
ScopeEntry, ScopeEntry,
@ -7,7 +7,7 @@ import type {
CompareOp, CompareOp,
LogicOp, LogicOp,
Operand, ResolvedOpt Operand, ResolvedOpt
} from '../lib/condition-editor.ts'; } from '@/vcen-edit/lib/condition-editor.ts';
import { import {
buildScope, buildScope,
compileCondition, compileCondition,
@ -20,7 +20,7 @@ import {
removeNode, removeNode,
addChild, addChild,
getValidOperators, getValidOperators,
} from '../lib/condition-editor.ts'; } from '@/vcen-edit/lib/condition-editor.ts';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// STORE // STORE

@ -5,7 +5,7 @@ import {
type EvaluationResult, serializeAttPath type EvaluationResult, serializeAttPath
} from '@/vcen/lib'; } from '@/vcen/lib';
import { Session, createSession } from '@/vcen/lib'; import { Session, createSession } from '@/vcen/lib';
import { JsonLogicEngine } from '@/jslg'; import { JsonLogicEngine } from '@/jslg/lib';

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import {asJsonCondition, type JsonLogicValue} from "@/jslg"; import {asJsonCondition, type JsonLogicValue} from "@/jslg/lib";
import { import {
buildScope, buildScope,
getValidOperators, getValidOperators,
@ -13,9 +13,9 @@ import {
replaceNode, replaceNode,
removeNode, removeNode,
addChild, addChild,
} from '../lib/condition-editor.ts'; } from '@/vcen-edit/lib/condition-editor.ts';
import type {Att, Sec, Obj, Opt} from '@/vcen/lib/types'; import type {Att, Sec, Obj, Opt} from '@/vcen/lib/types';
import type { ScopeEntry } from '../lib/condition-editor.ts'; import type { ScopeEntry } from '@/vcen-edit/lib/condition-editor.ts';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

@ -11,9 +11,9 @@ import type {
RuleID, OptID, AttOptionDomain, Opt, AttValueOption, AttState, ConfigState, RuleID, OptID, AttOptionDomain, Opt, AttValueOption, AttState, ConfigState,
} from './types'; } from './types';
import { ATT_STATE_PRECEDENCE } from './types'; import { ATT_STATE_PRECEDENCE } from './types';
import {validateAttValue} from "@/vcen/lib/validation.ts"; import {validateAttValue} from "./validation.ts";
import {serializeRelativePath, joinPath, keyToAttPath, parseAttPath} from "@/vcen/lib/node_util.ts"; import {serializeRelativePath, joinPath, keyToAttPath, parseAttPath} from "./node_util.ts";
import {JsonLogicEngine} from "@/jslg"; import {JsonLogicEngine} from "@/jslg/lib";

@ -4,7 +4,7 @@ import {
serializeRelativePath, serializeRelativePath,
joinPath, keyToAttPath, joinPath, keyToAttPath,
} from './node_util.ts'; } from './node_util.ts';
import { JsonLogicEngine } from "@/jslg"; import { JsonLogicEngine } from "@/jslg/lib";
import type { DependencyGraph} from "@/vcen/lib/types/graph.ts"; import type { DependencyGraph} from "@/vcen/lib/types/graph.ts";

@ -1,4 +1,4 @@
import { JsonLogicEngine } from '@/jslg'; import { JsonLogicEngine } from '@/jslg/lib';
import type { import type {
Cat, Cat,
Att, Att,

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.