ascua-scaffold 0.1.1 → 0.3.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.
Files changed (4) hide show
  1. package/README.md +43 -0
  2. package/generate.js +336 -1
  3. package/index.js +189 -77
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -37,6 +37,8 @@ src/resources/products.ts el esquema del formulario, sus campos y las conv
37
37
  src/routes/products/index.ts /products, de 50 en 50
38
38
  src/routes/products/new.ts /products/new
39
39
  src/routes/products/[id].ts /products/:id, editar y borrar (?/delete)
40
+ src/routes/products/grid.ts /products/grid, todas las filas editables a la vez
41
+ src/resources/grid.ts leer lo que manda una grilla (uno para todo el sitio)
40
42
  src/pool.ts el pool, con las migraciones aplicadas una vez (si no existe)
41
43
  ```
42
44
 
@@ -66,6 +68,46 @@ obligatoria y no tiene `DEFAULT`, el comando lo dice y no genera nada.
66
68
  Lo generado es tuyo: no se vuelve a generar. Con `--force` se reescribe
67
69
  encima.
68
70
 
71
+ ## La grilla
72
+
73
+ `/products/grid` es la página entera editable, como una DataWindow de
74
+ PowerBuilder: una fila por registro y una celda por campo, de 50 en 50. Es
75
+ un `<form>` de HTML, así que también funciona sin JavaScript.
76
+
77
+ - Cada fila lleva los valores con que se mostró: al guardar, solo se
78
+ escriben las filas que cambiaron.
79
+ - Cada fila cambiada se valida con el mismo esquema del formulario
80
+ (`ProductForm`) y se guardan todas en una sola transacción
81
+ (`transaction()` de brasa): si una falla, no se escribe ninguna, y el
82
+ error aparece en su celda.
83
+ - Un `UNIQUE` repetido vuelve con un 409 y el error bajo la celda de esa
84
+ fila.
85
+
86
+ ## El admin
87
+
88
+ ```sh
89
+ stil exec ascua-scaffold --admin
90
+ ```
91
+
92
+ Genera todas las tablas a la vez bajo `/admin`, como el admin de Django:
93
+ lista, alta, edición y grilla de cada una, un panel en `/admin` y una barra
94
+ con las tablas.
95
+
96
+ - **Pide usuario y contraseña** (HTTP Basic): `ADMIN_PASSWORD` y
97
+ `ADMIN_USER` («admin» por defecto). **Sin `ADMIN_PASSWORD`, `/admin` es un
98
+ 404**: nunca queda abierto por olvido. Va en `src/routes/admin/_middleware.ts`,
99
+ para cambiarlo por el inicio de sesión del sitio (passkeys, una sesión)
100
+ cuando lo tenga.
101
+ - **Reutiliza lo que hay**: una tabla que ya tiene sus consultas y su
102
+ `src/resources/<tabla>.ts` —de un `ascua-scaffold <tabla>` anterior— los
103
+ conserva y solo recibe sus páginas del admin.
104
+ - **Lo que no puede generar lo salta y dice por qué**: vistas, claves
105
+ compuestas, columnas obligatorias que el formulario no sabe editar. El
106
+ panel las lista.
107
+ - **Se puede volver a correr**: tras una migración nueva, agrega las tablas
108
+ nuevas y deja como están las que ya tenían página. `--force` las reescribe
109
+ todas.
110
+
69
111
  ## Requisitos
70
112
 
71
113
  - Un sitio con servidor: `ascua({ site: { mode: "server" } })` en
@@ -80,6 +122,7 @@ encima.
80
122
 
81
123
  ```text
82
124
  ascua-scaffold <tabla> [--config brasa.json] [--force]
125
+ ascua-scaffold --admin [--config brasa.json] [--force]
83
126
  ```
84
127
 
85
128
  ## Licencia
package/generate.js CHANGED
@@ -426,7 +426,7 @@ export default function ${n.plural}({ data }: { data: Awaited<ReturnType<typeof
426
426
  return view\`
427
427
  <section>
428
428
  <h1>${titulo}</h1>
429
- <p><a href="${base}/new">New ${uno}</a></p>
429
+ <p><a href="${base}/new">New ${uno}</a> · <a href="${base}/grid">Edit as a grid</a></p>
430
430
  <table>
431
431
  <thead><tr>${cabeceras}</tr></thead>
432
432
  <tbody>
@@ -554,6 +554,341 @@ export default function Edit${n.uno}({ data, form }: { data: Awaited<ReturnType<
554
554
  return { index, nuevo, editar };
555
555
  }
556
556
 
557
+ /** `src/resources/grid.ts`: leer lo que manda una grilla. Uno para todo el sitio. */
558
+ export function ayudaRejilla() {
559
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
560
+ //
561
+ // A grid is a plain <form>: one row per record, one field per cell. Each row
562
+ // carries its key (k0, k1…) and the values it was shown with (w0, w1…), so
563
+ // the server only writes the rows that changed. Cell names are c<row>.<column>.
564
+
565
+ export type FormValues = Partial<Record<string, string | string[]>>;
566
+ export type FormErrors = Partial<Record<string, string>>;
567
+
568
+ /** A row of the grid: its key, as text, and the values of its fields. */
569
+ export interface GridRow {
570
+ key: string;
571
+ values: FormValues;
572
+ }
573
+
574
+ /** Every row the grid sent, and the ones that changed. */
575
+ export function readGrid(formData: FormData, columns: readonly string[]): { rows: GridRow[]; changed: GridRow[] } {
576
+ const rows: GridRow[] = [];
577
+ const changed: GridRow[] = [];
578
+ for (let i = 0; formData.has(\`k\${i}\`); i++) {
579
+ const key = String(formData.get(\`k\${i}\`));
580
+ const values: FormValues = {};
581
+ // An unchecked checkbox sends nothing, and stays missing: field.checkbox()
582
+ // takes any value that arrived, even "", as checked.
583
+ for (const column of columns) {
584
+ const value = formData.get(\`c\${i}.\${column}\`);
585
+ if (typeof value === "string") values[column] = value;
586
+ }
587
+ let was: FormValues = {};
588
+ try {
589
+ was = JSON.parse(String(formData.get(\`w\${i}\`) ?? "{}"));
590
+ } catch {
591
+ // Without what it was shown with, the row counts as changed.
592
+ }
593
+ rows.push({ key, values });
594
+ if (columns.some((column) => (was[column] ?? "") !== (values[column] ?? ""))) changed.push({ key, values });
595
+ }
596
+ return { rows, changed };
597
+ }
598
+ `;
599
+ }
600
+
601
+ /** Una celda editable de la grilla, con su error debajo. */
602
+ function celda(c, valoresEnum) {
603
+ const nombre = `\${\`c\${r.index}.${c.name}\`}`;
604
+ const v = `r.values${acceso(c.name)}`;
605
+ const texto = `String(${v} ?? "")`;
606
+ let html;
607
+ switch (c.kind) {
608
+ case "boolean":
609
+ html = `<input type="checkbox" name=${nombre} prop:checked=\${${v} === "on"}>`;
610
+ break;
611
+ case "enum": {
612
+ const vacia = c.nullable ? `<option value=""></option>` : "";
613
+ html =
614
+ `<select name=${nombre}>${vacia}<For each=\${() => ${valoresEnum}} key=\${(v: string) => v} ` +
615
+ `render=\${(v: string) => view\`<option prop:selected=\${${v} === v}>\${v}</option>\`}/></select>`;
616
+ break;
617
+ }
618
+ case "integer":
619
+ html = `<input type="number" step="1" name=${nombre} prop:value=\${${texto}}>`;
620
+ break;
621
+ case "decimal":
622
+ case "number":
623
+ html = `<input type="number" step="any" name=${nombre} prop:value=\${${texto}}>`;
624
+ break;
625
+ case "date":
626
+ html = `<input type="date" name=${nombre} prop:value=\${${texto}}>`;
627
+ break;
628
+ case "datetime":
629
+ html = `<input type="datetime-local" name=${nombre} prop:value=\${${texto}}>`;
630
+ break;
631
+ case "time":
632
+ html = `<input type="time" name=${nombre} prop:value=\${${texto}}>`;
633
+ break;
634
+ default:
635
+ html = `<input name=${nombre} prop:value=\${${texto}}>`;
636
+ }
637
+ return `<td>${html}<small class="error">\${r.errors${acceso(c.name)} ?? ""}</small></td>`;
638
+ }
639
+
640
+ /** Los valores de un enum en el código generado: `<x>Values` o escritos. */
641
+ export function valoresEnumDe(c, enums) {
642
+ return c.values ? (enums.has(c.sqlType) ? `${camel(c.sqlType)}Values` : JSON.stringify(c.values)) : "[]";
643
+ }
644
+
645
+ /** `src/routes/<tabla>/grid.ts`: la página entera editable, como una DataWindow. */
646
+ export function rejilla(tabla, a, n, { db, pool, resource, grid, base, enums }) {
647
+ const { guarda, valor } = claveDeUrl(a);
648
+ const pk = a.clave.name;
649
+ const titulo = etiqueta(tabla.name);
650
+ const forma = `${n.uno}Form`;
651
+ const params = `${camel(n.uno)}Params`;
652
+ const values = `${camel(n.uno)}Values`;
653
+ const importsEnum = [...new Set(a.alEditar.filter((c) => c.kind === "enum" && enums.has(c.sqlType)).map((c) => `${camel(c.sqlType)}Values`))];
654
+ const columnas = a.alEditar.map((c) => JSON.stringify(c.name)).join(", ");
655
+ const cabeceras = [pk, ...a.alEditar.map((c) => c.name)].map((c) => `<th>${etiqueta(c)}</th>`).join("");
656
+ const celdas = a.alEditar.map((c) => celda(c, valoresEnumDe(c, enums))).join("\n ");
657
+
658
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
659
+ //
660
+ // Every ${singular(tabla.name)} of the page, editable at once. Saving validates each changed
661
+ // row with ${forma} and writes them all in one transaction: if one fails,
662
+ // none is written, and the error shows in its cell.
663
+ import { fail, notFound, redirect, validate, type ActionContext, type LoadContext } from "vite-plugin-ascua/site";
664
+
665
+ import { ${[...importsEnum, camel(n.list), "transaction", camel(n.update)].join(", ")} } from "${db}";
666
+ import { db } from "${pool}";
667
+ import { readGrid, type FormErrors, type FormValues } from "${grid}";
668
+ import { ${forma}, ${params}, ${values}, duplicate, type ${n.uno}Data } from "${resource}";
669
+
670
+ const PAGE = 50;
671
+ const COLUMNS = [${columnas}] as const;
672
+
673
+ export const title = "${titulo}";
674
+
675
+ /** The key from the form, or a 404 if it cannot be one. */
676
+ function key(id: string) {
677
+ if (!(${guarda})) throw notFound();
678
+ return ${valor};
679
+ }
680
+
681
+ export async function load(_params: Record<string, string>, { url }: LoadContext) {
682
+ const page = Math.max(1, Math.floor(Number(url.searchParams.get("page"))) || 1);
683
+ const rows = await ${camel(n.list)}(await db(), { limit: PAGE + 1, offset: (page - 1) * PAGE });
684
+ return {
685
+ page,
686
+ more: rows.length > PAGE,
687
+ rows: rows.slice(0, PAGE).map((row) => ({ key: String(row${acceso(pk)}), values: ${values}(row) })),
688
+ };
689
+ }
690
+
691
+ /** What a failed save sends back: the errors and what was written, by row key. */
692
+ interface Failed {
693
+ errors: Record<string, FormErrors>;
694
+ values: Record<string, FormValues>;
695
+ }
696
+
697
+ export const actions = {
698
+ async default({ formData, url }: ActionContext) {
699
+ const { rows, changed } = readGrid(formData, COLUMNS);
700
+ const values = Object.fromEntries(rows.map((row) => [row.key, row.values]));
701
+ const errors: Record<string, FormErrors> = {};
702
+ const valid: { key: string; data: ${n.uno}Data }[] = [];
703
+ for (const row of changed) {
704
+ const result = await validate(${forma}, row.values);
705
+ if (result.ok) valid.push({ key: row.key, data: result.data });
706
+ else errors[row.key] = result.errors;
707
+ }
708
+ if (Object.keys(errors).length) return fail(400, { errors, values } satisfies Failed);
709
+
710
+ let current = "";
711
+ try {
712
+ await transaction(await db(), async (tx) => {
713
+ for (const row of valid) {
714
+ current = row.key;
715
+ await ${camel(n.update)}(tx, { ...${params}(row.data), ${propiedad(pk)}: key(row.key) });
716
+ }
717
+ });
718
+ } catch (error) {
719
+ // A repeated UNIQUE value: the error goes under that row's cell.
720
+ const taken = duplicate(error, new FormData());
721
+ return fail(409, { errors: { [current]: taken.data.errors }, values } satisfies Failed);
722
+ }
723
+ throw redirect(url.pathname + url.search);
724
+ },
725
+ };
726
+
727
+ export default function ${n.plural}Grid({ data, form }: { data: Awaited<ReturnType<typeof load>>; form?: Failed }) {
728
+ const rows = data.rows.map((row, index) => ({
729
+ index,
730
+ key: row.key,
731
+ was: JSON.stringify(row.values),
732
+ values: form?.values[row.key] ?? row.values,
733
+ errors: form?.errors[row.key] ?? {},
734
+ }));
735
+ type Row = (typeof rows)[number];
736
+ return view\`
737
+ <section>
738
+ <h1>${titulo}</h1>
739
+ <p><a href="${base}">List</a> · <a href="${base}/new">New ${etiqueta(singular(tabla.name)).toLowerCase()}</a></p>
740
+ <form method="post">
741
+ <table class="grid">
742
+ <thead><tr>${cabeceras}</tr></thead>
743
+ <tbody>
744
+ <For each=\${() => rows} key=\${(r: Row) => r.key}
745
+ render=\${(r: Row) => view\`<tr>
746
+ <td><a href=\${\`${base}/\${r.key}\`}>\${r.key}</a><input type="hidden" name=\${\`k\${r.index}\`} value=\${r.key}><input type="hidden" name=\${\`w\${r.index}\`} value=\${r.was}><small class="error">\${r.errors[""] ?? ""}</small></td>
747
+ ${celdas}
748
+ </tr>\`}/>
749
+ </tbody>
750
+ </table>
751
+ <button>Save changes</button>
752
+ </form>
753
+ <nav>
754
+ <Show when=\${() => data.page > 1}><a href=\${\`?page=\${data.page - 1}\`}>Previous</a></Show>
755
+ <Show when=\${() => data.more}><a href=\${\`?page=\${data.page + 1}\`}>Next</a></Show>
756
+ </nav>
757
+ <style>
758
+ .grid input, .grid select { width: 100%; box-sizing: border-box; }
759
+ .grid input[type="checkbox"] { width: auto; }
760
+ .grid td { vertical-align: top; }
761
+ .error { display: block; color: crimson; }
762
+ </style>
763
+ </section>\`;
764
+ }
765
+ `;
766
+ }
767
+
768
+ /** `src/resources/admin.ts`: las tablas del admin. Se reescribe en cada `--admin`. */
769
+ export function datosAdmin(incluidas, saltadas) {
770
+ const filas = incluidas.map((x) => ` { name: ${JSON.stringify(x.name)}, label: ${JSON.stringify(etiqueta(x.name))}, path: ${JSON.stringify(x.path)} },`).join("\n");
771
+ // Los motivos vienen de la CLI, con \`nombres\` entre comillas invertidas: en la página, sin ellas.
772
+ const fuera = saltadas.map((x) => ` { name: ${JSON.stringify(x.name)}, reason: ${JSON.stringify(x.reason.replace(/\`/g, ""))} },`).join("\n");
773
+ return `// Written by \`ascua-scaffold --admin\` each time it runs: edits here are lost.
774
+
775
+ /** The tables the admin manages, in the order of the migrations. */
776
+ export const adminTables: readonly { name: string; label: string; path: string }[] = [
777
+ ${filas}
778
+ ];
779
+
780
+ /** The tables it left out, and why. */
781
+ export const skippedTables: readonly { name: string; reason: string }[] = [
782
+ ${fuera}
783
+ ];
784
+ `;
785
+ }
786
+
787
+ /** `src/routes/admin/_middleware.ts`: sin contraseña configurada, el admin no existe. */
788
+ export function guardiaAdmin() {
789
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
790
+ import { notFound, type Middleware } from "vite-plugin-ascua/site";
791
+
792
+ /**
793
+ * The admin asks for a user and a password (HTTP Basic): ADMIN_USER, "admin"
794
+ * by default, and ADMIN_PASSWORD. Without ADMIN_PASSWORD the admin does not
795
+ * exist: every page under /admin is a 404. Serve it over HTTPS only, and swap
796
+ * this for the site's own sign-in (passkeys, a session) when it has one.
797
+ */
798
+ const middleware: Middleware = ({ request, setHeaders }) => {
799
+ setHeaders({ "cache-control": "no-store", "x-robots-tag": "noindex" });
800
+ const password = process.env.ADMIN_PASSWORD;
801
+ if (!password) throw notFound();
802
+ const expected = \`Basic \${base64(\`\${process.env.ADMIN_USER || "admin"}:\${password}\`)}\`;
803
+ if (!same(request.headers.get("authorization") ?? "", expected)) {
804
+ return new Response("Sign in to the admin.", {
805
+ status: 401,
806
+ headers: { "www-authenticate": 'Basic realm="admin", charset="UTF-8"', "cache-control": "no-store" },
807
+ });
808
+ }
809
+ };
810
+
811
+ export default middleware;
812
+
813
+ /** UTF-8 to base64, the same on Node and on Workers. */
814
+ function base64(text: string): string {
815
+ return btoa(String.fromCharCode(...new TextEncoder().encode(text)));
816
+ }
817
+
818
+ /** Compares without stopping at the first difference: the time it takes does not tell how much matched. */
819
+ function same(a: string, b: string): boolean {
820
+ let diff = a.length ^ b.length;
821
+ for (let i = 0; i < Math.max(a.length, b.length); i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
822
+ return diff === 0;
823
+ }
824
+ `;
825
+ }
826
+
827
+ /** `src/routes/admin/_layout.ts`: la barra con las tablas. */
828
+ export function marcoAdmin(datos) {
829
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
830
+ import type { LayoutProps } from "vite-plugin-ascua/site";
831
+
832
+ import { adminTables } from "${datos}";
833
+
834
+ type AdminTable = (typeof adminTables)[number];
835
+
836
+ /** Every admin page: the tables on the side, the page on the right. */
837
+ export default function AdminLayout(props: LayoutProps) {
838
+ const frame = view\`
839
+ <div class="admin">
840
+ <nav>
841
+ <a href="/admin"><strong>Admin</strong></a>
842
+ <For each=\${() => adminTables} key=\${(t: AdminTable) => t.name}
843
+ render=\${(t: AdminTable) => view\`<a href=\${t.path} class:current=\${props.path.startsWith(t.path)}>\${t.label}</a>\`}/>
844
+ </nav>
845
+ <div class="page"></div>
846
+ <style>
847
+ .admin { display: grid; grid-template-columns: 12rem 1fr; gap: 1.5rem; }
848
+ .admin nav { display: grid; align-content: start; gap: .25rem; }
849
+ .admin nav .current { font-weight: bold; }
850
+ @media (max-width: 40rem) { .admin { grid-template-columns: 1fr; } }
851
+ </style>
852
+ </div>\`;
853
+ props.children(frame.querySelector(".page")!);
854
+ return frame;
855
+ }
856
+ `;
857
+ }
858
+
859
+ /** `src/routes/admin/index.ts`: el panel. */
860
+ export function panelAdmin(datos) {
861
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
862
+ import { adminTables, skippedTables } from "${datos}";
863
+
864
+ export const title = "Admin";
865
+
866
+ type AdminTable = (typeof adminTables)[number];
867
+ type Skipped = (typeof skippedTables)[number];
868
+
869
+ export default function Admin() {
870
+ return view\`
871
+ <section>
872
+ <h1>Admin</h1>
873
+ <table>
874
+ <thead><tr><th>Table</th><th></th></tr></thead>
875
+ <tbody>
876
+ <For each=\${() => adminTables} key=\${(t: AdminTable) => t.name}
877
+ render=\${(t: AdminTable) => view\`<tr><td><a href=\${t.path}>\${t.label}</a></td><td><a href=\${\`\${t.path}/new\`}>New</a> · <a href=\${\`\${t.path}/grid\`}>Grid</a></td></tr>\`}/>
878
+ </tbody>
879
+ </table>
880
+ <Show when=\${() => skippedTables.length > 0}>
881
+ <h2>Left out</h2>
882
+ <ul>
883
+ <For each=\${() => skippedTables} key=\${(t: Skipped) => t.name}
884
+ render=\${(t: Skipped) => view\`<li><code>\${t.name}</code>: \${t.reason}</li>\`}/>
885
+ </ul>
886
+ </Show>
887
+ </section>\`;
888
+ }
889
+ `;
890
+ }
891
+
557
892
  /** `src/pool.ts`: un pool para todo el servidor, con las migraciones aplicadas una vez. */
558
893
  export function piscina({ mysql, migrate, db }) {
559
894
  if (mysql) {
package/index.js CHANGED
@@ -16,10 +16,11 @@ import { createRequire } from "node:module";
16
16
  import { dirname, join, relative, resolve } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
 
19
- import { analizar, consultasSql, nombres, piscina, recurso, rutas, ScaffoldError, t } from "./generate.js";
19
+ import { analizar, ayudaRejilla, consultasSql, datosAdmin, guardiaAdmin, marcoAdmin, nombres, panelAdmin, piscina, recurso, rejilla, rutas, ScaffoldError, t } from "./generate.js";
20
20
 
21
21
  const AYUDA = t(
22
22
  `Uso: ascua-scaffold <tabla> [opciones]
23
+ ascua-scaffold --admin [opciones]
23
24
 
24
25
  Genera, a partir de una tabla de las migraciones de brasa:
25
26
  db/queries/<tabla>.sql List, Get, Create, Update y Delete
@@ -27,13 +28,17 @@ Genera, a partir de una tabla de las migraciones de brasa:
27
28
  src/routes/<tabla>/index.ts la lista, paginada
28
29
  src/routes/<tabla>/new.ts el alta
29
30
  src/routes/<tabla>/[id].ts la edición y la baja
31
+ src/routes/<tabla>/grid.ts todas las filas editables a la vez
32
+ src/resources/grid.ts leer lo que manda una grilla, si no existe
30
33
  src/pool.ts el pool de la base, si no existe
31
34
 
32
35
  Opciones:
33
36
  --config <ruta> el brasa.json (por defecto, el del directorio actual)
37
+ --admin todas las tablas bajo /admin, con su panel; pide ADMIN_PASSWORD
34
38
  --force reescribe los archivos que ya existan
35
39
  -h, --help esta ayuda`,
36
40
  `Usage: ascua-scaffold <table> [options]
41
+ ascua-scaffold --admin [options]
37
42
 
38
43
  Generates, from a table in the brasa migrations:
39
44
  db/queries/<table>.sql List, Get, Create, Update and Delete
@@ -41,10 +46,13 @@ Generates, from a table in the brasa migrations:
41
46
  src/routes/<table>/index.ts the list, paginated
42
47
  src/routes/<table>/new.ts creating
43
48
  src/routes/<table>/[id].ts editing and deleting
49
+ src/routes/<table>/grid.ts every row editable at once
50
+ src/resources/grid.ts reading what a grid sends, if missing
44
51
  src/pool.ts the database pool, if missing
45
52
 
46
53
  Options:
47
54
  --config <path> the brasa.json (default: the one in the current directory)
55
+ --admin every table under /admin, with a dashboard; asks for ADMIN_PASSWORD
48
56
  --force overwrite files that already exist
49
57
  -h, --help this help`,
50
58
  );
@@ -53,11 +61,12 @@ const POR_DEFECTO = { dialect: "postgres", migrations: "db/migrations", queries:
53
61
 
54
62
  /** `["posts", "--force"]` → `{ table: "posts", force: true }`. */
55
63
  export function leerArgumentos(argv) {
56
- const salida = { table: undefined, config: "brasa.json", force: false, ayuda: false };
64
+ const salida = { table: undefined, config: "brasa.json", force: false, admin: false, ayuda: false };
57
65
  for (let i = 0; i < argv.length; i++) {
58
66
  const a = argv[i];
59
67
  if (a === "-h" || a === "--help") salida.ayuda = true;
60
68
  else if (a === "--force") salida.force = true;
69
+ else if (a === "--admin") salida.admin = true;
61
70
  else if (a === "--config") salida.config = argv[++i];
62
71
  else if (a.startsWith("--config=")) salida.config = a.slice("--config=".length);
63
72
  else if (!a.startsWith("-") && salida.table === undefined) salida.table = a;
@@ -95,123 +104,226 @@ function importar(desde, hacia) {
95
104
  return r;
96
105
  }
97
106
 
98
- export async function main(argv = process.argv.slice(2)) {
99
- const args = leerArgumentos(argv);
100
- if (args.ayuda || !args.table) {
101
- console.log(AYUDA);
102
- return args.ayuda ? 0 : 1;
103
- }
104
-
107
+ /** brasa.json, el esquema y el brasa del proyecto. */
108
+ async function proyecto(args) {
105
109
  const rutaConfig = resolve(args.config);
106
110
  const raiz = dirname(rutaConfig);
107
111
  if (!existsSync(rutaConfig)) {
108
112
  throw new ScaffoldError(t(`no existe ${rutaConfig}: crea uno con \`brasa init\``, `${rutaConfig} does not exist: create one with \`brasa init\``));
109
113
  }
110
114
  const config = { ...POR_DEFECTO, ...JSON.parse(readFileSync(rutaConfig, "utf8")) };
111
- const mysql = config.dialect === "mysql";
112
- const dirMigraciones = resolve(raiz, config.migrations);
113
- const dirConsultas = resolve(raiz, config.queries);
114
- const out = resolve(raiz, config.out);
115
-
116
115
  const brasa = await cargarBrasa(raiz);
117
- const migrations = leerSql(dirMigraciones);
118
- const queries = leerSql(dirConsultas);
119
- const salida = brasa.compile({ dialect: config.dialect, migrate: config.migrate, migrations, queries });
116
+ const dirConsultas = resolve(raiz, config.queries);
117
+ const salida = brasa.compile({
118
+ dialect: config.dialect,
119
+ migrate: config.migrate,
120
+ migrations: leerSql(resolve(raiz, config.migrations)),
121
+ queries: leerSql(dirConsultas),
122
+ });
120
123
  if (salida.code === null) {
121
124
  throw new ScaffoldError(t("el esquema o las consultas tienen errores: corre `brasa generate` para verlos", "the schema or the queries have errors: run `brasa generate` to see them"));
122
125
  }
123
126
  if (!salida.tables) {
124
127
  throw new ScaffoldError(t("este brasa-sql no describe las tablas: actualízalo", "this brasa-sql does not describe tables: update it"));
125
128
  }
129
+ return {
130
+ rutaConfig,
131
+ raiz,
132
+ config,
133
+ brasa,
134
+ salida,
135
+ dirConsultas,
136
+ mysql: config.dialect === "mysql",
137
+ out: resolve(raiz, config.out),
138
+ pool: join(raiz, "src", "pool.ts"),
139
+ ayuda: join(raiz, "src", "resources", "grid.ts"),
140
+ };
141
+ }
126
142
 
127
- const tabla = salida.tables.find((x) => x.name === args.table) ?? salida.tables.find((x) => x.name.toLowerCase() === args.table.toLowerCase());
128
- if (!tabla) {
129
- const hay = salida.tables.filter((x) => !x.view).map((x) => x.name);
130
- throw new ScaffoldError(
131
- t(`no hay una tabla \`${args.table}\` en las migraciones. Hay: ${hay.join(", ") || "ninguna"}`, `there is no \`${args.table}\` table in the migrations. There are: ${hay.join(", ") || "none"}`),
132
- );
133
- }
134
-
135
- const a = analizar(tabla, { mysql });
143
+ /**
144
+ * Los archivos de una tabla y lo que llevan. Lanza `ScaffoldError` si no se
145
+ * puede generar. Con `reutilizar`, una tabla que ya tiene sus cinco consultas
146
+ * y su `resources/<tabla>.ts` los conserva y solo recibe rutas nuevas.
147
+ */
148
+ function planTabla(p, tabla, { prefijo, force, reutilizar }) {
149
+ const a = analizar(tabla, { mysql: p.mysql });
136
150
  const n = nombres(tabla);
137
- // Con --force, las del archivo que se va a reescribir no cuentan.
138
- const propio = join(dirConsultas, `${tabla.name}.sql`);
139
- const ajenas = salida.queries.filter((x) => !(args.force && resolve(x.file) === propio));
140
- const repetidas = [n.list, n.get, n.create, n.update, n.delete].filter((q) => ajenas.some((x) => x.name === q));
141
- const enPropio = repetidas.filter((q) => salida.queries.some((x) => x.name === q && resolve(x.file) === propio));
142
- if (repetidas.length && enPropio.length === repetidas.length) {
143
- // Las del ejemplo de `brasa init`, por ejemplo: están en el archivo que se va a escribir.
144
- const archivo = relative(process.cwd(), propio);
145
- throw new ScaffoldError(
146
- t(`${archivo} ya tiene ${repetidas.join(", ")}. Con --force se reemplaza ese archivo`, `${archivo} already has ${repetidas.join(", ")}. Use --force to replace that file`),
147
- );
148
- }
149
- if (repetidas.length) {
150
- throw new ScaffoldError(
151
- t(`ya hay consultas con estos nombres: ${repetidas.join(", ")}. Quita esas o genera otra tabla`, `queries with these names already exist: ${repetidas.join(", ")}. Remove them or scaffold another table`),
152
- );
153
- }
154
-
155
151
  const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
152
+ if (carpeta === "grid") {
153
+ throw new ScaffoldError(t("una tabla `grid` chocaría con src/resources/grid.ts, que comparten las grillas", "a `grid` table would clash with src/resources/grid.ts, which the grids share"));
154
+ }
156
155
  // Los enums de Postgres tienen su `<x>Values` en el módulo; los de MySQL
157
156
  // (`ENUM('a','b')` en la columna), no: sus valores van escritos.
158
- const enums = new Set(mysql ? [] : tabla.columns.filter((c) => c.kind === "enum").map((c) => c.sqlType));
157
+ const enums = new Set(p.mysql ? [] : tabla.columns.filter((c) => c.kind === "enum").map((c) => c.sqlType));
159
158
 
159
+ const sql = join(p.dirConsultas, `${tabla.name}.sql`);
160
+ const archivoRecurso = join(p.raiz, "src", "resources", `${carpeta}.ts`);
161
+ const consultas = [n.list, n.get, n.create, n.update, n.delete];
162
+ const hay = consultas.filter((q) => p.salida.queries.some((x) => x.name === q));
163
+ const reusa = reutilizar && !force && hay.length === consultas.length && existsSync(archivoRecurso);
164
+
165
+ if (!reusa) {
166
+ // Con --force, las del archivo que se va a reescribir no cuentan.
167
+ const ajenas = p.salida.queries.filter((x) => !(force && resolve(x.file) === sql));
168
+ const repetidas = consultas.filter((q) => ajenas.some((x) => x.name === q));
169
+ const enPropio = repetidas.filter((q) => p.salida.queries.some((x) => x.name === q && resolve(x.file) === sql));
170
+ if (repetidas.length && enPropio.length === repetidas.length) {
171
+ // Las del ejemplo de `brasa init`, por ejemplo: están en el archivo que se va a escribir.
172
+ const archivo = relative(process.cwd(), sql);
173
+ throw new ScaffoldError(t(`${archivo} ya tiene ${repetidas.join(", ")}. Con --force se reemplaza ese archivo`, `${archivo} already has ${repetidas.join(", ")}. Use --force to replace that file`));
174
+ }
175
+ if (repetidas.length) {
176
+ throw new ScaffoldError(t(`ya hay consultas con estos nombres: ${repetidas.join(", ")}. Quita esas o genera otra tabla`, `queries with these names already exist: ${repetidas.join(", ")}. Remove them or scaffold another table`));
177
+ }
178
+ }
179
+
180
+ const dirRutas = join(p.raiz, "src", "routes", ...(prefijo ? [prefijo] : []), carpeta);
181
+ const base = `${prefijo ? `/${prefijo}` : ""}/${carpeta}`;
160
182
  const archivos = {
161
- sql: join(dirConsultas, `${tabla.name}.sql`),
162
- recurso: join(raiz, "src", "resources", `${carpeta}.ts`),
163
- index: join(raiz, "src", "routes", carpeta, "index.ts"),
164
- nuevo: join(raiz, "src", "routes", carpeta, "new.ts"),
165
- editar: join(raiz, "src", "routes", carpeta, "[id].ts"),
166
- pool: join(raiz, "src", "pool.ts"),
183
+ sql,
184
+ recurso: archivoRecurso,
185
+ index: join(dirRutas, "index.ts"),
186
+ nuevo: join(dirRutas, "new.ts"),
187
+ editar: join(dirRutas, "[id].ts"),
188
+ rejilla: join(dirRutas, "grid.ts"),
167
189
  };
168
- const existentes = Object.entries(archivos).filter(([k, f]) => k !== "pool" && existsSync(f));
169
- if (existentes.length && !args.force) {
170
- throw new ScaffoldError(
171
- t(`ya existen: ${existentes.map(([, f]) => relative(raiz, f)).join(", ")}. Usa --force para reescribirlos`, `already exist: ${existentes.map(([, f]) => relative(raiz, f)).join(", ")}. Use --force to overwrite them`),
172
- );
190
+ if (reusa) {
191
+ delete archivos.sql;
192
+ delete archivos.recurso;
193
+ }
194
+ const existentes = Object.values(archivos).filter((f) => existsSync(f));
195
+ if (existentes.length && !force) {
196
+ const lista = existentes.map((f) => relative(p.raiz, f)).join(", ");
197
+ throw new ScaffoldError(t(`ya existen: ${lista}. Usa --force para reescribirlos`, `already exist: ${lista}. Use --force to overwrite them`));
173
198
  }
174
199
 
175
200
  const r = rutas(tabla, a, n, {
176
- db: { index: importar(archivos.index, out), nested: importar(archivos.nuevo, out) },
177
- pool: { index: importar(archivos.index, archivos.pool), nested: importar(archivos.nuevo, archivos.pool) },
178
- resource: { nested: importar(archivos.nuevo, archivos.recurso) },
179
- base: `/${carpeta}`,
201
+ db: { index: importar(archivos.index, p.out), nested: importar(archivos.nuevo, p.out) },
202
+ pool: { index: importar(archivos.index, p.pool), nested: importar(archivos.nuevo, p.pool) },
203
+ resource: { nested: importar(archivos.nuevo, archivoRecurso) },
204
+ base,
180
205
  });
181
206
  const contenidos = {
182
- sql: consultasSql(tabla, a, n),
183
- recurso: recurso(tabla, a, n, { db: importar(archivos.recurso, out), enums }),
184
207
  index: r.index,
185
208
  nuevo: r.nuevo,
186
209
  editar: r.editar,
210
+ rejilla: rejilla(tabla, a, n, {
211
+ db: importar(archivos.rejilla, p.out),
212
+ pool: importar(archivos.rejilla, p.pool),
213
+ resource: importar(archivos.rejilla, archivoRecurso),
214
+ grid: importar(archivos.rejilla, p.ayuda),
215
+ base,
216
+ enums,
217
+ }),
187
218
  };
188
- const escritos = [];
189
- for (const [k, contenido] of Object.entries(contenidos)) {
190
- mkdirSync(dirname(archivos[k]), { recursive: true });
191
- writeFileSync(archivos[k], contenido);
192
- escritos.push(archivos[k]);
219
+ if (!reusa) {
220
+ contenidos.sql = consultasSql(tabla, a, n);
221
+ contenidos.recurso = recurso(tabla, a, n, { db: importar(archivoRecurso, p.out), enums });
193
222
  }
194
- if (!existsSync(archivos.pool)) {
195
- writeFileSync(archivos.pool, piscina({ mysql, migrate: config.migrate, db: importar(archivos.pool, out) }));
196
- escritos.push(archivos.pool);
223
+ return { tabla: tabla.name, base, archivos, contenidos, reusa };
224
+ }
225
+
226
+ /** Escribe los planes y lo común (el pool, el ayudante de la grilla); devuelve lo escrito. */
227
+ function escribir(p, planes, extra = {}) {
228
+ const escritos = [];
229
+ const poner = (archivo, contenido) => {
230
+ mkdirSync(dirname(archivo), { recursive: true });
231
+ writeFileSync(archivo, contenido);
232
+ escritos.push(archivo);
233
+ };
234
+ for (const plan of planes) {
235
+ for (const [k, contenido] of Object.entries(plan.contenidos)) poner(plan.archivos[k], contenido);
197
236
  }
237
+ for (const [archivo, contenido] of Object.entries(extra)) poner(archivo, contenido);
238
+ if (!existsSync(p.ayuda)) poner(p.ayuda, ayudaRejilla());
239
+ if (!existsSync(p.pool)) poner(p.pool, piscina({ mysql: p.mysql, migrate: p.config.migrate, db: importar(p.pool, p.out) }));
240
+ return escritos;
241
+ }
198
242
 
199
- // El módulo de brasa, con las consultas nuevas.
200
- const generado = spawnSync(process.execPath, [brasa.bin, "generate", "--config", rutaConfig], { cwd: raiz, stdio: "inherit" });
243
+ /** El módulo de brasa con las consultas nuevas, y lo que falta en el proyecto. */
244
+ function terminar(p, escritos, listo) {
245
+ const generado = spawnSync(process.execPath, [p.brasa.bin, "generate", "--config", p.rutaConfig], { cwd: p.raiz, stdio: "inherit" });
201
246
  if (generado.status !== 0) {
202
247
  throw new ScaffoldError(t("brasa no pudo generar el módulo con las consultas nuevas (arriba, el error)", "brasa could not generate the module with the new queries (the error is above)"));
203
248
  }
204
-
205
249
  for (const f of escritos) console.log(` ${relative(process.cwd(), f)}`);
206
- const paquete = existsSync(join(raiz, "package.json")) ? JSON.parse(readFileSync(join(raiz, "package.json"), "utf8")) : {};
250
+ const paquete = existsSync(join(p.raiz, "package.json")) ? JSON.parse(readFileSync(join(p.raiz, "package.json"), "utf8")) : {};
207
251
  const deps = { ...paquete.dependencies, ...paquete.devDependencies };
208
- const faltan = (mysql ? ["mysql2"] : ["pg", "@types/pg"]).concat("@types/node").filter((d) => !deps[d]);
252
+ const faltan = (p.mysql ? ["mysql2"] : ["pg", "@types/pg"]).concat("@types/node").filter((d) => !deps[d]);
209
253
  console.log("");
210
- console.log(t(`Listo: /${carpeta} en el sitio.`, `Done: /${carpeta} on the site.`));
254
+ console.log(listo);
211
255
  if (faltan.length) console.log(t(`Falta instalar: stil add ${faltan.join(" ")}`, `Still to install: npm i ${faltan.join(" ")}`));
212
- if (!/mode:\s*["']server["']/.test(leer(join(raiz, "vite.config.ts")))) {
256
+ if (!/mode:\s*["']server["']/.test(leer(join(p.raiz, "vite.config.ts")))) {
213
257
  console.log(t('Las acciones necesitan servidor: ascua({ site: { mode: "server" } }) en vite.config.ts', 'Actions need a server: ascua({ site: { mode: "server" } }) in vite.config.ts'));
214
258
  }
259
+ }
260
+
261
+ /** `--admin`: todas las tablas bajo /admin, con su panel y su contraseña. */
262
+ function admin(p, args) {
263
+ const planes = [];
264
+ const saltadas = [];
265
+ const conservadas = [];
266
+ for (const tabla of p.salida.tables) {
267
+ // Una tabla que ya tiene su página en el admin se queda como está.
268
+ const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
269
+ if (!args.force && existsSync(join(p.raiz, "src", "routes", "admin", carpeta, "index.ts"))) {
270
+ conservadas.push({ name: tabla.name, path: `/admin/${carpeta}` });
271
+ continue;
272
+ }
273
+ try {
274
+ planes.push(planTabla(p, tabla, { prefijo: "admin", force: args.force, reutilizar: true }));
275
+ } catch (error) {
276
+ if (!(error instanceof ScaffoldError)) throw error;
277
+ saltadas.push({ name: tabla.name, reason: error.message });
278
+ }
279
+ }
280
+ if (planes.length === 0 && conservadas.length === 0) {
281
+ const motivos = saltadas.map((x) => `\n ${x.name}: ${x.reason}`).join("");
282
+ throw new ScaffoldError(t(`ninguna tabla se pudo generar:${motivos}`, `no table could be scaffolded:${motivos}`));
283
+ }
284
+
285
+ const dir = join(p.raiz, "src", "routes", "admin");
286
+ const datos = join(p.raiz, "src", "resources", "admin.ts");
287
+ // En el orden de las migraciones, las de antes y las nuevas.
288
+ const orden = p.salida.tables.map((x) => x.name);
289
+ const incluidas = [...conservadas, ...planes.map((x) => ({ name: x.tabla, path: x.base }))].sort((a, b) => orden.indexOf(a.name) - orden.indexOf(b.name));
290
+ const extra = { [datos]: datosAdmin(incluidas, saltadas) };
291
+ const propios = {
292
+ [join(dir, "_middleware.ts")]: guardiaAdmin(),
293
+ [join(dir, "_layout.ts")]: marcoAdmin(importar(join(dir, "_layout.ts"), datos)),
294
+ [join(dir, "index.ts")]: panelAdmin(importar(join(dir, "index.ts"), datos)),
295
+ };
296
+ // El panel, la barra y la contraseña son tuyos: se escriben si faltan.
297
+ for (const [archivo, contenido] of Object.entries(propios)) {
298
+ if (args.force || !existsSync(archivo)) extra[archivo] = contenido;
299
+ }
300
+
301
+ const escritos = escribir(p, planes, extra);
302
+ terminar(p, escritos, t(`Listo: /admin con ${incluidas.length} tablas (${planes.length} nuevas).`, `Done: /admin with ${incluidas.length} tables (${planes.length} new).`));
303
+ for (const plan of planes.filter((x) => x.reusa)) {
304
+ console.log(t(` ${plan.tabla}: usa las consultas y el formulario que ya tenía`, ` ${plan.tabla}: uses the queries and form it already had`));
305
+ }
306
+ for (const x of saltadas) console.log(t(` fuera: ${x.name}: ${x.reason}`, ` left out: ${x.name}: ${x.reason}`));
307
+ console.log(t("Pide usuario y contraseña: define ADMIN_PASSWORD (y ADMIN_USER, «admin» por defecto). Sin ella, /admin es un 404.", "It asks for a user and password: set ADMIN_PASSWORD (and ADMIN_USER, \"admin\" by default). Without it, /admin is a 404."));
308
+ return 0;
309
+ }
310
+
311
+ export async function main(argv = process.argv.slice(2)) {
312
+ const args = leerArgumentos(argv);
313
+ if (args.ayuda || (!args.table && !args.admin)) {
314
+ console.log(AYUDA);
315
+ return args.ayuda ? 0 : 1;
316
+ }
317
+ const p = await proyecto(args);
318
+ if (args.admin) return admin(p, args);
319
+
320
+ const tabla = p.salida.tables.find((x) => x.name === args.table) ?? p.salida.tables.find((x) => x.name.toLowerCase() === args.table.toLowerCase());
321
+ if (!tabla) {
322
+ const hay = p.salida.tables.filter((x) => !x.view).map((x) => x.name);
323
+ throw new ScaffoldError(t(`no hay una tabla \`${args.table}\` en las migraciones. Hay: ${hay.join(", ") || "ninguna"}`, `there is no \`${args.table}\` table in the migrations. There are: ${hay.join(", ") || "none"}`));
324
+ }
325
+ const plan = planTabla(p, tabla, { prefijo: "", force: args.force, reutilizar: false });
326
+ terminar(p, escribir(p, [plan]), t(`Listo: ${plan.base} en el sitio.`, `Done: ${plan.base} on the site.`));
215
327
  return 0;
216
328
  }
217
329
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ascua-scaffold",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "De una tabla de brasa a un recurso de Ascua: consultas, formulario validado y rutas para listar, crear, editar y borrar.",
5
5
  "keywords": ["ascua", "scaffold", "crud", "brasa", "sql", "forms"],
6
6
  "homepage": "https://ascua.gitweave.run",