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

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