Repository navigation
Compiler es
🌐 Esta página en: English · Português · Español · Français · Deutsch
El compilador es el componente central que hace posible la magia de eQuantic.UI. Transforma la semántica de C# en código TypeScript eficiente y legible.
La corrección se sostiene con una suite diferencial de conformidad: el mismo C# se evalúa en .NET y en el Bun incrustado, y las dos respuestas tienen que coincidir. Cualquier cosa que el compilador no pueda traducir con fidelidad es un error de compilación con una ubicación (los diagnósticos EQ2xxx), nunca una incorrección silenciosa.
Para mantener honesto el código del navegador, el compilador impone fronteras estrictas, inspiradas en Next.js (la división servidor/cliente) y Flutter (las restricciones), y las valida antes de emitir JS:
-
Componentes de cliente (
StatefulComponent/StatelessComponent): lógica de UI, gestión de estado,System.Linq, tipos básicos (string,int,DateTime). -
Prohibido en el cliente:
System.IO,System.Net.Httpdirecto, un.Wait()que bloquea.File.ReadAllText()en el cuerpo de un componente es un error de compilación. -
El puente: la obtención de datos pasa por métodos anotados con
[ServerAction](al estilo RPC). Ver Seguridad y Server Actions.
El TypeScriptEmitter es el punto de entrada para generar archivos .ts. Organiza los imports, define las clases y usa el CSharpToJsConverter para convertir los cuerpos de los métodos.
Despacha todo nodo de Roslyn a una estrategia, una por construcción (BinaryExpressionStrategy,
IfStatementStrategy, …), y devuelve IR, no texto. Las sentencias siempre construyen un
JsStatement. Una estrategia de expresión que ya cruzó construye un JsExpr
(IExpressionIrStrategy); una que todavía devuelve texto se empalma como un nodo opaco, idéntico
byte a byte a lo que siempre produjo. Esa frontera es lo que deja a la migración avanzar una
estrategia a la vez, y tests/eQuantic.UI.Compiler.Tests/Coverage/ir-migration.baseline.txt es la
lista de estrategias de texto: solo se encoge, y una estrategia nueva nace en el IR.
SourceMapGenerator escribe un mapa V3 estándar (VLQ en Base64) desde cada módulo de TypeScript que
eqc emite de vuelta a las líneas .cs de las que vino, nombrando todo archivo del que el módulo se
escribió (una clase que toma el default de una interfaz lleva ese default al archivo de la interfaz),
cada uno dentro del proyecto, y con el C# mismo dentro solo cuando el mapa es full. Bun entonces
mapea el JavaScript a ese TypeScript, y SourceMapComposer compone los dos, en C# y sin ningún
paquete de npm, en UN mapa desde el JavaScript que el navegador ejecuta hasta el C#, conservando el
debugId de bun. Los dos mapas pasan por un solo escritor de mapas, y ese escritor, como todo archivo
JSON que la compilación escribe, por un solo escritor de JSON cuyo escape es el de System.Text.Json,
así que un archivo de C# que contiene un carácter de control todavía tiene mapa. Lo que lleva un mapa
(EQuanticSourceMaps: full en Debug, none en lo demás, external a pedido) está en
Depuración.
El conversor pregunta al modelo semántico antes de adivinar. Un miembro, una variable local, un
parámetro, un estático: cada uno emite desde su símbolo (this.name, Counter.name, un nombre
desnudo), y Console.WriteLine es console.log porque el símbolo dice System.Console. Las
heurísticas de nombre (un guion bajo inicial, una propiedad capitalizada) son legales solo donde al
modelo no se le puede preguntar: un fragmento sin modelo, un nodo que una estrategia reescribió. Bajo
un modelo autoritativo, un nombre del árbol que no enlaza es un error de compilación (EQ2006), nunca
una traducción adivinada.
Desde 0.2.0-preview.36
CodeGen/Ir/ es un árbol pequeño con un escritor por nivel, JsExpr → JsStatement →
JsClassMember → JsClass → JsModule, y los escritores son dueños de todo lo que una estrategia
nunca debe escribir a mano:
-
Los paréntesis vienen de la precedencia y la asociatividad, nunca de una plantilla. Antes de
esto,
f ?? g && gse entregaba literal: C# no necesita paréntesis ahí, JavaScript rechaza la mezcla desnuda, y el bundle entero no se podía parsear. -
Una sola evaluación: un
JsTemplatenombra lo que computa ({0} === {0}.normalize()) y el escritor enlaza exactamente una vez una parte usada más de una vez: un nombre simple o un literal se inlinea, una lectura de miembro no (un getter puede contar). Una parte dentro de una función que la plantilla define también se enlaza, incluido un nombre simple, ya que esa función corre una vez por elemento: solo un literal,thisy una lambda escrita en el sitio se quedan dentro, y también una parte que lee un nombre que la función declara. -
La disposición: una sentencia por línea, bloques indentados (
JsLayout.Pretty);Compactreproduce el antiguo mundo de cadenas byte a byte, que es cómo se prueba cada paso de la migración. Una clase tiene una sola regla de disposición, una línea en blanco antes de un miembro con cuerpo, los campos contiguos, y un módulo son sus imports como recordsJsImport, una línea en blanco, su cuerpo. -
El emisor (
TypeScriptEmitter) decide qué contiene un módulo y entrega nodos al constructor; no ensambla texto.
Dos redes sostienen todo eso: las fijaciones de componente (el módulo generado de todo componente
compartido, byte a byte: un cambio de disposición tiene que ser solo de espacios contra ellas) y la
suite de conformidad, que ejecuta toda forma traducida en los dos lados y compara las respuestas. Los
gemelos generados bajo src/eQuantic.UI.Runtime/src/shared/components están fijados, con tipos
comprobados y probados, no pasados por linter: el código generado responde a su escritor, no a una
guía de estilo.
Actualmente el compilador soporta un amplio rango de construcciones de C#:
-
Expresiones: aritméticas, lógicas, ternarias, interpolación de cadenas, fusión de nulos (
??), acceso condicional (?.,?[]). Un argumento que espera detrás de?.se espera donde está escrito, solo cuando el receptor no es nulo (Desde 0.2.0-preview.60), sea cual sea el receptor: una llamada, un elemento o una propiedad delante de?.se lee una vez a una variable del método en el que está escrita (Desde 0.2.0-preview.61; hasta entonces tal receptor rompía la compilación con EQ1004). Una lectura condicional de nulo respondenulldonde su valor se usa, como lo hace C#, nunca elundefinedde JavaScript (Desde 0.2.0-preview.61). Un grupo de métodos es el delegado que hace C#: su receptor se lee una vez, cuando el delegado se hace,base.Mllama al método de la base sobre este objeto, y el grupo de un método de extensión se enlaza en la clase que lo declara, donde va su llamada. Un grupo sobre una extensión que nada en el bundle declara, comolist.Any, rompe la compilación con EQ2004 (Desde 0.2.0-preview.61) -
Flujo de control:
if,switch,for,foreach,while,do-while,break,continue,throw - Patrones modernos: soporte completo de patrones recursivos, de propiedad, posicionales, relacionales y lógicos (C# 9.0 a 12.0)
-
Gestión de recursos: soporte de sentencias
usingy declaracionesusing var -
Excepciones: soporte completo de
try-catch-finallyy de sentenciasthrow(Exception → Error) -
Índices y rangos: soporte del operador de índice desde el final (
array[^1]→array[array.length - 1]) -
Métodos de cadena: métodos de instancia (
Split,Replace,StartsWith,EndsWith,Contains,Substring,IndexOf,LastIndexOf,PadLeft,PadRight,Trim,TrimStart,TrimEnd,ToUpper,ToLower,ToUpperInvariant,ToLowerInvariant,Insert,Remove,ToCharArray) y métodos estáticos (IsNullOrEmpty,IsNullOrWhiteSpace,Join,Concat,Compare,Equals,Format). Un método al que se le da unStringComparisonresponde por él, por el runtime, y una búsqueda por una comparación de cultura rompe la compilación (EQ1004): el navegador no tiene búsqueda consciente de la cultura (Desde 0.2.0-preview.60) -
Métodos numéricos:
ParseyTryParseen todo tipo numérico (int,uint,long,ulong,short,ushort,byte,sbyte,float,double,decimal), leídos por el runtime con la gramática de .NET -
Métodos de List:
Add,AddRange,Insert,InsertRange,Remove,RemoveAt,RemoveRange,RemoveAll,Clear,IndexOf,LastIndexOf,Find,FindIndex,FindLast,FindLastIndex,FindAll,Exists,TrueForAll,Sort,ForEach,GetRange,CopyTo,BinarySearch -
Caras de List: un elemento leído o escrito por
IList<T>,IReadOnlyList<T>oIList→$eq.collections.item(list, i)y$eq.collections.setItem(list, i, value), suCount→$eq.collections.count(list), y elAddy elClearpropios deICollection<T>→$eq.collections.add(collection, item)y$eq.collections.clear(collection). Cada uno responde por la colección que la cara contenga cuando corre: un arreglo por su subíndice, el conjunto, la lista enlazada o el diccionario del runtime por sus propios miembros, y un tipo propio tuyo por su indexador, suCount, suAddy suClear(Desde 0.2.0-preview.61) -
Métodos estáticos de Array: soporte completo de los métodos estáticos de Array
-
Array.Sort(array)→array.sort()- Ordena el arreglo en el sitio -
Array.Sort(array, comparison)→array.sort(comparison)- Ordena con un comparador propio -
Array.Reverse(array)→array.reverse()- Invierte el arreglo en el sitio -
Array.Find(array, predicate)→array.find(predicate)- Encuentra el primer elemento que coincide -
Array.FindIndex(array, predicate)→array.findIndex(predicate)- Encuentra el índice de la primera coincidencia -
Array.FindAll(array, predicate)→array.filter(predicate)- Encuentra todos los elementos que coinciden -
Array.IndexOf(array, value)→array.indexOf(value)- Encuentra el índice de un valor -
Array.LastIndexOf(array, value)→array.lastIndexOf(value)- Encuentra el último índice de un valor -
Array.Exists(array, predicate)→array.some(predicate)- Comprueba si algún elemento coincide -
Array.TrueForAll(array, predicate)→array.every(predicate)- Comprueba si todos los elementos coinciden -
Array.Clear(array)→array.splice(0)- Quita todos los elementos -
Array.Resize(ref array, size)→array.length = size- Redimensiona el arreglo
-
-
Métodos de Enum: un enum no tiene objeto propio en el navegador, así que los estáticos de
Enumllaman a las funciones de enum del runtime,$eq.enums, con la forma del enum escrita en la llamada: sus nombres, los nombres camelCase que el navegador conserva, sus valores y si es un enum[Flags](Desde 0.2.0-preview.60; nombraban un objeto que ningún módulo declara, y lanzaban)-
Enum.Parse<T>(text)yEnum.Parse(typeof(T), text)→$eq.enums.parse(text, shape): un nombre, nombres unidos por comas o un número, el caso conservado salvo queignoreCasesea true, y un error donde .NET lanza uno -
Enum.TryParse<T>(text, out var result)→ true con el valor, o false con el valor por defecto del enum enresult, y null para la sobrecarga que toma unType -
Enum.GetName(value)→ el nombre del miembro con ese valor, o null donde ninguno lo tiene -
Enum.GetNames<T>()yEnum.GetValues<T>()→ los nombres y los valores, en el orden de los valores -
Enum.IsDefined(typeof(T), value)→ si algún miembro tiene ese valor, dado como el enum, un número, un nombre declarado, o unobjectque contenga cualquiera de ellos -
value.ToString(), una interpolación y una concatenación escriben el nombre del miembro, las banderas puestas de una combinación de flags (Read, Write), los dígitos de un valor que ningún miembro nombra, y nada para un enum nullable nulo.ToString("D"),"X","F"y"G", y el formato de una interpolación, escriben lo que escribe .NET, y una alineación rellena ese texto - Un cast desde un
objectque contiene el enum lo conserva ((Status)Enum.Parse(typeof(Status), text)), un cast entre dos enums pasa por sus valores, y un valor que ningún miembro nombra se guarda como su número
-
-
Métodos de Dictionary: todo Dictionary, IDictionary e IReadOnlyDictionary, y los ordenados, es una
clase del runtime bajo
$eq.collections.*, preguntada conhas/get(Desde 0.2.0-preview.60)-
new Dictionary<K, V>()→$eq.collections.dictionary(), o$eq.collections.dictionary(null, true)donde el tipo de la clave compara por valor (un record, un struct, una tupla, un decimal, una fecha, una clase que sobrescribeEquals); una copia lo siembra con su fuente, y un inicializador con pares[key, value] -
dict[key]→$eq.mapGet(dict, key)- lanza para una clave que falta, como lo hace .NET;dict[key] = value→$eq.mapSet(dict, key, value), que responde el valor escrito -
ContainsKey(key)→dict.has(key)- nunca responde por un miembro del prototipo -
TryGetValue(key, out var value)→(dict.has(key) ? ((value = dict.get(key)), true) : ((value = 0), false))- un fallo escribedefault(TValue)(0aquí, para unint) -
GetValueOrDefault(key[, defaultValue])→(dict.has(key) ? dict.get(key) : 0)- un valor por defecto explícito se evalúa una vez, se necesite o no - Un receptor o una clave leídos por un miembro o una llamada se enlazan una vez:
this.cache.TryGetValue(Next(), out var v)→(($0, $1) => …)(this.cache, next()) -
Add(key, value)→dict.set(key, value)- NO lanza para una clave ya presente, donde el de .NET sí (#440) -
Remove(key)→dict.delete(key), yRemove(key, out var value)mueve el valor al out -
TryAdd(key, value)→dict.tryAdd(key, value),ContainsValue(value)→dict.containsValue(value) -
Clear()→dict.clear() -
Keys/Values(propiedades) →dict.keys()/dict.values(), arreglos en el orden del diccionario;Count,Keys.CountyValues.Count→dict.size, yKeys.Contains(key)→dict.has(key)
-
-
LINQ: conversión directa de los métodos de LINQ a sus equivalentes de JS:
-
Proyección:
Select→map,SelectMany→flatMap -
Filtrado:
Where→filter,Distinct→[...new Set()] -
Ordenación:
OrderBy/OrderByDescending→sort,Reverse→[...arr].reverse() -
Partición:
Skip→slice(n),Take→slice(0, n) -
Elemento:
First/FirstOrDefault→find/[0],Last/LastOrDefault→arr[arr.length-1],Single/SingleOrDefault→find/[0]; con un valor por defecto,FirstOrDefault,LastOrDefaultySingleOrDefault→$eq.linq.firstOrDefault,$eq.linq.lastOrDefaulty$eq.linq.singleOrDefault, que lo responden donde nada coincide (Desde 0.2.0-preview.62) -
Cuantificadores:
Any→some/length > 0,All→every,Contains→includes -
Agregación:
Count→length/filter().length,Sum→reduce(($a, $b) => $a + $b, 0),Average→$eq.linq.average, que computa la media en el tipo del elemento, un long, un float, un decimal o un nullable (Desde 0.2.0-preview.62),Min→Math.min(...),Max→Math.max(...) -
Operaciones de conjunto:
-
Concat(other)→[...source, ...other]- Concatena dos secuencias -
Union(other)→[...new Set([...source, ...other])]- Elementos únicos de las dos secuencias -
Intersect(other)→[...new Set(source)].filter(($x) => other.includes($x))- Elementos comunes -
Except(other)→[...new Set(source)].filter(($x) => !other.includes($x))- Elementos en la fuente pero no en la otra -
other, como todo argumento de una llamada de LINQ que no es una lambda escrita en el sitio, se evalúa una vez, en el orden de C#, antes de que corra el lowering: corría una vez por elemento (Desde 0.2.0-preview.61)
-
-
Pares:
Zip(second)yZip(second, third)→$eq.linq.zipTuples, que responde los pares y las ternas que responde .NET (Desde 0.2.0-preview.62) -
Igualdad:
SequenceEqual→$eq.linq.sequenceEqual, que compara por la igualdad por defecto del tipo del elemento (Desde 0.2.0-preview.62) -
Comparadores: un comparador entregado a
ToDictionary,ToLookup,GroupBy,Distinct,ToHashSetoSequenceEqualque pide el por defecto (EqualityComparer<T>.Default,null,StringComparer.Ordinal) se descarta, ya que el lowering ya encuentra sus claves así, y cualquier otro rompe la compilación con EQ2007 (Desde 0.2.0-preview.61) -
Filtrado por tipo:
-
Cast<T>()→ paso directo (JavaScript es de tipado dinámico) -
OfType<T>()→filter(($x) => typeof $x === 'type')para primitivos,filter(($x) => $x instanceof Type)para objetos
-
-
Proyección:
-
Async/Await: mapeo de
TaskaPromisey soporte nativo deawait. -
Operadores modernos de C#: soporte de operadores y palabras clave modernas de C#
-
Asignación con fusión de nulos:
x ??= value→x ?? (x = value)- Asigna solo si es nulo o undefined -
Operador nameof:
nameof(variable)→'variable'- Obtiene el nombre como cadena en tiempo de compilación. Es el nombre, no su escritura:nameof(@class)→'class'(Desde 0.2.0-preview.60) -
Palabra clave default:
default(T)y el literaldefaultson el valor por defecto del tipo, siendo el tipo del literal aquel al que C# lo convierte:default(int)→0,default(long)→0n,default(int?)→null,int x = default→0(Desde 0.2.0-preview.58)
-
Asignación con fusión de nulos:
Dos valores por defecto son implícitos en C# y ausentes en JavaScript. Los dos se emitían como nada por un tiempo, y los dos fallan TARDE, no en tiempo de compilación, y no donde está la causa.
Desde 0.2.0-preview.22
Un campo de un tipo de valor es cero haya escrito alguien = 0 o no. En el cliente era undefined,
y los dos no son el mismo valor. Las lecturas sobreviven por suerte mientras son PRUEBAS
(undefined > 0 es false, que es lo que habría dicho 0), y entonces la primera ARITMÉTICA lo vuelve
NaN. Math.max(width, undefined) llega a la hoja de estilos como width:NaNpx, una regla que el
parser de CSS descarta entera: la clase se computa, se hashea, se emite, se pone en el elemento, y no
hace nada.
Aparece solo en páginas RENDERIZADAS en el cliente, nunca en SSR ni en una carga directa, porque el servidor computa la misma propiedad en C# donde fue 0 todo el tiempo. Así que los dos destinos discrepan sobre un campo y la página que lo prueba es la que nadie recarga.
Todo tipo de valor no nullable lleva ahora su valor por defecto (0, false, el miembro cero de un
enum). Los nullables no, porque ahí null ES la respuesta de C#, e inventar un cero sería la misma
divergencia apuntando al otro lado.
Desde 0.2.0-preview.21
public sealed class TocEntry(Action<string, bool> onSeen, string id) : StatelessComponent
{
public override VisualNode Build(ComponentContext context) =>
new InView(Heading(id), visible => onSeen(id, visible)); // this.onSeen, this.id
}Roslyn modela la captura como un IParameterSymbol, así que todo lugar que pregunta «¿es esto un
parámetro?» responde que sí sobre algo que se comporta como un campo. Emitido desnudo compila, la
página se renderiza, y el ReferenceError llega cuando el callback por fin se dispara, saliendo a la
superficie desde dentro del reconciliador como un TypeError sobre algo completamente distinto.
Un componente no es el único C# que una página necesita. El modelo de documento detrás de un editor, una pequeña máquina de estados, un parser: ninguno de ellos es un componente, y todos tienen que correr en los dos destinos. Transpilan igual, como módulos propios, y las reglas de abajo son lo que hace la emisión COMPROBADA en vez de meramente presente.
line[start..end] → line.slice(start, end)
line[2..] → line.slice(2)
line[..^1] → line.slice(0, -1)
line[..^n] → $eq.slice(line, 0, false, n, true)La última forma es la que JavaScript no puede decir directamente: ^0 significa el FINAL, mientras
que slice(0, -0) es slice(0, 0), que está vacío. Cualquier cosa que no sea un literal positivo
después de ^ se resuelve por tanto contra la longitud como lo hace Index.GetOffset. Un Range
guardado como VALOR se reporta (EQ2004): nada del otro lado recibe uno, e indexar en el punto de uso
es para lo que sirve.
Un rango sobre un tipo propio tuyo con un Length (o un Count) y un Slice(int start, int length)
no es la tajada de un arreglo: C# lo baja a ese Slice, que toma un inicio y una LONGITUD donde el
slice de JavaScript toma un final, y lo mismo hace el gemelo (Desde 0.2.0-preview.61):
strip[1..3] → strip.slice(1, 3 - 1)El receptor se lee una vez, luego los extremos en el orden en que están escritos, luego el Length o
el Count, leído solo donde un extremo cuenta desde el final o el final se deja abierto, como los
lee .NET. Un rango entregado a un indexador que toma el Range mismo le entregaría un valor Range,
y rompe la compilación (EQ2004) por la misma razón que lo hace uno guardado.
JavaScript no tiene ninguno. Un método que los declara devuelve un OBJETO (su propio valor bajo $,
cada out y ref bajo su nombre) y su cuerpo se mueve dentro de un cierre para que todo return en él
siga significando lo que significaba. El sitio de llamada lo desenvuelve con una flecha, lo cual
funciona en cualquier posición de expresión, incluso dentro de un if:
var next = document.Replace(range, text, out var caret);let caret: any;
let next = ($o => (caret = $o.caret, $o.$))(document.replace(range, text));out deja la lista de parámetros de JS (no se pasa HACIA DENTRO); ref se queda, porque se lee antes
de escribirse. out _ no asigna nada.
El enlace de un patrón (x is T t), un out var y el elemento de una deconstrucción
((var c, var d) = …) se asignan dentro de la expresión que los declara, así que la sentencia que
contiene esa expresión los declara, con el ámbito que Roslyn les da (Desde 0.2.0-preview.59):
- una sentencia de expresión, un
if, unreturn, unthrow, unyield return, una declaración, la expresión gobernante de unswitchy unlocklos declaran delante de sí mismos, donde el código después de ellos puede leerlos (if (!int.TryParse(s, out var n)) return;y luegon). Puestos directamente en una sección de switch, los dejan al switch, que los declara una vez para todo su bloque, como C# los enmarca, así que otra sección puede asignarlos; - un
while, undoy unforlos declaran en la cabeza propia del bucle,for (let n; …), que JavaScript copia para cada iteración, como .NET le da a la condición de un bucle una variable fresca: un cierre hecho en una iteración conserva el valor de esa iteración; - un
foreachy unusinglos declaran en un bloque alrededor de la sentencia, que es su ámbito en C#; - una lambda, una función local, la cláusula de una consulta y la rama de una expresión switch declaran los suyos, una vez por cada llamada;
- el inicializador de un campo o de una propiedad los declara en una flecha propia.
Un nombre que JavaScript rechaza, y que el escape textual de C# permite (@class, @new,
@default), se vuelve un nombre legal en su declaración y en toda referencia, de cualquier manera que
se haya enlazado: class$, con un $ que ningún nombre de C# puede contener. Una etiqueta y un
parámetro de tipo de un método escritos así toman el mismo nombre, y un miembro o una clave de with
es el nombre mismo: r.class, { class: 5 } (Desde 0.2.0-preview.61).
Una variable local nombrada como un global que el código emitido lee (crypto, undefined, Math,
Number, console) toma el mismo $, así que el global se queda visible para el código a su lado, y
todo nombre que el código emitido declara para sí mismo empieza con un $, así que nunca se encuentra
con una local que el código a su alrededor lee (Desde 0.2.0-preview.61).
new List<T>(x) significa dos cosas opuestas según qué sea x, y solo el constructor resuelto puede
decir cuál: new List<string>(other.Count) es una lista vacía dimensionada por adelantado,
new List<string>(other) es una copia. La primera emite un arreglo vacío, la segunda [...other].
Con un inicializador de colección, la lista es lo que su constructor puso en ella, y luego cada
elemento en orden: new List<string>(other) { "x" } es [...other, "x"] y
new List<string>(8) { "x" } es ["x"], escrito o tipado por destino. Una capacidad que no es una
constante que la lista toma se evalúa igual, antes de los elementos, y una negativa se rechaza como la
rechaza el constructor de List<T>: new List<string>(Size()) { "x" } es
($eq.collections.listCapacity(this.size()), ["x"]) (Desde 0.2.0-preview.61).
Un char de C# en + - * / % promueve a int y computa en la unidad de código, mientras que un char
transpilado es una cadena de longitud 1. Cuando el tipo del RESULTADO es numérico, los operandos char
bajan a unidades de código (los literales constantes se doblan al número; las expresiones leen
charCodeAt(0)), así que text[i] - '0' es el dígito y 'A' + col es un número, exactamente como en
.NET. (char)numeric baja a String.fromCharCode. char + string se queda concatenación: su tipo de
resultado es string, así que la rama numérica nunca lo ve.
JavaScript no tiene decimal ni entero de 64 bits, así que el cable envía esos como cadenas, un
long como "9007199254740993", un decimal como "0.1", la familia de fechas como texto ISO. El
compilador conoce el tipo de C# de todo campo de estado y de todo retorno de Server Action, así que
escribe ese conocimiento en el gemelo como un mapa estático $hydration, y el runtime coacciona UNA
VEZ, donde el valor llega, en vez de que todo sitio de uso coaccione a la defensiva. Un record nombra
su propio mapa, una lista es [spec], los valores de un diccionario son { dict: spec }, y una tupla
es posicional ({ tuple: [...] }) porque cruza como un arreglo. No se emite nada donde la hidratación
sería la identidad, que es la mayoría de los campos.
new int[0].SingleOrDefault() es 0 en .NET, no null, y lo mismo vale para FirstOrDefault,
LastOrDefault, ElementAtOrDefault y DefaultIfEmpty. Un campo declarado sin inicializador toma el
mismo valor por defecto: un int es 0, un bool false, un long 0n, y un ENUM su miembro de valor
cero, que es una cadena con el NOMBRE del miembro en este lado, así que un campo dejado sin asignar no
renderizaría nada en absoluto. Una sola tabla responde por los dos, por símbolo, así que un tipo
alcanzado por un alias (using Amount = decimal;) no es una respuesta distinta del tipo mismo.
La misma tabla llena un arreglo dimensionado, new T[n], y responde default(T) y el literal
default, cuyo tipo es aquel al que C# lo convierte: el de una variable local, el de un parámetro, el
de la otra rama de un condicional. Un elemento struct es una instancia cero propia, ya que fill
compartiría un objeto por toda ranura, y los gemelos de tiempo e identidad (DateTime, TimeSpan,
DateOnly, TimeOnly, DateTimeOffset, Guid) empiezan en su MinValue, Zero o Empty. Un
struct cuyo gemelo escrito a mano no puede construir su cero conserva el valor por defecto propio del
gemelo en un literal default: undefined, que el constructor del gemelo vuelve su valor por
defecto, donde null lo saltaría. Desde 0.2.0-preview.58.
xs.OrderBy(a).OrderBy(b) ordena por b. La ordenación anterior no se queda como clave primaria:
sobrevive solo como el desempate que le da una ordenación estable, así que es un resultado distinto de
xs.OrderBy(a).ThenBy(b) siempre que b tenga empates que a rompería. Las dos cruzan con
fidelidad: un OrderBy encadenado ordena su fuente primero y ordena eso, y ThenBy compone su clave
en la misma comparación.
Math.round(32.5) es 33 en JavaScript y 32 en .NET, que redondea un medio al par. En todos los
lugares donde el runtime espeja un MathF.Round usa la regla de .NET, así que un valor computado en el
navegador es el valor que computó el servidor: esto es lo que mantiene la altura de línea de una escala
tipográfica, y cualquier maquetación derivada de una, idéntica a través de la hidratación en vez de una
fracción desviada.
Es la aritmética de .NET, no una aproximación de ella. El punto medio es EXACTO:
Math.Round(0.5015, 3) es 0,501, porque 0.5015 * 1000 es 501,49999999999994: un valor cerca de un
medio no es un medio. Todo modo de MidpointRounding se honra, una cuenta de dígitos pasada de 15 (6
para un float) lanza como lo hace la de .NET, y MathF.Round(x, digits) escala y divide en precisión
simple.
La mayoría de los operadores de LINQ son una forma cada uno (Where es filter, Aggregate es
reduce con sus argumentos intercambiados), así que viven como entradas en una tabla indexada por
operador y cuenta de argumentos, con una sola puerta, y con el escritor del IR puntuando y enlazando
cualquier receptor usado dos veces. Un operador que tiene que RAZONAR conserva una estrategia propia:
la familia OrDefault lee el tipo del elemento, Sum elige una semilla por él, Cast y OfType
prueban tipos, Contains elige entre igualdad de identidad y estructural. La misma forma vale para la
BCL numérica, donde la superficie moderna (Double.AcosPi y su familia) es una tabla de plantillas en
vez de un método cada una.
Donde esas tablas no tienen entrada, la llamada es un ERROR de compilación que nombra el miembro,
nunca una adivinanza: un nombre emitido sin nada detrás es un ReferenceError al cargar, y una página
que nunca se renderiza.
Un byte de C# pasado de 255 da la vuelta; un número de JavaScript sigue contando. El compilador lee
el tipo del RESULTADO de todo + - * << (y de ++, -- y las formas compuestas) y asienta el valor
donde C# lo haría: byte, sbyte, short, ushort y uint SIEMPRE dan la vuelta (& 0xFF,
<< 16 >> 16, >>> 0: los valores empaquetados y los hashes dependen de ello, así que h *= 16777619
en un uint es el paso FNV que es en .NET, por Math.imul). int y long dan la vuelta solo donde
escribiste unchecked (unchecked(a * a) es 0 para 65536; un long da la vuelta por
BigInt.asIntN), porque de otro modo todo i + 1 simple en una UI llevaría un | 0 por un
desbordamiento que es un defecto en cualquier otro lugar, así que un int.MaxValue + 1 simple conserva
la cuenta del double, un límite documentado. Un contexto checked (el bloque, la expresión, o el ajuste
de todo el proyecto, leído del IsChecked del árbol enlazado y no de la sintaxis) lanza un
desbordamiento exactamente donde C# lo lanza. Un float es un simple donde sea que se PRODUZCA, todo
+ - * /, todo incremento y asignación compuesta sobre un destino float (uno float? todavía no se
redondea), un int pasado de 2^24 en su camino hacia dentro, una constante float, un valor hidratado
del servidor, porque RyuJIT redondea cada operación de float, y así a*x - b*x responde el último bit
del servidor también en el navegador. Se imprime como el decimal más corto que se vuelve a leer como el
mismo simple, así que 0.1f + 0.2f es "0.3" en los dos lados. char++ avanza el carácter; un enum en
aritmética (day + 1, a.CompareTo(b)) computa sobre el valor detrás del nombre del miembro.
La sintaxis nunca muestra una conversión implícita (int i = c con un char, Twice(c), a[c],
long l = n) y una regla escrita contra la sintaxis cubre solo las formas que su autor recordó. El
compilador las lee del árbol enlazado de Roslyn en su lugar: después de que toda expresión se traduce,
la conversión en la que el árbol enlazado la envuelve se aplica (ValueFlow), en todo sitio en el que
C# la aplica: inicializadores, argumentos, índices, retornos, comparaciones. Un char promovido a número
se vuelve su unidad de código (una constante se dobla: 'A' + col es 65 + col); un int que fluye a
un long se vuelve un BigInt (1 → 1n, que es además por lo que TimeSpan.FromSeconds(90) emite
90n: la sobrecarga de .NET 9 toma un long). Dos chars comparados se quedan caracteres: JavaScript
ordena las cadenas de longitud 1 por las mismas unidades de código. Una conversión definida por el
usuario cruza como una llamada al estático que lleva su gemelo, un tipo propio de una app como
Money.fromInt(5), un tipo del vocabulario como IconGlyph.fromIcons('search') para
Icon(Icons.Search), salvo que su gemelo tome el operando tal cual: SizeValue desde un número
declara exactamente eso, así que Width = 120 se queda un 120 simple, y un tipo de fuera del SDK
(Index) pasa su primitivo directo.
Un valor en su camino hacia TEXTO se asienta igual: encajado en una concatenación, un operando de
cadena de una, o un hueco de interpolación simple, se imprime como lo imprime C#, un nulo como nada, un
bool como "True", un enum por el nombre de su miembro, s += flag incluido, que ninguna regla de
sintaxis había visto.
JavaScript no puede sobrecargar +, así que un record o un struct que declares con operadores lleva
cada uno en su gemelo como un método estático, y todo sitio donde el árbol enlazado muestra un operador
lo llama: a + b es Money.opAdd(a, b), -m es Money.opNegate(m) (el - unario y el binario se
nombran por aridad, así que nunca chocan), m += other es m = Money.opAdd(m, other). Las conversiones
también: implicit operator Money(int v) se vuelve Money.fromInt, llamado donde sea que C# convierta,
una declaración, un argumento, el operando de un compuesto, y explicit operator int(Money m) se vuelve
Money.toInt, llamado por el cast. Solo para tipos en tu fuente: una envoltura del framework como
SizeValue o Index es su primitivo en este lado, y sus operadores pasan el valor directo.
El ELEMENTO de un foreach se convierte igual, uno a la vez: foreach (long l in ints) vuelve cada int
un BigInt, foreach (int code in chars) cada char su unidad de código, foreach (Money m in ints) llama
a la conversión del tipo: el bucle enlaza un $l crudo y declara l desde él.
Una DECLARACIÓN using (using var r = …;) es dueña del resto de su bloque: lo que sigue corre dentro
de un try cuyo finally libera r, después de que se toma un valor de retorno, cuando el cuerpo lanza, en
orden inverso cuando varios comparten un bloque, y await using espera a disposeAsync.
Un diccionario es la clase de diccionario del runtime, que conserva sus entradas por ranura como lo hace
el Dictionary de .NET: se enumera en orden de inserción mientras nada se quita, y una entrada agregada
después de un quitado toma la ranura de la quitada, la última liberada primero. foreach sobre uno (y
new List<KeyValuePair<,>>(dict), que lo esparce) produce pares que se desestructuran como
[key, value] Y responden .key/.value (las dos formas de consumo de C#), cada clave en su propio
tipo, así que el siguiente key + 1 suma. Un diccionario ordenado produce los mismos pares, en orden de
clave. Desde 0.2.0-preview.60.
new List<KeyValuePair<int,float>>(…) no puede anotar su let con el nombre desnudo de C#
(KeyValuePair[] no nombra nada en TS); los elementos genéricos dejan la anotación a la inferencia.
C# inicializa los estáticos de un tipo juntos: cada uno empieza en su cero, luego los inicializadores
corren en orden de declaración, luego el constructor estático, una vez, al primer uso. Un tipo cuyos
estáticos pueden observarse unos a otros (un inicializador que no es una constante, o un constructor
estático) los contiene todos en un solo objeto $slots que $init() construye la primera vez que uno
de ellos se lee o se escribe, y cada estático es un par de accesores sobre su ranura, así que
static int A = B + 1; encima de static int B = 2; lee el cero de B y responde 1, como lo hace .NET,
y dos tipos cuyos estáticos se leen uno al otro ven el cero que ve .NET. El mismo mecanismo sirve a un
record, un struct, una clase, una clase estática y un componente. Siendo perezoso, además sobrevive a
los ciclos de import de la biblioteca compartida (sus módulos se importan unos a otros por un solo
barril, y el que cargue primero ve la clase del otro como undefined), que es para lo que servía el
getter perezoso por estático que reemplaza; ese getter inicializaba cada estático en su propia primera
lectura, sin orden alguno. Un tipo cuyos inicializadores son constantes conserva campos simples, cada
uno escrito como el VALOR al que C# lo dobla (static readonly int Max = Default * 2; encima de
const int Default = 50; es 100, donde la expresión leía Default antes de que su declaración lo
definiera), y un estático cuyo cero construye un gemelo se construye al primer uso también, nunca
mientras su módulo se evalúa. Un tipo que declara un constructor estático además arranca la
inicialización primero en su constructor de instancia y en todo método, accesor y operador estáticos, ya
que C# corre ese constructor antes de la primera instancia y del primer uso de cualquier miembro
estático. Los estáticos se inicializan en orden de declaración a través de campos, propiedades y eventos
tipo campo; $slots existe antes de que nada corra, así que una lectura reentrante ve el cero que ve C#;
el cuerpo del constructor estático corre en una función propia después de los inicializadores, así que
un return en él lo termina solo; y un fallo se conserva, y todo uso del tipo entonces lanza una
TypeInitializationException cuya excepción interna es la original, como lo hace .NET. Desde
0.2.0-preview.61.
El .ts emitido se comprueba con tipos (esa es la segunda de las dos capas), así que una firma nunca
debe introducir un nombre que el módulo no puede resolver:
| C# | TypeScript |
|---|---|
| un enum |
string, ya que su representación en tiempo de ejecución es el nombre del miembro |
| una interfaz sin gemelo emitido | any |
IReadOnlyList<(char, char)> |
[string, string][] |
Action<T>? |
((t: T) => void) | null, entre paréntesis, o la unión se enlaza al retorno |
char |
string |
| un nombre que nada puede verificar |
any, porque un tipo equivocado es peor que uno abierto |
Los records llevan las mismas reglas, más sus campos estáticos y sus propiedades computadas (un record es un valor con COMPORTAMIENTO, no solo sus miembros posicionales).
Tanto el conjunto transpilado como el barril de exportación del runtime se generan desde el directorio de fuentes, nunca desde un registro mantenido a mano, así que la biblioteca incrustada nunca puede apartarse del código del que se construye.
Fuente de C#:
private void Increment() {
Count++;
if (Count > 10) Console.WriteLine("Max reached");
}Salida de TypeScript:
increment() {
this.count++;
if (this.count > 10) console.log("Max reached");
}Fuente de C#:
public enum OrderStatus { Pending, Processing, Shipped, Delivered }
private void HandleStatusChange(string input)
{
// Parse enum from string (case-insensitive)
if (Enum.TryParse<OrderStatus>(input, ignoreCase: true, out var status))
{
Console.WriteLine($"Status changed to: {status}");
}
// Get all enum values for dropdown
var allStatuses = Enum.GetValues<OrderStatus>();
foreach (var s in allStatuses)
{
Console.WriteLine($"Available status: {s}");
}
// Validate enum value
if (Enum.IsDefined(typeof(OrderStatus), "Shipped"))
{
Console.WriteLine("Valid status");
}
}Salida de TypeScript (shape representa la forma del enum, que eqc escribe en cada llamada:
{ names: ['Pending', 'Processing', 'Shipped', 'Delivered'], keys: ['pending', 'processing', 'shipped', 'delivered'], values: [0, 1, 2, 3], flags: false, digits: 8 }):
handleStatusChange(input: string) {
let status: any;
// Parse with TryParse: the enum's default when it fails, as .NET leaves it
if (((status = $eq.enums.tryParse(input, shape, true)) !== undefined || ((status = $eq.enums.zero(shape)), false))) {
console.log(`Status changed to: ${$eq.enums.text(status, shape)}`);
}
// Get all values, in the order of the values
let allStatuses = $eq.enums.values(shape);
for (const s of allStatuses) {
console.log(`Available status: ${$eq.enums.text(s, shape)}`);
}
// Validate a declared name
if ($eq.enums.isDefined('Shipped', shape, 'name')) {
console.log('Valid status');
}
}Fuente de C#:
private Dictionary<string, int> _settings = new();
private void ManageSettings()
{
// Add entries
_settings.Add("timeout", 5000);
_settings.Add("retries", 3);
// Check existence
if (_settings.ContainsKey("timeout"))
{
var timeout = _settings["timeout"];
Console.WriteLine($"Timeout: {timeout}");
}
// Safe retrieval
if (_settings.TryGetValue("maxItems", out var max))
{
Console.WriteLine($"Max: {max}");
}
// Iterate keys
foreach (var key in _settings.Keys)
{
Console.WriteLine($"{key} = {_settings[key]}");
}
// Clear all
_settings.Clear();
}Salida de TypeScript:
constructor() {
this._settings = $eq.collections.dictionary();
}
declare _settings: any;
manageSettings() {
let max: any;
this._settings.set('timeout', 5000);
this._settings.set('retries', 3);
if (this._settings.has('timeout')) {
let timeout = $eq.mapGet(this._settings, 'timeout');
console.log(`Timeout: ${timeout}`);
}
if ((($0: any) => ($0.has('maxItems') ? ((max = $0.get('maxItems')), true) : ((max = 0), false)))(this._settings)) {
console.log(`Max: ${max}`);
}
for (const key of this._settings.keys()) {
console.log(`${key ?? ''} = ${$eq.mapGet(this._settings, key)}`);
}
this._settings.clear();
}Fuente de C#:
private void ProcessCollections()
{
var list1 = new[] { 1, 2, 3, 4 };
var list2 = new[] { 3, 4, 5, 6 };
// Concatenate two lists
var combined = list1.Concat(list2);
// Result: [1, 2, 3, 4, 3, 4, 5, 6]
// Union - unique elements from both
var union = list1.Union(list2);
// Result: [1, 2, 3, 4, 5, 6]
// Intersect - common elements
var common = list1.Intersect(list2);
// Result: [3, 4]
// Except - elements in list1 but not in list2
var difference = list1.Except(list2);
// Result: [1, 2]
// Complex filtering with set operations
var activeUsers = GetActiveUsers();
var premiumUsers = GetPremiumUsers();
// Users that are both active AND premium
var activePremium = activeUsers.Intersect(premiumUsers);
// Users that are active but NOT premium
var activeFree = activeUsers.Except(premiumUsers);
}Salida de TypeScript:
processCollections() {
const list1 = [1, 2, 3, 4];
const list2 = [3, 4, 5, 6];
// Concatenate
const combined = [...list1, ...list2];
// Union (with Set to remove duplicates)
const union = [...new Set([...list1, ...list2])];
// Intersect (common elements)
const common = [...new Set(list1)].filter(x => list2.includes(x));
// Except (difference)
const difference = [...new Set(list1)].filter(x => !list2.includes(x));
// Complex filtering
const activeUsers = this.getActiveUsers();
const premiumUsers = this.getPremiumUsers();
const activePremium = [...new Set(activeUsers)].filter(x => premiumUsers.includes(x));
const activeFree = [...new Set(activeUsers)].filter(x => !premiumUsers.includes(x));
}Fuente de C#:
private void ProcessArrayOperations()
{
var numbers = new[] { 5, 2, 8, 1, 9 };
var items = new[] { "apple", "banana", "cherry" };
// Sort array in place
Array.Sort(numbers);
// Result: [1, 2, 5, 8, 9]
// Sort with custom comparison
Array.Sort(items, (a, b) => b.Length - a.Length);
// Result: ["banana", "cherry", "apple"]
// Reverse array
Array.Reverse(numbers);
// Result: [9, 8, 5, 2, 1]
// Find operations
var firstEven = Array.Find(numbers, n => n % 2 == 0);
var firstEvenIndex = Array.FindIndex(numbers, n => n % 2 == 0);
var allEvens = Array.FindAll(numbers, n => n % 2 == 0);
// Search operations
var index = Array.IndexOf(numbers, 5);
var lastIndex = Array.LastIndexOf(numbers, 5);
// Check operations
var hasEven = Array.Exists(numbers, n => n % 2 == 0);
var allPositive = Array.TrueForAll(numbers, n => n > 0);
// Clear and resize
Array.Clear(numbers);
Array.Resize(ref items, 5); // Expand to 5 elements
}Salida de TypeScript:
processArrayOperations() {
const numbers = [5, 2, 8, 1, 9];
const items = ["apple", "banana", "cherry"];
// Sort
numbers.sort();
// Sort with comparison
items.sort((a, b) => b.length - a.length);
// Reverse
numbers.reverse();
// Find operations
const firstEven = numbers.find(n => n % 2 == 0);
const firstEvenIndex = numbers.findIndex(n => n % 2 == 0);
const allEvens = numbers.filter(n => n % 2 == 0);
// Search operations
const index = numbers.indexOf(5);
const lastIndex = numbers.lastIndexOf(5);
// Check operations
const hasEven = numbers.some(n => n % 2 == 0);
const allPositive = numbers.every(n => n > 0);
// Clear and resize
numbers.splice(0);
items.length = 5;
}Fuente de C#:
private void FilterByType()
{
// Mixed type collection
object[] mixed = new object[] { 1, "hello", 2, "world", 3.14, true };
// Cast<T>() - assumes all elements are of type T (passthrough in JS)
var assumedStrings = mixed.Cast<string>();
// OfType<T>() - filters to only elements of type T
var onlyStrings = mixed.OfType<string>();
// Result: ["hello", "world"]
var onlyNumbers = mixed.OfType<int>();
// Result: [1, 2]
// Works with custom classes too
var shapes = new object[] { new Circle(), new Square(), new Circle() };
var circles = shapes.OfType<Circle>();
// Result: [Circle, Circle]
// Primitive type filtering
var primitives = new object[] { 1, "text", 2.5, true, null };
var strings = primitives.OfType<string>(); // ["text"]
var numbers = primitives.OfType<double>(); // [1, 2.5]
var booleans = primitives.OfType<bool>(); // [true]
}Salida de TypeScript:
filterByType() {
// Mixed type collection
const mixed = [1, "hello", 2, "world", 3.14, true];
// Cast - passthrough (JS is dynamically typed)
const assumedStrings = mixed;
// OfType - filter by typeof for primitives
const onlyStrings = mixed.filter(x => typeof x === 'string');
// Result: ["hello", "world"]
const onlyNumbers = mixed.filter(x => typeof x === 'number');
// Result: [1, 2, 3.14]
// OfType - filter by instanceof for objects
const shapes = [new Circle(), new Square(), new Circle()];
const circles = shapes.filter(x => x instanceof Circle);
// Result: [Circle, Circle]
// Primitive filtering
const primitives = [1, "text", 2.5, true, null];
const strings = primitives.filter(x => typeof x === 'string'); // ["text"]
const numbers = primitives.filter(x => typeof x === 'number'); // [1, 2.5]
const booleans = primitives.filter(x => typeof x === 'boolean'); // [true]
}Fuente de C#:
private void DemonstrateModernOperators()
{
// Null-coalescing assignment (??=)
string? cachedData = null;
cachedData ??= LoadDataFromDatabase(); // Only loads if null
cachedData ??= "Default"; // Won't execute, already assigned
// Property null-coalescing assignment
if (user.Settings ??= new Settings())
{
Console.WriteLine("Created new settings");
}
// nameof operator (useful for property binding, validation)
var propertyName = nameof(user.Email);
Console.WriteLine($"Validating {propertyName}"); // "Validating Email"
var methodName = nameof(ProcessOrder);
LogAction(methodName); // "ProcessOrder"
// default keyword - type-safe default values
int count = default(int); // 0
string? text = default(string); // null
bool flag = default(bool); // false
DateTime date = default(DateTime); // 1/1/0001 12:00:00 AM
// default literal (contextual)
int number = default; // 0 (inferred from type)
ProcessData(default); // passes default value for parameter type
}
private void ProcessData(int value = default)
{
// value defaults to 0 if not provided
}Salida de TypeScript:
demonstrateModernOperators() {
// Null-coalescing assignment
let cachedData = null;
cachedData ?? (cachedData = this.loadDataFromDatabase());
cachedData ?? (cachedData = 'Default');
// Property assignment
if (this.user.settings ?? (this.user.settings = new Settings())) {
console.log('Created new settings');
}
// nameof operator
const propertyName = 'Email';
console.log(`Validating ${propertyName}`);
const methodName = 'ProcessOrder';
this.logAction(methodName);
// default keyword
let count = 0;
let text = null;
let flag = false;
let date = $eq.time.dateTime.minValue();
// default literal
let number = 0;
this.processData(0);
}
processData(value = 0) {
// value defaults to 0
}Fuente de C#:
private void StringManipulation()
{
var text = " Hello World ";
// Trimming
var trimmed = text.Trim(); // "Hello World"
var leftTrim = text.TrimStart(); // "Hello World "
var rightTrim = text.TrimEnd(); // " Hello World"
// Case conversion
var upper = text.ToUpper(); // " HELLO WORLD "
var lower = text.ToLower(); // " hello world "
var upperInv = text.ToUpperInvariant(); // " HELLO WORLD "
var lowerInv = text.ToLowerInvariant(); // " hello world "
// Chaining methods
var clean = text.Trim().ToLower().Replace("world", "everyone");
// Result: "hello everyone"
}Salida de TypeScript:
stringManipulation() {
const text = " Hello World ";
// Trimming
const trimmed = $eq.text.trim(text);
const leftTrim = $eq.text.trimStart(text);
const rightTrim = $eq.text.trimEnd(text);
// Case conversion
const upper = text.toUpperCase();
const lower = text.toLowerCase();
const upperInv = text.toUpperCase();
const lowerInv = text.toLowerCase();
// Chaining
const clean = $eq.text.trim(text).toLowerCase().replaceAll("world", "everyone");
}El recorte pasa por el runtime porque el trim de JavaScript es un conjunto distinto del de .NET: deja U+0085 NEXT LINE, que .NET recorta, y toma U+FEFF, que .NET conserva. char.IsWhiteSpace, string.IsNullOrWhiteSpace y un Split() desnudo leen la misma lista, así que un texto se recorta, se parte y se tokeniza igual en los dos lados (Desde 0.2.0-preview.58).
🏁 Empieza aquí
🏗️ Arquitectura
- Visión general de la arquitectura
- Componentes write-once
- Superficie declarativa
- Arquitectura de paquetes
- Componentes
- Estilos
- Localización
- Analytics y GTM
📱 Write-once
- Motor Photon
- Design System
- Capacidades
- Almacenamiento
- Formularios
- Editor de código
- Markdown
- Mermaid
- Renderizado de email
⚙️ Compilación
⚡ Runtime
🔌 Servidor
🎨 Ecosistema
🚀 Desarrollo