@monimesl/dot-compiler 0.1.0-alpha.2 → 0.1.0-alpha.21

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