@tekki-forge/compiler 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/README.md +4 -2
- package/dist/compile.d.ts +17 -0
- package/dist/compile.js +691 -0
- package/dist/compile.js.map +1 -0
- package/dist/dashboard.d.ts +7 -0
- package/dist/dashboard.js +103 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/features.d.ts +13 -0
- package/dist/features.js +247 -0
- package/dist/features.js.map +1 -0
- package/dist/files.d.ts +34 -0
- package/dist/files.js +32 -0
- package/dist/files.js.map +1 -0
- package/dist/i18n.d.ts +24 -0
- package/dist/i18n.js +168 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/indexes.d.ts +13 -0
- package/dist/indexes.js +71 -0
- package/dist/indexes.js.map +1 -0
- package/dist/ir.d.ts +413 -0
- package/dist/ir.js +27 -0
- package/dist/ir.js.map +1 -0
- package/dist/landing.d.ts +5 -0
- package/dist/landing.js +73 -0
- package/dist/landing.js.map +1 -0
- package/dist/load.d.ts +12 -0
- package/dist/load.js +38 -0
- package/dist/load.js.map +1 -0
- package/dist/naming.d.ts +11 -0
- package/dist/naming.js +58 -0
- package/dist/naming.js.map +1 -0
- package/dist/notifications.d.ts +5 -0
- package/dist/notifications.js +80 -0
- package/dist/notifications.js.map +1 -0
- package/dist/rules.d.ts +4 -0
- package/dist/rules.js +43 -0
- package/dist/rules.js.map +1 -0
- package/dist/theme.d.ts +69 -0
- package/dist/theme.js +257 -0
- package/dist/theme.js.map +1 -0
- package/dist/ui.d.ts +83 -0
- package/dist/ui.js +102 -0
- package/dist/ui.js.map +1 -0
- package/package.json +41 -3
package/dist/compile.js
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { BUILTIN_ENTITIES, RESERVED_ENTITY_NAMES, RESERVED_FIELD_NAMES, } from "./ir.js";
|
|
2
|
+
import { compileTheme } from "./theme.js";
|
|
3
|
+
import { compileRule } from "./rules.js";
|
|
4
|
+
import { compileNotifications } from "./notifications.js";
|
|
5
|
+
import { compileDashboard } from "./dashboard.js";
|
|
6
|
+
import { compileI18n } from "./i18n.js";
|
|
7
|
+
import { compileLanding } from "./landing.js";
|
|
8
|
+
import { compileEntityFeatures, compileComputed } from "./features.js";
|
|
9
|
+
import { isCamelCase, isPascalCase, kebabCase, lowerFirst, pluralize, singularize, snakeCase, titleCase } from "./naming.js";
|
|
10
|
+
export class ForgeSchemaError extends Error {
|
|
11
|
+
issues;
|
|
12
|
+
constructor(issues) {
|
|
13
|
+
super(`Invalid Forge schema:\n${issues.map((i) => ` • ${i.path}: ${i.message}`).join("\n")}`);
|
|
14
|
+
this.issues = issues;
|
|
15
|
+
this.name = "ForgeSchemaError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const SOCIAL_PROVIDERS = ["google", "microsoft", "github", "apple", "gitlab", "discord"];
|
|
19
|
+
const TEXT_KINDS = new Set(["string", "text", "richtext", "email", "url", "phone", "color"]);
|
|
20
|
+
const NUMBER_KINDS = new Set(["integer", "decimal", "money", "percentage"]);
|
|
21
|
+
const DEFAULT_WIDGETS = {
|
|
22
|
+
string: "text",
|
|
23
|
+
text: "textarea",
|
|
24
|
+
richtext: "markdown",
|
|
25
|
+
email: "email",
|
|
26
|
+
url: "url",
|
|
27
|
+
phone: "tel",
|
|
28
|
+
color: "color",
|
|
29
|
+
integer: "number",
|
|
30
|
+
decimal: "number",
|
|
31
|
+
money: "money",
|
|
32
|
+
percentage: "percentage",
|
|
33
|
+
boolean: "switch",
|
|
34
|
+
date: "date",
|
|
35
|
+
datetime: "datetime",
|
|
36
|
+
enum: "select",
|
|
37
|
+
json: "json",
|
|
38
|
+
relation: "relation-select",
|
|
39
|
+
location: "location",
|
|
40
|
+
file: "file",
|
|
41
|
+
image: "image",
|
|
42
|
+
computed: "computed",
|
|
43
|
+
};
|
|
44
|
+
const COMPUTED_TYPES = ["integer", "decimal", "money", "percentage", "string", "boolean", "date", "datetime"];
|
|
45
|
+
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"];
|
|
46
|
+
/** "10MB" | "500KB" | "1.5 GB" | bytes → bytes. */
|
|
47
|
+
export function parseSize(v) {
|
|
48
|
+
if (typeof v === "number")
|
|
49
|
+
return Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
|
|
50
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?\s*$/i.exec(v);
|
|
51
|
+
if (!m)
|
|
52
|
+
return undefined;
|
|
53
|
+
const unit = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }[(m[2] ?? "b").toLowerCase()];
|
|
54
|
+
return Math.floor(Number(m[1]) * unit);
|
|
55
|
+
}
|
|
56
|
+
const RESERVED_PATHS = ["users", "me", "stats", "audit-logs", "auth", "docs", "health", "openapi.json", "admin", "account", "config", "notifications", "search", "saved-views", "dashboard", "import", "teams", "api-keys"];
|
|
57
|
+
const BUILTIN_RESERVED_INVERSES = ["sessions", "accounts", "members", "invitations", "twoFactors", "passkeys", "auditLogs", "users", "teams", "teamMemberships", "apiKeys", "createdApiKeys"];
|
|
58
|
+
const ICONS = ["folder", "users", "briefcase", "file-text", "box", "tag", "layers", "star"];
|
|
59
|
+
/** Compile a Forge app definition into the normalized IR, collecting every issue before failing. */
|
|
60
|
+
export function compile(config) {
|
|
61
|
+
const issues = [];
|
|
62
|
+
const issue = (path, message) => issues.push({ path, message });
|
|
63
|
+
if (!config || typeof config !== "object")
|
|
64
|
+
throw new ForgeSchemaError([{ path: "", message: "config must be an object" }]);
|
|
65
|
+
if (!config.name || typeof config.name !== "string")
|
|
66
|
+
issue("name", "application name is required");
|
|
67
|
+
const tenancyEnabled = typeof config.multiTenant === "boolean" ? config.multiTenant : !!config.multiTenant?.enabled;
|
|
68
|
+
const roles = uniq(["admin", ...(config.roles ?? []), ...(tenancyEnabled ? ["member"] : ["user"])]);
|
|
69
|
+
for (const role of roles) {
|
|
70
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(role))
|
|
71
|
+
issue("roles", `role "${role}" must be lowercase (letters, digits, - or _)`);
|
|
72
|
+
if (role === "owner" || role === "authenticated" || role === "public" || role === "team") {
|
|
73
|
+
issue("roles", `"${role}" is a reserved permission token and cannot be used as a role name`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const auth = {
|
|
77
|
+
emailPassword: config.auth?.emailPassword ?? true,
|
|
78
|
+
registration: config.auth?.registration ?? true,
|
|
79
|
+
social: [...(config.auth?.social ?? [])],
|
|
80
|
+
emailVerification: config.auth?.emailVerification ?? false,
|
|
81
|
+
passwordReset: config.auth?.passwordReset ?? true,
|
|
82
|
+
twoFactor: config.auth?.twoFactor ?? false,
|
|
83
|
+
passkeys: config.auth?.passkeys ?? false,
|
|
84
|
+
};
|
|
85
|
+
for (const provider of auth.social) {
|
|
86
|
+
if (!SOCIAL_PROVIDERS.includes(provider))
|
|
87
|
+
issue("auth.social", `unknown provider "${provider}" (supported: ${SOCIAL_PROVIDERS.join(", ")})`);
|
|
88
|
+
}
|
|
89
|
+
if (!auth.emailPassword && auth.social.length === 0 && !auth.passkeys) {
|
|
90
|
+
issue("auth", "enable at least one sign-in method (emailPassword, social or passkeys)");
|
|
91
|
+
}
|
|
92
|
+
if (config.database?.provider && config.database.provider !== "postgresql") {
|
|
93
|
+
issue("database.provider", `only "postgresql" is supported in this version`);
|
|
94
|
+
}
|
|
95
|
+
// Plugins: validate, then merge the entities they contribute into the schema.
|
|
96
|
+
const plugins = [];
|
|
97
|
+
const pluginEntities = [];
|
|
98
|
+
for (const [i, pl] of (config.plugins ?? []).entries()) {
|
|
99
|
+
const pat = `plugins[${i}]`;
|
|
100
|
+
if (!pl || typeof pl !== "object" || pl.__forge !== "plugin") {
|
|
101
|
+
issue(pat, "plugins must be created by a plugin factory, e.g. storage()");
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (plugins.some((x) => x.name === pl.name))
|
|
105
|
+
issue(pat, `plugin "${pl.name}" is listed twice`);
|
|
106
|
+
const entityNames = Object.keys(pl.entities ?? {});
|
|
107
|
+
for (const name of entityNames) {
|
|
108
|
+
if (config.entities?.[name])
|
|
109
|
+
issue(`${pat}.entities.${name}`, `plugin "${pl.name}" defines ${name}, which already exists in your entities`);
|
|
110
|
+
else
|
|
111
|
+
pluginEntities.push([name, pl.entities[name]]);
|
|
112
|
+
}
|
|
113
|
+
plugins.push({
|
|
114
|
+
name: pl.name,
|
|
115
|
+
package: pl.package,
|
|
116
|
+
version: pl.version ?? "0.1.0",
|
|
117
|
+
options: JSON.parse(JSON.stringify(pl.options ?? {})),
|
|
118
|
+
runtime: { api: !!pl.runtime?.api, worker: !!pl.runtime?.worker, web: !!pl.runtime?.web },
|
|
119
|
+
env: (pl.env ?? []).map((v) => ({ ...v, required: !!v.required, secret: !!v.secret })),
|
|
120
|
+
docker: { services: JSON.parse(JSON.stringify(pl.docker?.services ?? {})), volumes: [...(pl.docker?.volumes ?? [])] },
|
|
121
|
+
nav: (pl.nav ?? []).map((n) => ({ label: n.label, path: n.path, icon: n.icon ?? "box", admin: !!n.admin })),
|
|
122
|
+
reservedPaths: [...(pl.reservedPaths ?? [])],
|
|
123
|
+
entities: entityNames,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
for (const [i, pl] of plugins.entries()) {
|
|
127
|
+
for (const req of (config.plugins?.[i]?.requires ?? [])) {
|
|
128
|
+
if (!plugins.some((x) => x.name === req))
|
|
129
|
+
issue(`plugins[${i}]`, `plugin "${pl.name}" requires the "${req}" plugin — add ${req}() to plugins`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const reservedPaths = [...RESERVED_PATHS, ...plugins.flatMap((pl) => pl.reservedPaths)];
|
|
133
|
+
const entityDefs = [...Object.entries(config.entities ?? {}), ...pluginEntities];
|
|
134
|
+
if (entityDefs.length === 0)
|
|
135
|
+
issue("entities", "define at least one entity");
|
|
136
|
+
// Pass 1: entity-level identity (needed to resolve relations in pass 2).
|
|
137
|
+
const shells = new Map();
|
|
138
|
+
const reservedEntity = (n) => RESERVED_ENTITY_NAMES.includes(n);
|
|
139
|
+
for (const [name, def] of entityDefs) {
|
|
140
|
+
const at = `entities.${name}`;
|
|
141
|
+
if (!isPascalCase(name))
|
|
142
|
+
issue(at, "entity names must be PascalCase");
|
|
143
|
+
if (reservedEntity(name))
|
|
144
|
+
issue(at, `"${name}" is reserved by Forge`);
|
|
145
|
+
if (!def || typeof def !== "object" || !def.fields) {
|
|
146
|
+
issue(at, "entity must be created with entity({ fields: { ... } })");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// The Prisma model follows the storage name, so renaming an entity keeps every database object.
|
|
150
|
+
const storage = def.storageName ?? name;
|
|
151
|
+
if (def.storageName !== undefined) {
|
|
152
|
+
if (typeof def.storageName !== "string" || !isPascalCase(def.storageName))
|
|
153
|
+
issue(`${at}.storageName`, "must be PascalCase, like an entity name");
|
|
154
|
+
else if (reservedEntity(def.storageName))
|
|
155
|
+
issue(`${at}.storageName`, `"${def.storageName}" is reserved by Forge`);
|
|
156
|
+
}
|
|
157
|
+
shells.set(name, {
|
|
158
|
+
def,
|
|
159
|
+
model: storage,
|
|
160
|
+
delegate: lowerFirst(storage),
|
|
161
|
+
path: def.path ?? kebabCase(pluralize(name)),
|
|
162
|
+
display: def.display ?? pickDisplay(def.fields),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
for (const [n, s] of shells) {
|
|
166
|
+
if (reservedPaths.includes(s.path))
|
|
167
|
+
issue(`entities.${n}.path`, `"${s.path}" is reserved by Forge or a plugin; set path: "..."`);
|
|
168
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(s.path))
|
|
169
|
+
issue(`entities.${n}.path`, "must be lowercase letters, digits and dashes");
|
|
170
|
+
}
|
|
171
|
+
checkUnique([...shells].map(([n, s]) => [n, s.path]), "path", issue);
|
|
172
|
+
checkUnique([...shells].map(([n, s]) => [n, s.model]), "storage name", issue);
|
|
173
|
+
checkUnique([...shells].map(([n, s]) => [n, s.def.table ?? snakeCase(pluralize(n))]), "table", issue);
|
|
174
|
+
const entities = [];
|
|
175
|
+
for (const [name, shell] of shells) {
|
|
176
|
+
const { def } = shell;
|
|
177
|
+
const at = `entities.${name}`;
|
|
178
|
+
const fields = [];
|
|
179
|
+
for (const [fieldName, fieldDef] of Object.entries(def.fields)) {
|
|
180
|
+
const fat = `${at}.fields.${fieldName}`;
|
|
181
|
+
if (!isFieldDefinition(fieldDef)) {
|
|
182
|
+
issue(fat, "fields must be created with a field builder, e.g. string({ required: true })");
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!isCamelCase(fieldName))
|
|
186
|
+
issue(fat, "field names must be camelCase");
|
|
187
|
+
if (RESERVED_FIELD_NAMES.includes(fieldName))
|
|
188
|
+
issue(fat, `"${fieldName}" is managed by Forge`);
|
|
189
|
+
fields.push(compileField(fieldName, fieldDef, fat, shells, issue));
|
|
190
|
+
}
|
|
191
|
+
// features.teams: records belong to a team of the organization (built-in Team model).
|
|
192
|
+
const teamsOn = !!def.features?.teams;
|
|
193
|
+
if (teamsOn) {
|
|
194
|
+
if (!tenancyEnabled)
|
|
195
|
+
issue(`${at}.features.teams`, "teams live inside organizations — enable multiTenant first");
|
|
196
|
+
if (def.fields.team)
|
|
197
|
+
issue(`${at}.fields.team`, `"team" is added by features: { teams: true }; remove this field or rename it`);
|
|
198
|
+
else {
|
|
199
|
+
const team = compileField("team", { __forge: "field", kind: "relation", options: { entity: "Team", label: "Team", filterable: true } }, `${at}.features.teams`, shells, issue);
|
|
200
|
+
fields.push(team);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const f of fields) {
|
|
204
|
+
if (f.relation?.entity === "Team" && !tenancyEnabled && !teamsOn)
|
|
205
|
+
issue(`${at}.fields.${f.name}`, "relations to Team need multiTenant");
|
|
206
|
+
}
|
|
207
|
+
for (const f of fields) {
|
|
208
|
+
if (!f.relation || f.relation.inverse)
|
|
209
|
+
continue;
|
|
210
|
+
const siblings = fields.filter((o) => o.relation?.entity === f.relation.entity).length;
|
|
211
|
+
f.relation.inverse = siblings > 1 ? `${f.name}${pluralize(name)}` : lowerFirst(pluralize(name));
|
|
212
|
+
if (f.relation.builtin && BUILTIN_RESERVED_INVERSES.includes(f.relation.inverse)) {
|
|
213
|
+
issue(`${at}.fields.${f.name}.inverse`, `"${f.relation.inverse}" is used by the built-in ${f.relation.entity} model; set inverse: "..."`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const apiNames = new Map();
|
|
217
|
+
for (const f of fields) {
|
|
218
|
+
const other = apiNames.get(f.apiName);
|
|
219
|
+
if (other)
|
|
220
|
+
issue(`${at}.fields.${f.name}`, `API name "${f.apiName}" collides with field "${other}"`);
|
|
221
|
+
apiNames.set(f.apiName, f.name);
|
|
222
|
+
}
|
|
223
|
+
const scalar = fields.filter((f) => !["relation", "json", "location", "file", "image"].includes(f.kind) && !f.hidden);
|
|
224
|
+
if (!fields.some((f) => f.name === shell.display) || !scalar.some((f) => f.name === shell.display)) {
|
|
225
|
+
if (def.display)
|
|
226
|
+
issue(`${at}.display`, `"${def.display}" must be a visible scalar field`);
|
|
227
|
+
else if (scalar.length === 0)
|
|
228
|
+
shell.display = "id";
|
|
229
|
+
}
|
|
230
|
+
const userRelations = fields.filter((f) => f.relation?.entity === "User");
|
|
231
|
+
const explicitOwners = fields.filter((f) => f.relation?.owner);
|
|
232
|
+
if (explicitOwners.length > 1)
|
|
233
|
+
issue(at, "only one relation can be marked as owner");
|
|
234
|
+
for (const o of explicitOwners) {
|
|
235
|
+
if (o.relation.entity !== "User")
|
|
236
|
+
issue(`${at}.fields.${o.name}`, "the owner relation must target User");
|
|
237
|
+
}
|
|
238
|
+
const ownerField = explicitOwners[0] ?? userRelations.find((f) => f.name === "owner");
|
|
239
|
+
if (ownerField?.relation)
|
|
240
|
+
ownerField.relation.owner = true;
|
|
241
|
+
const features = {
|
|
242
|
+
timestamps: def.features?.timestamps ?? true,
|
|
243
|
+
softDelete: def.features?.softDelete ?? false,
|
|
244
|
+
audit: def.features?.audit ?? false,
|
|
245
|
+
tenant: def.features?.tenant ?? tenancyEnabled,
|
|
246
|
+
export: def.features?.export ?? true,
|
|
247
|
+
bulkDelete: def.features?.bulkDelete ?? true,
|
|
248
|
+
...(teamsOn ? { teams: true } : {}),
|
|
249
|
+
comments: def.features?.comments ?? false,
|
|
250
|
+
};
|
|
251
|
+
if (def.features?.comments !== undefined && typeof def.features.comments !== "boolean")
|
|
252
|
+
issue(`${at}.features.comments`, "must be true or false");
|
|
253
|
+
if (features.tenant && !tenancyEnabled)
|
|
254
|
+
issue(`${at}.features.tenant`, "requires multiTenant to be enabled");
|
|
255
|
+
const hasOwner = !!ownerField;
|
|
256
|
+
const hasTeam = teamsOn && fields.some((f) => f.name === "team");
|
|
257
|
+
const defaults = {
|
|
258
|
+
create: ["authenticated"],
|
|
259
|
+
read: ["authenticated"],
|
|
260
|
+
update: hasOwner ? ["admin", "owner"] : ["authenticated"],
|
|
261
|
+
delete: hasOwner ? ["admin", "owner"] : ["admin"],
|
|
262
|
+
};
|
|
263
|
+
const permissions = {};
|
|
264
|
+
for (const op of ["create", "read", "update", "delete"]) {
|
|
265
|
+
permissions[op] = compileRule(def.permissions?.[op] ?? defaults[op], `${at}.permissions.${op}`, op, roles, hasOwner, issue, hasTeam);
|
|
266
|
+
}
|
|
267
|
+
const actions = Object.entries(def.actions ?? {}).map(([actionName, a]) => {
|
|
268
|
+
const aat = `${at}.actions.${actionName}`;
|
|
269
|
+
if (!isCamelCase(actionName))
|
|
270
|
+
issue(aat, "action names must be camelCase");
|
|
271
|
+
return {
|
|
272
|
+
name: actionName,
|
|
273
|
+
label: a.label ?? titleCase(actionName),
|
|
274
|
+
...(a.confirm ? { confirm: a.confirm } : {}),
|
|
275
|
+
variant: a.variant ?? "default",
|
|
276
|
+
permission: a.permission ? compileRule(a.permission, `${aat}.permission`, "update", roles, hasOwner, issue, hasTeam) : permissions.update,
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
const systemSortable = ["id", ...(features.timestamps ? ["createdAt", "updatedAt"] : [])];
|
|
280
|
+
const sortable = new Set([...fields.filter((f) => f.sortable).map((f) => f.name), ...systemSortable]);
|
|
281
|
+
const defaultSort = parseSort(def.list?.defaultSort ?? (features.timestamps ? "-createdAt" : shell.display));
|
|
282
|
+
if (!sortable.has(defaultSort.field))
|
|
283
|
+
issue(`${at}.list.defaultSort`, `"${defaultSort.field}" is not a sortable field`);
|
|
284
|
+
const known = new Set([...fields.map((f) => f.name), ...systemSortable]);
|
|
285
|
+
const columns = def.list?.columns ? [...def.list.columns] : defaultColumns(fields, shell.display, features.timestamps);
|
|
286
|
+
for (const c of columns)
|
|
287
|
+
if (!known.has(c))
|
|
288
|
+
issue(`${at}.list.columns`, `unknown column "${c}"`);
|
|
289
|
+
for (const f of fields)
|
|
290
|
+
f.list = columns.includes(f.name);
|
|
291
|
+
const pageSize = def.list?.pageSize ?? 20;
|
|
292
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 500)
|
|
293
|
+
issue(`${at}.list.pageSize`, "must be an integer between 1 and 500");
|
|
294
|
+
const entity = {
|
|
295
|
+
name,
|
|
296
|
+
label: def.label ?? titleCase(name),
|
|
297
|
+
pluralLabel: def.pluralLabel ?? pluralize(def.label ?? titleCase(name)),
|
|
298
|
+
...(def.description ? { description: def.description } : {}),
|
|
299
|
+
model: shell.model,
|
|
300
|
+
storageName: shell.model,
|
|
301
|
+
delegate: shell.delegate,
|
|
302
|
+
tableName: def.table ?? snakeCase(pluralize(name)),
|
|
303
|
+
path: shell.path,
|
|
304
|
+
display: shell.display,
|
|
305
|
+
icon: def.icon ?? ICONS[entities.length % ICONS.length],
|
|
306
|
+
nav: def.nav ?? true,
|
|
307
|
+
fields,
|
|
308
|
+
inverseRelations: [],
|
|
309
|
+
...(ownerField ? { ownerField: ownerField.name } : {}),
|
|
310
|
+
...(hasTeam ? { teamField: "team" } : {}),
|
|
311
|
+
permissions,
|
|
312
|
+
features,
|
|
313
|
+
actions,
|
|
314
|
+
list: { columns, defaultSort, pageSize },
|
|
315
|
+
views: {},
|
|
316
|
+
computedOrder: [],
|
|
317
|
+
aggregateParents: [],
|
|
318
|
+
};
|
|
319
|
+
compileEntityFeatures(entity, def, { roles, hasOwner, hasTeam, issue });
|
|
320
|
+
entities.push(entity);
|
|
321
|
+
}
|
|
322
|
+
// Pass 3: inverse (one-to-many) relations on target entities.
|
|
323
|
+
const byName = new Map(entities.map((e) => [e.name, e]));
|
|
324
|
+
for (const source of entities) {
|
|
325
|
+
for (const f of source.fields) {
|
|
326
|
+
if (!f.relation)
|
|
327
|
+
continue;
|
|
328
|
+
const target = byName.get(f.relation.entity);
|
|
329
|
+
if (!target)
|
|
330
|
+
continue;
|
|
331
|
+
const clash = target.fields.find((tf) => tf.name === f.relation.inverse) ?? target.inverseRelations.find((ir) => ir.name === f.relation.inverse);
|
|
332
|
+
if (clash) {
|
|
333
|
+
issue(`entities.${source.name}.fields.${f.name}.inverse`, `"${f.relation.inverse}" already exists on ${target.name}; set a different inverse name`);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
target.inverseRelations.push({
|
|
337
|
+
type: f.relation.type === "many-to-many" ? "many-to-many" : "one-to-many",
|
|
338
|
+
name: f.relation.inverse,
|
|
339
|
+
label: source.pluralLabel,
|
|
340
|
+
entity: source.name,
|
|
341
|
+
field: f.name,
|
|
342
|
+
foreignKey: f.relation.foreignKey,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Two relations from the same source to User would produce ambiguous back-relations on User.
|
|
347
|
+
for (const source of entities) {
|
|
348
|
+
const seen = new Map();
|
|
349
|
+
for (const f of source.fields) {
|
|
350
|
+
if (!f.relation?.builtin)
|
|
351
|
+
continue;
|
|
352
|
+
const key = `${f.relation.entity}.${f.relation.inverse}`;
|
|
353
|
+
if (seen.has(key))
|
|
354
|
+
issue(`entities.${source.name}.fields.${f.name}.inverse`, `duplicate inverse name "${f.relation.inverse}" on ${f.relation.entity}`);
|
|
355
|
+
seen.set(key, f.name);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// The built-in RecordComment model adds `recordComments` to User.
|
|
359
|
+
if (entities.some((e) => e.features.comments)) {
|
|
360
|
+
for (const e of entities) {
|
|
361
|
+
for (const f of e.fields) {
|
|
362
|
+
if (f.relation?.entity === "User" && f.relation.inverse === "recordComments") {
|
|
363
|
+
issue(`entities.${e.name}.fields.${f.name}.inverse`, `"recordComments" is used by the built-in RecordComment model on User; set inverse: "..."`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
compileComputed(entities, issue);
|
|
369
|
+
if (!plugins.some((pl) => pl.name === "storage")) {
|
|
370
|
+
for (const e of entities) {
|
|
371
|
+
for (const f of e.fields) {
|
|
372
|
+
if (f.file)
|
|
373
|
+
issue(`entities.${e.name}.fields.${f.name}`, `${f.kind} fields need file storage — add storage() from "@tekki-forge/plugin-storage" to plugins (or run forge add storage)`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const notifications = compileNotifications(config.notifications, entities, roles, issue);
|
|
378
|
+
const dashboard = compileDashboard(config.dashboard, entities, issue);
|
|
379
|
+
const theme = compileTheme(config.theme, issue);
|
|
380
|
+
const i18n = compileI18n(config.i18n, issue);
|
|
381
|
+
const teamsEnabled = tenancyEnabled && entities.some((e) => e.fields.some((f) => f.relation?.entity === "Team"));
|
|
382
|
+
const backups = compileBackups(config.backups, issue);
|
|
383
|
+
const sdk = compileSdk(config.sdk, issue);
|
|
384
|
+
const landing = compileLanding(config.landing, issue);
|
|
385
|
+
if (issues.length > 0)
|
|
386
|
+
throw new ForgeSchemaError(issues);
|
|
387
|
+
return {
|
|
388
|
+
irVersion: 1,
|
|
389
|
+
name: config.name,
|
|
390
|
+
slug: kebabCase(config.name),
|
|
391
|
+
...(config.description ? { description: config.description } : {}),
|
|
392
|
+
database: { provider: "postgresql" },
|
|
393
|
+
auth,
|
|
394
|
+
tenancy: { enabled: tenancyEnabled, model: "organization" },
|
|
395
|
+
roles,
|
|
396
|
+
api: { prefix: config.api?.prefix ?? "/api", docs: config.api?.docs ?? true },
|
|
397
|
+
sdk,
|
|
398
|
+
admin: { enabled: config.admin?.enabled ?? true },
|
|
399
|
+
theme,
|
|
400
|
+
plugins,
|
|
401
|
+
notifications,
|
|
402
|
+
dashboard,
|
|
403
|
+
i18n,
|
|
404
|
+
entities,
|
|
405
|
+
teams: { enabled: teamsEnabled },
|
|
406
|
+
...(backups ? { backups } : {}),
|
|
407
|
+
...(landing ? { landing } : {}),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/** Five-field cron ("0 3 * * *"): minute hour day-of-month month day-of-week. */
|
|
411
|
+
export function isCronPattern(v) {
|
|
412
|
+
const parts = v.trim().split(/\s+/);
|
|
413
|
+
return parts.length === 5 && parts.every((p) => /^(\*|\d+(-\d+)?)(\/\d+)?(,(\*|\d+(-\d+)?)(\/\d+)?)*$/.test(p));
|
|
414
|
+
}
|
|
415
|
+
function compileBackups(raw, issue) {
|
|
416
|
+
if (raw === undefined)
|
|
417
|
+
return undefined;
|
|
418
|
+
if (!raw || typeof raw !== "object") {
|
|
419
|
+
issue("backups", "must be an object, e.g. { schedule: \"0 3 * * *\", keep: 14 }");
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
const keep = raw.keep ?? 14;
|
|
423
|
+
if (!Number.isInteger(keep) || keep < 1 || keep > 1000)
|
|
424
|
+
issue("backups.keep", "must be an integer between 1 and 1000");
|
|
425
|
+
if (raw.schedule !== undefined && (typeof raw.schedule !== "string" || !isCronPattern(raw.schedule))) {
|
|
426
|
+
issue("backups.schedule", `"${String(raw.schedule)}" is not a cron pattern (minute hour day month weekday), e.g. "0 3 * * *"`);
|
|
427
|
+
}
|
|
428
|
+
return { schedule: raw.schedule ?? null, keep };
|
|
429
|
+
}
|
|
430
|
+
const SDK_LANGUAGES = ["typescript", "python", "dart"];
|
|
431
|
+
function compileSdk(config, issue) {
|
|
432
|
+
const languages = [];
|
|
433
|
+
for (const [i, l] of (config?.languages ?? ["typescript"]).entries()) {
|
|
434
|
+
if (!SDK_LANGUAGES.includes(l))
|
|
435
|
+
issue(`sdk.languages[${i}]`, `unknown SDK language "${l}" (supported: ${SDK_LANGUAGES.join(", ")})`);
|
|
436
|
+
else if (!languages.includes(l))
|
|
437
|
+
languages.push(l);
|
|
438
|
+
}
|
|
439
|
+
return { languages };
|
|
440
|
+
}
|
|
441
|
+
function compileField(name, def, at, shells, issue) {
|
|
442
|
+
const o = def.options ?? {};
|
|
443
|
+
const isComputed = def.kind === "computed";
|
|
444
|
+
if (isComputed && !COMPUTED_TYPES.includes(o.type))
|
|
445
|
+
issue(`${at}.type`, `computed fields need a type: ${COMPUTED_TYPES.join(", ")}`);
|
|
446
|
+
if (isComputed && (typeof o.expression !== "string" || !o.expression.trim()))
|
|
447
|
+
issue(`${at}.expression`, "computed fields need an expression");
|
|
448
|
+
// A computed field is stored as its result type and is never writable.
|
|
449
|
+
const kind = (isComputed ? (COMPUTED_TYPES.includes(o.type) ? o.type : "string") : def.kind);
|
|
450
|
+
if (!DEFAULT_WIDGETS[kind])
|
|
451
|
+
issue(at, `unknown field type "${kind}"`);
|
|
452
|
+
const isText = TEXT_KINDS.has(kind);
|
|
453
|
+
const isNumber = NUMBER_KINDS.has(kind);
|
|
454
|
+
const isRelation = kind === "relation";
|
|
455
|
+
const isFile = kind === "file" || kind === "image";
|
|
456
|
+
const isManyToMany = isRelation && o.type === "many-to-many";
|
|
457
|
+
const isScalarSortable = !["json", "richtext", "text", "location", "file", "image"].includes(kind);
|
|
458
|
+
const field = {
|
|
459
|
+
name,
|
|
460
|
+
kind,
|
|
461
|
+
label: o.label ?? titleCase(name),
|
|
462
|
+
...(o.description ? { description: o.description } : {}),
|
|
463
|
+
required: !!o.required,
|
|
464
|
+
unique: !!o.unique,
|
|
465
|
+
index: !!o.index,
|
|
466
|
+
searchable: o.searchable ?? false,
|
|
467
|
+
filterable: o.filterable ?? (kind === "enum" || kind === "boolean" || isRelation),
|
|
468
|
+
sortable: o.sortable ?? (isScalarSortable && !isRelation),
|
|
469
|
+
list: !!o.list,
|
|
470
|
+
hidden: !!o.hidden,
|
|
471
|
+
readonly: !!o.readonly || isComputed,
|
|
472
|
+
widget: o.widget ?? DEFAULT_WIDGETS[kind] ?? "text",
|
|
473
|
+
...(o.placeholder ? { placeholder: o.placeholder } : {}),
|
|
474
|
+
column: snakeCase(name),
|
|
475
|
+
apiName: name,
|
|
476
|
+
validation: {},
|
|
477
|
+
};
|
|
478
|
+
if (field.searchable && !isText)
|
|
479
|
+
issue(`${at}.searchable`, "only text fields can be searchable");
|
|
480
|
+
if (field.unique && (kind === "json" || isRelation || isFile || kind === "location"))
|
|
481
|
+
issue(`${at}.unique`, `not supported for ${kind} fields`);
|
|
482
|
+
if (isComputed) {
|
|
483
|
+
if (o.required || o.unique || o.default !== undefined)
|
|
484
|
+
issue(at, "computed fields cannot be required, unique or have a default");
|
|
485
|
+
if (o.searchable && kind !== "string")
|
|
486
|
+
issue(`${at}.searchable`, "only text fields can be searchable");
|
|
487
|
+
field.required = false;
|
|
488
|
+
field.computed = { expression: String(o.expression ?? ""), ast: null, fields: [], aggregates: [] };
|
|
489
|
+
}
|
|
490
|
+
if ((kind === "location" || isFile) && (o.filterable || o.sortable))
|
|
491
|
+
issue(at, `${kind} fields cannot be filtered or sorted`);
|
|
492
|
+
if (kind === "location") {
|
|
493
|
+
field.filterable = false;
|
|
494
|
+
field.sortable = false;
|
|
495
|
+
}
|
|
496
|
+
if (isFile) {
|
|
497
|
+
const image = kind === "image";
|
|
498
|
+
const maxSize = o.maxSize === undefined ? (image ? 5 : 10) * 1024 ** 2 : parseSize(o.maxSize);
|
|
499
|
+
if (!maxSize)
|
|
500
|
+
issue(`${at}.maxSize`, 'use a size like "10MB", "500KB" or a number of bytes');
|
|
501
|
+
const accept = [...(o.accept ?? (image ? IMAGE_TYPES : []))].map((a) => a.trim().toLowerCase());
|
|
502
|
+
for (const a of accept) {
|
|
503
|
+
if (!/^(\.[a-z0-9]+|[a-z]+\/([a-z0-9.+-]+|\*))$/.test(a))
|
|
504
|
+
issue(`${at}.accept`, `"${a}" is not a MIME type (image/png, image/*) or extension (.pdf)`);
|
|
505
|
+
if (image && !a.startsWith("image/") && !/^\.(png|jpe?g|gif|webp|avif)$/.test(a))
|
|
506
|
+
issue(`${at}.accept`, `image fields only accept images, not "${a}"`);
|
|
507
|
+
if (a === "image/svg+xml" || a === ".svg")
|
|
508
|
+
issue(`${at}.accept`, "SVG can carry scripts; upload it with file() — it is then always downloaded, never displayed inline");
|
|
509
|
+
}
|
|
510
|
+
field.apiName = `${name}Id`;
|
|
511
|
+
field.column = `${snakeCase(name)}_id`;
|
|
512
|
+
field.filterable = false;
|
|
513
|
+
field.sortable = false;
|
|
514
|
+
field.file = { image, accept, maxSize: maxSize ?? 0 };
|
|
515
|
+
}
|
|
516
|
+
if (isText) {
|
|
517
|
+
for (const key of ["minLength", "maxLength"]) {
|
|
518
|
+
const v = o[key];
|
|
519
|
+
if (v !== undefined) {
|
|
520
|
+
if (!Number.isInteger(v) || v < 0)
|
|
521
|
+
issue(`${at}.${key}`, "must be a non-negative integer");
|
|
522
|
+
field.validation[key] = v;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (o.pattern !== undefined) {
|
|
526
|
+
try {
|
|
527
|
+
new RegExp(o.pattern);
|
|
528
|
+
field.validation.pattern = o.pattern;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
issue(`${at}.pattern`, "is not a valid regular expression");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (kind === "string" && field.validation.maxLength === undefined)
|
|
535
|
+
field.validation.maxLength = 255;
|
|
536
|
+
}
|
|
537
|
+
if (isNumber) {
|
|
538
|
+
if (isComputed && (o.min !== undefined || o.max !== undefined))
|
|
539
|
+
issue(at, "computed fields have no min/max");
|
|
540
|
+
if (o.min !== undefined)
|
|
541
|
+
field.validation.min = o.min;
|
|
542
|
+
if (o.max !== undefined)
|
|
543
|
+
field.validation.max = o.max;
|
|
544
|
+
if (kind === "percentage") {
|
|
545
|
+
field.validation.min ??= 0;
|
|
546
|
+
field.validation.max ??= 100;
|
|
547
|
+
}
|
|
548
|
+
if (kind === "decimal" || kind === "money") {
|
|
549
|
+
field.precision = o.precision ?? (kind === "money" ? 19 : 18);
|
|
550
|
+
field.scale = o.scale ?? (kind === "money" ? 4 : 6);
|
|
551
|
+
if (field.scale > field.precision)
|
|
552
|
+
issue(`${at}.scale`, "cannot exceed precision");
|
|
553
|
+
}
|
|
554
|
+
if (kind === "money") {
|
|
555
|
+
field.currency = (o.currency ?? "USD").toUpperCase();
|
|
556
|
+
if (!/^[A-Z]{3}$/.test(field.currency))
|
|
557
|
+
issue(`${at}.currency`, "must be an ISO 4217 code such as USD");
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (kind === "enum") {
|
|
561
|
+
const raw = o.values;
|
|
562
|
+
const values = Array.isArray(raw)
|
|
563
|
+
? raw.map((v) => ({ value: v, label: titleCase(v) }))
|
|
564
|
+
: raw && typeof raw === "object"
|
|
565
|
+
? Object.entries(raw).map(([value, label]) => ({ value, label }))
|
|
566
|
+
: [];
|
|
567
|
+
if (values.length === 0)
|
|
568
|
+
issue(`${at}.values`, "enum fields need at least one value");
|
|
569
|
+
for (const v of values) {
|
|
570
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(v.value))
|
|
571
|
+
issue(`${at}.values`, `"${v.value}" must start with a letter and contain only letters, digits or _`);
|
|
572
|
+
}
|
|
573
|
+
if (new Set(values.map((v) => v.value)).size !== values.length)
|
|
574
|
+
issue(`${at}.values`, "values must be unique");
|
|
575
|
+
field.enumValues = values;
|
|
576
|
+
}
|
|
577
|
+
if (isRelation) {
|
|
578
|
+
const target = o.entity;
|
|
579
|
+
const builtin = target ? BUILTIN_ENTITIES[target] : undefined;
|
|
580
|
+
const shell = target ? shells.get(target) : undefined;
|
|
581
|
+
if (!target)
|
|
582
|
+
issue(`${at}.entity`, "relation fields need a target entity");
|
|
583
|
+
else if (!builtin && !shell)
|
|
584
|
+
issue(`${at}.entity`, `unknown entity "${target}"`);
|
|
585
|
+
if (o.type && o.type !== "many-to-one" && o.type !== "many-to-many")
|
|
586
|
+
issue(`${at}.type`, `relation type must be "many-to-one" or "many-to-many"`);
|
|
587
|
+
if (isManyToMany) {
|
|
588
|
+
if (target === "Organization")
|
|
589
|
+
issue(`${at}.entity`, "many-to-many relations to Organization are not supported");
|
|
590
|
+
if (o.owner)
|
|
591
|
+
issue(`${at}.owner`, "the owner must be a many-to-one relation");
|
|
592
|
+
if (o.onDelete)
|
|
593
|
+
issue(`${at}.onDelete`, "many-to-many links are always removed with either side");
|
|
594
|
+
}
|
|
595
|
+
const onDelete = o.onDelete ?? (o.required ? "restrict" : "setNull");
|
|
596
|
+
if (!isManyToMany && onDelete === "setNull" && o.required)
|
|
597
|
+
issue(`${at}.onDelete`, `"setNull" requires an optional relation`);
|
|
598
|
+
field.apiName = isManyToMany ? `${singularize(name)}Ids` : `${name}Id`;
|
|
599
|
+
field.column = isManyToMany ? "" : `${snakeCase(name)}_id`;
|
|
600
|
+
if (isManyToMany)
|
|
601
|
+
field.sortable = false;
|
|
602
|
+
field.relation = {
|
|
603
|
+
type: isManyToMany ? "many-to-many" : "many-to-one",
|
|
604
|
+
entity: target ?? "",
|
|
605
|
+
model: builtin?.model ?? shell?.model ?? target ?? "",
|
|
606
|
+
delegate: builtin?.delegate ?? shell?.delegate ?? "",
|
|
607
|
+
...(shell ? { path: shell.path } : {}),
|
|
608
|
+
builtin: !!builtin,
|
|
609
|
+
foreignKey: isManyToMany ? "" : `${name}Id`,
|
|
610
|
+
foreignKeyColumn: isManyToMany ? "" : `${snakeCase(name)}_id`,
|
|
611
|
+
onDelete: isManyToMany ? "cascade" : onDelete,
|
|
612
|
+
owner: !!o.owner,
|
|
613
|
+
inverse: o.inverse ?? "",
|
|
614
|
+
targetDisplay: builtin?.display ?? shell?.display ?? "id",
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
if (o.default !== undefined)
|
|
618
|
+
field.default = compileDefault(field, o.default, at, issue);
|
|
619
|
+
return field;
|
|
620
|
+
}
|
|
621
|
+
function compileDefault(field, value, at, issue) {
|
|
622
|
+
const bad = (expected) => {
|
|
623
|
+
issue(`${at}.default`, `expected ${expected}`);
|
|
624
|
+
return undefined;
|
|
625
|
+
};
|
|
626
|
+
switch (field.kind) {
|
|
627
|
+
case "relation":
|
|
628
|
+
return bad("no default on relation fields (owner relations default to the current user)");
|
|
629
|
+
case "location":
|
|
630
|
+
case "file":
|
|
631
|
+
case "image":
|
|
632
|
+
return bad(`no default on ${field.kind} fields`);
|
|
633
|
+
case "date":
|
|
634
|
+
case "datetime":
|
|
635
|
+
if (value === "now")
|
|
636
|
+
return { kind: "now" };
|
|
637
|
+
if (typeof value !== "string" || Number.isNaN(Date.parse(value)))
|
|
638
|
+
return bad(`"now" or an ISO date string`);
|
|
639
|
+
return { kind: "value", value };
|
|
640
|
+
case "boolean":
|
|
641
|
+
return typeof value === "boolean" ? { kind: "value", value } : bad("a boolean");
|
|
642
|
+
case "integer":
|
|
643
|
+
return Number.isInteger(value) ? { kind: "value", value: value } : bad("an integer");
|
|
644
|
+
case "decimal":
|
|
645
|
+
case "money":
|
|
646
|
+
case "percentage":
|
|
647
|
+
return typeof value === "number" ? { kind: "value", value } : bad("a number");
|
|
648
|
+
case "enum":
|
|
649
|
+
return field.enumValues?.some((v) => v.value === value) ? { kind: "value", value: value } : bad(`one of the enum values`);
|
|
650
|
+
case "json":
|
|
651
|
+
return { kind: "value", value: value };
|
|
652
|
+
default:
|
|
653
|
+
return typeof value === "string" ? { kind: "value", value } : bad("a string");
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function isFieldDefinition(v) {
|
|
657
|
+
return !!v && typeof v === "object" && v.__forge === "field";
|
|
658
|
+
}
|
|
659
|
+
function pickDisplay(fields) {
|
|
660
|
+
const names = Object.keys(fields);
|
|
661
|
+
for (const preferred of ["name", "title", "label", "number", "code", "email"])
|
|
662
|
+
if (names.includes(preferred))
|
|
663
|
+
return preferred;
|
|
664
|
+
return names.find((n) => TEXT_KINDS.has(fields[n].kind)) ?? names.find((n) => fields[n].kind !== "relation") ?? "id";
|
|
665
|
+
}
|
|
666
|
+
function defaultColumns(fields, display, timestamps) {
|
|
667
|
+
const explicit = fields.filter((f) => f.list && !f.hidden).map((f) => f.name);
|
|
668
|
+
if (explicit.length > 0)
|
|
669
|
+
return uniq([display, ...explicit, ...(timestamps ? ["createdAt"] : [])]).filter((c) => c !== "id");
|
|
670
|
+
const picked = fields
|
|
671
|
+
.filter((f) => !f.hidden && !["text", "richtext", "json", "location", "file"].includes(f.kind) && f.name !== display)
|
|
672
|
+
.slice(0, 4)
|
|
673
|
+
.map((f) => f.name);
|
|
674
|
+
return uniq([display, ...picked, ...(timestamps ? ["createdAt"] : [])]).filter((c) => c !== "id");
|
|
675
|
+
}
|
|
676
|
+
export function parseSort(sort) {
|
|
677
|
+
return sort.startsWith("-") ? { field: sort.slice(1), direction: "desc" } : { field: sort.replace(/^\+/, ""), direction: "asc" };
|
|
678
|
+
}
|
|
679
|
+
function checkUnique(pairs, what, issue) {
|
|
680
|
+
const seen = new Map();
|
|
681
|
+
for (const [name, value] of pairs) {
|
|
682
|
+
const prev = seen.get(value);
|
|
683
|
+
if (prev)
|
|
684
|
+
issue(`entities.${name}`, `${what} "${value}" is already used by ${prev}`);
|
|
685
|
+
seen.set(value, name);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function uniq(xs) {
|
|
689
|
+
return [...new Set(xs)];
|
|
690
|
+
}
|
|
691
|
+
//# sourceMappingURL=compile.js.map
|