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.
- package/README.md +43 -0
- package/generate.js +336 -1
- package/index.js +189 -77
- 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
|
-
|
|
99
|
-
|
|
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
|
|
118
|
-
const
|
|
119
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
162
|
-
recurso:
|
|
163
|
-
index: join(
|
|
164
|
-
nuevo: join(
|
|
165
|
-
editar: join(
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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,
|
|
178
|
-
resource: { nested: importar(archivos.nuevo,
|
|
179
|
-
base
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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(
|
|
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.
|
|
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",
|