@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.
- package/README.md +34 -15
- package/dist/cli.js +386 -57
- package/dist/cli.js.map +1 -1
- package/dist/server-DFOUQnLA.js +11263 -0
- package/dist/server-DFOUQnLA.js.map +1 -0
- package/dist/templates/showcase/.env +3 -0
- package/dist/templates/showcase/dot.ts +22 -0
- package/dist/templates/showcase/pages/carousel/controller.ts +10 -0
- package/dist/templates/showcase/pages/carousel/state.ts +40 -0
- package/dist/templates/showcase/pages/carousel/view.dml +37 -0
- package/dist/templates/showcase/pages/device/controller.ts +70 -0
- package/dist/templates/showcase/pages/device/state.ts +7 -0
- package/dist/templates/showcase/pages/device/view.dml +44 -0
- package/dist/templates/showcase/pages/feedback/controller.ts +16 -0
- package/dist/templates/showcase/pages/feedback/state.ts +7 -0
- package/dist/templates/showcase/pages/feedback/view.dml +87 -0
- package/dist/templates/showcase/pages/files/controller.ts +47 -0
- package/dist/templates/showcase/pages/files/state.ts +11 -0
- package/dist/templates/showcase/pages/files/view.dml +35 -0
- package/dist/templates/showcase/pages/filters/controller.ts +28 -0
- package/dist/templates/showcase/pages/filters/state.ts +72 -0
- package/dist/templates/showcase/pages/filters/view.dml +43 -0
- package/dist/templates/showcase/pages/forms/controller.ts +44 -0
- package/dist/templates/showcase/pages/forms/state.ts +15 -0
- package/dist/templates/showcase/pages/forms/view.dml +39 -0
- package/dist/templates/showcase/pages/images/controller.ts +15 -0
- package/dist/templates/showcase/pages/images/state.ts +7 -0
- package/dist/templates/showcase/pages/images/view.dml +38 -0
- package/dist/templates/showcase/pages/index/controller.ts +10 -0
- package/dist/templates/showcase/pages/index/state.ts +116 -0
- package/dist/templates/showcase/pages/index/view.dml +20 -0
- package/dist/templates/showcase/pages/layout/controller.ts +26 -0
- package/dist/templates/showcase/pages/layout/state.ts +14 -0
- package/dist/templates/showcase/pages/layout/view.dml +80 -0
- package/dist/templates/showcase/pages/lists/controller.ts +36 -0
- package/dist/templates/showcase/pages/lists/state.ts +96 -0
- package/dist/templates/showcase/pages/lists/view.dml +59 -0
- package/dist/templates/showcase/pages/navigation/controller.ts +64 -0
- package/dist/templates/showcase/pages/navigation/state.ts +17 -0
- package/dist/templates/showcase/pages/navigation/view.dml +34 -0
- package/dist/templates/showcase/pages/network/controller.ts +107 -0
- package/dist/templates/showcase/pages/network/state.ts +15 -0
- package/dist/templates/showcase/pages/network/view.dml +56 -0
- package/dist/templates/showcase/pages/payments/controller.ts +42 -0
- package/dist/templates/showcase/pages/payments/state.ts +10 -0
- package/dist/templates/showcase/pages/payments/view.dml +35 -0
- package/dist/templates/showcase/pages/state/controller.ts +22 -0
- package/dist/templates/showcase/pages/state/state.ts +10 -0
- package/dist/templates/showcase/pages/state/view.dml +33 -0
- package/dist/templates/showcase/pages/text/controller.ts +10 -0
- package/dist/templates/showcase/pages/text/state.ts +7 -0
- package/dist/templates/showcase/pages/text/view.dml +49 -0
- package/dist/templates/starter/.env +3 -0
- package/dist/templates/starter/dot.ts +3 -0
- package/dist/templates/starter/pages/index/controller.ts +9 -0
- package/dist/templates/starter/pages/index/state.ts +4 -0
- package/dist/templates/starter/pages/index/view.dml +16 -0
- package/dist/templates/starter/statics/logo.png +0 -0
- package/package.json +12 -7
- package/dist/template/.env +0 -3
- package/dist/template/dot.ts +0 -6
- package/dist/template/pages/help/tips/controller.ts +0 -30
- package/dist/template/pages/help/tips/state.ts +0 -11
- package/dist/template/pages/help/tips/view.dml +0 -15
- package/dist/template/pages/index/controller.ts +0 -67
- package/dist/template/pages/index/state.ts +0 -17
- package/dist/template/pages/index/view.dml +0 -66
- /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]
|
|
8
|
-
dotctl
|
|
9
|
-
dotctl
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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":
|
|
27
|
-
|
|
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
|
|
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).
|
|
89
|
-
|
|
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.
|
|
93
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
170
|
+
if (!packageName) throw new CommandError(`"${name}" needs at least one letter or digit`);
|
|
114
171
|
const workspace = isInDotWorkspace(root);
|
|
115
|
-
const
|
|
172
|
+
const source = templateDir(template);
|
|
116
173
|
await mkdir(root, { recursive: true });
|
|
117
|
-
for (const path of TEMPLATE_FILES) await cp(join(
|
|
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
|
|
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/
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
434
|
+
return { files };
|
|
194
435
|
}
|
|
195
|
-
function
|
|
196
|
-
|
|
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
|
|
199
|
-
|
|
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)
|
|
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>] [--
|
|
290
|
-
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
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(
|
|
739
|
+
const answer = await ask(`${question} ${options.join(" / ")}`, fallback);
|
|
412
740
|
if (answer === void 0) return fallback;
|
|
413
|
-
|
|
414
|
-
|
|
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) => {
|