arckode-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/analyzer-Ctnj3WTI.js +424 -0
- package/dist/cli.js +581 -0
- package/dist/index.js +507 -0
- package/dist/router-DhUDyb8s.js +129 -0
- package/dist/vite.js +366 -0
- package/package.json +67 -0
- package/skills/analyzer/SKILL.md +128 -0
- package/skills/cli/SKILL.md +109 -0
- package/skills/compiler/SKILL.md +122 -0
- package/skills/components/SKILL.md +233 -0
- package/skills/runtime/SKILL.md +145 -0
- package/skills/testing/SKILL.md +169 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# SKILL: Compiler — parser, template-compiler, vite-plugin
|
|
2
|
+
|
|
3
|
+
> Cargá este skill cuando trabajés en `src/compiler/` o cuando necesites agregar/modificar una directiva.
|
|
4
|
+
|
|
5
|
+
## Parser (`src/compiler/parser.ts`)
|
|
6
|
+
|
|
7
|
+
Convierte un `.ark` (HTML con secciones) en `{ template, script, style }`.
|
|
8
|
+
|
|
9
|
+
### Validaciones que tira excepciones (`ArkParseError`)
|
|
10
|
+
|
|
11
|
+
- `MISSING_TEMPLATE` — no hay `<template>`
|
|
12
|
+
- `MISSING_SCRIPT` — no hay `<script>`
|
|
13
|
+
- `WRONG_ORDER` — `<script>` aparece antes que `<template>`
|
|
14
|
+
- `MISSING_LANG_TS` — `<script>` sin `lang="ts"`
|
|
15
|
+
- `DUPLICATE_SECTION` — más de un `<template>` o más de un `<script>`
|
|
16
|
+
|
|
17
|
+
Estos son distintos de las violations del analyzer — el parser tira excepción, el build se rompe.
|
|
18
|
+
|
|
19
|
+
## Template compiler (`src/compiler/template-compiler.ts`)
|
|
20
|
+
|
|
21
|
+
Convierte el `<template>` en una render function que retorna `VNode`.
|
|
22
|
+
|
|
23
|
+
### Directivas soportadas (verificadas en tests)
|
|
24
|
+
|
|
25
|
+
| Directiva | Sintaxis | Output |
|
|
26
|
+
|-----------|----------|--------|
|
|
27
|
+
| Interpolación | `{{ expr }}` | `h('span', null, expr)` o template literal |
|
|
28
|
+
| Atributo dinámico | `:attr="expr"` | `attr: expr` |
|
|
29
|
+
| Evento | `@event="handler"` | `onEvent: handler` (capitaliza primera letra) |
|
|
30
|
+
| Tecla | `@keydown.enter="x"` | wrapper `(e) => e.key === 'Enter' && x(e)` |
|
|
31
|
+
| Condicional | `v-if/v-else-if/v-else` | ternario `cond ? a : cond2 ? b : c` |
|
|
32
|
+
| Lista | `v-for="item in coll"` | `coll.map((item) => ...)` |
|
|
33
|
+
| Lista con índice | `v-for="(item, idx) in coll"` | `coll.map((item, idx) => ...)` |
|
|
34
|
+
| Visibilidad | `v-show="expr"` | `style: { display: expr ? '' : 'none' }` |
|
|
35
|
+
| Slot | `<slot />` | `(props.__slot_default ? props.__slot_default() : [])` |
|
|
36
|
+
| Componente hijo | `<PascalCase :prop="x" />` | `h(PascalCase, { prop: x })` |
|
|
37
|
+
|
|
38
|
+
### Modificadores de teclado (`KEY_MAP`)
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
enter | escape/esc | tab | space | up | down | left | right | delete | backspace
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Otros se pasan capitalizados: `@keydown.foo` → `e.key === 'Foo'`.
|
|
45
|
+
|
|
46
|
+
### Cómo agregar una directiva nueva
|
|
47
|
+
|
|
48
|
+
1. Si es atributo simple: ya funciona via `:attr` o `@event`
|
|
49
|
+
2. Si es estructural (como `v-if`): hay que agregar manejo especial en `genChildren()` o `genElement()`
|
|
50
|
+
3. Si es modificador de tecla: agregar al `KEY_MAP`
|
|
51
|
+
|
|
52
|
+
Después:
|
|
53
|
+
- Agregar test en `template-compiler.test.ts`
|
|
54
|
+
- Si afecta el contrato de predictabilidad, agregar regla al analyzer
|
|
55
|
+
- Documentar en `~/.claude/skills/arckode-ui/SKILL.md` (skill principal)
|
|
56
|
+
|
|
57
|
+
### Edge cases conocidos
|
|
58
|
+
|
|
59
|
+
- `>` dentro de valores de atributo: el `TOKEN_RE` los respeta (caso testeado)
|
|
60
|
+
- Self-closing tags (`<slot />`, `<input />`): manejados con detección del trailing `/`
|
|
61
|
+
- Whitespace entre `v-if/v-else-if/v-else`: el lookahead skipea text nodes blank
|
|
62
|
+
|
|
63
|
+
## Vite plugin (`src/compiler/vite-plugin.ts`)
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { arkcodeUi } from 'arckode-ui/vite' // NOMBRE EXACTO: arkcodeUi
|
|
67
|
+
arkcodeUi({
|
|
68
|
+
analyzer: {
|
|
69
|
+
failOnWarnings: false,
|
|
70
|
+
ignore: [],
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Comportamiento dev vs prod
|
|
76
|
+
|
|
77
|
+
| Modo | Errors del analyzer | Warnings del analyzer |
|
|
78
|
+
|------|---------------------|----------------------|
|
|
79
|
+
| `dev` | log + continúa | log + continúa |
|
|
80
|
+
| `prod` | **bloquea el build** | log (a menos que `failOnWarnings: true`) |
|
|
81
|
+
|
|
82
|
+
### Lo que hace el transform
|
|
83
|
+
|
|
84
|
+
1. Lee `.ark` source
|
|
85
|
+
2. Parse → `{ template, script, style }`
|
|
86
|
+
3. Corre `analyze()`, formatea violations
|
|
87
|
+
4. Genera `__scopeId` (hash determinístico del path)
|
|
88
|
+
5. Compila el template a render function
|
|
89
|
+
6. Concatena: imports + script preamble + render function + injection (`__component.__render = render`)
|
|
90
|
+
7. Pasa por `transformWithEsbuild` para strip TS
|
|
91
|
+
|
|
92
|
+
### Output del módulo compilado
|
|
93
|
+
|
|
94
|
+
```javascript
|
|
95
|
+
import { defineComponent, h } from 'arckode-ui'
|
|
96
|
+
// ... imports del script original
|
|
97
|
+
// ... script preamble
|
|
98
|
+
function render(props, state, computed, actions) { return ... }
|
|
99
|
+
const __scopeId = 'abc123'
|
|
100
|
+
const __component = defineComponent({ ... })
|
|
101
|
+
__component.__scopeId = __scopeId
|
|
102
|
+
__component.__render = render
|
|
103
|
+
export default __component
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
El renderer lee `component.__render` para saber qué renderizar.
|
|
107
|
+
|
|
108
|
+
## Anti-patterns
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// ❌ usar nombres distintos a 'arkcodeUi'
|
|
112
|
+
import { arckodeUi } from 'arckode-ui/vite' // wrong
|
|
113
|
+
import { arckodePlugin } from 'arckode-ui/vite' // wrong
|
|
114
|
+
// → import { arkcodeUi } from 'arckode-ui/vite'
|
|
115
|
+
|
|
116
|
+
// ❌ habilitar failOnWarnings en dev (rompe DX)
|
|
117
|
+
arkcodeUi({ analyzer: { failOnWarnings: true } }) // bloquea hasta en dev
|
|
118
|
+
|
|
119
|
+
// ❌ poner shebang en src/cli/index.ts
|
|
120
|
+
// esbuild lo rechaza. El shebang se inyecta vía rollupOptions.output.banner
|
|
121
|
+
// solo en el chunk cli.
|
|
122
|
+
```
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# SKILL: Components — patrones canónicos de UI
|
|
2
|
+
|
|
3
|
+
> Cargá este skill cuando escribas archivos `.ark` para usuarios finales del framework (no para desarrollar el framework en sí).
|
|
4
|
+
|
|
5
|
+
## Estructura siempre igual de un componente
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<template>
|
|
9
|
+
<!-- HTML con directivas -->
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
import { defineComponent, signal, computed } from 'arckode-ui'
|
|
14
|
+
|
|
15
|
+
export default defineComponent({
|
|
16
|
+
name: 'NombrePascal', // OBLIGATORIO
|
|
17
|
+
props: { /* tipos nativos */ },
|
|
18
|
+
emits: [ /* kebab-case */ ],
|
|
19
|
+
setup(props, { emit }) {
|
|
20
|
+
// 1. signals
|
|
21
|
+
// 2. computed
|
|
22
|
+
// 3. lifecycle (onMount)
|
|
23
|
+
// 4. watch
|
|
24
|
+
// 5. actions (function declarations)
|
|
25
|
+
// 6. return { state, computed, actions }
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
</script>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Patrón 1 — `:class` con ternario reactivo
|
|
32
|
+
|
|
33
|
+
❌ NO ponés el ternario en template (lo flagea el analyzer):
|
|
34
|
+
```html
|
|
35
|
+
<button :class="state.active.value ? 'on' : 'off'"> <!-- LOGIC_IN_TEMPLATE -->
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
✅ Computed simple cuando depende solo de un signal:
|
|
39
|
+
```typescript
|
|
40
|
+
const buttonClass = computed(() => state.active.value ? 'on' : 'off')
|
|
41
|
+
```
|
|
42
|
+
```html
|
|
43
|
+
<button :class="computed.buttonClass.value">
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
✅ Computed que retorna closure cuando depende de un item del loop:
|
|
47
|
+
```typescript
|
|
48
|
+
// SNAPSHOT PATTERN — leer la dep en el getter, no en la closure
|
|
49
|
+
const tabClass = computed(() => {
|
|
50
|
+
const active = activeTab.value // ← lee suscriptamente
|
|
51
|
+
return (tab: string) => tab === active ? CLS_ON : CLS_OFF
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
```html
|
|
55
|
+
<button v-for="tab in computed.tabs.value" :class="computed.tabClass.value(tab)">
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Por qué snapshot**: si leés `activeTab.value` adentro de la closure, el `computed` no se invalida cuando cambia activeTab (la closure se crea una vez y se cachea). Leerlo en el getter registra la dep.
|
|
59
|
+
|
|
60
|
+
## Patrón 2 — Input controlado
|
|
61
|
+
|
|
62
|
+
`v-model` NO EXISTE. Siempre explícito:
|
|
63
|
+
|
|
64
|
+
```html
|
|
65
|
+
<input
|
|
66
|
+
:value="state.email.value"
|
|
67
|
+
@input="actions.onEmailInput"
|
|
68
|
+
@keydown.escape="actions.clearEmail"
|
|
69
|
+
/>
|
|
70
|
+
```
|
|
71
|
+
```typescript
|
|
72
|
+
function onEmailInput(e: Event) {
|
|
73
|
+
email.value = (e.target as HTMLInputElement).value
|
|
74
|
+
}
|
|
75
|
+
function clearEmail() { email.value = '' }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Patrón 3 — Curried action para v-for
|
|
79
|
+
|
|
80
|
+
Cuando un handler en un loop necesita el item:
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
function toggleTodo(id: string) {
|
|
84
|
+
return () => {
|
|
85
|
+
todos.value = todos.value.map(t => t.id === id ? { ...t, done: !t.done } : t)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
```html
|
|
90
|
+
<button v-for="todo in state.todos.value" @click="actions.toggleTodo(todo.id)">
|
|
91
|
+
{{ todo.done ? '✓' : '○' }}
|
|
92
|
+
</button>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`actions.toggleTodo(todo.id)` se evalúa en cada render → devuelve la función real → se asigna como handler.
|
|
96
|
+
|
|
97
|
+
## Patrón 4 — Comunicación hijo→padre
|
|
98
|
+
|
|
99
|
+
**Funciona vía `@event` con emit (FIX del v0.1.0 corregido):**
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// Child
|
|
103
|
+
emits: ['user-saved'],
|
|
104
|
+
setup(props, { emit }) {
|
|
105
|
+
function save() { emit('user-saved', { id: '1', name: 'X' }) }
|
|
106
|
+
return { state: {}, computed: {}, actions: { save } }
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
```html
|
|
110
|
+
<!-- Padre template -->
|
|
111
|
+
<UserCard @user-saved="actions.onUserSaved" />
|
|
112
|
+
```
|
|
113
|
+
```typescript
|
|
114
|
+
// Padre setup
|
|
115
|
+
function onUserSaved(payload: { id: string; name: string }) {
|
|
116
|
+
console.log(payload) // ← recibe e.detail directo, no el Event
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Detalles:
|
|
121
|
+
- El payload llega como **primer argumento** del handler (no como `e.detail` — el wrapper ya lo extrae)
|
|
122
|
+
- El nombre del evento debe estar en `emits: []` (sino warning runtime)
|
|
123
|
+
- Eventos en kebab-case obligatorio
|
|
124
|
+
|
|
125
|
+
**Patrón alternativo (callback como prop):** sigue funcionando, útil cuando el child no tiene "ownership" del evento conceptualmente:
|
|
126
|
+
```html
|
|
127
|
+
<TaskForm :onSubmit="actions.handleCreate" :onCancel="actions.closeModal" />
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Patrón 5 — Modal con slot
|
|
131
|
+
|
|
132
|
+
```html
|
|
133
|
+
<!-- Modal.ark -->
|
|
134
|
+
<template>
|
|
135
|
+
<div v-show="props.visible" class="fixed inset-0 ...">
|
|
136
|
+
<div class="modal-content">
|
|
137
|
+
<header>
|
|
138
|
+
<h2>{{ props.title }}</h2>
|
|
139
|
+
<button @click="actions.close">✕</button>
|
|
140
|
+
</header>
|
|
141
|
+
<div class="body"><slot /></div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</template>
|
|
145
|
+
|
|
146
|
+
<script lang="ts">
|
|
147
|
+
export default defineComponent({
|
|
148
|
+
name: 'Modal',
|
|
149
|
+
props: {
|
|
150
|
+
visible: { type: Boolean, required: true },
|
|
151
|
+
title: { type: String, required: true },
|
|
152
|
+
onClose: { type: Function, required: true },
|
|
153
|
+
},
|
|
154
|
+
emits: [],
|
|
155
|
+
setup(props) {
|
|
156
|
+
function close() { (props.onClose as () => void)() }
|
|
157
|
+
return { state: {}, computed: {}, actions: { close } }
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
</script>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Uso:
|
|
164
|
+
```html
|
|
165
|
+
<Modal :visible="state.modalOpen.value" title="Confirmar" :onClose="actions.closeModal">
|
|
166
|
+
<p>¿Estás seguro?</p>
|
|
167
|
+
<button @click="actions.confirm">Sí</button>
|
|
168
|
+
</Modal>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Patrón 6 — Form con validación
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
const text = signal('')
|
|
175
|
+
const priority = signal<'low' | 'medium' | 'high'>('medium')
|
|
176
|
+
|
|
177
|
+
const textError = computed(() => {
|
|
178
|
+
const v = text.value.trim()
|
|
179
|
+
if (v.length === 0) return ''
|
|
180
|
+
if (v.length < 2) return 'Mínimo 2 caracteres.'
|
|
181
|
+
return ''
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const formInvalid = computed(() => text.value.trim().length < 2)
|
|
185
|
+
|
|
186
|
+
function submit(e: Event) {
|
|
187
|
+
e.preventDefault()
|
|
188
|
+
if (formInvalid.value) return
|
|
189
|
+
// ... emit o callback
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
```html
|
|
193
|
+
<form @submit="actions.submit">
|
|
194
|
+
<input :value="state.text.value" @input="actions.onTextInput" />
|
|
195
|
+
<p v-show="computed.textError.value">{{ computed.textError.value }}</p>
|
|
196
|
+
<button type="submit" :disabled="computed.formInvalid.value">Enviar</button>
|
|
197
|
+
</form>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Anti-patterns universales
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
// ❌ ref/reactive
|
|
204
|
+
const x = ref(0) // → signal(0)
|
|
205
|
+
|
|
206
|
+
// ❌ v-model
|
|
207
|
+
<input v-model="state.x" /> // → :value + @input explícito
|
|
208
|
+
|
|
209
|
+
// ❌ provide/inject
|
|
210
|
+
provide('key', val) // → defineStore o prop
|
|
211
|
+
|
|
212
|
+
// ❌ fetch en componente
|
|
213
|
+
onMount(() => fetch('/x')) // → createService
|
|
214
|
+
|
|
215
|
+
// ❌ arrow function en actions
|
|
216
|
+
actions: { submit: () => {} } // → function submit() {}
|
|
217
|
+
|
|
218
|
+
// ❌ llaves extra en return
|
|
219
|
+
return { state, computed, actions, methods } // → solo las 3
|
|
220
|
+
|
|
221
|
+
// ❌ handler sin actions.
|
|
222
|
+
@click="handleClick" // → @click="actions.handleClick"
|
|
223
|
+
|
|
224
|
+
// ❌ v-for sin namespace
|
|
225
|
+
v-for="x in items" // → v-for="x in state.items.value"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Antes de declarar listo
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npx ark analyze # 0 errores
|
|
232
|
+
npx vitest run # cuando trabajás en el framework
|
|
233
|
+
```
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# SKILL: Runtime — signals, componentes, renderer
|
|
2
|
+
|
|
3
|
+
> Cargá este skill cuando trabajés en `src/runtime/` o cuando uses signals/lifecycle/h/mount en código de usuario.
|
|
4
|
+
|
|
5
|
+
## Primitivas reactivas (`src/runtime/signals.ts`)
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
const count = signal(0)
|
|
9
|
+
count.value = 5 // set — dispara re-render
|
|
10
|
+
count.value // get — suscribe al efecto activo
|
|
11
|
+
count.peek // get SIN suscribirse (lectura no reactiva)
|
|
12
|
+
|
|
13
|
+
const double = computed(() => count.value * 2)
|
|
14
|
+
double.value // readonly
|
|
15
|
+
|
|
16
|
+
const stop = watch(count, (newVal, oldVal) => { ... })
|
|
17
|
+
watch(count, cb, { immediate: true }) // dispara cb al inicio
|
|
18
|
+
|
|
19
|
+
const stopE = effect(() => { document.title = `${count.value}` })
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Reglas no negociables:**
|
|
23
|
+
- `computed.value` NUNCA mutar (es readonly)
|
|
24
|
+
- Dentro de `computed`, NO mutar otros signals (eso es side effect → usar `watch` o `effect`)
|
|
25
|
+
- `signal.peek` es la única forma de leer sin suscribirse — útil para evitar loops o leer al instante
|
|
26
|
+
|
|
27
|
+
## defineComponent (`src/runtime/define-component.ts`)
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
export default defineComponent({
|
|
31
|
+
name: 'MiComp', // string PascalCase, OBLIGATORIO
|
|
32
|
+
props: {
|
|
33
|
+
label: { type: String, required: true },
|
|
34
|
+
count: { type: Number, default: 0 },
|
|
35
|
+
},
|
|
36
|
+
emits: ['save', 'cancel'], // SIEMPRE kebab-case
|
|
37
|
+
setup(props, { emit }) {
|
|
38
|
+
// ...
|
|
39
|
+
return { state, computed, actions } // SOLO estas 3 llaves
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Tipos válidos de prop:** `String | Number | Boolean | Array | Object | Function` (clases nativas, no strings)
|
|
45
|
+
|
|
46
|
+
**Validaciones en dev** (sin afectar producción):
|
|
47
|
+
- Prop sin `type` → warning
|
|
48
|
+
- Emit no declarado en `emits[]` → warning
|
|
49
|
+
- Emit en camelCase → warning
|
|
50
|
+
- Return con llave distinta a `state|computed|actions` → warning
|
|
51
|
+
|
|
52
|
+
## Lifecycle hooks
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
setup(props, { emit }) {
|
|
56
|
+
onMount(() => {
|
|
57
|
+
// ejecuta al montar en el DOM
|
|
58
|
+
return () => { /* cleanup opcional al unmount */ }
|
|
59
|
+
})
|
|
60
|
+
onUnmount(() => { /* ejecuta al desmontar */ })
|
|
61
|
+
onUpdate(() => { /* ejecuta en cada re-render */ })
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Importante:** los hooks usan un estado global del módulo (`currentMountHooks`). Solo funcionan si se llaman SINCRÓNICAMENTE dentro de `setup()`. No los llames en callbacks async ni en setTimeout.
|
|
66
|
+
|
|
67
|
+
## Renderer (`src/runtime/renderer.ts`)
|
|
68
|
+
|
|
69
|
+
### `mount(component: Component, selector: string)`
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import { mount } from 'arckode-ui'
|
|
73
|
+
import App from './App.ark'
|
|
74
|
+
mount(App, '#app') // toma un selector CSS string, NO HTMLElement
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Retorna una función de unmount.
|
|
78
|
+
|
|
79
|
+
### Cómo funciona el renderer
|
|
80
|
+
|
|
81
|
+
1. Llama `component.setup(props, ctx)` → obtiene `{ state, computed, actions }`
|
|
82
|
+
2. Busca `component.__render` (lo inyecta el Vite plugin desde el `<template>`)
|
|
83
|
+
3. Envuelve el render en un `effect()` que se re-ejecuta cuando cualquier signal usado cambia
|
|
84
|
+
4. Crea VNodes y los aplica al DOM via `createNode` + `patch`
|
|
85
|
+
|
|
86
|
+
### Componentes hijos en el VDOM
|
|
87
|
+
|
|
88
|
+
Cuando `h(ChildComp, props, ...children)`:
|
|
89
|
+
- Crea un wrapper `<div style="display:contents">` (transparente al layout)
|
|
90
|
+
- Pasa props al child via `mountComponent`
|
|
91
|
+
- Las props `onX` (function) **también** se registran como event listeners en el wrapper
|
|
92
|
+
|
|
93
|
+
### Comunicación hijo→padre (verificado)
|
|
94
|
+
|
|
95
|
+
```html
|
|
96
|
+
<!-- Padre -->
|
|
97
|
+
<Child @save="actions.onSave" />
|
|
98
|
+
```
|
|
99
|
+
```typescript
|
|
100
|
+
// Padre
|
|
101
|
+
function onSave(payload: { id: string }) { console.log(payload) }
|
|
102
|
+
|
|
103
|
+
// Child
|
|
104
|
+
emit('save', { id: '1' }) // dispatch CustomEvent('save', { detail: payload })
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
El emit del child dispatcha `CustomEvent(eventName)` SIN prefijo. Bubblea hasta el wrapper que tiene el listener. El handler recibe `e.detail` directo como primer argumento (no el `Event`).
|
|
108
|
+
|
|
109
|
+
**Anti-pattern:** dispatcher con prefijo `ark:event` — eso rompía el binding `@event`. NO volver a poner el prefijo.
|
|
110
|
+
|
|
111
|
+
## Anti-patterns
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
// ❌ mutar dentro de computed
|
|
115
|
+
const x = computed(() => { signal2.value = 5; return signal1.value * 2 })
|
|
116
|
+
|
|
117
|
+
// ❌ llamar hooks dentro de async
|
|
118
|
+
onMount(async () => {
|
|
119
|
+
await fetch('/x')
|
|
120
|
+
onUnmount(() => {}) // NO — onUnmount fuera del setup sync
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// ❌ pasar HTMLElement a mount
|
|
124
|
+
mount(App, document.getElementById('app')!)
|
|
125
|
+
// → mount(App, '#app')
|
|
126
|
+
|
|
127
|
+
// ❌ leer signal en lugar de .value
|
|
128
|
+
console.log(count) // imprime el objeto
|
|
129
|
+
console.log(count.value) // ✓
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Tests del runtime
|
|
133
|
+
|
|
134
|
+
Los tests usan `@vitest-environment happy-dom`. Para crear container:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
function makeContainer(): HTMLElement {
|
|
138
|
+
const div = document.createElement('div')
|
|
139
|
+
div.id = 'app'
|
|
140
|
+
document.body.appendChild(div)
|
|
141
|
+
return div
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Helper `setEffectMicrotaskFlush` no existe — el `effect()` se ejecuta sincrónicamente.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SKILL: Testing — Vitest + happy-dom
|
|
2
|
+
|
|
3
|
+
> Cargá este skill cuando escribas o modifiques tests del framework o de proyectos que usan arckode-ui.
|
|
4
|
+
|
|
5
|
+
## Setup base
|
|
6
|
+
|
|
7
|
+
Tests con `vitest`. DOM tests usan `happy-dom` (más rápido que jsdom).
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// @vitest-environment happy-dom ← agregar al tope del archivo para tests con DOM
|
|
11
|
+
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Test de signals / computed / watch
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { signal, computed, watch } from 'arckode-ui'
|
|
18
|
+
|
|
19
|
+
test('computed reacciona', () => {
|
|
20
|
+
const a = signal(2)
|
|
21
|
+
const double = computed(() => a.value * 2)
|
|
22
|
+
expect(double.value).toBe(4)
|
|
23
|
+
a.value = 5
|
|
24
|
+
expect(double.value).toBe(10)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('watch callback recibe new + old', async () => {
|
|
28
|
+
const a = signal(0)
|
|
29
|
+
let captured: [number, number] | null = null
|
|
30
|
+
watch(a, (nv, ov) => { captured = [nv, ov] })
|
|
31
|
+
a.value = 7
|
|
32
|
+
await new Promise(r => queueMicrotask(r as () => void))
|
|
33
|
+
expect(captured).toEqual([7, 0])
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Nota:** `watch` dispara el callback en `queueMicrotask`. Esperar una microtask antes de assertear.
|
|
38
|
+
|
|
39
|
+
## Test del store (`defineStore`)
|
|
40
|
+
|
|
41
|
+
El registry global es singleton. Limpiar entre tests:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { defineStore, _clearRegistry } from 'arckode-ui'
|
|
45
|
+
// _clearRegistry NO está exportado del barrel — importarlo del path directo
|
|
46
|
+
// para tests internos del framework.
|
|
47
|
+
|
|
48
|
+
beforeEach(() => { _clearRegistry() })
|
|
49
|
+
|
|
50
|
+
test('singleton', () => {
|
|
51
|
+
const useStore = defineStore('test', { state: { x: signal(0) }, actions: {} })
|
|
52
|
+
expect(useStore()).toBe(useStore()) // misma instancia
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Para tests de consumidores:** no necesitan `_clearRegistry` si cada test usa un `id` distinto.
|
|
57
|
+
|
|
58
|
+
## Test del service (`createService`)
|
|
59
|
+
|
|
60
|
+
Mockear `fetch` con vi:
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { createService } from 'arckode-ui'
|
|
64
|
+
|
|
65
|
+
function mockFetchOk(body: unknown, status = 200) {
|
|
66
|
+
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
67
|
+
new Response(JSON.stringify(body), {
|
|
68
|
+
status,
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
beforeEach(() => { vi.restoreAllMocks() })
|
|
75
|
+
|
|
76
|
+
test('GET retorna JSON parseado', async () => {
|
|
77
|
+
mockFetchOk({ id: '1' })
|
|
78
|
+
const svc = createService(
|
|
79
|
+
{ baseUrl: '/api' },
|
|
80
|
+
{ async getById(id: string) { return this.get<{ id: string }>(`/${id}`) } },
|
|
81
|
+
)
|
|
82
|
+
const result = await svc.getById('1')
|
|
83
|
+
expect(result).toEqual({ id: '1' })
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Test de componentes (`.ark` compilado)
|
|
88
|
+
|
|
89
|
+
Los `.ark` necesitan el Vite plugin para compilarse. Para tests unitarios del runtime/renderer, **NO** importes `.ark` files — construí el component manualmente:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { defineComponent, h } from 'arckode-ui'
|
|
93
|
+
import { mountComponent } from 'arckode-ui'
|
|
94
|
+
|
|
95
|
+
const TestComp = defineComponent({
|
|
96
|
+
name: 'Test',
|
|
97
|
+
setup: () => ({ state: {}, computed: {}, actions: {} }),
|
|
98
|
+
})
|
|
99
|
+
;(TestComp as any).__render = () => h('div', null, 'hello')
|
|
100
|
+
|
|
101
|
+
const container = document.createElement('div')
|
|
102
|
+
const unmount = mountComponent(TestComp, {}, container, null)
|
|
103
|
+
expect(container.textContent).toBe('hello')
|
|
104
|
+
unmount()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Test del analyzer
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { analyze } from 'arckode-ui' // bueno, el analyzer no se exporta — usar path directo
|
|
111
|
+
import { analyze } from '../analyzer'
|
|
112
|
+
|
|
113
|
+
function findViolation(violations, code) {
|
|
114
|
+
return violations.find(v => v.code === code)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
test('detecta el patrón X', () => {
|
|
118
|
+
const source = `<template>...</template><script lang="ts">...</script>`
|
|
119
|
+
const violations = analyze(source, 'Test.ark')
|
|
120
|
+
const v = findViolation(violations, 'MY_CODE')
|
|
121
|
+
expect(v).toBeDefined()
|
|
122
|
+
expect(v?.severity).toBe('error')
|
|
123
|
+
expect(v?.fix).toContain('lo que esperás como sugerencia')
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Test de templates del CLI
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import { componentTemplate } from '../templates'
|
|
131
|
+
import { analyze } from '../../compiler/analyzer'
|
|
132
|
+
|
|
133
|
+
test('scaffold genera código válido', () => {
|
|
134
|
+
const content = componentTemplate('Foo')
|
|
135
|
+
const violations = analyze(content, 'Foo.ark')
|
|
136
|
+
const errors = violations.filter(v => v.severity === 'error')
|
|
137
|
+
expect(errors).toHaveLength(0)
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Hay test que valida que **TODAS** las plantillas del CLI pasen el analyzer con 0 errors (`src/cli/__tests__/templates.test.ts`). Si agregás un tipo nuevo al CLI, agregar test ahí.
|
|
142
|
+
|
|
143
|
+
## Patrones a evitar
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// ❌ assertear === en async sin await
|
|
147
|
+
const x = signal(0)
|
|
148
|
+
watch(x, ...)
|
|
149
|
+
x.value = 5
|
|
150
|
+
expect(captured).toBe(...) // ← falla, watch es async
|
|
151
|
+
|
|
152
|
+
// ❌ NO importar .ark files en tests del runtime
|
|
153
|
+
import App from '../App.ark' // requeriría el Vite plugin
|
|
154
|
+
|
|
155
|
+
// ❌ NO compartir state global entre tests
|
|
156
|
+
const sharedState = signal(0)
|
|
157
|
+
test('a', () => { sharedState.value = 1; ... })
|
|
158
|
+
test('b', () => { ... }) // contaminado por 'a'
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Comandos
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
bun test # corre todos los tests (vitest run)
|
|
165
|
+
bun run test:watch # modo watch
|
|
166
|
+
bun run type-check # tsc --noEmit (excluye __tests__/)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Nota sobre type-check:** los tests están excluidos del strict TS check via `tsconfig.json` para evitar fricción con `noUncheckedIndexedAccess + exactOptionalPropertyTypes`. Vitest los corre OK pero `tsc` no los valida estrictamente. Si querés strict en tests, crear un `tsconfig.test.json` separado.
|