ascua-scaffold 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +86 -2
- package/generate.js +619 -0
- package/index.js +225 -0
- package/package.json +23 -4
package/LICENSE-APACHE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/LICENSE-MIT
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Los autores de Ascua
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ascua-scaffold
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
De una tabla a un recurso completo de [Ascua](https://ascua.gitweave.run):
|
|
4
|
+
lista paginada, alta, edición y baja, con el formulario validado en el
|
|
5
|
+
servidor. Un comando, como en Rails o Django, pero con SQL de verdad: lee el
|
|
6
|
+
esquema de tus migraciones con [brasa](https://www.npmjs.com/package/brasa-sql).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
stil add -D ascua-scaffold brasa-sql # o npm i -D ascua-scaffold brasa-sql
|
|
10
|
+
stil add pg # o mysql2, con "dialect": "mysql"
|
|
11
|
+
stil exec ascua-scaffold products # o npx ascua-scaffold products
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Con esta migración:
|
|
15
|
+
|
|
16
|
+
```sql
|
|
17
|
+
CREATE TYPE category AS ENUM ('tools', 'books', 'garden');
|
|
18
|
+
|
|
19
|
+
CREATE TABLE products (
|
|
20
|
+
id serial PRIMARY KEY,
|
|
21
|
+
sku text NOT NULL UNIQUE,
|
|
22
|
+
name text NOT NULL,
|
|
23
|
+
description text,
|
|
24
|
+
price numeric(10, 2) NOT NULL,
|
|
25
|
+
active boolean NOT NULL DEFAULT true,
|
|
26
|
+
category category NOT NULL DEFAULT 'tools',
|
|
27
|
+
released_on date,
|
|
28
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
29
|
+
);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
escribe:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
db/queries/products.sql ListProducts, GetProduct, CreateProduct, UpdateProduct, DeleteProduct
|
|
36
|
+
src/resources/products.ts el esquema del formulario, sus campos y las conversiones
|
|
37
|
+
src/routes/products/index.ts /products, de 50 en 50
|
|
38
|
+
src/routes/products/new.ts /products/new
|
|
39
|
+
src/routes/products/[id].ts /products/:id, editar y borrar (?/delete)
|
|
40
|
+
src/pool.ts el pool, con las migraciones aplicadas una vez (si no existe)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
y vuelve a generar el módulo de brasa con las consultas nuevas.
|
|
44
|
+
|
|
45
|
+
## Qué genera
|
|
46
|
+
|
|
47
|
+
- **Consultas en SQL**, en un archivo tuyo: cámbialas como quieras (un
|
|
48
|
+
`WHERE`, otro orden, un `JOIN`) y `brasa generate` revisa que sigan
|
|
49
|
+
valiendo. Llevan parámetros `:nombre`, que sirven en Postgres y en MySQL.
|
|
50
|
+
- **Un formulario por columna**, según su tipo: `field.number` para números,
|
|
51
|
+
`field.checkbox` para booleanos, un `<select>` con `field.choice` para un
|
|
52
|
+
enum, `type="date"` para fechas, `field.email` para una columna que se
|
|
53
|
+
llame `email`, `<textarea>` para `description`, `body` o `notes`. Lo
|
|
54
|
+
nullable es opcional y vacío se guarda como `NULL`.
|
|
55
|
+
- **Formularios de HTML**, sin islas: funcionan sin una línea de JavaScript.
|
|
56
|
+
Un error de validación vuelve a la página con un 400, cada mensaje bajo su
|
|
57
|
+
campo y lo escrito en su sitio. Un `UNIQUE` repetido, con un 409 y el error
|
|
58
|
+
bajo esa columna, no con un 500.
|
|
59
|
+
- **La clave de la URL se valida** antes de llegar a la base: `/products/abc`
|
|
60
|
+
es un 404. Un `bigint` entra como texto, para no perder precisión.
|
|
61
|
+
|
|
62
|
+
Lo que el formulario no sabe editar (JSON, binarios, arreglos) y las columnas
|
|
63
|
+
`created_at` o `updated_at` con `DEFAULT` quedan fuera. Si una columna así es
|
|
64
|
+
obligatoria y no tiene `DEFAULT`, el comando lo dice y no genera nada.
|
|
65
|
+
|
|
66
|
+
Lo generado es tuyo: no se vuelve a generar. Con `--force` se reescribe
|
|
67
|
+
encima.
|
|
68
|
+
|
|
69
|
+
## Requisitos
|
|
70
|
+
|
|
71
|
+
- Un sitio con servidor: `ascua({ site: { mode: "server" } })` en
|
|
72
|
+
`vite.config.ts` (`create-ascua --template server`).
|
|
73
|
+
- `brasa.json` y las migraciones de brasa (`brasa init`).
|
|
74
|
+
- Una tabla con clave primaria de una sola columna: entero, texto o uuid. Si
|
|
75
|
+
es `serial` o tiene `DEFAULT`, la pone la base; si no, se pide al crear y
|
|
76
|
+
no se cambia al editar.
|
|
77
|
+
- La conexión sale de `DATABASE_URL`.
|
|
78
|
+
|
|
79
|
+
## Opciones
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
ascua-scaffold <tabla> [--config brasa.json] [--force]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Licencia
|
|
86
|
+
|
|
87
|
+
MIT o Apache-2.0, a elección.
|
package/generate.js
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* De una tabla del esquema de brasa a los archivos de un recurso: consultas,
|
|
3
|
+
* esquema del formulario y rutas. Sin E/S: recibe la tabla y devuelve
|
|
4
|
+
* `{ ruta: contenido }`, para poder probarlo sin disco ni base.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const español = /^es/i.test(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || "");
|
|
8
|
+
export const t = (es, en) => (español ? es : en);
|
|
9
|
+
|
|
10
|
+
/** Lo que el formulario sabe editar; el resto (json, binarios, arreglos…) se deja fuera. */
|
|
11
|
+
const EDITABLES = new Set(["integer", "decimal", "number", "boolean", "text", "uuid", "date", "datetime", "time", "enum"]);
|
|
12
|
+
|
|
13
|
+
/** Columnas de auditoría que la base llena sola: con DEFAULT, no van al formulario. */
|
|
14
|
+
const AUTOMATICAS = new Set(["created_at", "updated_at", "inserted_at", "createdAt", "updatedAt"]);
|
|
15
|
+
|
|
16
|
+
/** Columnas de texto largo: `<textarea>` en vez de `<input>`. */
|
|
17
|
+
const LARGAS = /^(body|content|description|bio|notes?|text|summary|comment)$/i;
|
|
18
|
+
|
|
19
|
+
/** Palabras reservadas que no pueden ir sin comillas como nombre de columna o tabla. */
|
|
20
|
+
const RESERVADAS = new Set(
|
|
21
|
+
"all and any as asc both case check column constraint create default desc distinct do else end false for foreign from grant group having in into is key limit not null offset on or order primary references select table then to true union unique user using when where with".split(
|
|
22
|
+
" ",
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/** `list_posts` → `["list", "posts"]`. */
|
|
27
|
+
function palabras(n) {
|
|
28
|
+
return n
|
|
29
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
30
|
+
.split(/[^A-Za-z0-9]+/)
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((p) => p.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const pascal = (n) => palabras(n).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
|
|
36
|
+
export const camel = (n) => {
|
|
37
|
+
const p = pascal(n);
|
|
38
|
+
return p[0].toLowerCase() + p.slice(1);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Singular en inglés, lo justo para nombres de tablas: posts → post, categories → category. */
|
|
42
|
+
export function singular(n) {
|
|
43
|
+
if (/ies$/i.test(n)) return n.slice(0, -3) + "y";
|
|
44
|
+
if (/(ss|us|is)$/i.test(n)) return n;
|
|
45
|
+
if (/(x|ch|sh|ss)es$/i.test(n)) return n.slice(0, -2);
|
|
46
|
+
if (/s$/i.test(n)) return n.slice(0, -1);
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `published_at` → `Published at`. */
|
|
51
|
+
function etiqueta(n) {
|
|
52
|
+
const p = palabras(n).join(" ");
|
|
53
|
+
return p[0].toUpperCase() + p.slice(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** El nombre en SQL, con comillas solo si hace falta. */
|
|
57
|
+
function sqlNombre(n, mysql) {
|
|
58
|
+
const simple = mysql ? /^[A-Za-z_][A-Za-z0-9_]*$/ : /^[a-z_][a-z0-9_]*$/;
|
|
59
|
+
if (simple.test(n) && !RESERVADAS.has(n.toLowerCase())) return n;
|
|
60
|
+
return mysql ? "`" + n.replace(/`/g, "``") + "`" : '"' + n.replace(/"/g, '""') + '"';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const esIdentificador = (n) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(n);
|
|
64
|
+
const propiedad = (n) => (esIdentificador(n) ? n : JSON.stringify(n));
|
|
65
|
+
const acceso = (n) => (esIdentificador(n) ? `.${n}` : `[${JSON.stringify(n)}]`);
|
|
66
|
+
|
|
67
|
+
/** Error con un mensaje para quien usa la CLI, no una traza. */
|
|
68
|
+
export class ScaffoldError extends Error {}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Qué columnas van en cada parte del recurso. Lanza `ScaffoldError` si la
|
|
72
|
+
* tabla no se puede generar.
|
|
73
|
+
*/
|
|
74
|
+
export function analizar(tabla, { mysql }) {
|
|
75
|
+
if (tabla.view) throw new ScaffoldError(t(`\`${tabla.name}\` es una vista: no se puede editar`, `\`${tabla.name}\` is a view: it cannot be edited`));
|
|
76
|
+
if (tabla.primaryKey.length !== 1) {
|
|
77
|
+
throw new ScaffoldError(
|
|
78
|
+
tabla.primaryKey.length === 0
|
|
79
|
+
? t(`\`${tabla.name}\` no tiene clave primaria`, `\`${tabla.name}\` has no primary key`)
|
|
80
|
+
: t(`\`${tabla.name}\` tiene una clave primaria compuesta; por ahora solo se genera con una columna`, `\`${tabla.name}\` has a composite primary key; only single-column keys are supported for now`),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const clave = tabla.columns.find((c) => c.name === tabla.primaryKey[0]);
|
|
84
|
+
if (!["integer", "text", "uuid"].includes(clave.kind) || clave.array) {
|
|
85
|
+
throw new ScaffoldError(t(`la clave primaria \`${clave.name}\` es ${clave.sqlType}: se admite entero, texto o uuid`, `primary key \`${clave.name}\` is ${clave.sqlType}: integer, text or uuid are supported`));
|
|
86
|
+
}
|
|
87
|
+
const claveAutomatica = clave.hasDefault;
|
|
88
|
+
|
|
89
|
+
const editables = [];
|
|
90
|
+
const fuera = [];
|
|
91
|
+
for (const c of tabla.columns) {
|
|
92
|
+
if (c.generated) continue;
|
|
93
|
+
if (c === clave && claveAutomatica) continue;
|
|
94
|
+
if (c.hasDefault && AUTOMATICAS.has(c.name)) continue;
|
|
95
|
+
if (!EDITABLES.has(c.kind) || c.array) {
|
|
96
|
+
if (!c.nullable && !c.hasDefault) {
|
|
97
|
+
throw new ScaffoldError(
|
|
98
|
+
t(
|
|
99
|
+
`la columna \`${c.name}\` (${c.sqlType}) es obligatoria y el formulario no sabe editarla; dale un DEFAULT o hazla nullable`,
|
|
100
|
+
`column \`${c.name}\` (${c.sqlType}) is required and the form cannot edit it; give it a DEFAULT or make it nullable`,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
fuera.push(c);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!esIdentificador(c.name)) {
|
|
108
|
+
throw new ScaffoldError(t(`la columna \`${c.name}\` no sirve como nombre de parámetro`, `column \`${c.name}\` cannot be used as a parameter name`));
|
|
109
|
+
}
|
|
110
|
+
editables.push(c);
|
|
111
|
+
}
|
|
112
|
+
// Sin clave automática, la clave se escribe al crear, pero no se cambia al editar.
|
|
113
|
+
const alCrear = editables;
|
|
114
|
+
const alEditar = editables.filter((c) => c !== clave);
|
|
115
|
+
if (alEditar.length === 0) throw new ScaffoldError(t(`\`${tabla.name}\` no tiene columnas que editar`, `\`${tabla.name}\` has no columns to edit`));
|
|
116
|
+
|
|
117
|
+
const visibles = tabla.columns.filter((c) => c !== clave && EDITABLES.has(c.kind) && !c.array).slice(0, 4);
|
|
118
|
+
return { clave, claveAutomatica, alCrear, alEditar, fuera, visibles, mysql };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Los nombres de las consultas del recurso. */
|
|
122
|
+
export function nombres(tabla) {
|
|
123
|
+
const plural = pascal(tabla.name);
|
|
124
|
+
const uno = pascal(singular(tabla.name));
|
|
125
|
+
return {
|
|
126
|
+
plural,
|
|
127
|
+
uno,
|
|
128
|
+
list: `List${plural}`,
|
|
129
|
+
get: `Get${uno}`,
|
|
130
|
+
create: `Create${uno}`,
|
|
131
|
+
update: `Update${uno}`,
|
|
132
|
+
delete: `Delete${uno}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** El SQL de las cinco consultas. Los parámetros `:nombre` valen en Postgres y en MySQL. */
|
|
137
|
+
export function consultasSql(tabla, a, n) {
|
|
138
|
+
const q = (x) => sqlNombre(x, a.mysql);
|
|
139
|
+
const tab = q(tabla.name);
|
|
140
|
+
const pk = q(a.clave.name);
|
|
141
|
+
const columnas = a.alCrear.map((c) => q(c.name)).join(", ");
|
|
142
|
+
const valores = a.alCrear.map((c) => `:${c.name}`).join(", ");
|
|
143
|
+
const sets = a.alEditar.map((c) => `${q(c.name)} = :${c.name}`).join(",\n ");
|
|
144
|
+
const crear =
|
|
145
|
+
a.mysql
|
|
146
|
+
? `-- name: ${n.create} ${a.claveAutomatica ? ":execlastid" : ":exec"}\nINSERT INTO ${tab} (${columnas})\nVALUES (${valores});`
|
|
147
|
+
: `-- name: ${n.create} :one\nINSERT INTO ${tab} (${columnas})\nVALUES (${valores})\nRETURNING ${pk};`;
|
|
148
|
+
const actualizar = a.alEditar.length
|
|
149
|
+
? `-- name: ${n.update} :execrows\nUPDATE ${tab}\nSET ${sets}\nWHERE ${pk} = :${a.clave.name};\n\n`
|
|
150
|
+
: "";
|
|
151
|
+
return (
|
|
152
|
+
`-- Generated by ascua-scaffold. Yours to edit: it is not generated again.\n\n` +
|
|
153
|
+
`-- name: ${n.list} :many\nSELECT * FROM ${tab}\nORDER BY ${pk} DESC\nLIMIT :limit OFFSET :offset;\n\n` +
|
|
154
|
+
`-- name: ${n.get} :one\nSELECT * FROM ${tab} WHERE ${pk} = :${a.clave.name};\n\n` +
|
|
155
|
+
`${crear}\n\n` +
|
|
156
|
+
actualizar +
|
|
157
|
+
`-- name: ${n.delete} :execrows\nDELETE FROM ${tab} WHERE ${pk} = :${a.clave.name};\n`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** El esquema de `field` para una columna. */
|
|
162
|
+
function campo(c, valoresEnum) {
|
|
163
|
+
const opcional = (s) => (c.nullable ? `field.optional(${s})` : s);
|
|
164
|
+
switch (c.kind) {
|
|
165
|
+
case "integer":
|
|
166
|
+
return opcional("field.number({ integer: true })");
|
|
167
|
+
case "decimal":
|
|
168
|
+
case "number":
|
|
169
|
+
return opcional("field.number()");
|
|
170
|
+
case "boolean":
|
|
171
|
+
// Una casilla sin marcar es `false`, nunca nula.
|
|
172
|
+
return "field.checkbox()";
|
|
173
|
+
case "enum":
|
|
174
|
+
return opcional(`field.choice(${valoresEnum})`);
|
|
175
|
+
case "text":
|
|
176
|
+
if (/(^|_)e-?mail$/i.test(c.name)) return opcional("field.email()");
|
|
177
|
+
// Obligatorio solo si la base lo exige y no tiene DEFAULT.
|
|
178
|
+
return c.nullable ? "field.optional(field.text())" : c.hasDefault ? "field.text({ min: 0 })" : "field.text()";
|
|
179
|
+
default:
|
|
180
|
+
// uuid, fechas y horas llegan como texto; la base las valida.
|
|
181
|
+
return opcional("field.text()");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** El control HTML de una columna, dentro del `view` de `…Fields`. */
|
|
186
|
+
function control(c, valoresEnum) {
|
|
187
|
+
const nombre = c.name;
|
|
188
|
+
const v = `values${acceso(nombre)}`;
|
|
189
|
+
const texto = `String(${v} ?? "")`;
|
|
190
|
+
// El esquema exige todo lo NOT NULL salvo casillas y texto con DEFAULT (que acepta vacío).
|
|
191
|
+
const req = !c.nullable && c.kind !== "boolean" && !(c.kind === "text" && c.hasDefault) ? " required" : "";
|
|
192
|
+
let html;
|
|
193
|
+
switch (c.kind) {
|
|
194
|
+
case "boolean":
|
|
195
|
+
html = `<input type="checkbox" name="${nombre}" prop:checked=\${${v} === "on"}>`;
|
|
196
|
+
break;
|
|
197
|
+
case "enum": {
|
|
198
|
+
const vacia = c.nullable ? `<option value=""></option>` : "";
|
|
199
|
+
html =
|
|
200
|
+
`<select name="${nombre}"${req}>${vacia}\n` +
|
|
201
|
+
` <For each=\${() => ${valoresEnum}} key=\${(v: string) => v}\n` +
|
|
202
|
+
` render=\${(v: string) => view\`<option prop:selected=\${${v} === v}>\${v}</option>\`}/>\n` +
|
|
203
|
+
` </select>`;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "integer":
|
|
207
|
+
html = `<input type="number" name="${nombre}" step="1" prop:value=\${${texto}}${req}>`;
|
|
208
|
+
break;
|
|
209
|
+
case "decimal":
|
|
210
|
+
case "number":
|
|
211
|
+
html = `<input type="number" name="${nombre}" step="any" prop:value=\${${texto}}${req}>`;
|
|
212
|
+
break;
|
|
213
|
+
case "date":
|
|
214
|
+
html = `<input type="date" name="${nombre}" prop:value=\${${texto}}${req}>`;
|
|
215
|
+
break;
|
|
216
|
+
case "datetime":
|
|
217
|
+
html = `<input type="datetime-local" name="${nombre}" prop:value=\${${texto}}${req}>`;
|
|
218
|
+
break;
|
|
219
|
+
case "time":
|
|
220
|
+
html = `<input type="time" name="${nombre}" prop:value=\${${texto}}${req}>`;
|
|
221
|
+
break;
|
|
222
|
+
default:
|
|
223
|
+
if (LARGAS.test(nombre)) html = `<textarea name="${nombre}"${req}>\${${texto}}</textarea>`;
|
|
224
|
+
else if (/(^|_)e-?mail$/i.test(nombre)) html = `<input type="email" name="${nombre}" prop:value=\${${texto}}${req}>`;
|
|
225
|
+
else html = `<input name="${nombre}" prop:value=\${${texto}}${req}>`;
|
|
226
|
+
}
|
|
227
|
+
return (
|
|
228
|
+
` <label>${etiqueta(nombre)}\n ${html}\n </label>\n` +
|
|
229
|
+
` <p class="error">\${errors${acceso(nombre)} ?? ""}</p>`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Una fila de la base a los textos del formulario. */
|
|
234
|
+
function aTexto(c, fila) {
|
|
235
|
+
const v = `${fila}${acceso(c.name)}`;
|
|
236
|
+
switch (c.kind) {
|
|
237
|
+
case "boolean":
|
|
238
|
+
return `${v} ? "on" : ""`;
|
|
239
|
+
case "date":
|
|
240
|
+
return `${v} == null ? "" : toDate(${v})`;
|
|
241
|
+
case "datetime":
|
|
242
|
+
return `${v} == null ? "" : toDateTime(${v})`;
|
|
243
|
+
default:
|
|
244
|
+
return `${v} == null ? "" : String(${v})`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `duplicate()` del recurso: un UNIQUE o una clave repetidos vuelven al
|
|
250
|
+
* formulario con un 409 en vez de un 500. Postgres dice la columna en el
|
|
251
|
+
* `detail`; MySQL da el nombre del índice, así que ahí el error es general.
|
|
252
|
+
*/
|
|
253
|
+
function duplicado(mysql) {
|
|
254
|
+
const deteccion = mysql
|
|
255
|
+
? ` if (e?.code !== "ER_DUP_ENTRY") throw error;
|
|
256
|
+
const field = "";`
|
|
257
|
+
: ` if (e?.code !== "23505") throw error;
|
|
258
|
+
// "Key (sku)=(H-1) already exists."
|
|
259
|
+
const column = /^Key \\(([^)]+)\\)=/.exec(String(e.detail))?.[1];
|
|
260
|
+
const field = column && !column.includes(",") ? column : "";`;
|
|
261
|
+
return `/**
|
|
262
|
+
* A duplicate of a UNIQUE column or of the key, as the same form again with
|
|
263
|
+
* the error under that field (409). Any other error is thrown again.
|
|
264
|
+
*/
|
|
265
|
+
export function duplicate(error: unknown, formData: FormData) {
|
|
266
|
+
const e = error as { code?: unknown; detail?: unknown } | null;
|
|
267
|
+
${deteccion}
|
|
268
|
+
return fail(409, { ok: false as const, errors: { [field]: "Already taken." }, values: texts(formData) });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The text fields of a form, to fill it in again. */
|
|
272
|
+
function texts(formData: FormData): FormValues {
|
|
273
|
+
const values: Record<string, string> = {};
|
|
274
|
+
for (const [name, value] of formData) if (typeof value === "string") values[name] = value;
|
|
275
|
+
return values;
|
|
276
|
+
}
|
|
277
|
+
`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `src/resources/<tabla>.ts`: los esquemas, los campos y las conversiones. */
|
|
281
|
+
export function recurso(tabla, a, n, { db, enums }) {
|
|
282
|
+
const valoresEnum = (c) => (c.values ? (enums.has(c.sqlType) ? `${camel(c.sqlType)}Values` : JSON.stringify(c.values)) : "[]");
|
|
283
|
+
const importsEnum = [...new Set(a.alCrear.filter((c) => c.kind === "enum" && enums.has(c.sqlType)).map((c) => `${camel(c.sqlType)}Values`))];
|
|
284
|
+
const usaFechas = a.alEditar.some((c) => c.kind === "date" || c.kind === "datetime");
|
|
285
|
+
const conClave = !a.claveAutomatica;
|
|
286
|
+
|
|
287
|
+
const esquema = a.alEditar.map((c) => ` ${propiedad(c.name)}: ${campo(c, valoresEnum(c))},`).join("\n");
|
|
288
|
+
const controles = (cols) => cols.map((c) => control(c, valoresEnum(c))).join("\n");
|
|
289
|
+
const aParams = a.alEditar
|
|
290
|
+
.map((c) => {
|
|
291
|
+
const v = `data${acceso(c.name)}`;
|
|
292
|
+
return ` ${propiedad(c.name)}: ${c.nullable && c.kind !== "boolean" ? `${v} ?? null` : v},`;
|
|
293
|
+
})
|
|
294
|
+
.join("\n");
|
|
295
|
+
const aValores = a.alEditar.map((c) => ` ${propiedad(c.name)}: ${aTexto(c, "row")},`).join("\n");
|
|
296
|
+
|
|
297
|
+
const fechas = usaFechas
|
|
298
|
+
? `
|
|
299
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
300
|
+
/** A \`Date\` as the value of \`<input type="date">\`. */
|
|
301
|
+
const toDate = (d: Date) => \`\${d.getFullYear()}-\${pad(d.getMonth() + 1)}-\${pad(d.getDate())}\`;
|
|
302
|
+
/** A \`Date\` as the value of \`<input type="datetime-local">\`, in local time. */
|
|
303
|
+
const toDateTime = (d: Date) => \`\${toDate(d)}T\${pad(d.getHours())}:\${pad(d.getMinutes())}\`;
|
|
304
|
+
`
|
|
305
|
+
: "";
|
|
306
|
+
|
|
307
|
+
const nuevoEsquema = conClave
|
|
308
|
+
? `
|
|
309
|
+
/** The new ${singular(tabla.name)} form: the same fields plus the key, which is not edited later. */
|
|
310
|
+
export const New${n.uno}Form = fields({
|
|
311
|
+
${propiedad(a.clave.name)}: ${campo(a.clave, "[]")},
|
|
312
|
+
...shape,
|
|
313
|
+
});
|
|
314
|
+
`
|
|
315
|
+
: `
|
|
316
|
+
/** Creating asks for the same fields: the key comes from the database. */
|
|
317
|
+
export const New${n.uno}Form = ${n.uno}Form;
|
|
318
|
+
`;
|
|
319
|
+
|
|
320
|
+
const cuerpo = conClave
|
|
321
|
+
? `<Show when=\${() => props.withKey === true}>\n${controles([a.clave]).replace(/^/gm, " ")}\n </Show>\n${controles(a.alEditar)}`
|
|
322
|
+
: controles(a.alEditar);
|
|
323
|
+
|
|
324
|
+
return `// Generated by ascua-scaffold from the \`${tabla.name}\` table. Yours to edit:
|
|
325
|
+
// it is not generated again.
|
|
326
|
+
import type { Infer } from "ascua";
|
|
327
|
+
import { fail, field, fields } from "vite-plugin-ascua/site";
|
|
328
|
+
|
|
329
|
+
import { ${[...importsEnum, `type ${n.get}Row`].join(", ")} } from "${db}";
|
|
330
|
+
|
|
331
|
+
const shape = {
|
|
332
|
+
${esquema}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/** What the ${singular(tabla.name)} form sends, validated on the server. */
|
|
336
|
+
export const ${n.uno}Form = fields(shape);
|
|
337
|
+
${nuevoEsquema}
|
|
338
|
+
export type ${n.uno}Data = Infer<typeof ${n.uno}Form>;
|
|
339
|
+
export type FormValues = Partial<Record<string, string | string[]>>;
|
|
340
|
+
export type FormErrors = Partial<Record<string, string>>;
|
|
341
|
+
${fechas}
|
|
342
|
+
/** The form data as query parameters: empty optional fields become NULL. */
|
|
343
|
+
export function ${camel(n.uno)}Params(data: ${n.uno}Data) {
|
|
344
|
+
return {
|
|
345
|
+
${aParams}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
${duplicado(a.mysql)}
|
|
350
|
+
/** A row as the values of the form. */
|
|
351
|
+
export function ${camel(n.uno)}Values(row: ${n.get}Row): FormValues {
|
|
352
|
+
return {
|
|
353
|
+
${aValores}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The fields of the form, with the error of each one under it. */
|
|
358
|
+
export function ${n.uno}Fields(props: { values: FormValues; errors: FormErrors${conClave ? "; withKey?: boolean" : ""} }) {
|
|
359
|
+
const { values, errors } = props;
|
|
360
|
+
return view\`
|
|
361
|
+
<div class="fields">
|
|
362
|
+
${cuerpo}
|
|
363
|
+
<p class="error">\${errors[""] ?? ""}</p>
|
|
364
|
+
<style>
|
|
365
|
+
.fields { display: grid; gap: .25rem; max-width: 32rem; }
|
|
366
|
+
.fields label { display: grid; gap: .25rem; }
|
|
367
|
+
.error { color: crimson; margin: 0; min-height: 1.25em; }
|
|
368
|
+
</style>
|
|
369
|
+
</div>\`;
|
|
370
|
+
}
|
|
371
|
+
`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Cómo se convierte el texto de la URL en el tipo de la clave. */
|
|
375
|
+
function claveDeUrl(a) {
|
|
376
|
+
if (a.clave.kind === "integer") {
|
|
377
|
+
// `int8` entra como texto para no perder precisión; los demás, como número.
|
|
378
|
+
const ancho = a.clave.sqlType === "int8" && !a.mysql;
|
|
379
|
+
return { guarda: `/^\\d+$/.test(id)`, valor: ancho ? "id" : "Number(id)" };
|
|
380
|
+
}
|
|
381
|
+
if (a.clave.kind === "uuid") return { guarda: `/^[0-9a-f-]{36}$/i.test(id)`, valor: "id" };
|
|
382
|
+
return { guarda: "id !== \"\"", valor: "id" };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Las tres rutas del recurso. */
|
|
386
|
+
export function rutas(tabla, a, n, { db, pool, resource, base }) {
|
|
387
|
+
const { guarda, valor } = claveDeUrl(a);
|
|
388
|
+
const pk = a.clave.name;
|
|
389
|
+
const fila = (r) => `${r}${acceso(pk)}`;
|
|
390
|
+
const titulo = etiqueta(tabla.name);
|
|
391
|
+
const uno = etiqueta(singular(tabla.name)).toLowerCase();
|
|
392
|
+
const forma = `${n.uno}Form`;
|
|
393
|
+
const params = `${camel(n.uno)}Params`;
|
|
394
|
+
const values = `${camel(n.uno)}Values`;
|
|
395
|
+
const campos = `${n.uno}Fields`;
|
|
396
|
+
|
|
397
|
+
const cabeceras = [pk, ...a.visibles.map((c) => c.name)].map((c) => `<th>${etiqueta(c)}</th>`).join("");
|
|
398
|
+
const celdas = a.visibles.map((c) => `<td>\${show(r${acceso(c.name)})}</td>`).join("");
|
|
399
|
+
|
|
400
|
+
const index = `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
401
|
+
import type { LoadContext } from "vite-plugin-ascua/site";
|
|
402
|
+
|
|
403
|
+
import { ${camel(n.list)}, type ${n.list}Row } from "${db.index}";
|
|
404
|
+
import { db } from "${pool.index}";
|
|
405
|
+
|
|
406
|
+
const PAGE = 50;
|
|
407
|
+
|
|
408
|
+
export const title = "${titulo}";
|
|
409
|
+
|
|
410
|
+
export async function load(_params: Record<string, string>, { url }: LoadContext) {
|
|
411
|
+
const page = Math.max(1, Math.floor(Number(url.searchParams.get("page"))) || 1);
|
|
412
|
+
// One more than a page, to know whether there is a next one.
|
|
413
|
+
const rows = await ${camel(n.list)}(await db(), { limit: PAGE + 1, offset: (page - 1) * PAGE });
|
|
414
|
+
return { page, rows: rows.slice(0, PAGE), more: rows.length > PAGE };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** How a value of any column reads in a cell. */
|
|
418
|
+
function show(value: unknown): string {
|
|
419
|
+
if (value == null) return "";
|
|
420
|
+
if (value instanceof Date) return value.toLocaleString();
|
|
421
|
+
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
422
|
+
return String(value);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export default function ${n.plural}({ data }: { data: Awaited<ReturnType<typeof load>> }) {
|
|
426
|
+
return view\`
|
|
427
|
+
<section>
|
|
428
|
+
<h1>${titulo}</h1>
|
|
429
|
+
<p><a href="${base}/new">New ${uno}</a></p>
|
|
430
|
+
<table>
|
|
431
|
+
<thead><tr>${cabeceras}</tr></thead>
|
|
432
|
+
<tbody>
|
|
433
|
+
<For each=\${() => data.rows} key=\${(r: ${n.list}Row) => String(${fila("r")})}
|
|
434
|
+
render=\${(r: ${n.list}Row) => view\`<tr><td><a href=\${\`${base}/\${${fila("r")}}\`}>\${String(${fila("r")})}</a></td>${celdas}</tr>\`}/>
|
|
435
|
+
</tbody>
|
|
436
|
+
</table>
|
|
437
|
+
<nav>
|
|
438
|
+
<Show when=\${() => data.page > 1}><a href=\${\`?page=\${data.page - 1}\`}>Previous</a></Show>
|
|
439
|
+
<Show when=\${() => data.more}><a href=\${\`?page=\${data.page + 1}\`}>Next</a></Show>
|
|
440
|
+
</nav>
|
|
441
|
+
</section>\`;
|
|
442
|
+
}
|
|
443
|
+
`;
|
|
444
|
+
|
|
445
|
+
const nuevaForma = `New${n.uno}Form`;
|
|
446
|
+
const datos = a.claveAutomatica
|
|
447
|
+
? `${params}(result.data)`
|
|
448
|
+
: `{ ...${params}(result.data), ${propiedad(pk)}: result.data${acceso(pk)} }`;
|
|
449
|
+
// Crear dentro de try: un UNIQUE repetido vuelve al formulario. La
|
|
450
|
+
// redirección va fuera, porque también se lanza.
|
|
451
|
+
const intento = (asignar, despues) =>
|
|
452
|
+
`${asignar ? `let ${asignar};\n ` : ""}try {\n ${asignar ? `${asignar} = ` : ""}await ${camel(n.create)}(await db(), ${datos});\n } catch (error) {\n return duplicate(error, formData);\n }\n throw redirect(\`${base}/\${${despues}}\`);`;
|
|
453
|
+
const despuesDeCrear = a.mysql
|
|
454
|
+
? a.claveAutomatica
|
|
455
|
+
? intento("id", "id")
|
|
456
|
+
: intento(null, `result.data${acceso(pk)}`)
|
|
457
|
+
: intento("row", fila("row!"));
|
|
458
|
+
|
|
459
|
+
const nuevo = `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
460
|
+
import { fail, redirect, validate, type ActionContext, type FormResult } from "vite-plugin-ascua/site";
|
|
461
|
+
|
|
462
|
+
import { ${camel(n.create)} } from "${db.nested}";
|
|
463
|
+
import { db } from "${pool.nested}";
|
|
464
|
+
import { ${nuevaForma}, ${campos}, ${params}, duplicate } from "${resource.nested}";
|
|
465
|
+
|
|
466
|
+
export const title = "New ${uno}";
|
|
467
|
+
|
|
468
|
+
export const actions = {
|
|
469
|
+
async default({ formData }: ActionContext) {
|
|
470
|
+
const result = await validate(${nuevaForma}, formData);
|
|
471
|
+
if (!result.ok) return fail(400, result);
|
|
472
|
+
${despuesDeCrear}
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
export default function New${n.uno}({ form }: { form?: FormResult<typeof ${nuevaForma}> }) {
|
|
477
|
+
const failed = form?.ok === false ? form : undefined;
|
|
478
|
+
return view\`
|
|
479
|
+
<section>
|
|
480
|
+
<h1>New ${uno}</h1>
|
|
481
|
+
<form method="post">
|
|
482
|
+
<${campos} values=\${failed?.values ?? {}} errors=\${failed?.errors ?? {}}${a.claveAutomatica ? "" : " withKey=\${true}"}/>
|
|
483
|
+
<button>Create</button>
|
|
484
|
+
</form>
|
|
485
|
+
<p><a href="${base}">Back</a></p>
|
|
486
|
+
</section>\`;
|
|
487
|
+
}
|
|
488
|
+
`;
|
|
489
|
+
|
|
490
|
+
const actualizar = a.alEditar.length
|
|
491
|
+
? `
|
|
492
|
+
async default({ params, formData }: ActionContext<Params>) {
|
|
493
|
+
const id = key(params.id);
|
|
494
|
+
const result = await validate(${forma}, formData);
|
|
495
|
+
if (!result.ok) return fail(400, result);
|
|
496
|
+
let changed;
|
|
497
|
+
try {
|
|
498
|
+
changed = await ${camel(n.update)}(await db(), { ...${params}(result.data), ${propiedad(pk)}: id });
|
|
499
|
+
} catch (error) {
|
|
500
|
+
return duplicate(error, formData);
|
|
501
|
+
}
|
|
502
|
+
if (changed === 0) throw notFound();
|
|
503
|
+
throw redirect("${base}");
|
|
504
|
+
},`
|
|
505
|
+
: "";
|
|
506
|
+
|
|
507
|
+
const editar = `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
508
|
+
import { fail, notFound, redirect, validate, type ActionContext, type FormResult } from "vite-plugin-ascua/site";
|
|
509
|
+
|
|
510
|
+
import { ${[camel(n.get), a.alEditar.length ? camel(n.update) : null, camel(n.delete)].filter(Boolean).join(", ")} } from "${db.nested}";
|
|
511
|
+
import { db } from "${pool.nested}";
|
|
512
|
+
import { ${forma}, ${campos}, ${params}, ${values}, duplicate } from "${resource.nested}";
|
|
513
|
+
|
|
514
|
+
type Params = { id: string };
|
|
515
|
+
|
|
516
|
+
/** The key from the URL, or a 404 if it cannot be one. */
|
|
517
|
+
function key(id: string) {
|
|
518
|
+
if (!(${guarda})) throw notFound();
|
|
519
|
+
return ${valor};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export const title = ({ id }: Params) => \`${etiqueta(singular(tabla.name))} \${id}\`;
|
|
523
|
+
|
|
524
|
+
export async function load({ id }: Params) {
|
|
525
|
+
const row = await ${camel(n.get)}(await db(), { ${propiedad(pk)}: key(id) });
|
|
526
|
+
if (!row) throw notFound();
|
|
527
|
+
return row;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export const actions = {${actualizar}
|
|
531
|
+
async delete({ params }: ActionContext<Params>) {
|
|
532
|
+
await ${camel(n.delete)}(await db(), { ${propiedad(pk)}: key(params.id) });
|
|
533
|
+
throw redirect("${base}");
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
export default function Edit${n.uno}({ data, form }: { data: Awaited<ReturnType<typeof load>>; form?: FormResult<typeof ${forma}> }) {
|
|
538
|
+
const failed = form?.ok === false ? form : undefined;
|
|
539
|
+
return view\`
|
|
540
|
+
<section>
|
|
541
|
+
<h1>${etiqueta(singular(tabla.name))} \${String(${fila("data")})}</h1>
|
|
542
|
+
${a.alEditar.length ? `<form method="post">
|
|
543
|
+
<${campos} values=\${failed?.values ?? ${values}(data)} errors=\${failed?.errors ?? {}}/>
|
|
544
|
+
<button>Save</button>
|
|
545
|
+
</form>
|
|
546
|
+
` : ""}<form method="post" action="?/delete">
|
|
547
|
+
<button>Delete</button>
|
|
548
|
+
</form>
|
|
549
|
+
<p><a href="${base}">Back</a></p>
|
|
550
|
+
</section>\`;
|
|
551
|
+
}
|
|
552
|
+
`;
|
|
553
|
+
|
|
554
|
+
return { index, nuevo, editar };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** `src/pool.ts`: un pool para todo el servidor, con las migraciones aplicadas una vez. */
|
|
558
|
+
export function piscina({ mysql, migrate, db }) {
|
|
559
|
+
if (mysql) {
|
|
560
|
+
return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
561
|
+
import mysql from "mysql2/promise";
|
|
562
|
+
${migrate ? `\nimport { migrate } from "${db}";\n` : ""}
|
|
563
|
+
/** One pool for the whole server, from DATABASE_URL. */
|
|
564
|
+
export const pool = mysql.createPool(process.env.DATABASE_URL ?? "mysql://root@127.0.0.1/app");
|
|
565
|
+
${
|
|
566
|
+
migrate
|
|
567
|
+
? `
|
|
568
|
+
let ready: Promise<typeof pool> | undefined;
|
|
569
|
+
|
|
570
|
+
/** The pool, once the pending migrations are applied. A failure is retried on the next request. */
|
|
571
|
+
export function db(): Promise<typeof pool> {
|
|
572
|
+
ready ??= migrate(pool).then(
|
|
573
|
+
() => pool,
|
|
574
|
+
(error) => {
|
|
575
|
+
ready = undefined;
|
|
576
|
+
throw error;
|
|
577
|
+
},
|
|
578
|
+
);
|
|
579
|
+
return ready;
|
|
580
|
+
}
|
|
581
|
+
`
|
|
582
|
+
: `
|
|
583
|
+
/** The pool. Migrations are applied by hand. */
|
|
584
|
+
export async function db(): Promise<typeof pool> {
|
|
585
|
+
return pool;
|
|
586
|
+
}
|
|
587
|
+
`
|
|
588
|
+
}`;
|
|
589
|
+
}
|
|
590
|
+
return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
591
|
+
import pg from "pg";
|
|
592
|
+
${migrate ? `\nimport { migrate } from "${db}";\n` : ""}
|
|
593
|
+
/** One pool for the whole server: DATABASE_URL, or the PG* variables. */
|
|
594
|
+
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
|
595
|
+
${
|
|
596
|
+
migrate
|
|
597
|
+
? `
|
|
598
|
+
let ready: Promise<pg.Pool> | undefined;
|
|
599
|
+
|
|
600
|
+
/** The pool, once the pending migrations are applied. A failure is retried on the next request. */
|
|
601
|
+
export function db(): Promise<pg.Pool> {
|
|
602
|
+
ready ??= migrate(pool).then(
|
|
603
|
+
() => pool,
|
|
604
|
+
(error) => {
|
|
605
|
+
ready = undefined;
|
|
606
|
+
throw error;
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
return ready;
|
|
610
|
+
}
|
|
611
|
+
`
|
|
612
|
+
: `
|
|
613
|
+
/** The pool. Migrations are applied by hand. */
|
|
614
|
+
export async function db(): Promise<pg.Pool> {
|
|
615
|
+
return pool;
|
|
616
|
+
}
|
|
617
|
+
`
|
|
618
|
+
}`;
|
|
619
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ascua-scaffold: de una tabla a un recurso con lista, alta, edición y baja.
|
|
4
|
+
*
|
|
5
|
+
* stil exec ascua-scaffold posts
|
|
6
|
+
* npx ascua-scaffold posts
|
|
7
|
+
*
|
|
8
|
+
* Lee el esquema con el `brasa-sql` del proyecto (las migraciones de
|
|
9
|
+
* `brasa.json`), escribe las consultas, vuelve a generar el módulo de brasa y
|
|
10
|
+
* escribe el esquema del formulario y las rutas. Sin dependencias propias.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { analizar, consultasSql, nombres, piscina, recurso, rutas, ScaffoldError, t } from "./generate.js";
|
|
20
|
+
|
|
21
|
+
const AYUDA = t(
|
|
22
|
+
`Uso: ascua-scaffold <tabla> [opciones]
|
|
23
|
+
|
|
24
|
+
Genera, a partir de una tabla de las migraciones de brasa:
|
|
25
|
+
db/queries/<tabla>.sql List, Get, Create, Update y Delete
|
|
26
|
+
src/resources/<tabla>.ts el esquema del formulario y sus campos
|
|
27
|
+
src/routes/<tabla>/index.ts la lista, paginada
|
|
28
|
+
src/routes/<tabla>/new.ts el alta
|
|
29
|
+
src/routes/<tabla>/[id].ts la edición y la baja
|
|
30
|
+
src/pool.ts el pool de la base, si no existe
|
|
31
|
+
|
|
32
|
+
Opciones:
|
|
33
|
+
--config <ruta> el brasa.json (por defecto, el del directorio actual)
|
|
34
|
+
--force reescribe los archivos que ya existan
|
|
35
|
+
-h, --help esta ayuda`,
|
|
36
|
+
`Usage: ascua-scaffold <table> [options]
|
|
37
|
+
|
|
38
|
+
Generates, from a table in the brasa migrations:
|
|
39
|
+
db/queries/<table>.sql List, Get, Create, Update and Delete
|
|
40
|
+
src/resources/<table>.ts the form schema and its fields
|
|
41
|
+
src/routes/<table>/index.ts the list, paginated
|
|
42
|
+
src/routes/<table>/new.ts creating
|
|
43
|
+
src/routes/<table>/[id].ts editing and deleting
|
|
44
|
+
src/pool.ts the database pool, if missing
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
--config <path> the brasa.json (default: the one in the current directory)
|
|
48
|
+
--force overwrite files that already exist
|
|
49
|
+
-h, --help this help`,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const POR_DEFECTO = { dialect: "postgres", migrations: "db/migrations", queries: "db/queries", out: "src/db.ts", migrate: true };
|
|
53
|
+
|
|
54
|
+
/** `["posts", "--force"]` → `{ table: "posts", force: true }`. */
|
|
55
|
+
export function leerArgumentos(argv) {
|
|
56
|
+
const salida = { table: undefined, config: "brasa.json", force: false, ayuda: false };
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
if (a === "-h" || a === "--help") salida.ayuda = true;
|
|
60
|
+
else if (a === "--force") salida.force = true;
|
|
61
|
+
else if (a === "--config") salida.config = argv[++i];
|
|
62
|
+
else if (a.startsWith("--config=")) salida.config = a.slice("--config=".length);
|
|
63
|
+
else if (!a.startsWith("-") && salida.table === undefined) salida.table = a;
|
|
64
|
+
else throw new ScaffoldError(t(`opción desconocida: ${a}`, `unknown option: ${a}`));
|
|
65
|
+
}
|
|
66
|
+
return salida;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** El `brasa-sql` del proyecto: el mismo que genera su módulo. */
|
|
70
|
+
async function cargarBrasa(raiz) {
|
|
71
|
+
const require = createRequire(join(raiz, "package.json"));
|
|
72
|
+
let entrada;
|
|
73
|
+
try {
|
|
74
|
+
entrada = require.resolve("brasa-sql");
|
|
75
|
+
} catch {
|
|
76
|
+
throw new ScaffoldError(t("falta brasa-sql en el proyecto: stil add -D brasa-sql", "brasa-sql is missing from the project: npm i -D brasa-sql"));
|
|
77
|
+
}
|
|
78
|
+
const brasa = await import(pathToFileURL(entrada).href);
|
|
79
|
+
const bin = join(dirname(entrada), "bin", "brasa.js");
|
|
80
|
+
return { compile: brasa.compile, bin };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function leerSql(dir) {
|
|
84
|
+
if (!existsSync(dir)) return [];
|
|
85
|
+
return readdirSync(dir)
|
|
86
|
+
.filter((n) => n.endsWith(".sql"))
|
|
87
|
+
.sort()
|
|
88
|
+
.map((n) => ({ name: relative(process.cwd(), join(dir, n)), content: readFileSync(join(dir, n), "utf8") }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Una importación relativa de TypeScript con extensión `.js`, como pide `nodenext`. */
|
|
92
|
+
function importar(desde, hacia) {
|
|
93
|
+
let r = relative(dirname(desde), hacia).replace(/\\/g, "/").replace(/\.ts$/, ".js");
|
|
94
|
+
if (!r.startsWith(".")) r = `./${r}`;
|
|
95
|
+
return r;
|
|
96
|
+
}
|
|
97
|
+
|
|
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
|
+
|
|
105
|
+
const rutaConfig = resolve(args.config);
|
|
106
|
+
const raiz = dirname(rutaConfig);
|
|
107
|
+
if (!existsSync(rutaConfig)) {
|
|
108
|
+
throw new ScaffoldError(t(`no existe ${rutaConfig}: crea uno con \`brasa init\``, `${rutaConfig} does not exist: create one with \`brasa init\``));
|
|
109
|
+
}
|
|
110
|
+
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
|
+
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 });
|
|
120
|
+
if (salida.code === null) {
|
|
121
|
+
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
|
+
}
|
|
123
|
+
if (!salida.tables) {
|
|
124
|
+
throw new ScaffoldError(t("este brasa-sql no describe las tablas: actualízalo", "this brasa-sql does not describe tables: update it"));
|
|
125
|
+
}
|
|
126
|
+
|
|
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 });
|
|
136
|
+
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
|
+
if (repetidas.length) {
|
|
142
|
+
throw new ScaffoldError(
|
|
143
|
+
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`),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
|
|
148
|
+
// Los enums de Postgres tienen su `<x>Values` en el módulo; los de MySQL
|
|
149
|
+
// (`ENUM('a','b')` en la columna), no: sus valores van escritos.
|
|
150
|
+
const enums = new Set(mysql ? [] : tabla.columns.filter((c) => c.kind === "enum").map((c) => c.sqlType));
|
|
151
|
+
|
|
152
|
+
const archivos = {
|
|
153
|
+
sql: join(dirConsultas, `${tabla.name}.sql`),
|
|
154
|
+
recurso: join(raiz, "src", "resources", `${carpeta}.ts`),
|
|
155
|
+
index: join(raiz, "src", "routes", carpeta, "index.ts"),
|
|
156
|
+
nuevo: join(raiz, "src", "routes", carpeta, "new.ts"),
|
|
157
|
+
editar: join(raiz, "src", "routes", carpeta, "[id].ts"),
|
|
158
|
+
pool: join(raiz, "src", "pool.ts"),
|
|
159
|
+
};
|
|
160
|
+
const existentes = Object.entries(archivos).filter(([k, f]) => k !== "pool" && existsSync(f));
|
|
161
|
+
if (existentes.length && !args.force) {
|
|
162
|
+
throw new ScaffoldError(
|
|
163
|
+
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`),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const r = rutas(tabla, a, n, {
|
|
168
|
+
db: { index: importar(archivos.index, out), nested: importar(archivos.nuevo, out) },
|
|
169
|
+
pool: { index: importar(archivos.index, archivos.pool), nested: importar(archivos.nuevo, archivos.pool) },
|
|
170
|
+
resource: { nested: importar(archivos.nuevo, archivos.recurso) },
|
|
171
|
+
base: `/${carpeta}`,
|
|
172
|
+
});
|
|
173
|
+
const contenidos = {
|
|
174
|
+
sql: consultasSql(tabla, a, n),
|
|
175
|
+
recurso: recurso(tabla, a, n, { db: importar(archivos.recurso, out), enums }),
|
|
176
|
+
index: r.index,
|
|
177
|
+
nuevo: r.nuevo,
|
|
178
|
+
editar: r.editar,
|
|
179
|
+
};
|
|
180
|
+
const escritos = [];
|
|
181
|
+
for (const [k, contenido] of Object.entries(contenidos)) {
|
|
182
|
+
mkdirSync(dirname(archivos[k]), { recursive: true });
|
|
183
|
+
writeFileSync(archivos[k], contenido);
|
|
184
|
+
escritos.push(archivos[k]);
|
|
185
|
+
}
|
|
186
|
+
if (!existsSync(archivos.pool)) {
|
|
187
|
+
writeFileSync(archivos.pool, piscina({ mysql, migrate: config.migrate, db: importar(archivos.pool, out) }));
|
|
188
|
+
escritos.push(archivos.pool);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// El módulo de brasa, con las consultas nuevas.
|
|
192
|
+
const generado = spawnSync(process.execPath, [brasa.bin, "generate", "--config", rutaConfig], { cwd: raiz, stdio: "inherit" });
|
|
193
|
+
if (generado.status !== 0) {
|
|
194
|
+
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)"));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const f of escritos) console.log(` ${relative(process.cwd(), f)}`);
|
|
198
|
+
const paquete = existsSync(join(raiz, "package.json")) ? JSON.parse(readFileSync(join(raiz, "package.json"), "utf8")) : {};
|
|
199
|
+
const deps = { ...paquete.dependencies, ...paquete.devDependencies };
|
|
200
|
+
const faltan = (mysql ? ["mysql2"] : ["pg", "@types/pg"]).concat("@types/node").filter((d) => !deps[d]);
|
|
201
|
+
console.log("");
|
|
202
|
+
console.log(t(`Listo: /${carpeta} en el sitio.`, `Done: /${carpeta} on the site.`));
|
|
203
|
+
if (faltan.length) console.log(t(`Falta instalar: stil add ${faltan.join(" ")}`, `Still to install: npm i ${faltan.join(" ")}`));
|
|
204
|
+
if (!/mode:\s*["']server["']/.test(leer(join(raiz, "vite.config.ts")))) {
|
|
205
|
+
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'));
|
|
206
|
+
}
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function leer(f) {
|
|
211
|
+
return existsSync(f) ? readFileSync(f, "utf8") : "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
215
|
+
main().then(
|
|
216
|
+
(codigo) => process.exit(codigo),
|
|
217
|
+
(error) => {
|
|
218
|
+
if (error instanceof ScaffoldError) {
|
|
219
|
+
console.error(`ascua-scaffold: ${error.message}`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
throw error;
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ascua-scaffold",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "De una tabla de brasa a un recurso de Ascua: consultas, formulario validado y rutas para listar, crear, editar y borrar.",
|
|
5
|
+
"keywords": ["ascua", "scaffold", "crud", "brasa", "sql", "forms"],
|
|
6
|
+
"homepage": "https://ascua.gitweave.run",
|
|
7
|
+
"license": "MIT OR Apache-2.0",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"ascua-scaffold": "index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": ["index.js", "generate.js", "README.md", "LICENSE-MIT", "LICENSE-APACHE"],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"vitest": "^5.0.3"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
}
|
|
25
|
+
}
|