@monimesl/dot-cli 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
Files changed (68) hide show
  1. package/README.md +34 -15
  2. package/dist/cli.js +386 -57
  3. package/dist/cli.js.map +1 -1
  4. package/dist/server-DFOUQnLA.js +11263 -0
  5. package/dist/server-DFOUQnLA.js.map +1 -0
  6. package/dist/templates/showcase/.env +3 -0
  7. package/dist/templates/showcase/dot.ts +22 -0
  8. package/dist/templates/showcase/pages/carousel/controller.ts +10 -0
  9. package/dist/templates/showcase/pages/carousel/state.ts +40 -0
  10. package/dist/templates/showcase/pages/carousel/view.dml +37 -0
  11. package/dist/templates/showcase/pages/device/controller.ts +70 -0
  12. package/dist/templates/showcase/pages/device/state.ts +7 -0
  13. package/dist/templates/showcase/pages/device/view.dml +44 -0
  14. package/dist/templates/showcase/pages/feedback/controller.ts +16 -0
  15. package/dist/templates/showcase/pages/feedback/state.ts +7 -0
  16. package/dist/templates/showcase/pages/feedback/view.dml +87 -0
  17. package/dist/templates/showcase/pages/files/controller.ts +47 -0
  18. package/dist/templates/showcase/pages/files/state.ts +11 -0
  19. package/dist/templates/showcase/pages/files/view.dml +35 -0
  20. package/dist/templates/showcase/pages/filters/controller.ts +28 -0
  21. package/dist/templates/showcase/pages/filters/state.ts +72 -0
  22. package/dist/templates/showcase/pages/filters/view.dml +43 -0
  23. package/dist/templates/showcase/pages/forms/controller.ts +44 -0
  24. package/dist/templates/showcase/pages/forms/state.ts +15 -0
  25. package/dist/templates/showcase/pages/forms/view.dml +39 -0
  26. package/dist/templates/showcase/pages/images/controller.ts +15 -0
  27. package/dist/templates/showcase/pages/images/state.ts +7 -0
  28. package/dist/templates/showcase/pages/images/view.dml +38 -0
  29. package/dist/templates/showcase/pages/index/controller.ts +10 -0
  30. package/dist/templates/showcase/pages/index/state.ts +116 -0
  31. package/dist/templates/showcase/pages/index/view.dml +20 -0
  32. package/dist/templates/showcase/pages/layout/controller.ts +26 -0
  33. package/dist/templates/showcase/pages/layout/state.ts +14 -0
  34. package/dist/templates/showcase/pages/layout/view.dml +80 -0
  35. package/dist/templates/showcase/pages/lists/controller.ts +36 -0
  36. package/dist/templates/showcase/pages/lists/state.ts +96 -0
  37. package/dist/templates/showcase/pages/lists/view.dml +59 -0
  38. package/dist/templates/showcase/pages/navigation/controller.ts +64 -0
  39. package/dist/templates/showcase/pages/navigation/state.ts +17 -0
  40. package/dist/templates/showcase/pages/navigation/view.dml +34 -0
  41. package/dist/templates/showcase/pages/network/controller.ts +107 -0
  42. package/dist/templates/showcase/pages/network/state.ts +15 -0
  43. package/dist/templates/showcase/pages/network/view.dml +56 -0
  44. package/dist/templates/showcase/pages/payments/controller.ts +42 -0
  45. package/dist/templates/showcase/pages/payments/state.ts +10 -0
  46. package/dist/templates/showcase/pages/payments/view.dml +35 -0
  47. package/dist/templates/showcase/pages/state/controller.ts +22 -0
  48. package/dist/templates/showcase/pages/state/state.ts +10 -0
  49. package/dist/templates/showcase/pages/state/view.dml +33 -0
  50. package/dist/templates/showcase/pages/text/controller.ts +10 -0
  51. package/dist/templates/showcase/pages/text/state.ts +7 -0
  52. package/dist/templates/showcase/pages/text/view.dml +49 -0
  53. package/dist/templates/starter/.env +3 -0
  54. package/dist/templates/starter/dot.ts +3 -0
  55. package/dist/templates/starter/pages/index/controller.ts +9 -0
  56. package/dist/templates/starter/pages/index/state.ts +4 -0
  57. package/dist/templates/starter/pages/index/view.dml +16 -0
  58. package/dist/templates/starter/statics/logo.png +0 -0
  59. package/package.json +12 -7
  60. package/dist/template/.env +0 -3
  61. package/dist/template/dot.ts +0 -6
  62. package/dist/template/pages/help/tips/controller.ts +0 -30
  63. package/dist/template/pages/help/tips/state.ts +0 -11
  64. package/dist/template/pages/help/tips/view.dml +0 -15
  65. package/dist/template/pages/index/controller.ts +0 -67
  66. package/dist/template/pages/index/state.ts +0 -17
  67. package/dist/template/pages/index/view.dml +0 -66
  68. /package/dist/{template → templates/showcase}/statics/logo.png +0 -0
package/README.md CHANGED
@@ -4,23 +4,42 @@ The `dotctl` command: build mini apps and run them in the simulator. Platform ta
4
4
  packaging, signing and publishing mini apps will live here too.
5
5
 
6
6
  ```sh
7
- dotctl create [dir] # scaffold a dot (asks for its name), with a sample home page
8
- dotctl build [root] # compile the dot into dist/ui and dist/logic, minified
9
- dotctl dev [root] # build, open it in the browser simulator, rebuild and reload on change
7
+ dotctl create [dir] # scaffold a dot (asks for its name and template)
8
+ dotctl create page <path> # add a page to the dot you are in
9
+ dotctl build [root] # compile the dot into dist/ui and dist/logic, minified
10
+ dotctl dev [root] # build, run it in the browser simulator, and reload it on each change
11
+ dotctl lsp # the .dml language server, which editors start
10
12
  ```
11
13
 
12
- | Option | Applies to | What it does |
13
- | --------------------- | ---------- | ------------------------------------------------------- |
14
- | `--out-dir <dir>` | both | Output directory (default: `<root>/dist`) |
15
- | `--conditions <list>` | both | Extra export conditions, such as `@monimesl/dot-source` |
16
- | `--port <port>` | `dev` | Simulator port (default: `3680`, or the next free one) |
14
+ | Option | Applies to | What it does |
15
+ | --------------------- | ---------- | ------------------------------------------------------------------------ |
16
+ | `--out-dir <dir>` | both | Output directory (default: `<root>/dist`) |
17
+ | `--conditions <list>` | both | Extra export conditions, such as `@monimesl/dot-source` |
18
+ | `--port <port>` | `dev` | Simulator port (default: `3680`, or the next free one) |
19
+ | `--device` | `dev` | Also serve the dot to the Dot example apps on phones, through a QR code |
20
+ | `--name <name>` | `create` | The dot's name, instead of asking |
21
+ | `--template <name>` | `create` | `starter` (one page to build on, the default) or `showcase` (every page) |
22
+ | `--pm <name>` | `create` | The package manager to install with: npm, pnpm, yarn or bun |
17
23
 
18
24
  ## `dotctl create`
19
25
 
20
- Asks for the dot's name (`Dot` by default, or pass `--name`) and creates a self-contained project
21
- in `dir` (by default, the name in kebab case) with one page, `home`: a short welcome, localized for
22
- Sierra Leone, to a dot running inside Dash. The dot depends on `@monimesl/dot-runtime` and `@monimesl/dot-cli`; inside
23
- the Dot repository it uses the workspace packages instead. It then offers to install the
24
- dependencies and start the simulator inside the new dot, asking which of the installed package
25
- managers to use (npm, pnpm, yarn or bun; the one that launched `dotctl`, or npm, by default).
26
- `--pm <name>` answers that up front.
26
+ Asks for the dot's name (`Dot` by default) and template, and creates a self-contained project in
27
+ `dir` (by default, the name in kebab case): `starter` is one page to build on, and `showcase` a page
28
+ for each feature. The dot depends on `@monimesl/dot-runtime` and `@monimesl/dot-cli`; inside the Dot
29
+ repository it uses the workspace packages instead. It then offers to install the dependencies and
30
+ start the simulator inside the new dot, asking which of the installed package managers to use (npm,
31
+ pnpm, yarn or bun; the one that launched `dotctl`, or npm, by default).
32
+
33
+ ## `dotctl dev`
34
+
35
+ Builds the dot, opens it in the browser simulator, and builds it again on each save:
36
+
37
+ - A change to its pages (`pages/**`) runs in place: each open page keeps its state, and runs the new
38
+ code. A page whose initial state changed starts over from the new one.
39
+ - Any other change, such as to `dot.ts`, `dot.json`, `.env` or `statics/`, starts the dot again, on
40
+ the pages it had open.
41
+ - A failed build shows its error, and the dot keeps its last good build.
42
+
43
+ With `--device`, it also serves each build on the local network to the Dot example apps. It prints a
44
+ QR code to scan with one, which then follows the dot in the same way: see
45
+ [the example apps](../hosts/native/README.md#the-example-apps).
package/dist/cli.js CHANGED
@@ -3,13 +3,52 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { parseArgs } from "node:util";
6
- import { CompileError, build, installedRuntime, loadProject } from "@monimesl/dot-compiler";
6
+ import { CompileError, PAGES_DIR, build, installedRuntime, loadProject } from "@monimesl/dot-compiler";
7
7
  import { DEFAULT_PORT, startSimulator } from "@monimesl/dot-simulator";
8
8
  import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
9
- import { cp, mkdir, writeFile } from "node:fs/promises";
9
+ import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
10
10
  import { DmlParseError } from "@monimesl/dot-dml-parser";
11
- import { ScriptParseError } from "@monimesl/dot-script-parser";
11
+ import { ScriptParseError, parseDotModule } from "@monimesl/dot-script-parser";
12
+ import { isPageId } from "@monimesl/dot-core";
13
+ import { renderUnicodeCompact } from "uqr";
14
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { createServer } from "node:http";
16
+ import { networkInterfaces } from "node:os";
17
+ //#region src/report.ts
18
+ /** A mistake in how a command was used, which its message alone explains. */
19
+ var CommandError = class extends Error {
20
+ name = "CommandError";
21
+ };
22
+ function describeError(error) {
23
+ if (error instanceof CommandError || error instanceof CompileError || error instanceof DmlParseError || error instanceof ScriptParseError) return error.message;
24
+ if (error.code?.startsWith("ERR_PARSE_ARGS")) {
25
+ const [firstSentence] = error.message.split(". ");
26
+ return `${firstSentence}. See dotctl --help.`;
27
+ }
28
+ return error instanceof Error ? error.stack ?? error.message : String(error);
29
+ }
30
+ function printWarnings({ warnings }) {
31
+ for (const warning of warnings) console.warn(`warning: ${warning.message}`);
32
+ }
33
+ function printBuildTime(startedAt) {
34
+ console.log(`Built in ${Math.round(performance.now() - startedAt)}ms`);
35
+ }
36
+ //#endregion
12
37
  //#region src/create/template.ts
38
+ const TEMPLATES = ["starter", "showcase"];
39
+ function isTemplate(name) {
40
+ return TEMPLATES.includes(name);
41
+ }
42
+ const PAGE_FILES = {
43
+ "view.dml": "<View>\n <Text>My new page</Text>\n</View>\n",
44
+ "state.ts": "export default {};\n",
45
+ "controller.ts": `import state from './state';
46
+
47
+ export default definePage({
48
+ initialState: state,
49
+ });
50
+ `
51
+ };
13
52
  const TEMPLATE_FILES = [
14
53
  "dot.ts",
15
54
  "pages",
@@ -23,13 +62,8 @@ function projectFiles(options) {
23
62
  "package.json": packageJson(options),
24
63
  "tsconfig.json": tsconfig(options),
25
64
  ".gitignore": "node_modules/\ndist/\n# Types generated by dotctl\n.dot/\n",
26
- "dot.json": json({
27
- $schema: DOT_JSON_SCHEMA,
28
- id: `com.example.${idWord(options.packageName)}`,
29
- name: options.name,
30
- version: "0.1.0"
31
- }),
32
- "README.md": readme(options.name)
65
+ "dot.json": dotJson(options),
66
+ "README.md": readme(options)
33
67
  };
34
68
  }
35
69
  function packageJson({ packageName, dotVersion, workspace }) {
@@ -73,7 +107,29 @@ function tsconfig({ workspace }) {
73
107
  ]
74
108
  });
75
109
  }
76
- function readme(name) {
110
+ function dotJson({ name, packageName, template }) {
111
+ const dot = {
112
+ $schema: DOT_JSON_SCHEMA,
113
+ id: `com.example.${idWord(packageName)}`,
114
+ name,
115
+ version: "0.1.0"
116
+ };
117
+ if (template === "showcase") dot.network = { hosts: [
118
+ "api.github.com",
119
+ "avatars.githubusercontent.com",
120
+ "httpbin.org"
121
+ ] };
122
+ return json(dot);
123
+ }
124
+ const TEMPLATE_READMES = {
125
+ starter: `It starts with one page, \`index\`: a greeting from \`.env\` and a count to tap. Change it,
126
+ and add pages as the dot grows. \`npx dotctl create my-tour --template showcase\` makes a dot with a
127
+ page for each feature, to read how each is done.`,
128
+ showcase: `It starts as a showcase: \`index\` lists a page for each feature, from layout, text, images and
129
+ inputs to state, navigation, and Dash's alerts, network and files. Read a page to see how it's
130
+ done, then delete the ones you don't need, from \`pages/\` and from \`dot.ts\`.`
131
+ };
132
+ function readme({ name, template }) {
77
133
  return `# ${name}
78
134
 
79
135
  A dot: a mini app that runs inside Dash.
@@ -85,20 +141,21 @@ npm run build # build it for Dash
85
141
  \`\`\`
86
142
 
87
143
  Each page is a folder under \`pages/\` with three files: \`view.dml\` (what it shows),
88
- \`state.ts\` (what it starts with) and \`controller.ts\` (how it changes). List new pages in
89
- \`dot.ts\`; the first one is where the dot starts.
144
+ \`state.ts\` (what it starts with) and \`controller.ts\` (how it changes). A page's markup is one
145
+ root \`<View>\` with everything else inside it. \`npx dotctl create page <path>\` adds a page, such
146
+ as \`cart/checkout\`, with the three files to start from, and lists it in \`dot.ts\`. The first page
147
+ there is where the dot starts.
90
148
 
91
149
  \`dot.json\` describes the dot to Dash. Change its \`id\` from \`com.example.…\` to a reverse domain
92
- name you own before you publish: it can't change afterwards. Your editor completes the other fields
93
- from the schema it points to.
150
+ name you own before you publish: it can't change afterwards. Its \`network.hosts\` lists every host
151
+ the dot sends requests to with \`dash.network\`; requests to any other fail. Your editor completes
152
+ the other fields from the schema it points to.
94
153
 
95
- \`.env\` holds public configuration: variables named \`DOT_PUBLIC_…\`, which the service reads as
96
- \`env.DOT_PUBLIC_…\`. They are built into the dot, so anyone who has it can read them: never put a
97
- secret there. \`.env.development\` and \`.env.production\` override it for \`dev\` and \`build\`.
154
+ \`.env\` holds public configuration, which the service reads as \`env.GREETING\`. Every variable
155
+ there is built into the dot, so anyone who has it can read them: never put a secret there.
156
+ \`.env.development\` and \`.env.production\` override it for \`dev\` and \`build\`.
98
157
 
99
- It starts as a counter with two pages. \`index\` counts taps with mutations. \`help/tips\` shows
100
- navigation: each page you open goes on top of the stack, Back returns to the one below, and the
101
- trail at the top shows where you are.
158
+ ${TEMPLATE_READMES[template]}
102
159
  `;
103
160
  }
104
161
  function idWord(packageName) {
@@ -106,20 +163,21 @@ function idWord(packageName) {
106
163
  }
107
164
  //#endregion
108
165
  //#region src/create/create.ts
109
- async function create({ name, dir }) {
166
+ async function create({ name, dir, template = "starter" }) {
110
167
  const root = resolve(dir);
111
- if (existsSync(root) && readdirSync(root).length > 0) throw new Error(`${dir} already exists and is not empty`);
168
+ if (existsSync(root) && readdirSync(root).length > 0) throw new CommandError(`${dir} already exists and is not empty`);
112
169
  const packageName = toPackageName(name);
113
- if (!packageName) throw new Error(`"${name}" needs at least one letter or digit`);
170
+ if (!packageName) throw new CommandError(`"${name}" needs at least one letter or digit`);
114
171
  const workspace = isInDotWorkspace(root);
115
- const template = templateDir();
172
+ const source = templateDir(template);
116
173
  await mkdir(root, { recursive: true });
117
- for (const path of TEMPLATE_FILES) await cp(join(template, path), join(root, path), { recursive: true });
174
+ for (const path of TEMPLATE_FILES) await cp(join(source, path), join(root, path), { recursive: true });
118
175
  const generated = projectFiles({
119
176
  name,
120
177
  packageName,
121
178
  dotVersion: workspace ? "workspace:*" : `^${cliVersion()}`,
122
- workspace
179
+ workspace,
180
+ template
123
181
  });
124
182
  for (const [path, content] of Object.entries(generated)) await writeFile(join(root, path), content);
125
183
  return {
@@ -146,9 +204,9 @@ function isInDotWorkspace(dir) {
146
204
  current = parent;
147
205
  }
148
206
  }
149
- function templateDir() {
150
- const example = join(cliRoot(), "../../examples/counter");
151
- return existsSync(example) ? example : join(cliRoot(), "dist/template");
207
+ function templateDir(template) {
208
+ const example = join(cliRoot(), "../../examples", template);
209
+ return existsSync(example) ? example : join(cliRoot(), "dist/templates", template);
152
210
  }
153
211
  function cliVersion() {
154
212
  return readPackageJson(join(cliRoot(), "package.json")).version;
@@ -160,6 +218,74 @@ function cliRoot() {
160
218
  return dirname(createRequire(import.meta.url).resolve("@monimesl/dot-cli/package.json"));
161
219
  }
162
220
  //#endregion
221
+ //#region src/create/page.ts
222
+ /**
223
+ * Adds a page to the dot around `cwd`: its folder under pages/, with its three files, listed last in
224
+ * dot.ts.
225
+ */
226
+ async function createPage(path, cwd) {
227
+ if (!isPageId(path)) throw new CommandError(`"${path}" is not a page path: use letters, digits, "-" and "_", with "/" between folders, such as "cart/checkout"`);
228
+ const root = findDotRoot(resolve(cwd));
229
+ if (root === void 0) throw new CommandError("There is no dot.json here or in a folder above: run it inside a dot");
230
+ const dir = join(root, "pages", path);
231
+ if (existsSync(dir)) throw new CommandError(`pages/${path} already exists`);
232
+ await mkdir(dir, { recursive: true });
233
+ const files = [];
234
+ for (const [name, content] of Object.entries(PAGE_FILES)) {
235
+ const file = join(dir, name);
236
+ await writeFile(file, content);
237
+ files.push(file);
238
+ }
239
+ return {
240
+ dir,
241
+ files,
242
+ listing: await listPage(join(root, "dot.ts"), path)
243
+ };
244
+ }
245
+ async function listPage(dotFile, path) {
246
+ if (!existsSync(dotFile)) return "manual";
247
+ const source = await readFile(dotFile, "utf8");
248
+ let dot;
249
+ try {
250
+ dot = parseDotModule(source, dotFile);
251
+ } catch {
252
+ return "manual";
253
+ }
254
+ if (dot.pages.includes(path)) return "already";
255
+ const updated = withPage(source, dot.lastPage, path);
256
+ if (!listsPage(updated, dotFile, path)) return "manual";
257
+ await writeFile(dotFile, updated);
258
+ return "added";
259
+ }
260
+ function withPage(source, lastPage, path) {
261
+ const quote = source[lastPage.start] === "\"" ? "\"" : "'";
262
+ const page = `${quote}${path}${quote}`;
263
+ const lineStart = source.lastIndexOf("\n", lastPage.start - 1) + 1;
264
+ const indent = source.slice(lineStart, lastPage.start);
265
+ if (!/^\s*$/.test(indent)) return `${source.slice(0, lastPage.end)}, ${page}${source.slice(lastPage.end)}`;
266
+ const trailingComma = /^\s*,/.exec(source.slice(lastPage.end));
267
+ if (trailingComma) {
268
+ const end = lastPage.end + trailingComma[0].length;
269
+ return `${source.slice(0, end)}\n${indent}${page},${source.slice(end)}`;
270
+ }
271
+ return `${source.slice(0, lastPage.end)},\n${indent}${page}${source.slice(lastPage.end)}`;
272
+ }
273
+ function listsPage(source, dotFile, path) {
274
+ try {
275
+ return parseDotModule(source, dotFile).pages.includes(path);
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+ function findDotRoot(dir) {
281
+ while (true) {
282
+ if (existsSync(join(dir, "dot.json"))) return dir;
283
+ const parent = dirname(dir);
284
+ if (parent === dir) return;
285
+ dir = parent;
286
+ }
287
+ }
288
+ //#endregion
163
289
  //#region src/create/package-manager.ts
164
290
  const PACKAGE_MANAGERS = [
165
291
  "npm",
@@ -183,25 +309,152 @@ function onPath(name) {
183
309
  }).status === 0;
184
310
  }
185
311
  //#endregion
186
- //#region src/report.ts
187
- function describeError(error) {
188
- if (error instanceof CompileError || error instanceof DmlParseError || error instanceof ScriptParseError) return error.message;
189
- if (error.code?.startsWith("ERR_PARSE_ARGS")) {
190
- const [firstSentence] = error.message.split(". ");
191
- return `${firstSentence}. See dotctl --help.`;
312
+ //#region src/device/server.ts
313
+ const DEVICE_SCHEME = "dotdev";
314
+ const WAIT_MS = 25e3;
315
+ const MAX_LOG_BYTES = 1048576;
316
+ async function startDeviceServer({ address = localNetworkAddress(), port = 0, onLog = () => {}, onConnect = () => {} } = {}) {
317
+ if (address === void 0) throw new Error("This computer has no local network address for devices to reach it on");
318
+ const token = randomBytes(18).toString("base64url");
319
+ let build = { files: /* @__PURE__ */ new Map() };
320
+ let generation = 0;
321
+ let hotSince = 0;
322
+ let error = null;
323
+ const waiting = /* @__PURE__ */ new Set();
324
+ const connected = /* @__PURE__ */ new Set();
325
+ const announce = () => {
326
+ generation += 1;
327
+ for (const wake of waiting) wake();
328
+ };
329
+ const server = createServer((request, response) => {
330
+ const from = request.socket.remoteAddress ?? "a device";
331
+ if (!isAuthorized(request, token)) {
332
+ response.writeHead(401).end();
333
+ return;
334
+ }
335
+ const url = new URL(request.url ?? "/", "http://device");
336
+ const path = url.pathname;
337
+ if (request.method === "GET" && path === "/dev/build") {
338
+ if (!connected.has(from)) {
339
+ connected.add(from);
340
+ onConnect(from);
341
+ }
342
+ const files = Object.fromEntries([...build.files].map(([name, file]) => [name, file.hash]));
343
+ sendJson(response, {
344
+ generation,
345
+ hotSince,
346
+ files,
347
+ error
348
+ });
349
+ } else if (request.method === "GET" && path.startsWith("/dev/files/")) {
350
+ if (url.searchParams.get("generation") !== String(generation)) {
351
+ response.writeHead(409).end();
352
+ return;
353
+ }
354
+ const name = path.slice(11).split("/").map(decodeURIComponent).join("/");
355
+ const file = build.files.get(name);
356
+ if (!file) {
357
+ response.writeHead(404).end();
358
+ return;
359
+ }
360
+ response.writeHead(200, { "content-type": "application/octet-stream" }).end(file.bytes);
361
+ } else if (request.method === "GET" && path === "/dev/changes") waitForChange(response, Number(url.searchParams.get("after")));
362
+ else if (request.method === "POST" && path === "/dev/log") readText(request).then((text) => {
363
+ for (const line of text.split("\n")) onLog(line, from);
364
+ response.writeHead(204).end();
365
+ }).catch(() => response.writeHead(413).end());
366
+ else response.writeHead(404).end();
367
+ });
368
+ const waitForChange = (response, after) => {
369
+ if (generation > after) {
370
+ sendJson(response, { generation });
371
+ return;
372
+ }
373
+ const wake = () => {
374
+ clearTimeout(timer);
375
+ waiting.delete(wake);
376
+ sendJson(response, { generation });
377
+ };
378
+ const timer = setTimeout(() => {
379
+ waiting.delete(wake);
380
+ response.writeHead(204).end();
381
+ }, WAIT_MS);
382
+ waiting.add(wake);
383
+ response.on("close", () => {
384
+ clearTimeout(timer);
385
+ waiting.delete(wake);
386
+ });
387
+ };
388
+ await new Promise((resolve, reject) => {
389
+ server.once("error", reject);
390
+ server.listen(port, address, () => resolve());
391
+ });
392
+ const { port: listening } = server.address();
393
+ const origin = `http://${address}:${listening}`;
394
+ return {
395
+ link: `${DEVICE_SCHEME}://connect?server=${encodeURIComponent(origin)}&token=${token}`,
396
+ async publish(dir, { hot = false } = {}) {
397
+ build = await readBuild(dir);
398
+ error = null;
399
+ announce();
400
+ if (!hot) hotSince = generation;
401
+ },
402
+ fail(message) {
403
+ error = message;
404
+ announce();
405
+ },
406
+ close() {
407
+ for (const wake of waiting) wake();
408
+ return new Promise((resolve) => {
409
+ server.close(() => resolve());
410
+ server.closeAllConnections();
411
+ });
412
+ }
413
+ };
414
+ }
415
+ function localNetworkAddress() {
416
+ for (const addresses of Object.values(networkInterfaces())) for (const candidate of addresses ?? []) if (candidate.family === "IPv4" && !candidate.internal) return candidate.address;
417
+ }
418
+ async function readBuild(dir) {
419
+ const files = /* @__PURE__ */ new Map();
420
+ const entries = await readdir(dir, {
421
+ recursive: true,
422
+ withFileTypes: true
423
+ });
424
+ for (const entry of entries) {
425
+ if (!entry.isFile() || entry.name.endsWith(".map") || entry.name.endsWith(".zip")) continue;
426
+ const path = join(entry.parentPath, entry.name);
427
+ const bytes = await readFile(path);
428
+ const hash = createHash("sha256").update(bytes).digest("base64url");
429
+ files.set(relative(dir, path).split(sep).join("/"), {
430
+ bytes,
431
+ hash
432
+ });
192
433
  }
193
- return error instanceof Error ? error.stack ?? error.message : String(error);
434
+ return { files };
194
435
  }
195
- function printWarnings({ warnings }) {
196
- for (const warning of warnings) console.warn(`warning: ${warning.message}`);
436
+ function isAuthorized(request, token) {
437
+ const given = Buffer.from(request.headers.authorization ?? "");
438
+ const expected = Buffer.from(`Bearer ${token}`);
439
+ return given.length === expected.length && timingSafeEqual(given, expected);
197
440
  }
198
- function printBuildTime(startedAt) {
199
- console.log(`Built in ${Math.round(performance.now() - startedAt)}ms`);
441
+ function sendJson(response, body) {
442
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body));
443
+ }
444
+ async function readText(request) {
445
+ const chunks = [];
446
+ let size = 0;
447
+ for await (const chunk of request) {
448
+ size += chunk.length;
449
+ if (size > MAX_LOG_BYTES) throw new Error("Too much log at once");
450
+ chunks.push(chunk);
451
+ }
452
+ return Buffer.concat(chunks).toString("utf8");
200
453
  }
201
454
  //#endregion
202
455
  //#region src/dev.ts
203
456
  const CHANGE_DEBOUNCE_MS = 50;
204
- async function dev({ port, ...options }) {
457
+ async function dev({ port, device, ...options }) {
205
458
  const project = await loadProject(options.root);
206
459
  const outDir = resolve(project.root, options.outDir ?? "dist");
207
460
  const buildOptions = {
@@ -226,20 +479,43 @@ async function dev({ port, ...options }) {
226
479
  dotDir: outDir,
227
480
  reactHostDir: installedRuntime(project.root).react.dir,
228
481
  name: project.dotJson.name,
229
- port
482
+ port,
483
+ storageDir: join(project.root, ".dot", "files")
230
484
  });
231
485
  if (firstError) simulator.error(firstError);
232
486
  console.log(`\n Simulator running at: ${simulator.url}\n`);
487
+ let devices;
488
+ if (device) {
489
+ devices = await startDeviceServer({
490
+ onConnect: (from) => console.log(`A device connected from ${from}`),
491
+ onLog: (line) => console.log(`device ${line}`)
492
+ });
493
+ if (firstError) devices.fail(firstError);
494
+ else await devices.publish(outDir);
495
+ printDeviceLink(devices.link);
496
+ }
497
+ const changed = /* @__PURE__ */ new Set();
498
+ const pagesDir = join(project.root, PAGES_DIR) + sep;
233
499
  const rebuilds = new RebuildQueue(async () => {
500
+ const building = [...changed];
234
501
  const error = await buildApp();
235
- if (error) simulator.error(error);
502
+ if (error) {
503
+ simulator.error(error);
504
+ devices?.fail(error);
505
+ return;
506
+ }
507
+ for (const path of building) changed.delete(path);
508
+ const hot = building.every((path) => path.startsWith(pagesDir));
509
+ if (hot) simulator.hotReload();
236
510
  else simulator.reload();
511
+ await devices?.publish(outDir, { hot });
237
512
  });
238
513
  let debounceTimer;
239
514
  const watcher = watch(project.root, { recursive: true }, (_event, file) => {
240
515
  if (!file || isIgnored(file)) return;
241
516
  const path = resolve(project.root, file);
242
517
  if (path === outDir || path.startsWith(outDir + sep)) return;
518
+ changed.add(path);
243
519
  clearTimeout(debounceTimer);
244
520
  debounceTimer = setTimeout(() => {
245
521
  console.log(`Changed ${relative(project.root, path)}`);
@@ -249,9 +525,16 @@ async function dev({ port, ...options }) {
249
525
  return async () => {
250
526
  clearTimeout(debounceTimer);
251
527
  watcher.close();
252
- await simulator.close();
528
+ await Promise.all([simulator.close(), devices?.close()]);
253
529
  };
254
530
  }
531
+ function printDeviceLink(link) {
532
+ console.log(" Scan this in a Dot example app on a phone:\n");
533
+ console.log(renderUnicodeCompact(link, { border: 2 }).replace(/^/gm, " "));
534
+ console.log(`\n Or open the link on a device, an emulator or a simulator:\n\n ${link}\n`);
535
+ console.log(` Android emulator: adb shell am start -a android.intent.action.VIEW -d '${link}'`);
536
+ console.log(` iOS Simulator: xcrun simctl openurl booted '${link}'\n`);
537
+ }
255
538
  var RebuildQueue = class {
256
539
  #rebuild;
257
540
  #building = false;
@@ -286,16 +569,25 @@ function isIgnored(file) {
286
569
  //#endregion
287
570
  //#region src/cli.ts
288
571
  const USAGE = `Usage:
289
- dotctl create [dir] [--name <name>] [--pm <npm|pnpm|yarn|bun>]
290
- Create a dot (asks for its name)
572
+ dotctl create [dir] [--name <name>] [--template <starter|showcase>]
573
+ [--pm <npm|pnpm|yarn|bun>]
574
+ Create a dot (asks for its name and template):
575
+ starter is one page to build on, showcase a page
576
+ for each feature
577
+ dotctl create page <path> Add a page to the dot you are in: pages/<path>/ with
578
+ view.dml, state.ts and controller.ts
291
579
  dotctl build [root] [options] Build the minified bundles the host app runs
292
580
  dotctl dev [root] [options] Run in the browser simulator, rebuilding on change
581
+ dotctl lsp [--stdio] Serve .dml files to an editor over the Language Server
582
+ Protocol, on stdin and stdout
293
583
 
294
584
  Options:
295
585
  --out-dir <dir> Output directory (default: <root>/dist)
296
586
  --conditions <list> Extra export conditions, comma separated
297
587
  --port <port> Simulator port for \`dev\`; the next free one is used if taken
298
588
  (default: ${DEFAULT_PORT})
589
+ --device With \`dev\`, also serve the dot to the Dot example apps on phones on
590
+ the local network, through a link and QR code; they reload on each build
299
591
  -h, --help Show this message`;
300
592
  async function main(argv) {
301
593
  const { values, positionals } = parseArgs({
@@ -305,16 +597,25 @@ async function main(argv) {
305
597
  "out-dir": { type: "string" },
306
598
  conditions: { type: "string" },
307
599
  port: { type: "string" },
600
+ device: { type: "boolean" },
308
601
  name: { type: "string" },
602
+ template: { type: "string" },
309
603
  pm: { type: "string" },
310
604
  help: {
311
605
  type: "boolean",
312
606
  short: "h"
313
- }
607
+ },
608
+ stdio: { type: "boolean" }
314
609
  }
315
610
  });
316
- const [command, target] = positionals;
317
- if (command === "create" && !values.help) return createApp(target, values.name, values.pm);
611
+ const [command, target, pagePath] = positionals;
612
+ if (command === "lsp" && !values.help) {
613
+ const { serveStdio } = await import("./server-DFOUQnLA.js");
614
+ serveStdio();
615
+ return new Promise(() => {});
616
+ }
617
+ if (command === "create" && target === "page" && !values.help) return addPage(pagePath);
618
+ if (command === "create" && !values.help) return createApp(target, values.name, values.template, values.pm);
318
619
  if (values.help || command !== "build" && command !== "dev") {
319
620
  console.log(USAGE);
320
621
  return values.help ? 0 : 1;
@@ -330,7 +631,8 @@ async function main(argv) {
330
631
  if (command === "dev") {
331
632
  const stop = await dev({
332
633
  ...options,
333
- port
634
+ port,
635
+ device: values.device
334
636
  });
335
637
  await new Promise((resolve) => process.once("SIGINT", resolve));
336
638
  await stop();
@@ -343,7 +645,24 @@ async function main(argv) {
343
645
  printBuildTime(started);
344
646
  return 0;
345
647
  }
346
- async function createApp(dir, name, packageManagerFlag) {
648
+ async function addPage(path) {
649
+ if (path === void 0) {
650
+ console.error("Which page? dotctl create page <path>, such as dotctl create page cart/checkout");
651
+ return 1;
652
+ }
653
+ if (path.startsWith("/")) path = path.substring(1);
654
+ if (path.endsWith("/")) path = path.substring(0, path.length - 1);
655
+ const { files, listing } = await createPage(path, process.cwd());
656
+ for (const file of files) console.log(` ${relative(process.cwd(), file)}`);
657
+ if (listing === "added") console.log(`\nListed '${path}' in dot.ts.`);
658
+ else if (listing === "manual") console.log(`\nList it in dot.ts to build it: defineDot({ pages: [..., '${path}'] })`);
659
+ return 0;
660
+ }
661
+ async function createApp(dir, name, templateFlag, packageManagerFlag) {
662
+ if (templateFlag !== void 0 && !isTemplate(templateFlag)) {
663
+ console.error(`--template must be one of ${TEMPLATES.join(", ")}`);
664
+ return 1;
665
+ }
347
666
  if (packageManagerFlag !== void 0 && !isPackageManager(packageManagerFlag)) {
348
667
  console.error(`--pm must be one of ${PACKAGE_MANAGERS.join(", ")}`);
349
668
  return 1;
@@ -353,9 +672,11 @@ async function createApp(dir, name, packageManagerFlag) {
353
672
  console.error("A dot needs a name: dotctl create [dir] --name \"My Dot\"");
354
673
  return 1;
355
674
  }
675
+ const template = templateFlag ?? await chooseTemplate();
356
676
  const { root, workspace } = await create({
357
677
  name,
358
- dir: dir ?? toPackageName(name)
678
+ dir: dir ?? toPackageName(name),
679
+ template
359
680
  });
360
681
  const path = relative(process.cwd(), root) || ".";
361
682
  console.log(`\nCreated ${name} in ${path}`);
@@ -403,15 +724,23 @@ async function readAnswer(prompt, fallback) {
403
724
  readline.close();
404
725
  }
405
726
  }
727
+ async function chooseTemplate() {
728
+ if (!process.stdin.isTTY) return "starter";
729
+ console.log("\nTemplates:\n starter one page to build on\n showcase a page for each feature, to read");
730
+ return chooseOne("Template?", TEMPLATES, "starter");
731
+ }
406
732
  async function choosePackageManager(suggested) {
407
733
  const installed = installedManagers();
408
734
  if (installed.length < 2) return installed[0] ?? suggested;
409
- const fallback = installed.includes(suggested) ? suggested : installed[0];
735
+ return chooseOne("Package manager?", installed, installed.includes(suggested) ? suggested : installed[0]);
736
+ }
737
+ async function chooseOne(question, options, fallback) {
410
738
  while (true) {
411
- const answer = await ask(`Package manager? ${installed.join(" / ")}`, fallback);
739
+ const answer = await ask(`${question} ${options.join(" / ")}`, fallback);
412
740
  if (answer === void 0) return fallback;
413
- if (isPackageManager(answer) && installed.includes(answer)) return answer;
414
- console.log(`Choose one of ${installed.join(", ")}.`);
741
+ const chosen = options.find((option) => option === answer);
742
+ if (chosen) return chosen;
743
+ console.log(`Choose one of ${options.join(", ")}.`);
415
744
  }
416
745
  }
417
746
  main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {