@monimesl/dot-compiler 0.1.0-alpha.10
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 +1 -0
- package/dist/index.d.ts +119 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1013 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { ScriptParseError, parseDotModule, parseExpression, parseLogicModule, parseStateModule, unboundReferences } from "@monimesl/dot-script-parser";
|
|
6
|
+
import { PERMISSIONS, REACT_HOST_MODULES, SourceError, builtinComponents, builtinRegistry, componentRegistry, eventName, isNetworkHost, isPermission, isSemanticVersion, reactHostUrl } from "@monimesl/dot-core";
|
|
7
|
+
import { zipSync } from "fflate";
|
|
8
|
+
import { rolldown } from "rolldown";
|
|
9
|
+
import { esmExternalRequirePlugin } from "rolldown/plugins";
|
|
10
|
+
import { parseDmlToIR } from "@monimesl/dot-dml-parser";
|
|
11
|
+
import { parseEnv } from "node:util";
|
|
12
|
+
//#region src/errors.ts
|
|
13
|
+
var CompileError = class extends SourceError {
|
|
14
|
+
name = "CompileError";
|
|
15
|
+
};
|
|
16
|
+
/** A problem that does not stop compilation. */
|
|
17
|
+
var CompileWarning = class extends SourceError {
|
|
18
|
+
name = "CompileWarning";
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/project/categories.ts
|
|
22
|
+
const CATEGORIES = [
|
|
23
|
+
"agriculture",
|
|
24
|
+
"asset_finance",
|
|
25
|
+
"donations",
|
|
26
|
+
"education",
|
|
27
|
+
"electricity",
|
|
28
|
+
"entertainment",
|
|
29
|
+
"events",
|
|
30
|
+
"finance",
|
|
31
|
+
"food_and_drink",
|
|
32
|
+
"gambling",
|
|
33
|
+
"games",
|
|
34
|
+
"government",
|
|
35
|
+
"health",
|
|
36
|
+
"insurance",
|
|
37
|
+
"internet_service_provider",
|
|
38
|
+
"investments",
|
|
39
|
+
"jobs",
|
|
40
|
+
"lifestyle",
|
|
41
|
+
"loans",
|
|
42
|
+
"micro_credit",
|
|
43
|
+
"microfinance",
|
|
44
|
+
"news",
|
|
45
|
+
"other",
|
|
46
|
+
"productivity",
|
|
47
|
+
"real_estate",
|
|
48
|
+
"religion",
|
|
49
|
+
"remittances",
|
|
50
|
+
"savings",
|
|
51
|
+
"shopping",
|
|
52
|
+
"social",
|
|
53
|
+
"sports",
|
|
54
|
+
"telecom",
|
|
55
|
+
"television",
|
|
56
|
+
"transport",
|
|
57
|
+
"travel",
|
|
58
|
+
"utilities",
|
|
59
|
+
"water"
|
|
60
|
+
];
|
|
61
|
+
function isCategory(value) {
|
|
62
|
+
return CATEGORIES.includes(value);
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/project/dot-json.ts
|
|
66
|
+
const DOT_JSON_FILE = "dot.json";
|
|
67
|
+
const DOT_JSON_FIELDS = [
|
|
68
|
+
"$schema",
|
|
69
|
+
"id",
|
|
70
|
+
"name",
|
|
71
|
+
"version",
|
|
72
|
+
"description",
|
|
73
|
+
"categories",
|
|
74
|
+
"icon",
|
|
75
|
+
"permissions",
|
|
76
|
+
"network",
|
|
77
|
+
"dash"
|
|
78
|
+
];
|
|
79
|
+
const REQUIRED_DOT_JSON_FIELDS = [
|
|
80
|
+
"id",
|
|
81
|
+
"name",
|
|
82
|
+
"version"
|
|
83
|
+
];
|
|
84
|
+
const DASH_FIELDS = ["minVersion"];
|
|
85
|
+
const NETWORK_FIELDS = ["hosts"];
|
|
86
|
+
const ID_WORD = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
|
|
87
|
+
const DOT_ID_PATTERN = new RegExp(`^(?=[a-z])${ID_WORD}(?:\\.${ID_WORD})+$`);
|
|
88
|
+
async function readDotJson(file) {
|
|
89
|
+
let dotJson;
|
|
90
|
+
try {
|
|
91
|
+
dotJson = JSON.parse(await readFile(file, "utf8"));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new CompileError(`This isn't valid JSON: ${error.message}`, file);
|
|
94
|
+
}
|
|
95
|
+
if (!isObject(dotJson)) throw new CompileError("dot.json must be an object, such as { \"id\": \"com.example.shop\", \"name\": \"Shop\", \"version\": \"1.0.0\" }", file);
|
|
96
|
+
if ("pages" in dotJson) throw new CompileError("List pages in dot.ts, not here: defineDot({ pages: [...] })", file);
|
|
97
|
+
rejectUnknownFields(dotJson, DOT_JSON_FIELDS, file);
|
|
98
|
+
for (const field of REQUIRED_DOT_JSON_FIELDS) if (dotJson[field] === void 0) throw new CompileError(`"${field}" is required`, file);
|
|
99
|
+
const { $schema, id, name, version, description, categories, icon, permissions, network, dash } = dotJson;
|
|
100
|
+
if ($schema !== void 0 && typeof $schema !== "string") throw new CompileError("\"$schema\" must be a string", file);
|
|
101
|
+
if (typeof id !== "string" || !DOT_ID_PATTERN.test(id)) throw new CompileError("\"id\" must be a reverse domain name, such as \"com.example.counter\". Use lowercase letters, digits and hyphens, with dots between words. A word can't start or end with a hyphen, or be longer than 63 characters.", file);
|
|
102
|
+
if (id.length > 128) throw new CompileError(`"id" must be at most 128 characters`, file);
|
|
103
|
+
if (!isNonEmptyString(name)) throw new CompileError("\"name\" must be text, and not empty", file);
|
|
104
|
+
if (!isSemanticVersion(version)) throw new CompileError("\"version\" must be a semantic version, such as \"1.0.0\"", file);
|
|
105
|
+
if (description !== void 0 && !isNonEmptyString(description)) throw new CompileError("\"description\" must be text, and not empty", file);
|
|
106
|
+
if (icon !== void 0 && !(isNonEmptyString(icon) && icon.endsWith(".png"))) throw new CompileError("\"icon\" must be the path to a PNG file, such as \"icon.png\"", file);
|
|
107
|
+
return {
|
|
108
|
+
id,
|
|
109
|
+
name,
|
|
110
|
+
version,
|
|
111
|
+
description,
|
|
112
|
+
categories: readCategories(categories, file),
|
|
113
|
+
icon,
|
|
114
|
+
permissions: readPermissions(permissions, file),
|
|
115
|
+
network: readNetworkAccess(network, file),
|
|
116
|
+
dash: readDashRequirements(dash, file)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function readCategories(categories, file) {
|
|
120
|
+
if (categories === void 0) return;
|
|
121
|
+
if (!Array.isArray(categories) || categories.length === 0) throw new CompileError("\"categories\" must list one or more categories, such as [\"finance\", \"savings\"]", file);
|
|
122
|
+
const listed = [];
|
|
123
|
+
for (const category of categories) {
|
|
124
|
+
if (!isCategory(category)) throw new CompileError(`"${String(category)}" is not a category. The categories are ${quotedList(CATEGORIES)}`, file);
|
|
125
|
+
if (listed.includes(category)) throw new CompileError(`"categories" lists "${category}" more than once`, file);
|
|
126
|
+
listed.push(category);
|
|
127
|
+
}
|
|
128
|
+
return listed;
|
|
129
|
+
}
|
|
130
|
+
function readPermissions(permissions, file) {
|
|
131
|
+
if (permissions === void 0) return;
|
|
132
|
+
if (!isObject(permissions)) throw new CompileError("\"permissions\" must be an object, such as { \"account\": \"Show your name on receipts\" }", file);
|
|
133
|
+
for (const [permission, reason] of Object.entries(permissions)) {
|
|
134
|
+
if (!isPermission(permission)) throw new CompileError(`Unknown permission "${permission}". The permissions are ${quotedList(PERMISSIONS)}`, file);
|
|
135
|
+
if (!isNonEmptyString(reason)) throw new CompileError(`"permissions.${permission}" must say why the dot needs it, such as "Show your name on receipts"`, file);
|
|
136
|
+
}
|
|
137
|
+
return permissions;
|
|
138
|
+
}
|
|
139
|
+
function readNetworkAccess(network, file) {
|
|
140
|
+
if (network === void 0) return;
|
|
141
|
+
if (!isObject(network)) throw new CompileError("\"network\" must be an object, such as { \"hosts\": [\"api.example.com\"] }", file);
|
|
142
|
+
rejectUnknownFields(network, NETWORK_FIELDS, file, "network");
|
|
143
|
+
const { hosts } = network;
|
|
144
|
+
if (!Array.isArray(hosts) || hosts.length === 0) throw new CompileError("\"network.hosts\" must list the hosts the dot sends requests to, such as [\"api.example.com\"]", file);
|
|
145
|
+
const listed = [];
|
|
146
|
+
for (const host of hosts) {
|
|
147
|
+
if (typeof host !== "string" || !isNetworkHost(host)) throw new CompileError(`"${String(host)}" is not a host. Use a lowercase host name, such as "api.example.com", or "*.example.com" for all its subdomains. Leave out https://, the port and the path.`, file);
|
|
148
|
+
if (listed.includes(host)) throw new CompileError(`"network.hosts" lists "${host}" more than once`, file);
|
|
149
|
+
listed.push(host);
|
|
150
|
+
}
|
|
151
|
+
return { hosts: listed };
|
|
152
|
+
}
|
|
153
|
+
function readDashRequirements(dash, file) {
|
|
154
|
+
if (dash === void 0) return;
|
|
155
|
+
if (!isObject(dash)) throw new CompileError("\"dash\" must be an object, such as { \"minVersion\": \"2.4.0\" }", file);
|
|
156
|
+
rejectUnknownFields(dash, DASH_FIELDS, file, "dash");
|
|
157
|
+
const { minVersion } = dash;
|
|
158
|
+
if (minVersion !== void 0 && !isSemanticVersion(minVersion)) throw new CompileError("\"dash.minVersion\" must be a semantic version, such as \"2.4.0\"", file);
|
|
159
|
+
return { minVersion };
|
|
160
|
+
}
|
|
161
|
+
function rejectUnknownFields(object, allowed, file, parent) {
|
|
162
|
+
for (const field of Object.keys(object)) {
|
|
163
|
+
if (allowed.includes(field)) continue;
|
|
164
|
+
if (parent === void 0) throw new CompileError(`Unknown field "${field}". The fields allowed are ${quotedList(allowed)}`, file);
|
|
165
|
+
throw new CompileError(`Unknown field "${parent}.${field}". The fields allowed in "${parent}" are ${quotedList(allowed)}`, file);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function quotedList(values) {
|
|
169
|
+
return values.map((value) => `"${value}"`).join(", ");
|
|
170
|
+
}
|
|
171
|
+
function isObject(value) {
|
|
172
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
173
|
+
}
|
|
174
|
+
function isNonEmptyString(value) {
|
|
175
|
+
return typeof value === "string" && value !== "";
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/project/statics.ts
|
|
179
|
+
const STATICS_DIR = "statics";
|
|
180
|
+
const MAX_STATIC_ASSET_BYTES = 1048576;
|
|
181
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
182
|
+
137,
|
|
183
|
+
80,
|
|
184
|
+
78,
|
|
185
|
+
71,
|
|
186
|
+
13,
|
|
187
|
+
10,
|
|
188
|
+
26,
|
|
189
|
+
10
|
|
190
|
+
]);
|
|
191
|
+
const IMAGE_FORMATS = {
|
|
192
|
+
".png": (header) => header.subarray(0, 8).equals(PNG_SIGNATURE),
|
|
193
|
+
".jpg": isJpeg,
|
|
194
|
+
".jpeg": isJpeg,
|
|
195
|
+
".gif": (header) => ["GIF87a", "GIF89a"].includes(header.toString("latin1", 0, 6)),
|
|
196
|
+
".webp": (header) => header.toString("latin1", 0, 4) === "RIFF" && header.toString("latin1", 8, 12) === "WEBP"
|
|
197
|
+
};
|
|
198
|
+
const STATIC_ASSET_EXTENSIONS = Object.keys(IMAGE_FORMATS);
|
|
199
|
+
async function listStatics(root) {
|
|
200
|
+
const staticsDir = join(root, STATICS_DIR);
|
|
201
|
+
if (!existsSync(staticsDir)) return [];
|
|
202
|
+
const statics = [];
|
|
203
|
+
for (const entry of await readdir(staticsDir, {
|
|
204
|
+
recursive: true,
|
|
205
|
+
withFileTypes: true
|
|
206
|
+
})) {
|
|
207
|
+
if (!entry.isFile() || entry.name.startsWith(".")) continue;
|
|
208
|
+
const file = join(entry.parentPath, entry.name);
|
|
209
|
+
await checkImage(file, extname(entry.name).toLowerCase());
|
|
210
|
+
statics.push(`${STATICS_DIR}/${relative(staticsDir, file).split(sep).join("/")}`);
|
|
211
|
+
}
|
|
212
|
+
return statics.sort();
|
|
213
|
+
}
|
|
214
|
+
async function checkImage(file, extension) {
|
|
215
|
+
const isFormat = IMAGE_FORMATS[extension];
|
|
216
|
+
if (!isFormat) throw new CompileError(`${STATICS_DIR}/ can only hold images: ${STATIC_ASSET_EXTENSIONS.join(", ")}`, file);
|
|
217
|
+
const { size } = await stat(file);
|
|
218
|
+
if (size > 1048576) throw new CompileError(`The image is ${(size / MAX_STATIC_ASSET_BYTES).toFixed(1)} MB. Images must be 1 MB or smaller`, file);
|
|
219
|
+
if (!isFormat(await readFile(file))) throw new CompileError(`The file isn't really a ${extension.slice(1)} image`, file);
|
|
220
|
+
}
|
|
221
|
+
function isJpeg(header) {
|
|
222
|
+
return header[0] === 255 && header[1] === 216 && header[2] === 255;
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/project/project.ts
|
|
226
|
+
const DOT_FILE = "dot.ts";
|
|
227
|
+
const PAGES_DIR = "pages";
|
|
228
|
+
const PAGE_FILES = {
|
|
229
|
+
view: "view.dml",
|
|
230
|
+
state: "state.ts",
|
|
231
|
+
controller: "controller.ts"
|
|
232
|
+
};
|
|
233
|
+
async function loadProject(root) {
|
|
234
|
+
root = resolve(root);
|
|
235
|
+
const dotJsonFile = join(root, DOT_JSON_FILE);
|
|
236
|
+
if (!existsSync(dotJsonFile)) throw new CompileError(`There is no ${DOT_JSON_FILE} in this folder`, root);
|
|
237
|
+
const dotJson = await readDotJson(dotJsonFile);
|
|
238
|
+
if (dotJson.icon !== void 0 && !existsSync(join(root, dotJson.icon))) throw new CompileError(`The icon "${dotJson.icon}" is missing`, dotJsonFile);
|
|
239
|
+
const dotFile = join(root, DOT_FILE);
|
|
240
|
+
if (!existsSync(dotFile)) throw new CompileError(`There is no ${DOT_FILE}. Add one: export default defineDot({ pages: [...] })`, root);
|
|
241
|
+
const { pages } = parseDotModule(await readFile(dotFile, "utf8"), dotFile);
|
|
242
|
+
const sources = await Promise.all(pages.map((path) => loadPage(root, path, dotFile)));
|
|
243
|
+
const seen = /* @__PURE__ */ new Map();
|
|
244
|
+
for (const page of sources) {
|
|
245
|
+
const other = seen.get(page.id.toLowerCase());
|
|
246
|
+
if (other) {
|
|
247
|
+
let file = dotFile;
|
|
248
|
+
if (page.id !== page.path) file = page.controller;
|
|
249
|
+
else if (other.id !== other.path) file = other.controller;
|
|
250
|
+
throw new CompileError(`Page id "${page.id}" (${page.path}) is already used by ${other.path}`, file);
|
|
251
|
+
}
|
|
252
|
+
seen.set(page.id.toLowerCase(), page);
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
root,
|
|
256
|
+
dotJson,
|
|
257
|
+
dot: dotFile,
|
|
258
|
+
pages: sources,
|
|
259
|
+
statics: await listStatics(root)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
async function loadPage(root, path, dotFile) {
|
|
263
|
+
const dir = join(root, PAGES_DIR, path);
|
|
264
|
+
const files = {
|
|
265
|
+
view: join(dir, PAGE_FILES.view),
|
|
266
|
+
state: join(dir, PAGE_FILES.state),
|
|
267
|
+
controller: join(dir, PAGE_FILES.controller)
|
|
268
|
+
};
|
|
269
|
+
const missing = Object.values(files).filter((file) => !existsSync(file));
|
|
270
|
+
if (missing.length > 0) throw new CompileError(`Page "${path}" is missing ${missing.join(", ")}`, dotFile);
|
|
271
|
+
const { id } = parseLogicModule(await readFile(files.controller, "utf8"), files.controller);
|
|
272
|
+
return {
|
|
273
|
+
path,
|
|
274
|
+
id: id ?? path,
|
|
275
|
+
...files
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/entries.ts
|
|
280
|
+
const RUNTIME_PACKAGE = "@monimesl/dot-runtime";
|
|
281
|
+
const RUNTIME = {
|
|
282
|
+
ui: `${RUNTIME_PACKAGE}/ui`,
|
|
283
|
+
logic: `${RUNTIME_PACKAGE}/logic`,
|
|
284
|
+
renderer: `${RUNTIME_PACKAGE}/react`
|
|
285
|
+
};
|
|
286
|
+
const ENTRY = {
|
|
287
|
+
ui: "dot:ui",
|
|
288
|
+
logic: "dot:logic"
|
|
289
|
+
};
|
|
290
|
+
const ENV_MODULE = "dot:env";
|
|
291
|
+
const viewModuleId = (id) => `dot:view/${id}`;
|
|
292
|
+
function literal(value) {
|
|
293
|
+
return JSON.stringify(value);
|
|
294
|
+
}
|
|
295
|
+
function pageTable(project, binding) {
|
|
296
|
+
return `{${project.pages.map((page, index) => `\n ${literal(page.id)}: ${binding}${index},`).join("")}\n }`;
|
|
297
|
+
}
|
|
298
|
+
function uiEntry(project, components) {
|
|
299
|
+
const renderer = components ? [`import { createReactRenderer, ${components.join(", ")} } from ${literal(RUNTIME.renderer)};`, `const createRenderer = () => createReactRenderer({ components: { ${components.join(", ")} } });`] : [`import createRenderer from ${literal(RUNTIME.renderer)};`];
|
|
300
|
+
return [
|
|
301
|
+
`import { runWebview } from ${literal(RUNTIME.ui)};`,
|
|
302
|
+
...renderer,
|
|
303
|
+
...project.pages.map((page, index) => `import view${index} from ${literal(viewModuleId(page.id))};`),
|
|
304
|
+
"",
|
|
305
|
+
"runWebview({",
|
|
306
|
+
" renderer: createRenderer,",
|
|
307
|
+
` views: ${pageTable(project, "view")},`,
|
|
308
|
+
"});",
|
|
309
|
+
""
|
|
310
|
+
].join("\n");
|
|
311
|
+
}
|
|
312
|
+
function logicEntry(project, { strict }) {
|
|
313
|
+
const options = [" dot,", ` pages: ${pageTable(project, "page")},`];
|
|
314
|
+
if (strict) options.push(" strict: true,");
|
|
315
|
+
return [
|
|
316
|
+
`import { runService } from ${literal(RUNTIME.logic)};`,
|
|
317
|
+
`import dot from ${literal(project.dot)};`,
|
|
318
|
+
...project.pages.map((page, index) => `import page${index} from ${literal(page.controller)};`),
|
|
319
|
+
"",
|
|
320
|
+
"runService({",
|
|
321
|
+
...options,
|
|
322
|
+
"});",
|
|
323
|
+
""
|
|
324
|
+
].join("\n");
|
|
325
|
+
}
|
|
326
|
+
function envModule(env) {
|
|
327
|
+
return `export const env = Object.freeze(${JSON.stringify(env, null, 2)});\n`;
|
|
328
|
+
}
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/globals.ts
|
|
331
|
+
const DOT_GLOBALS = {
|
|
332
|
+
"@monimesl/dot-runtime": [
|
|
333
|
+
"definePage",
|
|
334
|
+
"defineDot",
|
|
335
|
+
"createEvent",
|
|
336
|
+
"dash"
|
|
337
|
+
],
|
|
338
|
+
[ENV_MODULE]: ["env"]
|
|
339
|
+
};
|
|
340
|
+
const SCRIPT = /\.[cm]?[jt]sx?$/;
|
|
341
|
+
function dotGlobals(root) {
|
|
342
|
+
const realRoot = realpathSync(root);
|
|
343
|
+
const isDotModule = (id) => id.startsWith(realRoot + sep) && !id.includes(`${sep}node_modules${sep}`) && SCRIPT.test(id);
|
|
344
|
+
const isPageModule = (id) => id.startsWith(join(realRoot, PAGES_DIR) + sep);
|
|
345
|
+
return {
|
|
346
|
+
name: "dot:globals",
|
|
347
|
+
transform(code, id) {
|
|
348
|
+
if (!isDotModule(id)) return null;
|
|
349
|
+
const used = new Set(unboundReferences(code, Object.values(DOT_GLOBALS).flat(), id));
|
|
350
|
+
if (isPageModule(id) && used.has("dash")) this.error(`${relative(realRoot, id)} uses the global \`dash\`. In a page, use \`this.dash\` in actions and hooks, so its calls stop when the page closes.`);
|
|
351
|
+
const imports = [];
|
|
352
|
+
for (const [source, names] of Object.entries(DOT_GLOBALS)) {
|
|
353
|
+
const imported = names.filter((name) => used.has(name));
|
|
354
|
+
if (imported.length > 0) imports.push(`import { ${imported.join(", ")} } from ${JSON.stringify(source)};`);
|
|
355
|
+
}
|
|
356
|
+
if (imports.length === 0) return null;
|
|
357
|
+
return {
|
|
358
|
+
code: `${code}\n${imports.join("\n")}\n`,
|
|
359
|
+
map: null
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/build/bundle.ts
|
|
366
|
+
/** Bundles one entry with rolldown, replacing `outDir`. Returns the written files. */
|
|
367
|
+
async function bundle(options) {
|
|
368
|
+
await rm(options.outDir, {
|
|
369
|
+
recursive: true,
|
|
370
|
+
force: true
|
|
371
|
+
});
|
|
372
|
+
const plugins = [];
|
|
373
|
+
if (options.external.length > 0) plugins.push(esmExternalRequirePlugin({ external: [...options.external] }));
|
|
374
|
+
plugins.push(generatedModules(options.root, options.modules), dotGlobals(options.root));
|
|
375
|
+
const bundler = await rolldown({
|
|
376
|
+
input: options.entry,
|
|
377
|
+
cwd: options.root,
|
|
378
|
+
platform: "browser",
|
|
379
|
+
plugins,
|
|
380
|
+
resolve: { conditionNames: [
|
|
381
|
+
...options.conditions,
|
|
382
|
+
"browser",
|
|
383
|
+
"import",
|
|
384
|
+
"require",
|
|
385
|
+
"default"
|
|
386
|
+
] },
|
|
387
|
+
transform: { define: { "process.env.NODE_ENV": JSON.stringify(options.mode) } },
|
|
388
|
+
onLog(level, log, defaultHandler) {
|
|
389
|
+
if (log.code !== "MODULE_LEVEL_DIRECTIVE") defaultHandler(level, log);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
try {
|
|
393
|
+
const { output } = await bundler.write({
|
|
394
|
+
dir: options.outDir,
|
|
395
|
+
format: options.format,
|
|
396
|
+
entryFileNames: options.fileName,
|
|
397
|
+
sourcemap: options.mode === "development",
|
|
398
|
+
sourcemapExcludeSources: options.mode === "production",
|
|
399
|
+
minify: options.minify
|
|
400
|
+
});
|
|
401
|
+
return output.map((chunk) => join(options.outDir, chunk.fileName));
|
|
402
|
+
} finally {
|
|
403
|
+
await bundler.close();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** Serves generated modules and resolves their imports as if they lived in the project root. */
|
|
407
|
+
function generatedModules(root, modules) {
|
|
408
|
+
const importer = join(root, DOT_JSON_FILE);
|
|
409
|
+
return {
|
|
410
|
+
name: "dot:generated",
|
|
411
|
+
async resolveId(source, from) {
|
|
412
|
+
if (modules.has(source)) return `\0${source}`;
|
|
413
|
+
if (!from?.startsWith("\0dot:")) return null;
|
|
414
|
+
const resolved = await this.resolve(source, importer, { skipSelf: true });
|
|
415
|
+
if (!resolved) this.error(`Can't find "${source}" from ${root}. Is it installed?`);
|
|
416
|
+
return resolved;
|
|
417
|
+
},
|
|
418
|
+
load(id) {
|
|
419
|
+
if (!id.startsWith("\0")) return null;
|
|
420
|
+
return modules.get(id.slice(1)) ?? null;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/build/packages.ts
|
|
426
|
+
const RENDERER_PACKAGE = "@monimesl/dot-renderer-react";
|
|
427
|
+
function installedRuntime(root) {
|
|
428
|
+
const runtime = requirePackage(root, RUNTIME_PACKAGE, "the project");
|
|
429
|
+
const renderer = requirePackage(runtime.dir, RENDERER_PACKAGE, RUNTIME_PACKAGE);
|
|
430
|
+
const react = requirePackage(renderer.dir, "react", RENDERER_PACKAGE);
|
|
431
|
+
return {
|
|
432
|
+
version: runtime.version,
|
|
433
|
+
react: {
|
|
434
|
+
version: react.version,
|
|
435
|
+
dir: join(renderer.dir, "dist", "react-host")
|
|
436
|
+
},
|
|
437
|
+
stylesheet: join(renderer.dir, "dist", "styles.css")
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function requirePackage(fromDir, name, installedIn) {
|
|
441
|
+
const require = createRequire(join(fromDir, "package.json"));
|
|
442
|
+
let packageFile;
|
|
443
|
+
try {
|
|
444
|
+
packageFile = require.resolve(`${name}/package.json`);
|
|
445
|
+
} catch {
|
|
446
|
+
throw new CompileError(`${name} isn't installed in ${installedIn}. Install the project's dependencies, then try again`, fromDir);
|
|
447
|
+
}
|
|
448
|
+
const { version } = JSON.parse(readFileSync(packageFile, "utf8"));
|
|
449
|
+
return {
|
|
450
|
+
dir: dirname(packageFile),
|
|
451
|
+
version
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/view/expression.ts
|
|
456
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
457
|
+
const EMPTY_SCOPE = /* @__PURE__ */ new Map();
|
|
458
|
+
function isIdentifier(name) {
|
|
459
|
+
return IDENTIFIER.test(name);
|
|
460
|
+
}
|
|
461
|
+
function printExpr(expr, scope) {
|
|
462
|
+
const print = (node) => printExpr(node, scope);
|
|
463
|
+
switch (expr.type) {
|
|
464
|
+
case "Literal": return JSON.stringify(expr.value);
|
|
465
|
+
case "Identifier": return scope.get(expr.name) ?? `d.${expr.name}`;
|
|
466
|
+
case "Member": return expr.property.type === "Literal" && typeof expr.property.value === "string" ? `${print(expr.object)}?.${expr.property.value}` : `${print(expr.object)}?.[${print(expr.property)}]`;
|
|
467
|
+
case "Unary": return `(${expr.operator}${expr.operator === "typeof" ? " " : ""}${print(expr.argument)})`;
|
|
468
|
+
case "Binary": return `(${print(expr.left)} ${expr.operator} ${print(expr.right)})`;
|
|
469
|
+
case "Conditional": return `(${print(expr.test)} ? ${print(expr.consequent)} : ${print(expr.alternate)})`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function stateReads(expr, scope) {
|
|
473
|
+
const reads = (node) => stateReads(node, scope);
|
|
474
|
+
switch (expr.type) {
|
|
475
|
+
case "Literal": return [];
|
|
476
|
+
case "Identifier": return scope.has(expr.name) ? [] : [expr.name];
|
|
477
|
+
case "Member": return [...reads(expr.object), ...reads(expr.property)];
|
|
478
|
+
case "Unary": return reads(expr.argument);
|
|
479
|
+
case "Binary": return [...reads(expr.left), ...reads(expr.right)];
|
|
480
|
+
case "Conditional": return [
|
|
481
|
+
...reads(expr.test),
|
|
482
|
+
...reads(expr.consequent),
|
|
483
|
+
...reads(expr.alternate)
|
|
484
|
+
];
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/view/control-flow.ts
|
|
489
|
+
const CONTROL_FLOW_TAGS = {
|
|
490
|
+
If: {
|
|
491
|
+
attributes: ["condition"],
|
|
492
|
+
required: ["condition"]
|
|
493
|
+
},
|
|
494
|
+
ElseIf: {
|
|
495
|
+
attributes: ["condition"],
|
|
496
|
+
required: ["condition"]
|
|
497
|
+
},
|
|
498
|
+
Else: {
|
|
499
|
+
attributes: [],
|
|
500
|
+
required: []
|
|
501
|
+
},
|
|
502
|
+
For: {
|
|
503
|
+
attributes: [
|
|
504
|
+
"each",
|
|
505
|
+
"item",
|
|
506
|
+
"index",
|
|
507
|
+
"key"
|
|
508
|
+
],
|
|
509
|
+
required: ["each"]
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
function compileConditional(compiler, branches, parent, scope) {
|
|
513
|
+
let code = "[]";
|
|
514
|
+
for (const branch of [...branches].reverse()) {
|
|
515
|
+
const body = `[${compiler.compile(branch.children, parent, scope).join(", ")}]`;
|
|
516
|
+
compiler.checkControlFlowElementAttributes(branch);
|
|
517
|
+
if (branch.tag === "Else") code = body;
|
|
518
|
+
else code = `${compiler.compileSingleBinding(branch, "condition", scope)} ? ${body} : ${code}`;
|
|
519
|
+
}
|
|
520
|
+
return `...(${code})`;
|
|
521
|
+
}
|
|
522
|
+
function compileLoop(compiler, node, parent, scope) {
|
|
523
|
+
compiler.checkControlFlowElementAttributes(node);
|
|
524
|
+
const list = compiler.compileSingleBinding(node, "each", scope);
|
|
525
|
+
const { item = "item", index = "index", key } = node.attributes;
|
|
526
|
+
const checkVariableName = (attribute, name) => {
|
|
527
|
+
if (!isIdentifier(name)) compiler.fail(`<For> \`${attribute}\` must be a variable name, got "${name}"`, node.loc);
|
|
528
|
+
};
|
|
529
|
+
checkVariableName("item", item);
|
|
530
|
+
checkVariableName("index", index);
|
|
531
|
+
if (item === index) compiler.fail("<For> `item` and `index` need different names", node.loc);
|
|
532
|
+
const itemVar = "$" + item;
|
|
533
|
+
const indexVar = "$" + index;
|
|
534
|
+
const inner = new Map(scope).set(item, itemVar).set(index, indexVar);
|
|
535
|
+
const args = [list, `(${itemVar}, ${indexVar}) => [${compiler.compile(node.children, parent, inner).join(", ")}]`];
|
|
536
|
+
if (key === void 0) compiler.warn("<For> has no `key`, so items are matched by position. If items can move, add key=\"<property>\" or key=\"*this\". To keep matching by position, add key=\"*index\".", node.loc);
|
|
537
|
+
else if (key === "*index") {} else if (key === "*this") args.push(`(${itemVar}) => ${itemVar}`);
|
|
538
|
+
else if (isIdentifier(key)) args.push(`(${itemVar}) => ${itemVar}?.${key}`);
|
|
539
|
+
else compiler.fail(`<For> \`key\` must be a property name, *this or *index, got "${key}"`, node.loc);
|
|
540
|
+
return `each(${args.join(", ")})`;
|
|
541
|
+
}
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/view/compiler.ts
|
|
544
|
+
const ROOT_TAG = "View";
|
|
545
|
+
const ROOT_DEFAULTS = {
|
|
546
|
+
padding: "large",
|
|
547
|
+
gap: "large",
|
|
548
|
+
grow: true
|
|
549
|
+
};
|
|
550
|
+
const SEGMENT_BINDING_REGEXP = /{{([\s\S]*?)}}/g;
|
|
551
|
+
const DATA_PREFIX = "data-";
|
|
552
|
+
const DATA_ATTRIBUTE = /^data-[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
553
|
+
var ViewCompiler = class {
|
|
554
|
+
#context;
|
|
555
|
+
#components;
|
|
556
|
+
constructor(context) {
|
|
557
|
+
this.#context = context;
|
|
558
|
+
this.#components = context.components ?? builtinRegistry;
|
|
559
|
+
for (const name of Object.keys(CONTROL_FLOW_TAGS)) if (name in this.#components) this.fail(`<${name}> is reserved for control flow`);
|
|
560
|
+
}
|
|
561
|
+
compile(nodes, parent, scope) {
|
|
562
|
+
const out = [];
|
|
563
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
564
|
+
const node = nodes[i];
|
|
565
|
+
if (node.type === "Text") {
|
|
566
|
+
out.push(...this.#compileText(node.content, parent, scope, node.loc));
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
switch (node.tag) {
|
|
570
|
+
case "If": {
|
|
571
|
+
const branches = ifChain(node, nodes.slice(i + 1));
|
|
572
|
+
out.push(compileConditional(this, branches, parent, scope));
|
|
573
|
+
i += branches.length - 1;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
case "ElseIf":
|
|
577
|
+
case "Else": return this.fail(`<${node.tag}> must come right after <If> or <ElseIf>`, node.loc);
|
|
578
|
+
case "For":
|
|
579
|
+
out.push(compileLoop(this, node, parent, scope));
|
|
580
|
+
break;
|
|
581
|
+
default: out.push(`h(${this.#compileElementArgs(node, parent, scope).join(", ")})`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return out;
|
|
585
|
+
}
|
|
586
|
+
#compileText(content, parent, scope, loc) {
|
|
587
|
+
if (parent?.children !== "text") {
|
|
588
|
+
const wrappers = Object.values(this.#components).filter((component) => component.inline && component.children === "text").sort((a, b) => Number(b.name === "Text") - Number(a.name === "Text")).map((component) => `<${component.name}>`).join(" or ");
|
|
589
|
+
this.fail(`'${content}' must be inside ${wrappers || "an inline component"}`, loc);
|
|
590
|
+
}
|
|
591
|
+
return this.#compileAsSegments(content, scope, loc).map(printSegment);
|
|
592
|
+
}
|
|
593
|
+
compileRoot(nodes) {
|
|
594
|
+
if (!this.#components["View"]) this.fail(`The components must include <${ROOT_TAG}>, which is the root of every page`);
|
|
595
|
+
const [root, outside] = nodes;
|
|
596
|
+
if (root?.type !== "Element" || root.tag !== "View") return this.fail(`A page must have one <${ROOT_TAG}> around everything else`, root?.loc);
|
|
597
|
+
if (outside) {
|
|
598
|
+
const name = outside.type === "Text" ? `'${outside.content}'` : `<${outside.tag}>`;
|
|
599
|
+
this.fail(`${name} must be inside the page's <${ROOT_TAG}>`, outside.loc);
|
|
600
|
+
}
|
|
601
|
+
return this.#compileElementArgs(root, null, EMPTY_SCOPE, ROOT_DEFAULTS);
|
|
602
|
+
}
|
|
603
|
+
#compileElementArgs(node, parent, scope, defaults = {}) {
|
|
604
|
+
const component = this.#components[node.tag];
|
|
605
|
+
if (!component) return this.fail(`Unknown component <${node.tag}>`, node.loc);
|
|
606
|
+
this.#context.onComponent?.(component.name);
|
|
607
|
+
if (parent && !canHaveChildren(parent, component)) this.fail(`<${node.tag}> can't go inside <${parent.name}>`, node.loc);
|
|
608
|
+
const attributes = Object.entries(node.attributes);
|
|
609
|
+
for (const { prop, ignores, because } of component.overrides ?? []) if (prop in node.attributes && ignores in node.attributes) this.warn(`<${component.name}> ignores \`${ignores}\` with \`${prop}\`: ${because}`, node.loc);
|
|
610
|
+
const data = this.#compileData(attributes.filter(([name]) => name.startsWith(DATA_PREFIX)), scope, node.loc);
|
|
611
|
+
const props = [
|
|
612
|
+
...attributes.filter(([name]) => !name.startsWith(DATA_PREFIX)).map(([name, value]) => this.#compileProp(component, name, value, scope, node.loc)),
|
|
613
|
+
...Object.entries(node.events).map(([name, handler]) => this.#compileEvent(component, name, handler, data, node.loc)),
|
|
614
|
+
...Object.entries(defaults).filter(([name]) => !(name in node.attributes)).map(([name, value]) => `${name}: ${JSON.stringify(value)}`)
|
|
615
|
+
];
|
|
616
|
+
return [
|
|
617
|
+
JSON.stringify(component.name),
|
|
618
|
+
props.length ? `{ ${props.join(", ")} }` : "null",
|
|
619
|
+
...this.compile(node.children, component, scope)
|
|
620
|
+
];
|
|
621
|
+
}
|
|
622
|
+
#compileProp(component, name, value, scope, loc) {
|
|
623
|
+
const type = component.props[name];
|
|
624
|
+
if (!type) return this.fail(`<${component.name}> has no \`${name}\` attribute`, loc);
|
|
625
|
+
const segments = this.#compileAsSegments(value, scope, loc);
|
|
626
|
+
const bindingCode = singleBindingCode(segments);
|
|
627
|
+
if (bindingCode !== null) return `${name}: ${bindingCode}`;
|
|
628
|
+
if (segments.every((segment) => segment.kind === "static")) {
|
|
629
|
+
this.#checkStaticsReference(value, loc);
|
|
630
|
+
return `${name}: ${this.#compileLiteral(type, value, loc)}`;
|
|
631
|
+
}
|
|
632
|
+
if (type !== "string" && type !== "any") {
|
|
633
|
+
const kind = typeof type === "string" ? `a ${type}` : "an enum";
|
|
634
|
+
this.fail(`\`${name}\` is ${kind}, so set it with one {{ }} binding`, loc);
|
|
635
|
+
}
|
|
636
|
+
return `${name}: ${segments.map(printSegment).join(" + ")}`;
|
|
637
|
+
}
|
|
638
|
+
#compileData(attributes, scope, loc) {
|
|
639
|
+
if (attributes.length === 0) return null;
|
|
640
|
+
return `{ ${attributes.map(([name, value]) => {
|
|
641
|
+
if (!DATA_ATTRIBUTE.test(name)) this.fail(`\`${name}\` must be lowercase words joined by -, such as data-account-id`, loc);
|
|
642
|
+
return `${dataKey(name)}: ${this.#compileDataValue(value, scope, loc)}`;
|
|
643
|
+
}).join(", ")} }`;
|
|
644
|
+
}
|
|
645
|
+
#compileDataValue(value, scope, loc) {
|
|
646
|
+
const segments = this.#compileAsSegments(value, scope, loc);
|
|
647
|
+
const bindingCode = singleBindingCode(segments);
|
|
648
|
+
if (bindingCode !== null) return bindingCode;
|
|
649
|
+
if (segments.length === 0) return "\"\"";
|
|
650
|
+
return segments.map(printSegment).join(" + ");
|
|
651
|
+
}
|
|
652
|
+
#checkStaticsReference(value, loc) {
|
|
653
|
+
const { statics } = this.#context;
|
|
654
|
+
if (statics && value.startsWith(`statics/`) && !statics.includes(value)) this.fail(`"${value}" isn't in ${STATICS_DIR}/`, loc);
|
|
655
|
+
}
|
|
656
|
+
#compileEvent(component, attribute, handler, data, loc) {
|
|
657
|
+
const event = eventName(attribute);
|
|
658
|
+
if (!component.events.includes(event)) this.fail(`<${component.name}> has no \`${attribute}\` event`, loc);
|
|
659
|
+
if (!isIdentifier(handler)) this.fail(`\`${attribute}\` must name a mutation or action in controller.ts, got "${handler}"`, loc);
|
|
660
|
+
const { handlers } = this.#context;
|
|
661
|
+
if (handlers && !handlers.includes(handler)) this.fail(`\`${handler}\` isn't a mutation or action in controller.ts`, loc);
|
|
662
|
+
return `${attribute}: on(${data ? `${JSON.stringify(handler)}, ${data}` : JSON.stringify(handler)})`;
|
|
663
|
+
}
|
|
664
|
+
checkControlFlowElementAttributes(node) {
|
|
665
|
+
const { attributes: allowed, required } = CONTROL_FLOW_TAGS[node.tag];
|
|
666
|
+
const [event] = Object.keys(node.events);
|
|
667
|
+
if (event) this.fail(`<${node.tag}> can't handle events such as \`${event}\`. Put the handler on a component inside it.`, node.loc);
|
|
668
|
+
for (const name of Object.keys(node.attributes)) if (!allowed.includes(name)) this.fail(`<${node.tag}> has no \`${name}\` attribute`, node.loc);
|
|
669
|
+
for (const name of required) if (!(name in node.attributes)) this.fail(`<${node.tag}> requires \`${name}\``, node.loc);
|
|
670
|
+
}
|
|
671
|
+
compileSingleBinding(node, attribute, scope) {
|
|
672
|
+
const bindingCode = singleBindingCode(this.#compileAsSegments(node.attributes[attribute] ?? "", scope, node.loc));
|
|
673
|
+
if (bindingCode === null) return this.fail(`<${node.tag}> \`${attribute}\` must be one {{ }} binding`, node.loc);
|
|
674
|
+
return bindingCode;
|
|
675
|
+
}
|
|
676
|
+
#compileLiteral(type, value, loc) {
|
|
677
|
+
if (typeof type !== "string") {
|
|
678
|
+
if (type.includes(value)) return JSON.stringify(value);
|
|
679
|
+
return this.fail(`Expected one of ${type.map((option) => `"${option}"`).join(", ")}, got "${value}"`, loc);
|
|
680
|
+
}
|
|
681
|
+
switch (type) {
|
|
682
|
+
case "boolean":
|
|
683
|
+
if (value === "" || value === "true") return "true";
|
|
684
|
+
if (value === "false") return "false";
|
|
685
|
+
return this.fail(`Expected true or false, got "${value}"`, loc);
|
|
686
|
+
case "number":
|
|
687
|
+
if (value.trim() !== "" && Number.isFinite(Number(value))) return String(Number(value));
|
|
688
|
+
return this.fail(`Expected a number, got "${value}"`, loc);
|
|
689
|
+
case "string":
|
|
690
|
+
case "any": return JSON.stringify(value);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
#compileAsSegments(input, scope, loc) {
|
|
694
|
+
const segments = [];
|
|
695
|
+
let last = 0;
|
|
696
|
+
for (const match of input.matchAll(SEGMENT_BINDING_REGEXP)) {
|
|
697
|
+
if (match.index > last) segments.push({
|
|
698
|
+
kind: "static",
|
|
699
|
+
value: input.slice(last, match.index)
|
|
700
|
+
});
|
|
701
|
+
const expr = this.#parseBindingExpression(match[1], scope, loc);
|
|
702
|
+
segments.push({
|
|
703
|
+
kind: "binding",
|
|
704
|
+
code: printExpr(expr, scope)
|
|
705
|
+
});
|
|
706
|
+
last = match.index + match[0].length;
|
|
707
|
+
}
|
|
708
|
+
const rest = input.slice(last);
|
|
709
|
+
if (rest.includes("{{")) this.fail("A binding is missing its closing `}}`", loc);
|
|
710
|
+
if (rest) segments.push({
|
|
711
|
+
kind: "static",
|
|
712
|
+
value: rest
|
|
713
|
+
});
|
|
714
|
+
return segments;
|
|
715
|
+
}
|
|
716
|
+
#parseBindingExpression(source, scope, loc) {
|
|
717
|
+
let expr;
|
|
718
|
+
try {
|
|
719
|
+
expr = parseExpression(source);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (!(error instanceof ScriptParseError)) throw error;
|
|
722
|
+
return this.fail(`Invalid binding \`{{${source}}}\`: ${error.reason}`, loc);
|
|
723
|
+
}
|
|
724
|
+
const { stateKeys } = this.#context;
|
|
725
|
+
if (stateKeys) {
|
|
726
|
+
const undeclared = stateReads(expr, scope).find((name) => !stateKeys.includes(name));
|
|
727
|
+
if (undeclared) {
|
|
728
|
+
const declared = stateKeys.join(", ") || "none";
|
|
729
|
+
this.fail(`\`${undeclared}\` isn't in state.ts. It has: ${declared}`, loc);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return expr;
|
|
733
|
+
}
|
|
734
|
+
warn(message, loc) {
|
|
735
|
+
this.#context.onWarning?.(new CompileWarning(message, this.#context.file, loc));
|
|
736
|
+
}
|
|
737
|
+
fail(message, loc) {
|
|
738
|
+
throw new CompileError(message, this.#context.file, loc);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
function ifChain(ifNode, nextSiblings) {
|
|
742
|
+
const branches = [ifNode];
|
|
743
|
+
for (const sibling of nextSiblings) {
|
|
744
|
+
if (branches.at(-1).tag === "Else" || !continuesIf(sibling)) break;
|
|
745
|
+
branches.push(sibling);
|
|
746
|
+
}
|
|
747
|
+
return branches;
|
|
748
|
+
}
|
|
749
|
+
function continuesIf(node) {
|
|
750
|
+
return node.type === "Element" && (node.tag === "ElseIf" || node.tag === "Else");
|
|
751
|
+
}
|
|
752
|
+
function singleBindingCode(segments) {
|
|
753
|
+
const [first] = segments;
|
|
754
|
+
if (segments.length === 1 && first?.kind === "binding") return first.code;
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
function dataKey(attribute) {
|
|
758
|
+
const [first = "", ...rest] = attribute.slice(5).split("-");
|
|
759
|
+
return first + rest.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
760
|
+
}
|
|
761
|
+
function printSegment(segment) {
|
|
762
|
+
return segment.kind === "static" ? JSON.stringify(segment.value) : `text(${segment.code})`;
|
|
763
|
+
}
|
|
764
|
+
function canHaveChildren(parent, child) {
|
|
765
|
+
switch (parent.children) {
|
|
766
|
+
case "none": return false;
|
|
767
|
+
case "text": return child.inline === true;
|
|
768
|
+
case "elements": return true;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/view/compile.ts
|
|
773
|
+
function compileView(source, context) {
|
|
774
|
+
const nodes = parseDmlToIR(source, {
|
|
775
|
+
locations: true,
|
|
776
|
+
file: context.file,
|
|
777
|
+
whitespace: "collapse"
|
|
778
|
+
});
|
|
779
|
+
return [
|
|
780
|
+
"export default function render(d, { h, on, text, each }) {",
|
|
781
|
+
` return h(${new ViewCompiler(context).compileRoot(nodes).map((arg) => `\n ${arg},`).join("")}\n );`,
|
|
782
|
+
"}",
|
|
783
|
+
""
|
|
784
|
+
].join("\n");
|
|
785
|
+
}
|
|
786
|
+
//#endregion
|
|
787
|
+
//#region src/types.ts
|
|
788
|
+
const PAGE_TYPES_FILE = ".dot/pages.d.ts";
|
|
789
|
+
const ENV_TYPES_FILE = ".dot/env.d.ts";
|
|
790
|
+
async function writePageTypes(project) {
|
|
791
|
+
const members = project.pages.map((page) => `${JSON.stringify(page.id)}: true;`);
|
|
792
|
+
return writeIfChanged(join(project.root, PAGE_TYPES_FILE), globalInterface("dot.ts", "DotPageIds", members));
|
|
793
|
+
}
|
|
794
|
+
async function writeEnvTypes(project, env) {
|
|
795
|
+
const members = Object.keys(env).map((name) => `readonly ${name}: string;`);
|
|
796
|
+
return writeIfChanged(join(project.root, ENV_TYPES_FILE), globalInterface("the variables in .env files", "DotPublicEnv", members));
|
|
797
|
+
}
|
|
798
|
+
function globalInterface(source, name, members) {
|
|
799
|
+
return [
|
|
800
|
+
`// Generated by dotctl from ${source}. Do not edit.`,
|
|
801
|
+
"declare global {",
|
|
802
|
+
` interface ${name} {`,
|
|
803
|
+
...members.map((member) => ` ${member}`),
|
|
804
|
+
" }",
|
|
805
|
+
"}",
|
|
806
|
+
"",
|
|
807
|
+
"export {};",
|
|
808
|
+
""
|
|
809
|
+
].join("\n");
|
|
810
|
+
}
|
|
811
|
+
async function writeIfChanged(file, content) {
|
|
812
|
+
if (await readFile(file, "utf8").catch(() => null) !== content) {
|
|
813
|
+
await mkdir(dirname(file), { recursive: true });
|
|
814
|
+
await writeFile(file, content);
|
|
815
|
+
}
|
|
816
|
+
return file;
|
|
817
|
+
}
|
|
818
|
+
//#endregion
|
|
819
|
+
//#region src/project/env.ts
|
|
820
|
+
const PUBLIC_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
821
|
+
async function loadPublicEnv(root, mode, shellEnv = process.env) {
|
|
822
|
+
const env = {};
|
|
823
|
+
for (const fileName of [".env", `.env.${mode}`]) {
|
|
824
|
+
const file = join(root, fileName);
|
|
825
|
+
if (existsSync(file)) addVariables(env, parseEnv(await readFile(file, "utf8")), file);
|
|
826
|
+
}
|
|
827
|
+
const sorted = {};
|
|
828
|
+
for (const name of Object.keys(env).sort()) sorted[name] = shellEnv[name] ?? env[name];
|
|
829
|
+
return sorted;
|
|
830
|
+
}
|
|
831
|
+
function addVariables(env, variables, file) {
|
|
832
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
833
|
+
if (value === void 0) continue;
|
|
834
|
+
if (!PUBLIC_ENV_NAME.test(name)) throw new CompileError(`"${name}" must be capital letters, digits and underscores, starting with a letter`, file);
|
|
835
|
+
env[name] = value;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
//#endregion
|
|
839
|
+
//#region src/build/build.ts
|
|
840
|
+
const MANIFEST_FILE = "manifest.json";
|
|
841
|
+
const ICON_FILE = "icon.png";
|
|
842
|
+
const STYLES_FILE = "dot.css";
|
|
843
|
+
const ARCHIVE_DATE = new Date(1980, 0, 1);
|
|
844
|
+
/** Compiles and bundles the dot into `outDir` for hosts, and refreshes the types in `.dot/`. */
|
|
845
|
+
async function build(options) {
|
|
846
|
+
const project = await loadProject(options.root);
|
|
847
|
+
const mode = options.mode ?? "production";
|
|
848
|
+
const env = await loadPublicEnv(project.root, mode);
|
|
849
|
+
const runtime = installedRuntime(project.root);
|
|
850
|
+
await writePageTypes(project);
|
|
851
|
+
await writeEnvTypes(project, env);
|
|
852
|
+
const warnings = [];
|
|
853
|
+
const modules = await compileModules(project, env, componentRegistry([...Object.values(builtinComponents), ...options.components ?? []]), mode, warnings);
|
|
854
|
+
const outDir = resolve(project.root, options.outDir ?? "dist");
|
|
855
|
+
const uiDir = join(outDir, "ui");
|
|
856
|
+
const bundleLayer = (entry, layerDir, format, external) => bundle({
|
|
857
|
+
root: project.root,
|
|
858
|
+
modules,
|
|
859
|
+
entry,
|
|
860
|
+
outDir: layerDir,
|
|
861
|
+
fileName: "dot.js",
|
|
862
|
+
format,
|
|
863
|
+
mode,
|
|
864
|
+
minify: mode === "production",
|
|
865
|
+
conditions: options.conditions ?? [],
|
|
866
|
+
external
|
|
867
|
+
});
|
|
868
|
+
const files = [
|
|
869
|
+
...await bundleLayer(ENTRY.ui, uiDir, "esm", Object.keys(REACT_HOST_MODULES)),
|
|
870
|
+
...await bundleLayer(ENTRY.logic, join(outDir, "logic"), "iife", []),
|
|
871
|
+
await copyRendererStyles(runtime, uiDir),
|
|
872
|
+
await writeIndexHtml(project, uiDir, runtime.react.version),
|
|
873
|
+
...await copyStatics(project, uiDir),
|
|
874
|
+
await writeManifest(project, outDir, runtime)
|
|
875
|
+
];
|
|
876
|
+
const icon = await copyIcon(project, outDir);
|
|
877
|
+
if (icon) files.push(icon);
|
|
878
|
+
if (mode === "production") files.push(await writeArchive(project, outDir, files));
|
|
879
|
+
return {
|
|
880
|
+
outDir,
|
|
881
|
+
files,
|
|
882
|
+
warnings
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
async function copyRendererStyles(runtime, uiDir) {
|
|
886
|
+
const file = join(uiDir, STYLES_FILE);
|
|
887
|
+
await mkdir(uiDir, { recursive: true });
|
|
888
|
+
await copyFile(runtime.stylesheet, file);
|
|
889
|
+
return file;
|
|
890
|
+
}
|
|
891
|
+
async function writeArchive(project, outDir, files) {
|
|
892
|
+
const entries = {};
|
|
893
|
+
for (const file of files) if (!file.endsWith(".map")) {
|
|
894
|
+
const name = relative(outDir, file).split(sep).join("/");
|
|
895
|
+
entries[name] = [await readFile(file), { mtime: ARCHIVE_DATE }];
|
|
896
|
+
}
|
|
897
|
+
const { id, version } = project.dotJson;
|
|
898
|
+
for (const name of await readdir(outDir)) if (name.startsWith(`${id}-`) && name.endsWith(".zip")) await rm(join(outDir, name));
|
|
899
|
+
const file = join(outDir, `${id}-${version}.zip`);
|
|
900
|
+
await writeFile(file, zipSync(entries, { level: 9 }));
|
|
901
|
+
return file;
|
|
902
|
+
}
|
|
903
|
+
async function compileModules(project, env, components, mode, warnings) {
|
|
904
|
+
const modules = /* @__PURE__ */ new Map();
|
|
905
|
+
const used = /* @__PURE__ */ new Set();
|
|
906
|
+
for (const page of project.pages) {
|
|
907
|
+
const view = await compilePage(page, components, project.statics, warnings, used);
|
|
908
|
+
modules.set(viewModuleId(page.id), view);
|
|
909
|
+
}
|
|
910
|
+
const builtins = [...used].filter((name) => name in builtinComponents).sort();
|
|
911
|
+
modules.set(ENTRY.ui, uiEntry(project, mode === "production" ? builtins : void 0));
|
|
912
|
+
modules.set(ENTRY.logic, logicEntry(project, { strict: mode === "development" }));
|
|
913
|
+
modules.set(ENV_MODULE, envModule(env));
|
|
914
|
+
return modules;
|
|
915
|
+
}
|
|
916
|
+
async function writeIndexHtml(project, uiDir, reactVersion) {
|
|
917
|
+
const file = join(uiDir, "index.html");
|
|
918
|
+
await mkdir(uiDir, { recursive: true });
|
|
919
|
+
await writeFile(file, indexHtml(project.dotJson.name, reactVersion));
|
|
920
|
+
return file;
|
|
921
|
+
}
|
|
922
|
+
async function copyStatics(project, uiDir) {
|
|
923
|
+
const copies = [];
|
|
924
|
+
for (const file of project.statics) {
|
|
925
|
+
const copy = join(uiDir, file);
|
|
926
|
+
await mkdir(dirname(copy), { recursive: true });
|
|
927
|
+
await copyFile(join(project.root, file), copy);
|
|
928
|
+
copies.push(copy);
|
|
929
|
+
}
|
|
930
|
+
return copies;
|
|
931
|
+
}
|
|
932
|
+
async function writeManifest(project, outDir, runtime) {
|
|
933
|
+
const file = join(outDir, MANIFEST_FILE);
|
|
934
|
+
const manifest = dotManifest(project, runtime);
|
|
935
|
+
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
936
|
+
return file;
|
|
937
|
+
}
|
|
938
|
+
async function copyIcon(project, outDir) {
|
|
939
|
+
if (project.dotJson.icon === void 0) return null;
|
|
940
|
+
const file = join(outDir, ICON_FILE);
|
|
941
|
+
await copyFile(join(project.root, project.dotJson.icon), file);
|
|
942
|
+
return file;
|
|
943
|
+
}
|
|
944
|
+
function dotManifest(project, runtime) {
|
|
945
|
+
const { id, name, version, description, categories, permissions, network, dash } = project.dotJson;
|
|
946
|
+
const pages = project.pages.map((page) => page.id);
|
|
947
|
+
return {
|
|
948
|
+
id,
|
|
949
|
+
name,
|
|
950
|
+
version,
|
|
951
|
+
runtime: runtime.version,
|
|
952
|
+
react: runtime.react.version,
|
|
953
|
+
description,
|
|
954
|
+
categories,
|
|
955
|
+
icon: project.dotJson.icon === void 0 ? void 0 : ICON_FILE,
|
|
956
|
+
permissions,
|
|
957
|
+
network,
|
|
958
|
+
dash,
|
|
959
|
+
entry: pages[0],
|
|
960
|
+
pages
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
async function compilePage(page, components, statics, warnings, used) {
|
|
964
|
+
const [view, state, controller] = await Promise.all([
|
|
965
|
+
readFile(page.view, "utf8"),
|
|
966
|
+
readFile(page.state, "utf8"),
|
|
967
|
+
readFile(page.controller, "utf8")
|
|
968
|
+
]);
|
|
969
|
+
const context = {
|
|
970
|
+
file: page.view,
|
|
971
|
+
components,
|
|
972
|
+
statics,
|
|
973
|
+
onWarning: (warning) => warnings.push(warning),
|
|
974
|
+
onComponent: (name) => used.add(name)
|
|
975
|
+
};
|
|
976
|
+
const { keys } = parseStateModule(state, page.state);
|
|
977
|
+
if (keys) context.stateKeys = keys;
|
|
978
|
+
const { mutations, actions } = parseLogicModule(controller, page.controller);
|
|
979
|
+
if (mutations && actions) context.handlers = [...mutations, ...actions];
|
|
980
|
+
return compileView(view, context);
|
|
981
|
+
}
|
|
982
|
+
function indexHtml(title, reactVersion) {
|
|
983
|
+
const escaped = title.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
984
|
+
const imports = {};
|
|
985
|
+
for (const [name, file] of Object.entries(REACT_HOST_MODULES)) imports[name] = reactHostUrl(reactVersion, file);
|
|
986
|
+
return `<!doctype html>
|
|
987
|
+
<html lang="en">
|
|
988
|
+
<head>
|
|
989
|
+
<meta charset="UTF-8" />
|
|
990
|
+
<meta
|
|
991
|
+
name="viewport"
|
|
992
|
+
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
|
993
|
+
/>
|
|
994
|
+
<title>${escaped}</title>
|
|
995
|
+
<link rel="stylesheet" href="./${STYLES_FILE}" />
|
|
996
|
+
<style>
|
|
997
|
+
body {
|
|
998
|
+
margin: 0;
|
|
999
|
+
}
|
|
1000
|
+
</style>
|
|
1001
|
+
</head>
|
|
1002
|
+
<body>
|
|
1003
|
+
<div id="root"></div>
|
|
1004
|
+
<script type="importmap">${JSON.stringify({ imports })}<\/script>
|
|
1005
|
+
<script type="module" src="./dot.js"><\/script>
|
|
1006
|
+
</body>
|
|
1007
|
+
</html>
|
|
1008
|
+
`;
|
|
1009
|
+
}
|
|
1010
|
+
//#endregion
|
|
1011
|
+
export { CONTROL_FLOW_TAGS, CompileError, CompileWarning, DATA_PREFIX, PAGES_DIR, ROOT_TAG, STATICS_DIR, build, canHaveChildren, compileView, dataKey, installedRuntime, listStatics, loadProject };
|
|
1012
|
+
|
|
1013
|
+
//# sourceMappingURL=index.js.map
|