@meekojs/meeko 0.0.2 → 0.0.3

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/README.md CHANGED
@@ -17,6 +17,7 @@ El proyecto está en etapa temprana. Ya funciona el recorrido completo de un con
17
17
  - [Plan de desarrollo y criterios de aceptación](docs/roadmap.md)
18
18
  - [Comparativa reproducible con Vue](docs/benchmarks.md)
19
19
  - [DevTools para depurar componentes](docs/devtools.md)
20
+ - [Aplicación piloto SSR](examples/pilot/README.md)
20
21
  - [Publicación y verificaciones](docs/releasing.md)
21
22
  - [Historial de cambios](CHANGELOG.md)
22
23
  - [Guía para contribuir](CONTRIBUTING.md)
@@ -45,6 +46,21 @@ export default defineConfig({ plugins: [meeko()] });
45
46
 
46
47
  El paquete incluye TypeScript 7 y la CLI `meeko`. Puedes comprobar un componente con `pnpm exec meeko check ruta/Componente.meeko`. Vite es opcional si sólo usas el compilador y SSR.
47
48
 
49
+ ### Crear una aplicación SSR
50
+
51
+ Con el paquete instalado, `meeko init` crea un proyecto independiente. El servidor renderiza el componente `.meeko` y transfiere props y señales para que el navegador hidrate el HTML existente.
52
+
53
+ ```powershell
54
+ pnpm exec meeko init mi-app
55
+ cd mi-app
56
+ npm install
57
+ npm run dev
58
+ ```
59
+
60
+ Abre `http://127.0.0.1:4173`. `npm run check` genera los tipos del componente y ejecuta TypeScript 7. `npm run build` compila cliente y servidor; `npm start` sirve la versión compilada con SSR. La CLI no sobrescribe archivos de aplicación existentes.
61
+
62
+ Desde `0.0.3`, si ya ejecutaste `pnpm add @meekojs/meeko` en una carpeta sin código, puedes inicializarla directamente con `pnpm exec meeko init .`. Se conservan el manifiesto, los lockfiles, el workspace y `node_modules`; la CLI rechaza archivos de aplicación o scripts que entrarían en conflicto. Después ejecuta `pnpm install` y `pnpm run dev`. Para actualizar desde `0.0.2`, usa `pnpm add @meekojs/meeko@0.0.3` antes de `init .`.
63
+
48
64
  ## Estructura
49
65
 
50
66
  | Ruta | Responsabilidad |
@@ -73,11 +89,12 @@ El paquete incluye TypeScript 7 y la CLI `meeko`. Puedes comprobar un componente
73
89
  pnpm install
74
90
  pnpm run verify
75
91
  pnpm run e2e
92
+ pnpm run pilot:e2e
76
93
  pnpm run package:smoke
77
94
  pnpm run example:serve
78
95
  ```
79
96
 
80
- `e2e` usa Chrome instalado localmente; `MEEKO_CHROME_PATH` permite indicar otro ejecutable compatible. Prueba Vite en desarrollo y producción, además de una página SSR con navegación. `package:smoke` instala el tarball en un proyecto temporal externo y prueba CLI, SSR, tipos y Vite. La última orden sirve el ejemplo en `http://127.0.0.1:4173`. El servidor envía HTML y las props iniciales como JSON escapado; el navegador hidrata el botón sin reemplazarlo.
97
+ `e2e` usa Chrome instalado localmente; `MEEKO_CHROME_PATH` permite indicar otro ejecutable compatible. Prueba Vite en desarrollo y producción, además de una página SSR con navegación. `pilot:e2e` prueba la aplicación piloto en Chromium, Firefox y WebKit instalados por Playwright, tanto en desarrollo como en producción. `package:smoke` instala el tarball en un proyecto temporal externo y prueba CLI, SSR, tipos, Vite y `meeko init`. La última orden sirve el contador en `http://127.0.0.1:4173`.
81
98
 
82
99
  Para probar la compilación automática con Vite:
83
100
 
@@ -108,6 +125,10 @@ node dist/cli.js types ruta/Componente.meeko
108
125
 
109
126
  La aplicación debe pasar **las mismas props** al render y a `hydrate`. `renderWithState(props)` entrega HTML y señales editables serializables; el ejemplo SSR transfiere ambos junto con las props mediante `serializeJson` y llama `hydrate(root, props, state)`. Meeko comprueba que el texto existente coincida durante la hidratación y devuelve una función que desconecta eventos y observadores. El HTML dinámico se escapa en el servidor.
110
127
 
128
+ ## Aplicación piloto
129
+
130
+ [`examples/pilot`](examples/pilot/README.md) reúne SSR, hidratación, componentes anidados, listas, formularios, rutas y una API asíncrona. Se ejecuta con `pnpm run pilot:dev`; `pnpm run pilot:build` y `pnpm run pilot:serve` prueban los artefactos de producción sin middleware de Vite. La prueba `pnpm run pilot:e2e` recorre ambos modos en Chromium, Firefox y WebKit. WebKit de Playwright no equivale a una prueba en Safari real.
131
+
111
132
  ## Sintaxis inicial
112
133
 
113
134
  Un archivo tiene `<script lang="ts">`, `<template>` y un `<style>` opcional; `scoped` limita las reglas CSS al componente. `defineProps<Props>()` declara las props. La plantilla acepta expresiones de texto `{expression}`, eventos `on:click="handler"` y atributos dinámicos `bind:title="expression"`. Los atributos booleanos, como `bind:disabled`, se agregan o eliminan según el valor.
package/dist/cli.js CHANGED
@@ -1,40 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
3
5
  import { checkSource } from "./check.js";
4
6
  import { compile } from "./compiler.js";
5
7
  import { generateTypes } from "./types.js";
8
+ import { initProject } from "./init.js";
6
9
  const [command, input, output] = process.argv.slice(2);
7
- if ((command !== "compile" && command !== "check" && command !== "types") || !input || (command === "compile" && !output) || (command === "check" && output !== undefined && output !== "--json") || (command === "types" && output !== undefined)) {
8
- process.stderr.write("Usage: meeko check <component.meeko> [--json]\n meeko compile <component.meeko> <output.ts>\n meeko types <component.meeko>\n");
10
+ if ((command !== "compile" && command !== "check" && command !== "types" && command !== "init") || !input || (command === "compile" && !output) || (command === "check" && output !== undefined && output !== "--json") || ((command === "types" || command === "init") && output !== undefined)) {
11
+ process.stderr.write("Usage: meeko check <component.meeko> [--json]\n meeko compile <component.meeko> <output.ts>\n meeko types <component.meeko>\n meeko init <directory>\n");
9
12
  process.exitCode = 1;
10
13
  }
11
14
  else {
12
15
  try {
13
- const source = await readFile(input, "utf8");
14
- const diagnostics = await checkSource(source, input);
15
- if (command === "check" && output === "--json") {
16
- process.stdout.write(`${JSON.stringify(diagnostics)}\n`);
17
- }
18
- else if (diagnostics.length > 0) {
19
- for (const diagnostic of diagnostics) {
20
- const { line, column } = diagnostic.location;
21
- process.stderr.write(`${diagnostic.filename}:${line}:${column} ${diagnostic.code}: ${diagnostic.message}\n`);
22
- }
23
- }
24
- else if (command === "compile") {
25
- const result = compile(source, input);
26
- await writeFile(output, result.code, "utf8");
27
- process.stdout.write(`${output}\n`);
28
- }
29
- else if (command === "types") {
30
- for (const generated of await generateTypes(source, input))
31
- process.stdout.write(`${generated}\n`);
16
+ if (command === "init") {
17
+ const target = await initProject(input);
18
+ const manager = existsSync(join(target, "pnpm-lock.yaml")) ? "pnpm" : "npm";
19
+ const prefix = target === resolve(".") ? "" : `cd ${target} && `;
20
+ process.stdout.write(`Created ${target}\nNext: ${prefix}${manager} install && ${manager} run dev\n`);
32
21
  }
33
22
  else {
34
- process.stdout.write(`${input}: OK\n`);
23
+ const source = await readFile(input, "utf8");
24
+ const diagnostics = await checkSource(source, input);
25
+ if (command === "check" && output === "--json") {
26
+ process.stdout.write(`${JSON.stringify(diagnostics)}\n`);
27
+ }
28
+ else if (diagnostics.length > 0) {
29
+ for (const diagnostic of diagnostics) {
30
+ const { line, column } = diagnostic.location;
31
+ process.stderr.write(`${diagnostic.filename}:${line}:${column} ${diagnostic.code}: ${diagnostic.message}\n`);
32
+ }
33
+ }
34
+ else if (command === "compile") {
35
+ const result = compile(source, input);
36
+ await writeFile(output, result.code, "utf8");
37
+ process.stdout.write(`${output}\n`);
38
+ }
39
+ else if (command === "types") {
40
+ for (const generated of await generateTypes(source, input))
41
+ process.stdout.write(`${generated}\n`);
42
+ }
43
+ else {
44
+ process.stdout.write(`${input}: OK\n`);
45
+ }
46
+ if (diagnostics.length > 0)
47
+ process.exitCode = 1;
35
48
  }
36
- if (diagnostics.length > 0)
37
- process.exitCode = 1;
38
49
  }
39
50
  catch (error) {
40
51
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
package/dist/compiler.js CHANGED
@@ -311,18 +311,20 @@ function compileScript(section, source, filename) {
311
311
  }
312
312
  return { imports, types, setup, names, propsType: propsType ?? "Record<string, never>", propsTypeOffset };
313
313
  }
314
- function hydrationBody(bindings, context, locals = new Map()) {
314
+ function hydrationBody(bindings, context, hot, locals = new Map()) {
315
315
  const statements = [];
316
316
  const mappedExpression = (expression, offset, expressionLocals = locals) => markSource(rewriteLocals(expression, expressionLocals), offset, expression.length);
317
317
  for (const { id, expression, offset } of bindings.texts) {
318
318
  const point = locate(context.source, offset);
319
319
  const location = `${basename(context.filename)}:${point.line}:${point.column} (text ${id})`;
320
- statements.push(`cleanups.push(__meeko.bindText(__scope, ${id}, () => ${mappedExpression(expression, offset)}, ${quote(location)}));`);
320
+ const source = hot ? `, { file: ${quote(context.filename)}, line: ${point.line}, column: ${point.column}, expression: ${quote(expression)}, kind: "text" }` : "";
321
+ statements.push(`cleanups.push(__meeko.bindText(__scope, ${id}, () => ${mappedExpression(expression, offset)}, ${quote(location)}${source}));`);
321
322
  }
322
323
  for (const { id, name, expression, offset } of bindings.attributes) {
323
324
  const point = locate(context.source, offset);
324
325
  const location = `${basename(context.filename)}:${point.line}:${point.column} (attribute ${name})`;
325
- statements.push(`cleanups.push(__meeko.bindAttribute(__scope, ${id}, ${quote(name)}, () => ${mappedExpression(expression, offset)}, ${quote(location)}));`);
326
+ const source = hot ? `, { file: ${quote(context.filename)}, line: ${point.line}, column: ${point.column}, expression: ${quote(expression)}, kind: "attribute" }` : "";
327
+ statements.push(`cleanups.push(__meeko.bindAttribute(__scope, ${id}, ${quote(name)}, () => ${mappedExpression(expression, offset)}, ${quote(location)}${source}));`);
326
328
  }
327
329
  for (const { id, name, expression, offset } of bindings.events) {
328
330
  const eventType = ["click", "input", "change", "submit", "keydown", "keyup", "focus", "blur"].includes(name)
@@ -345,7 +347,7 @@ function hydrationBody(bindings, context, locals = new Map()) {
345
347
  statements.unshift(...componentStatements);
346
348
  for (const entry of bindings.regions) {
347
349
  if (entry.kind === "condition") {
348
- statements.push(`cleanups.push(__meeko.bindIf(__scope, ${entry.id}, () => ${mappedExpression(entry.expression, entry.offset)}, () => ${rewriteLocals(entry.render, locals)}, (__scope) => { ${hydrationBody(entry.bindings, context, locals)} }));`);
350
+ statements.push(`cleanups.push(__meeko.bindIf(__scope, ${entry.id}, () => ${mappedExpression(entry.expression, entry.offset)}, () => ${rewriteLocals(entry.render, locals)}, (__scope) => { ${hydrationBody(entry.bindings, context, hot, locals)} }));`);
349
351
  }
350
352
  else {
351
353
  const outerLocals = new Map(locals);
@@ -354,7 +356,10 @@ function hydrationBody(bindings, context, locals = new Map()) {
354
356
  const innerLocals = new Map(locals);
355
357
  innerLocals.set(entry.itemName, "__meeko_item.value");
356
358
  innerLocals.set(entry.indexName, "__meeko_index.value");
357
- statements.push(`cleanups.push(__meeko.bindFor(__scope, ${entry.id}, () => ${mappedExpression(entry.expression, entry.offset)}, (${entry.itemName}, ${entry.indexName}) => ${mappedExpression(entry.key, entry.keyOffset, outerLocals)}, (${entry.itemName}, ${entry.indexName}) => ${rewriteLocals(entry.render, outerLocals)}, (__meeko_item, __meeko_index, __scope) => { ${hydrationBody(entry.bindings, context, innerLocals)} }));`);
359
+ const innerHydration = hydrationBody(entry.bindings, context, hot, innerLocals);
360
+ // El callback generado sólo usa la señal de índice si aparece en su hidratación.
361
+ const trackIndex = innerHydration.includes("__meeko_index.value");
362
+ statements.push(`cleanups.push(__meeko.bindFor(__scope, ${entry.id}, () => ${mappedExpression(entry.expression, entry.offset)}, (${entry.itemName}, ${entry.indexName}) => ${mappedExpression(entry.key, entry.keyOffset, outerLocals)}, (${entry.itemName}, ${entry.indexName}) => ${rewriteLocals(entry.render, outerLocals)}, (__meeko_item, __meeko_index, __scope) => { ${innerHydration} }, ${trackIndex}));`);
358
363
  }
359
364
  }
360
365
  return `const cleanups: Array<() => void> = [];
@@ -398,7 +403,7 @@ export function compile(source, filename = "anonymous.meeko", options = {}) {
398
403
  const scopeHotRootParam = hot ? ", __hotRoot?: Element" : "";
399
404
  const scopeForward = hot ? "__scope, __inputProps, __state, root" : "__scope, __inputProps, __state";
400
405
  const propsType = scriptParts.propsTypeOffset === undefined ? scriptParts.propsType : markSource(scriptParts.propsType, scriptParts.propsTypeOffset);
401
- const markedCode = `// Generated by Meeko. Edit the .meeko source instead.\nimport * as __meeko from "@meekojs/meeko/internal";\n${scriptParts.imports.join("\n")}\n${scriptParts.types.join("\n")}\nfunction __setup(__props: ${propsType}) {\n${scriptParts.setup.join("\n")}\nreturn { ${bindings} };\n}\nexport type ComponentProps = ${propsType};\nexport const css = ${quote(css)};\n${hotIdExport}export function renderWithState(__inputProps: ComponentProps): { html: string; state: __meeko.HotState } {\nreturn __meeko.createRoot((dispose) => {\nconst __meeko_transfer = true;\nconst __bindings = __setup(__inputProps);\nconst { ${bindings} } = __bindings;\ntry {\nconst html = ${html};\nconst state = __meeko.snapshotSignals(__bindings);\n__meeko.serializeJson(state);\nreturn { html, state };\n} finally { dispose(); }\n});\n}\nexport function renderToString(__inputProps: ComponentProps${stateParam}): string {\nreturn __meeko.createRoot((dispose) => {\nconst __meeko_transfer = false;\n${setupCode}\ntry { return ${html}; } finally { dispose(); }\n});\n}\nexport function hydrate(root: Element, __inputProps: ComponentProps${stateParam}): () => void {\nconst __scope: __meeko.DomScope = { root };\nreturn hydrateScope(${scopeForward});\n}\nexport function hydrateScope(__scope: __meeko.DomScope, __inputProps: ComponentProps${stateParam}${scopeHotRootParam}): () => void {\nreturn __meeko.createRoot((dispose) => {\nconst __meeko_transfer = false;\n${setupCode}\nconst __release = (() => { ${hydrationBody(bindingsForTemplate, context)} })();\n${releaseCode}\n});\n}\n`;
406
+ const markedCode = `// Generated by Meeko. Edit the .meeko source instead.\nimport * as __meeko from "@meekojs/meeko/internal";\n${scriptParts.imports.join("\n")}\n${scriptParts.types.join("\n")}\nfunction __setup(__props: ${propsType}) {\n${scriptParts.setup.join("\n")}\nreturn { ${bindings} };\n}\nexport type ComponentProps = ${propsType};\nexport const css = ${quote(css)};\n${hotIdExport}export function renderWithState(__inputProps: ComponentProps): { html: string; state: __meeko.HotState } {\nreturn __meeko.createRoot((dispose) => {\nconst __meeko_transfer = true;\nconst __bindings = __setup(__inputProps);\nconst { ${bindings} } = __bindings;\ntry {\nconst html = ${html};\nconst state = __meeko.snapshotSignals(__bindings);\n__meeko.serializeJson(state);\nreturn { html, state };\n} finally { dispose(); }\n});\n}\nexport function renderToString(__inputProps: ComponentProps${stateParam}): string {\nreturn __meeko.createRoot((dispose) => {\nconst __meeko_transfer = false;\n${setupCode}\ntry { return ${html}; } finally { dispose(); }\n});\n}\nexport function hydrate(root: Element, __inputProps: ComponentProps${stateParam}): () => void {\nconst __scope: __meeko.DomScope = { root };\nreturn hydrateScope(${scopeForward});\n}\nexport function hydrateScope(__scope: __meeko.DomScope, __inputProps: ComponentProps${stateParam}${scopeHotRootParam}): () => void {\nreturn __meeko.createRoot((dispose) => {\nconst __meeko_transfer = false;\n${setupCode}\nconst __release = (() => { ${hydrationBody(bindingsForTemplate, context, hot)} })();\n${releaseCode}\n});\n}\n`;
402
407
  const { code, mappings } = extractSourceMappings(markedCode);
403
408
  return { code, css, mappings };
404
409
  }
@@ -25,7 +25,14 @@ button[aria-pressed="true"] { border-color: #79b8ff; background: #25476c; }
25
25
  .muted { color: #aab7ca; }
26
26
  .path { overflow-wrap: anywhere; }
27
27
  pre { margin: 4px 0 0; padding: 8px; max-height: 130px; overflow: auto; border-radius: 6px; background: #0a111e; color: #dbeafe; font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
28
- .events { border-top: 1px solid #334155; padding: 10px 12px; max-height: 130px; overflow: auto; }
28
+ .events { border-top: 1px solid #334155; padding: 10px 12px; max-height: 160px; overflow: auto; }
29
+ .events-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px; }
30
+ .events-head label { color: #aab7ca; }
31
+ .event-button { width: 100%; text-align: left; }
32
+ .event-reads { margin: 4px 0 8px; color: #aab7ca; }
33
+ .event-reads summary { cursor: pointer; }
34
+ .event-reads li { overflow-wrap: anywhere; }
35
+ .event-reads code { color: #dbeafe; }
29
36
  .events li { overflow-wrap: anywhere; }
30
37
  .status { padding: 7px 12px; border-top: 1px solid #334155; color: #aab7ca; }
31
38
  @media (max-width: 540px) {
@@ -81,7 +88,10 @@ function eventLabel(event) {
81
88
  };
82
89
  const time = Number.isFinite(event.at) ? new Date(event.at).toLocaleTimeString("es", { hour12: false }) : "hora desconocida";
83
90
  const detail = event.detail ? ` · ${event.detail}` : "";
84
- return `${time} · ${kinds[event.kind]} · ${event.instanceId}${detail}`;
91
+ const source = event.componentName ? ` · ${event.componentName}` : "";
92
+ const file = event.file ? ` · ${event.file}` : "";
93
+ const value = event.kind === "signal" && "value" in event ? ` → ${formatValue(event.value).replace(/\s+/g, " ").slice(0, 240)}` : "";
94
+ return `${time} · ${kinds[event.kind]} · ${event.instanceId}${source}${file}${detail}${value}`;
85
95
  }
86
96
  /** Exige un documento conectado a una ventana para registrar eventos del hook. */
87
97
  function requireView(document) {
@@ -131,9 +141,16 @@ export function mountDevtoolsPanel(options = {}) {
131
141
  main.append(instancesSection, detailSection);
132
142
  const eventsSection = element(ownerDocument, "section", undefined, "events");
133
143
  const eventsTitle = element(ownerDocument, "h2", "Eventos recientes");
144
+ const eventsHead = element(ownerDocument, "div", undefined, "events-head");
145
+ const signalFilterLabel = element(ownerDocument, "label", " Sólo señales");
146
+ const signalFilter = element(ownerDocument, "input");
147
+ signalFilter.type = "checkbox";
148
+ signalFilter.setAttribute("aria-label", "Mostrar sólo eventos de señales");
149
+ signalFilterLabel.prepend(signalFilter);
150
+ eventsHead.append(eventsTitle, signalFilterLabel);
134
151
  const eventsList = element(ownerDocument, "ol");
135
152
  eventsList.setAttribute("aria-label", "Eventos recientes de Meeko");
136
- eventsSection.append(eventsTitle, eventsList);
153
+ eventsSection.append(eventsHead, eventsList);
137
154
  const status = element(ownerDocument, "div", undefined, "status");
138
155
  status.setAttribute("role", "status");
139
156
  status.setAttribute("aria-live", "polite");
@@ -262,11 +279,42 @@ export function mountDevtoolsPanel(options = {}) {
262
279
  else {
263
280
  detailSection.append(element(ownerDocument, "p", "Selecciona una instancia para ver props y señales."));
264
281
  }
265
- const recent = snapshot.events.slice(-30).reverse();
282
+ const recent = snapshot.events.filter((event) => !signalFilter.checked || event.kind === "signal").slice(-30).reverse();
266
283
  if (recent.length === 0)
267
284
  eventsList.append(element(ownerDocument, "li", "Sin eventos.", "muted"));
268
- for (const event of recent)
269
- eventsList.append(element(ownerDocument, "li", eventLabel(event)));
285
+ for (const event of recent) {
286
+ const row = element(ownerDocument, "li");
287
+ const instance = byId.get(event.instanceId);
288
+ const label = eventLabel(event);
289
+ if (instance) {
290
+ const button = element(ownerDocument, "button", label, "event-button");
291
+ button.type = "button";
292
+ button.dataset.instanceId = instance.id;
293
+ button.dataset.action = "event";
294
+ button.addEventListener("click", () => {
295
+ hook?.select(instance.id);
296
+ showMobileView("detail");
297
+ render();
298
+ });
299
+ row.append(button);
300
+ }
301
+ else {
302
+ row.textContent = label;
303
+ }
304
+ if (event.kind === "signal" && event.reads?.length) {
305
+ const sites = element(ownerDocument, "details", undefined, "event-reads");
306
+ sites.append(element(ownerDocument, "summary", `${event.reads.length} expresión(es) que dependen de la señal`));
307
+ const list = element(ownerDocument, "ul");
308
+ for (const read of event.reads) {
309
+ const usage = element(ownerDocument, "li");
310
+ usage.append(element(ownerDocument, "code", `${read.file}:${read.line}:${read.column}`), element(ownerDocument, "span", ` ${read.kind}: `), element(ownerDocument, "code", read.expression));
311
+ list.append(usage);
312
+ }
313
+ sites.append(list);
314
+ row.append(sites);
315
+ }
316
+ eventsList.append(row);
317
+ }
270
318
  restoreFocus(focusId, focusAction);
271
319
  }
272
320
  /** Descubre el hook al inicio, al recibir el evento de disponibilidad o al pulsar Actualizar. */
@@ -295,6 +343,7 @@ export function mountDevtoolsPanel(options = {}) {
295
343
  hook = undefined;
296
344
  }
297
345
  view.addEventListener("meeko:devtools-ready", connect);
346
+ signalFilter.addEventListener("change", render);
298
347
  refreshButton.addEventListener("click", connect);
299
348
  closeButton.addEventListener("click", unmount);
300
349
  connect();
@@ -1,4 +1,5 @@
1
1
  import type { DomScope } from "./dom.js";
2
+ import type { EffectSource } from "./reactivity.js";
2
3
  /** Valores seguros para transportar una captura al panel de desarrollo. */
3
4
  export type DevtoolsValue = null | boolean | number | string | DevtoolsValue[] | {
4
5
  [key: string]: DevtoolsValue;
@@ -23,6 +24,14 @@ export interface DevtoolsEvent {
23
24
  kind: "mount" | "unmount" | "signal" | "breakpoint" | "select" | "hmr";
24
25
  instanceId: string;
25
26
  detail?: string;
27
+ /** Identidad del componente conservada incluso después del desmontaje. */
28
+ componentName?: string;
29
+ /** Archivo fuente `.meeko` proporcionado por la compilación de desarrollo. */
30
+ file?: string;
31
+ /** Valor posterior de una señal, copiado con los mismos límites que una captura. */
32
+ value?: DevtoolsValue;
33
+ /** Expresiones de plantilla que dependían de la señal en esta instancia. No son el origen de la escritura. */
34
+ reads?: EffectSource[];
26
35
  }
27
36
  /** Captura independiente de referencias DOM y de valores no serializables. */
28
37
  export interface DevtoolsSnapshot {
package/dist/devtools.js CHANGED
@@ -1,4 +1,4 @@
1
- import { effect, isWritableSignal, untrack } from "./reactivity.js";
1
+ import { currentEffectOwner, effect, isWritableSignal, signalReadSites, untrack } from "./reactivity.js";
2
2
  const active = new Map();
3
3
  const identities = new WeakMap();
4
4
  const listeners = new Set();
@@ -82,10 +82,19 @@ function notify() {
82
82
  catch { /* Un panel externo no debe romper la aplicación. */ }
83
83
  }
84
84
  }
85
- function record(kind, instanceId, detail) {
85
+ function record(kind, instanceId, detail, value, source, reads) {
86
86
  const event = { id: nextEvent++, at: Date.now(), kind, instanceId };
87
87
  if (detail !== undefined)
88
88
  event.detail = detail;
89
+ const entry = source ?? active.get(instanceId);
90
+ if (entry) {
91
+ event.componentName = entry.name;
92
+ event.file = entry.file;
93
+ }
94
+ if (value !== undefined)
95
+ event.value = value;
96
+ if (reads?.length)
97
+ event.reads = reads;
89
98
  recent.push(event);
90
99
  if (recent.length > 100)
91
100
  recent.shift();
@@ -246,7 +255,7 @@ function unregister(entry) {
246
255
  selectedId = null;
247
256
  if (highlightedId === entry.id)
248
257
  removeHighlight();
249
- record("unmount", entry.id);
258
+ record("unmount", entry.id, undefined, undefined, entry);
250
259
  }
251
260
  /** Registra señales y ámbito de una instancia hidratada en Vite desarrollo. */
252
261
  export function registerDevtoolsInstance(input) {
@@ -263,7 +272,7 @@ export function registerDevtoolsInstance(input) {
263
272
  const existing = active.get(identity.id);
264
273
  if (existing)
265
274
  unregister(existing);
266
- const entry = { ...input, id: identity.id, key, breakpoint: identity.breakpoint, stopSignals: [] };
275
+ const entry = { ...input, id: identity.id, key, owner: currentEffectOwner(), breakpoint: identity.breakpoint, stopSignals: [] };
267
276
  active.set(entry.id, entry);
268
277
  for (const [name, value] of Object.entries(input.bindings)) {
269
278
  if (!isWritableSignal(value))
@@ -271,12 +280,12 @@ export function registerDevtoolsInstance(input) {
271
280
  const signal = value;
272
281
  let initial = true;
273
282
  entry.stopSignals.push(effect(() => {
274
- void signal.value;
283
+ const current = signal.value;
275
284
  if (initial) {
276
285
  initial = false;
277
286
  return;
278
287
  }
279
- record("signal", entry.id, name);
288
+ record("signal", entry.id, name, safeValue(current), entry, signalReadSites(signal, entry.owner));
280
289
  if (entry.breakpoint) {
281
290
  debugger;
282
291
  }
package/dist/dom.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Signal } from "./reactivity.js";
1
+ import type { EffectSource, Signal } from "./reactivity.js";
2
2
  /** Fragmento de DOM que pertenece a una instancia o a una región dinámica. */
3
3
  export interface DomScope {
4
4
  root: Element;
@@ -26,9 +26,9 @@ export declare function renderComponent<Props extends object>(id: number, compon
26
26
  /** Hidrata un hijo dentro de su rango y actualiza props reactivas con limpieza previa. */
27
27
  export declare function bindComponent<Props extends object>(scope: DomScope, id: number, component: ComponentModule<Props>, read: () => Props, location: string): () => void;
28
28
  /** Conecta un texto reactivo al marcador SSR y comprueba su valor inicial. */
29
- export declare function bindText(scope: DomScope, id: number, read: () => unknown, location: string): () => void;
29
+ export declare function bindText(scope: DomScope, id: number, read: () => unknown, location: string, source?: EffectSource): () => void;
30
30
  /** Conecta un atributo dinámico al elemento SSR y comprueba su valor inicial. */
31
- export declare function bindAttribute(scope: DomScope, id: number, name: string, read: () => unknown, location: string): () => void;
31
+ export declare function bindAttribute(scope: DomScope, id: number, name: string, read: () => unknown, location: string, source?: EffectSource): () => void;
32
32
  /** Conecta un evento al elemento SSR y entrega su función de limpieza. */
33
33
  export declare function bindEvent(scope: DomScope, id: number, name: string, handler: (event: Event) => void): () => void;
34
34
  /** Genera los límites estables de una condición en SSR. */
@@ -39,5 +39,9 @@ export declare function bindIf(scope: DomScope, id: number, read: () => unknown,
39
39
  export declare function keyToken(key: unknown): string;
40
40
  /** Renderiza una lista con límites identificados por claves únicas. */
41
41
  export declare function renderFor<T>(id: number, items: Iterable<T>, keyOf: (item: T, index: number) => string | number, render: (item: T, index: number) => string): string;
42
- /** Hidrata una lista y reordena regiones por clave sin reemplazar nodos conservados. */
43
- export declare function bindFor<T>(scope: DomScope, id: number, read: () => Iterable<T>, keyOf: (item: T, index: number) => string | number, render: (item: T, index: number) => string, hydrate: (item: Signal<T>, index: Signal<number>, inner: DomScope) => () => void): () => void;
42
+ /**
43
+ * Hidrata una lista y reordena regiones por clave sin reemplazar nodos conservados.
44
+ * El compilador desactiva `trackIndex` sólo cuando el cuerpo de hidratación no lee
45
+ * la señal de índice; las llamadas directas conservan la señal por defecto.
46
+ */
47
+ export declare function bindFor<T>(scope: DomScope, id: number, read: () => Iterable<T>, keyOf: (item: T, index: number) => string | number, render: (item: T, index: number) => string, hydrate: (item: Signal<T>, index: Signal<number>, inner: DomScope) => () => void, trackIndex?: boolean): () => void;
package/dist/dom.js CHANGED
@@ -133,27 +133,44 @@ export function bindComponent(scope, id, component, read, location) {
133
133
  return () => { stop(); release(); };
134
134
  }
135
135
  /** Conecta un texto reactivo al marcador SSR y comprueba su valor inicial. */
136
- export function bindText(scope, id, read, location) {
136
+ export function bindText(scope, id, read, location, source) {
137
137
  const marker = findComment(scope, `meeko:text:${id}`);
138
138
  const text = marker.nextSibling;
139
139
  if (!text || text.nodeType !== 3)
140
140
  throw new Error(`Missing text node in ${location}`);
141
- if (text.textContent !== String(read()))
141
+ const initial = String(read());
142
+ if (text.textContent !== initial)
142
143
  throw new Error(`Hydration mismatch in ${location}`);
143
- return effect(() => { text.textContent = String(read()); });
144
+ let first = true;
145
+ return effect(() => {
146
+ const next = String(read());
147
+ if (first) {
148
+ first = false;
149
+ if (text.textContent === next)
150
+ return;
151
+ }
152
+ text.textContent = next;
153
+ }, source);
144
154
  }
145
155
  /** Conecta un atributo dinámico al elemento SSR y comprueba su valor inicial. */
146
- export function bindAttribute(scope, id, name, read, location) {
156
+ export function bindAttribute(scope, id, name, read, location, source) {
147
157
  const target = findElement(scope, `data-meeko-a-${id}`);
148
- if (target.getAttribute(name) !== attributeValue(name, read()))
158
+ const initial = attributeValue(name, read());
159
+ if (target.getAttribute(name) !== initial)
149
160
  throw new Error(`Hydration mismatch in ${location}`);
161
+ let first = true;
150
162
  return effect(() => {
151
163
  const next = attributeValue(name, read());
164
+ if (first) {
165
+ first = false;
166
+ if (target.getAttribute(name) === next)
167
+ return;
168
+ }
152
169
  if (next === null)
153
170
  target.removeAttribute(name);
154
171
  else
155
172
  target.setAttribute(name, next);
156
- });
173
+ }, source);
157
174
  }
158
175
  /** Conecta un evento al elemento SSR y entrega su función de limpieza. */
159
176
  export function bindEvent(scope, id, name, handler) {
@@ -227,11 +244,42 @@ function moveRange(start, end, before) {
227
244
  }
228
245
  parent.insertBefore(fragment, before);
229
246
  }
230
- /** Hidrata una lista y reordena regiones por clave sin reemplazar nodos conservados. */
231
- export function bindFor(scope, id, read, keyOf, render, hydrate) {
247
+ /** Encuentra las posiciones que ya mantienen el orden relativo de la lista anterior. */
248
+ function stableListPositions(indices) {
249
+ const tails = [];
250
+ const previous = new Int32Array(indices.length).fill(-1);
251
+ for (let index = 0; index < indices.length; index++) {
252
+ const oldIndex = indices[index];
253
+ if (oldIndex < 0)
254
+ continue;
255
+ let low = 0;
256
+ let high = tails.length;
257
+ while (low < high) {
258
+ const middle = (low + high) >>> 1;
259
+ if (indices[tails[middle]] < oldIndex)
260
+ low = middle + 1;
261
+ else
262
+ high = middle;
263
+ }
264
+ if (low > 0)
265
+ previous[index] = tails[low - 1];
266
+ tails[low] = index;
267
+ }
268
+ const stable = new Set();
269
+ for (let index = tails.at(-1) ?? -1; index >= 0; index = previous[index])
270
+ stable.add(index);
271
+ return stable;
272
+ }
273
+ /**
274
+ * Hidrata una lista y reordena regiones por clave sin reemplazar nodos conservados.
275
+ * El compilador desactiva `trackIndex` sólo cuando el cuerpo de hidratación no lee
276
+ * la señal de índice; las llamadas directas conservan la señal por defecto.
277
+ */
278
+ export function bindFor(scope, id, read, keyOf, render, hydrate, trackIndex = true) {
232
279
  const outer = region(scope, "for", id);
233
280
  const entries = new Map();
234
281
  const initial = [...read()];
282
+ let currentKeys = [];
235
283
  let cursor = outer.start.nextSibling;
236
284
  for (let index = 0; index < initial.length; index++) {
237
285
  const item = initial[index];
@@ -249,23 +297,35 @@ export function bindFor(scope, id, read, keyOf, render, hydrate) {
249
297
  if (!end)
250
298
  throw new Error(`Missing end marker for list key ${key}`);
251
299
  const value = signal(item);
252
- const position = signal(index);
300
+ const position = trackIndex ? signal(index) : undefined;
253
301
  const release = createRoot((dispose) => {
254
302
  const cleanup = hydrate(value, position, { root: scope.root, start, end: end });
255
303
  return () => { cleanup(); dispose(); };
256
304
  });
257
305
  entries.set(key, { key, start, end: end, value, index: position, release });
306
+ currentKeys.push(key);
258
307
  cursor = end.nextSibling;
259
308
  }
260
309
  if (cursor !== outer.end)
261
310
  throw new Error(`Hydration mismatch in list ${id}: extra nodes`);
311
+ let initialItems = initial;
262
312
  const stop = effect(() => {
263
313
  const items = [...read()];
314
+ if (initialItems) {
315
+ const previous = initialItems;
316
+ initialItems = undefined;
317
+ if (items.length === previous.length && items.every((item, index) => Object.is(item, previous[index]) && keyToken(keyOf(item, index)) === currentKeys[index]))
318
+ return;
319
+ }
264
320
  const nextKeys = items.map((item, index) => keyToken(keyOf(item, index)));
265
- if (new Set(nextKeys).size !== nextKeys.length)
321
+ const nextKeySet = new Set(nextKeys);
322
+ if (nextKeySet.size !== nextKeys.length)
266
323
  throw new Error(`Duplicate Meeko list key in region ${id}`);
324
+ const oldPositions = new Map(currentKeys.map((key, index) => [key, index]));
325
+ const oldIndices = nextKeys.map((key) => oldPositions.get(key) ?? -1);
326
+ const stable = stableListPositions(oldIndices);
267
327
  for (const [key, entry] of entries) {
268
- if (nextKeys.includes(key))
328
+ if (nextKeySet.has(key))
269
329
  continue;
270
330
  entry.release();
271
331
  let node = entry.start;
@@ -293,7 +353,7 @@ export function bindFor(scope, id, read, keyOf, render, hydrate) {
293
353
  if (!start)
294
354
  throw new Error(`Missing new list marker for ${key}`);
295
355
  const value = signal(item);
296
- const position = signal(index);
356
+ const position = trackIndex ? signal(index) : undefined;
297
357
  const release = createRoot((dispose) => {
298
358
  const cleanup = hydrate(value, position, { root: scope.root, start: start, end });
299
359
  return () => { cleanup(); dispose(); };
@@ -303,12 +363,14 @@ export function bindFor(scope, id, read, keyOf, render, hydrate) {
303
363
  }
304
364
  else {
305
365
  entry.value.value = item;
306
- entry.index.value = index;
366
+ if (entry.index)
367
+ entry.index.value = index;
307
368
  }
308
- if (entry.end.nextSibling !== before)
369
+ if (oldIndices[index] !== -1 && !stable.has(index) && entry.end.nextSibling !== before)
309
370
  moveRange(entry.start, entry.end, before);
310
371
  before = entry.start;
311
372
  }
373
+ currentKeys = nextKeys;
312
374
  });
313
375
  return () => { stop(); for (const entry of entries.values())
314
376
  entry.release(); entries.clear(); };
package/dist/init.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Crea un proyecto SSR independiente sin sobrescribir archivos del usuario. */
2
+ export declare function initProject(directory: string): Promise<string>;
package/dist/init.js ADDED
@@ -0,0 +1,207 @@
1
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ /** Entradas que puede dejar un gestor de paquetes antes de generar el proyecto. */
4
+ const packageScaffoldEntries = new Set(["package.json", "package-lock.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "yarn.lock", "bun.lock", "bun.lockb", "node_modules", ".npmrc", ".git"]);
5
+ /** Comprueba que un valor JSON sea un objeto de configuración. */
6
+ function isRecord(value) {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+ /** Archivos autocontenidos del proyecto inicial, incluidos en la CLI publicada. */
10
+ const starterFiles = {
11
+ ".gitignore": "node_modules/\ndist/\n*.d.meeko.ts\n",
12
+ "index.html": `<!doctype html>
13
+ <html lang="es">
14
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Meeko SSR</title></head>
15
+ <body><div id="app"></div><script type="module" src="/src/entry-client.ts"></script></body>
16
+ </html>
17
+ `,
18
+ "vite.config.ts": `import { defineConfig } from "vite";
19
+ import { meeko } from "@meekojs/meeko/vite";
20
+
21
+ export default defineConfig({ plugins: [meeko()] });
22
+ `,
23
+ "tsconfig.json": `{
24
+ "compilerOptions": {
25
+ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
26
+ "lib": ["ES2022", "DOM"], "types": ["node", "vite/client"],
27
+ "strict": true, "noEmit": true, "allowArbitraryExtensions": true,
28
+ "skipLibCheck": true
29
+ },
30
+ "include": ["src/**/*.ts", "vite.config.ts"]
31
+ }
32
+ `,
33
+ "src/App.meeko": `<script lang="ts">
34
+ import { signal } from "@meekojs/meeko";
35
+
36
+ /** Propiedades transferidas desde el servidor. */
37
+ export interface Props { initial: number }
38
+
39
+ const props = defineProps<Props>();
40
+ const count = signal(props.initial);
41
+ </script>
42
+
43
+ <template>
44
+ <main>
45
+ <h1>Meeko SSR</h1>
46
+ <button type="button" on:click="() => count.value++">Contador: {count.value}</button>
47
+ </main>
48
+ </template>
49
+
50
+ <style scoped>
51
+ main { max-width: 40rem; margin: 4rem auto; font: 1.2rem system-ui; }
52
+ button { cursor: pointer; padding: .65rem 1rem; }
53
+ </style>
54
+ `,
55
+ "src/entry-server.ts": `import { serializeJson } from "@meekojs/meeko";
56
+ import { css, renderWithState } from "./App.meeko";
57
+
58
+ /** Genera HTML y estado inicial desde el mismo componente que hidrata el cliente. */
59
+ export function renderPage(clientSource: string, initial = 1): string {
60
+ const props = { initial };
61
+ const rendered = renderWithState(props);
62
+ return '<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Meeko SSR</title><style>'
63
+ + css + '</style></head><body><div id="app">' + rendered.html + '</div><script id="meeko-payload" type="application/json">'
64
+ + serializeJson({ props, state: rendered.state }) + '</script><script type="module" src="' + clientSource + '"></script></body></html>';
65
+ }
66
+ `,
67
+ "src/entry-client.ts": `import { hydrate } from "./App.meeko";
68
+ import type { HotState } from "@meekojs/meeko";
69
+
70
+ const root = document.getElementById("app");
71
+ const payloadNode = document.getElementById("meeko-payload");
72
+ if (!root || !payloadNode?.textContent) throw new Error("Missing SSR root or payload");
73
+ const payload: unknown = JSON.parse(payloadNode.textContent);
74
+ if (typeof payload !== "object" || payload === null || !("props" in payload) || !("state" in payload)) throw new TypeError("Invalid SSR payload");
75
+ const { props, state } = payload;
76
+ if (typeof props !== "object" || props === null || !("initial" in props) || typeof props.initial !== "number" || !Number.isFinite(props.initial)) throw new TypeError("Invalid SSR props");
77
+ if (typeof state !== "object" || state === null || !("count" in state) || typeof state.count !== "number" || !Number.isFinite(state.count)) throw new TypeError("Invalid SSR state");
78
+ hydrate(root, { initial: props.initial }, { count: state.count } satisfies HotState);
79
+ `,
80
+ "server.mjs": `import { createServer } from "node:http";
81
+ import { readFile } from "node:fs/promises";
82
+ import { dirname, extname, resolve, sep } from "node:path";
83
+ import { fileURLToPath, pathToFileURL } from "node:url";
84
+ import { createServer as createViteServer } from "vite";
85
+
86
+ const root = dirname(fileURLToPath(import.meta.url));
87
+ const production = process.argv.includes("--production");
88
+ const vite = production ? undefined : await createViteServer({ root, server: { middlewareMode: true }, appType: "custom" });
89
+ const clientRoot = resolve(root, "dist/client");
90
+ let clientSource = "/src/entry-client.ts";
91
+ if (production) {
92
+ const manifest = JSON.parse(await readFile(resolve(clientRoot, ".vite/manifest.json"), "utf8"));
93
+ const entry = manifest["index.html"];
94
+ if (!entry?.file) throw new Error("Missing Vite client entry");
95
+ clientSource = "/" + entry.file;
96
+ }
97
+
98
+ const contentTypes = { ".js": "text/javascript", ".css": "text/css", ".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon" };
99
+ const server = createServer(async (request, response) => {
100
+ try {
101
+ const path = new URL(request.url ?? "/", "http://localhost").pathname;
102
+ if (path === "/") {
103
+ const module = vite ? await vite.ssrLoadModule("/src/entry-server.ts") : await import(pathToFileURL(resolve(root, "dist/server/entry-server.js")).href);
104
+ const html = module.renderPage(clientSource);
105
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
106
+ response.end(vite ? await vite.transformIndexHtml(path, html) : html);
107
+ return;
108
+ }
109
+ if (vite) {
110
+ vite.middlewares(request, response, () => { response.writeHead(404); response.end(); });
111
+ return;
112
+ }
113
+ const file = resolve(clientRoot, "." + path);
114
+ if (!file.startsWith(clientRoot + sep)) { response.writeHead(403); response.end(); return; }
115
+ const body = await readFile(file);
116
+ response.writeHead(200, { "content-type": contentTypes[extname(file)] ?? "application/octet-stream" });
117
+ response.end(body);
118
+ } catch (error) {
119
+ console.error(error);
120
+ response.writeHead(500);
121
+ response.end("Internal server error");
122
+ }
123
+ });
124
+
125
+ const port = Number(process.env.PORT ?? 4173);
126
+ server.listen(port, "127.0.0.1", () => console.log("http://127.0.0.1:" + port));
127
+ process.on("SIGINT", () => { server.close(); void vite?.close(); });
128
+ `,
129
+ "README.md": `# Meeko SSR starter
130
+
131
+ Proyecto inicial con renderizado en servidor e hidratación del mismo componente \`.meeko\`.
132
+
133
+ \`\`\`sh
134
+ npm install
135
+ npm run dev
136
+ \`\`\`
137
+
138
+ Abre http://127.0.0.1:4173. El servidor produce HTML y transfiere las props y señales al navegador.
139
+
140
+ \`npm run check\` genera los tipos del componente y comprueba TypeScript. \`npm run build\` compila cliente y servidor; \`npm start\` sirve ambas compilaciones con SSR.
141
+ `,
142
+ };
143
+ /** Crea un proyecto SSR independiente sin sobrescribir archivos del usuario. */
144
+ export async function initProject(directory) {
145
+ const target = resolve(directory);
146
+ const name = basename(target).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") || "meeko-app";
147
+ const packageMetadata = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
148
+ if (typeof packageMetadata !== "object" || packageMetadata === null || !("version" in packageMetadata) || typeof packageMetadata.version !== "string") {
149
+ throw new Error("Meeko package version is unavailable");
150
+ }
151
+ let existingEntries = [];
152
+ try {
153
+ if (!(await stat(target)).isDirectory())
154
+ throw new Error(`Destination is not a directory: ${target}`);
155
+ existingEntries = await readdir(target);
156
+ }
157
+ catch (error) {
158
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
159
+ throw error;
160
+ }
161
+ const unexpected = existingEntries.filter((entry) => !packageScaffoldEntries.has(entry));
162
+ if (unexpected.length > 0 || (existingEntries.length > 0 && !existingEntries.includes("package.json"))) {
163
+ throw new Error(`Destination is not empty: ${target}${unexpected.length > 0 ? ` (${unexpected.join(", ")})` : ""}`);
164
+ }
165
+ const existing = existingEntries.includes("package.json") ? JSON.parse(await readFile(join(target, "package.json"), "utf8")) : {};
166
+ if (!isRecord(existing))
167
+ throw new Error(`Invalid package.json in ${target}`);
168
+ const existingScripts = existing.scripts ?? {};
169
+ const existingDependencies = existing.dependencies ?? {};
170
+ const existingDevDependencies = existing.devDependencies ?? {};
171
+ const existingEngines = existing.engines ?? {};
172
+ if (!isRecord(existingScripts) || !isRecord(existingDependencies) || !isRecord(existingDevDependencies) || !isRecord(existingEngines)) {
173
+ throw new Error(`Invalid package.json in ${target}`);
174
+ }
175
+ const scripts = {
176
+ dev: "node server.mjs",
177
+ check: "meeko types src/App.meeko && tsc --noEmit",
178
+ build: "npm run check && vite build --outDir dist/client --manifest && vite build --ssr src/entry-server.ts --outDir dist/server",
179
+ start: "node server.mjs --production",
180
+ };
181
+ const devDependencies = { "@types/node": "^24.0.0", typescript: "^7.0.2", vite: "^8.3.5" };
182
+ for (const command of Object.keys(scripts)) {
183
+ if (command in existingScripts)
184
+ throw new Error(`Destination already defines the ${command} script: ${target}`);
185
+ }
186
+ if (Object.keys(existingDependencies).some((dependency) => dependency !== "@meekojs/meeko") || Object.keys(existingDevDependencies).some((dependency) => !(dependency in devDependencies))) {
187
+ throw new Error(`Destination contains other dependencies: ${target}`);
188
+ }
189
+ const packageFile = JSON.stringify({
190
+ ...existing,
191
+ name: typeof existing.name === "string" ? existing.name : name,
192
+ private: true,
193
+ type: "module",
194
+ engines: { ...existingEngines, node: ">=24" },
195
+ scripts: { ...existingScripts, ...scripts },
196
+ dependencies: { ...existingDependencies, "@meekojs/meeko": packageMetadata.version },
197
+ devDependencies: { ...existingDevDependencies, ...devDependencies },
198
+ }, null, 2) + "\n";
199
+ await mkdir(target, { recursive: true });
200
+ for (const [filename, content] of Object.entries(starterFiles)) {
201
+ const path = join(target, filename);
202
+ await mkdir(dirname(path), { recursive: true });
203
+ await writeFile(path, content, { encoding: "utf8", flag: "wx" });
204
+ }
205
+ await writeFile(join(target, "package.json"), packageFile, { encoding: "utf8", flag: existingEntries.includes("package.json") ? "w" : "wx" });
206
+ return target;
207
+ }
@@ -6,12 +6,24 @@ export interface ReadonlySignal<T> {
6
6
  export interface Signal<T> extends ReadonlySignal<T> {
7
7
  value: T;
8
8
  }
9
+ /** Ubicación de una expresión de plantilla que lee una señal durante desarrollo. */
10
+ export interface EffectSource {
11
+ file: string;
12
+ line: number;
13
+ column: number;
14
+ expression: string;
15
+ kind: "text" | "attribute";
16
+ }
9
17
  /** Identifica señales editables creadas por Meeko para conservarlas durante HMR. */
10
18
  export declare function isWritableSignal(value: unknown): value is Signal<unknown>;
11
19
  /** Crea un valor observable. Las escrituras iguales no notifican cambios. */
12
20
  export declare function signal<T>(initial: T): Signal<T>;
13
21
  /** Ejecuta una función cuando cambian las señales leídas por ella. */
14
- export declare function effect(fn: () => void): () => void;
22
+ export declare function effect(fn: () => void, source?: EffectSource): () => void;
23
+ /** Identidad opaca de la raíz reactiva activa; permite aislar instancias anidadas. */
24
+ export declare function currentEffectOwner(): object | undefined;
25
+ /** Devuelve expresiones de plantilla actualmente dependientes de una señal en una raíz. */
26
+ export declare function signalReadSites(value: Signal<unknown>, owner: object | undefined): EffectSource[];
15
27
  /** Deriva un valor de otras señales y lo actualiza cuando cambian. */
16
28
  export declare function computed<T>(fn: () => T): ReadonlySignal<T>;
17
29
  /** Agrupa varias escrituras en una sola ronda de notificaciones. */
@@ -1,5 +1,6 @@
1
1
  const writableSignals = new WeakSet();
2
2
  const derivedSignals = new WeakSet();
3
+ const effectSources = new WeakMap();
3
4
  /** Identifica señales editables creadas por Meeko para conservarlas durante HMR. */
4
5
  export function isWritableSignal(value) {
5
6
  return typeof value === "object" && value !== null && writableSignals.has(value) && !derivedSignals.has(value);
@@ -11,7 +12,7 @@ let flushing = false;
11
12
  const pending = new Set();
12
13
  function detach(observer) {
13
14
  for (const dependency of observer.dependencies)
14
- dependency.subscribers.delete(observer);
15
+ dependency.subscribers?.delete(observer);
15
16
  observer.dependencies.clear();
16
17
  }
17
18
  function flush() {
@@ -37,12 +38,11 @@ function flush() {
37
38
  /** Crea un valor observable. Las escrituras iguales no notifican cambios. */
38
39
  export function signal(initial) {
39
40
  let current = initial;
40
- const subscribers = new Set();
41
41
  const result = {
42
- subscribers,
42
+ subscribers: undefined,
43
43
  get value() {
44
44
  if (activeObserver) {
45
- subscribers.add(activeObserver);
45
+ (result.subscribers ??= new Set()).add(activeObserver);
46
46
  activeObserver.dependencies.add(result);
47
47
  }
48
48
  return current;
@@ -51,8 +51,9 @@ export function signal(initial) {
51
51
  if (Object.is(current, next))
52
52
  return;
53
53
  current = next;
54
- for (const observer of subscribers)
55
- pending.add(observer);
54
+ if (result.subscribers)
55
+ for (const observer of result.subscribers)
56
+ pending.add(observer);
56
57
  flush();
57
58
  },
58
59
  };
@@ -60,7 +61,7 @@ export function signal(initial) {
60
61
  return result;
61
62
  }
62
63
  /** Ejecuta una función cuando cambian las señales leídas por ella. */
63
- export function effect(fn) {
64
+ export function effect(fn, source) {
64
65
  const owner = activeOwner;
65
66
  const observer = {
66
67
  dependencies: new Set(),
@@ -80,6 +81,8 @@ export function effect(fn) {
80
81
  }
81
82
  },
82
83
  };
84
+ if (source)
85
+ effectSources.set(observer, { owner, source });
83
86
  owner?.observers.add(observer);
84
87
  observer.run();
85
88
  return () => {
@@ -89,6 +92,25 @@ export function effect(fn) {
89
92
  owner?.observers.delete(observer);
90
93
  };
91
94
  }
95
+ /** Identidad opaca de la raíz reactiva activa; permite aislar instancias anidadas. */
96
+ export function currentEffectOwner() {
97
+ return activeOwner;
98
+ }
99
+ /** Devuelve expresiones de plantilla actualmente dependientes de una señal en una raíz. */
100
+ export function signalReadSites(value, owner) {
101
+ if (!owner || !isWritableSignal(value))
102
+ return [];
103
+ const sites = new Map();
104
+ for (const observer of value.subscribers ?? []) {
105
+ const metadata = effectSources.get(observer);
106
+ if (observer.disposed || metadata?.owner !== owner || !metadata)
107
+ continue;
108
+ const source = metadata.source;
109
+ const key = `${source.file}\u0000${source.line}\u0000${source.column}\u0000${source.kind}\u0000${source.expression}`;
110
+ sites.set(key, source);
111
+ }
112
+ return [...sites.values()];
113
+ }
92
114
  /** Deriva un valor de otras señales y lo actualiza cuando cambian. */
93
115
  export function computed(fn) {
94
116
  const result = signal(undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meekojs/meeko",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "packageManager": "pnpm@12.14.0",
5
5
  "description": "Typed .meeko component engine with SSR, hydration, signals, Vite, and DevTools.",
6
6
  "license": "MIT",
@@ -45,13 +45,18 @@
45
45
  "vite:dev": "vite examples/vite --config examples/vite/vite.config.ts",
46
46
  "vite:types": "pnpm run build && node dist/cli.js types examples/counter/Counter.meeko && node dist/cli.js types examples/regions/Regions.meeko && node dist/cli.js types examples/vite/Hot.meeko && node dist/cli.js types examples/nested/Parent.meeko && tsc -p examples/vite/tsconfig.json --noEmit",
47
47
  "vite:build": "vite build examples/vite --config examples/vite/vite.config.ts",
48
+ "pilot:dev": "tsx examples/pilot/server.ts",
49
+ "pilot:check": "pnpm run build && node dist/cli.js check examples/pilot/App.meeko && node dist/cli.js types examples/pilot/App.meeko && tsc -p examples/pilot/tsconfig.json --noEmit",
50
+ "pilot:build": "tsx examples/pilot/build.ts",
51
+ "pilot:serve": "node examples/pilot/.output/server/http.js",
52
+ "pilot:e2e": "tsx --test tests/pilot.e2e.ts",
48
53
  "e2e": "pnpm run example:bundle && pnpm run vite:build && tsx --test tests/browser.e2e.ts",
49
54
  "benchmark:check": "pnpm run example:compile && node dist/cli.js compile benchmarks/cases/TreeLeaf.meeko benchmarks/cases/TreeLeaf.generated.ts && node dist/cli.js compile benchmarks/cases/TreeBranch.meeko benchmarks/cases/TreeBranch.generated.ts && node dist/cli.js compile benchmarks/cases/Tree.meeko benchmarks/cases/Tree.generated.ts && node dist/cli.js compile benchmarks/cases/List.meeko benchmarks/cases/List.generated.ts && node dist/cli.js compile benchmarks/cases/Navigation.meeko benchmarks/cases/Navigation.generated.ts && tsc -p tsconfig.benchmark.json --noEmit",
50
55
  "benchmark": "pnpm run build && pnpm run benchmark:check && node --expose-gc --import tsx benchmarks/run.ts",
51
56
  "benchmark:scenarios": "pnpm run build && pnpm run benchmark:check && node --expose-gc --import tsx benchmarks/scenarios.ts",
52
57
  "package:smoke": "pwsh -NoProfile -File tests/package-consumer.ps1",
53
58
  "devtools:check": "node --test tools/devtools-extension/tests/*.test.js && tsc -p tools/devtools-extension/tsconfig.json --noEmit",
54
- "verify": "pnpm run check && pnpm run test && pnpm run devtools:check && pnpm run example:check && tsc -p tsconfig.test.json --noEmit && pnpm run vite:types && pnpm run benchmark:check && pnpm run vite:build"
59
+ "verify": "pnpm run check && pnpm run test && pnpm run devtools:check && pnpm run example:check && tsc -p tsconfig.test.json --noEmit && pnpm run vite:types && pnpm run benchmark:check && pnpm run vite:build && pnpm run pilot:check"
55
60
  },
56
61
  "dependencies": {
57
62
  "@typescript/typescript6": "6.0.2",