tachlon 0.0.0-stage → 0.1.0

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 (44) hide show
  1. package/README.md +155 -2
  2. package/dist-lib/LiberationMono-Regular.woff2 +0 -0
  3. package/dist-lib/LiberationSans-Bold.woff2 +0 -0
  4. package/dist-lib/LiberationSans-Regular.woff2 +0 -0
  5. package/dist-lib/adapters/validate.js +159 -0
  6. package/dist-lib/app_bg.wasm +0 -0
  7. package/dist-lib/cli/args.js +36 -0
  8. package/dist-lib/cli/csp.js +68 -0
  9. package/dist-lib/cli/detect.js +86 -0
  10. package/dist-lib/cli/doctor.js +205 -0
  11. package/dist-lib/cli/gpu.js +66 -0
  12. package/dist-lib/cli/init.js +103 -0
  13. package/dist-lib/cli/main.js +7 -0
  14. package/dist-lib/cli/probe.js +35 -0
  15. package/dist-lib/cli/run.js +92 -0
  16. package/dist-lib/cli/template.js +52 -0
  17. package/dist-lib/engine-size.json +1 -0
  18. package/dist-lib/errors.js +44 -0
  19. package/dist-lib/gantt.css +2 -0
  20. package/dist-lib/product.js +4 -0
  21. package/dist-lib/react.js +75 -0
  22. package/dist-lib/schema.json +38 -0
  23. package/dist-lib/src-CTpvK70m.js +7212 -0
  24. package/dist-lib/tachlon.js +1 -0
  25. package/dist-lib/types/adapters/bryntum.d.ts +68 -0
  26. package/dist-lib/types/adapters/dhtmlx.d.ts +96 -0
  27. package/dist-lib/types/adapters/index.d.ts +7 -0
  28. package/dist-lib/types/adapters/plain.d.ts +35 -0
  29. package/dist-lib/types/adapters/validate.d.ts +26 -0
  30. package/dist-lib/types/api.d.ts +207 -0
  31. package/dist-lib/types/commands.d.ts +76 -0
  32. package/dist-lib/types/document.d.ts +59 -0
  33. package/dist-lib/types/edit-result.d.ts +51 -0
  34. package/dist-lib/types/errors.d.ts +33 -0
  35. package/dist-lib/types/events.d.ts +80 -0
  36. package/dist-lib/types/filter-names.d.ts +5 -0
  37. package/dist-lib/types/index.d.ts +92 -0
  38. package/dist-lib/types/mount-load.d.ts +30 -0
  39. package/dist-lib/types/open-schedule.d.ts +81 -0
  40. package/dist-lib/types/product.d.ts +2 -0
  41. package/dist-lib/types/react.d.ts +32 -0
  42. package/dist-lib/types/set-filter.d.ts +20 -0
  43. package/dist-lib/types/view-state.d.ts +95 -0
  44. package/package.json +63 -4
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # tachlon
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A Gantt chart for the browser. The engine is a WebAssembly file and the chart draws on the GPU (WebGPU, or WebGL2 where WebGPU is missing). It needs no Rust toolchain.
4
+
5
+ Tachlon is proprietary software with a free trial: the chart works on any page, and shows a small "Unlicensed" mark outside localhost until you pass a license key (see [The watermark and `licenseKey`](#the-watermark-and-licensekey) and [License](#license)).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install tachlon
11
+ ```
12
+
13
+ The package holds the chart's script, its stylesheet, TypeScript declarations, the engine file `app_bg.wasm` beside the script, and the task data's schema. It runs in the browser: use it from a bundler such as Vite or Next.js, or serve the files as they are.
14
+
15
+ ## Quick start
16
+
17
+ Give `mount` an element and the tasks to draw. The element is a `div` with the class `gantt` and a box with a height:
18
+
19
+ ```html
20
+ <div id="chart" class="gantt" style="width: 800px; height: 300px"></div>
21
+ ```
22
+
23
+ ```js
24
+ import { mount } from "tachlon";
25
+ import "tachlon/gantt.css";
26
+
27
+ const tasks = [
28
+ { id: 1, name: "Design", start: "2026-11-02", duration: 3 },
29
+ { id: 2, name: "Build", start: "2026-11-05", duration: 5 },
30
+ { id: 3, name: "Ship", start: "2026-11-12", duration: 1 },
31
+ ];
32
+ const links = [{ from: 1, to: 2 }, { from: 2, to: 3 }];
33
+
34
+ const chart = await mount(document.getElementById("chart"), { tasks, links });
35
+ chart.on("select", ({ task }) => console.log("picked task", task));
36
+ ```
37
+
38
+ `mount` returns a promise of the chart. `start` is `YYYY-MM-DD`. `duration` counts working days. A link is finish-to-start unless it gives a `type` of `"SS"`, `"FF"` or `"SF"`.
39
+
40
+ `forceWebgl: true` draws with WebGL2 and never tries WebGPU. If WebGPU fails, the chart moves to WebGL2 by itself and fires an `error` event.
41
+
42
+ `mount` rejects with a `MountError` if the first schedule cannot be read or the first frame cannot start. Its `message` says why, and its `fix` says what to do.
43
+
44
+ ## Use it in React
45
+
46
+ `tachlon/react` is a React component, `Gantt`, from the same package. It takes `schedule`, `tasks`, `links`, `licenseKey` and `forceWebgl` as props (to load the engine from another address with `wasmUrl`, call `mount` yourself) and the chart's events as callbacks (`onSelect`, `onMove` and the rest). It mounts the chart when it mounts and calls `destroy()` when it unmounts. React 18 or later is a peer dependency that you install yourself; an app with no React needs none.
47
+
48
+ ```jsx
49
+ import { Gantt } from "tachlon/react";
50
+ import "tachlon/gantt.css";
51
+
52
+ // Keep `tasks` and `links` the same objects between renders: the chart starts again when they change.
53
+ <Gantt tasks={tasks} links={links} style={{ width: 800, height: 300 }} onSelect={({ task }) => console.log("picked task", task)} />;
54
+ ```
55
+
56
+ ## Load a P6 or MS Project file
57
+
58
+ The chart opens Primavera P6 XER files, MS Project XML files, and its own `.gantt` documents. Pass `chart.load` an object with the `File` as its `file` property:
59
+
60
+ ```js
61
+ const result = await chart.load({ file });
62
+ if (!result.ok) console.error(result.error);
63
+ ```
64
+
65
+ `load` returns a result and never throws; a bad file leaves the schedule as it was. To start a chart from a file, pass it to `mount` as `schedule` instead of `tasks`: `mount(element, { schedule: file })`. `chart.open(file)` replaces the schedule the same way and also takes an `OpenOptions` for a file with several projects.
66
+
67
+ `chart.toDocument()` returns the open schedule as a document that `chart.load({ document })` reads back. `chart.exportXer()` and `chart.exportMspdi()` return the open schedule as a P6 XER or MS Project XML file (`bytes`, and `leftOut`, one line for each thing the format cannot carry).
68
+
69
+ Already have DHTMLX Gantt or Bryntum Gantt data? `fromDhtmlx(data)` and `fromBryntum(data)` read it into a result whose `document` is what `chart.load({ document })` takes (`fromBryntum` returns `{ ok: false, error }` when it cannot read the data). `fromTasks({ tasks, links })` does the same for plain tasks and links, and `validate({ tasks, links })` names each problem in them by path with a fix.
70
+
71
+ ## Add it to a Vite app
72
+
73
+ Inside a Vite project (with or without React), `npx tachlon init` installs the package with the project's own manager (found from its lockfile, else npm) and writes a chart file beside your code: `src/tachlon-chart.js`, `.ts`, `.jsx` or `.tsx`, or the same name at the project folder when there is no `src/`. The file mounts the chart with three sample tasks, imports `tachlon/gantt.css`, and removes the chart when its page or component goes away. The command prints the one line to add to a page and the command to start the app. It writes new files only: never an existing page, Vite config, lockfile or tsconfig.
74
+
75
+ - `--dry-run` prints the plan and writes nothing.
76
+ - `--dir <folder>` names the project folder; without it the nearest `package.json` at or above the working folder is the project.
77
+ - `--spec <spec>` installs that spec (a tarball path, a version range) instead of this package's own version; `--no-install` installs nothing.
78
+ - `--plain` writes the module that needs no framework (a function that takes the chart's `div`), for a project whose bundler `init` does not know.
79
+
80
+ A second run changes nothing and says so. `init` exits 2 and writes nothing when there is no `package.json`, when the folder is a workspace root and `--dir` is not given, when the project is neither Vite nor Next.js (it names the bundler it found, or says there is none, and offers `--plain`), when a file it would write already exists with other contents (it names the path), or when the install fails. In a Next.js project (an `app/` or `pages/` folder, in the project folder or in `src/`), `init` instead writes the chart as a client component, `components/tachlon-chart.jsx` or `.tsx` beside that folder, and copies the installed engine to `public/app_bg.wasm`, which the component loads with `wasmUrl: "/app_bg.wasm"`; it exits 2 when there is neither folder, or when the package is not in `node_modules` to copy the engine from.
81
+
82
+ ## Check an install with `tachlon doctor`
83
+
84
+ When the chart does not draw, run `npx tachlon doctor` in the project. It reads the project and writes nothing in it. It prints one line for each check (`ok`, `FAIL` or `skip`) and a one-sentence `fix:` under each failure and under each `skip` line. `--dir <folder>` names the project folder; a workspace root needs it. The checks, with the code each failure carries:
85
+
86
+ - `package.json` lists the package and `node_modules` holds one copy of it: `NOT_LISTED`, `NOT_INSTALLED`, `MANY_COPIES`.
87
+ - The installed engine file `dist-lib/app_bg.wasm` is there, starts with `\0asm`, and has the size the package's build recorded: `ENGINE_MISSING`, `ENGINE_NOT_WASM`, `ENGINE_UNRECORDED`, `ENGINE_SIZE`.
88
+ - A source file imports `tachlon/gantt.css`: `STYLESHEET_MISSING`.
89
+ - In a Next app, the engine copy `public/app_bg.wasm` that `init` writes is the installed engine, not one left from an older version: `ENGINE_COPY_MISSING`, `ENGINE_COPY_STALE`.
90
+ - A Content-Security-Policy in the project's config (`next.config.*`, `vercel.json`, `netlify.toml`, `public/_headers`, `index.html`, `public/index.html`) lets the browser compile the engine: `CSP_WASM_EVAL` when its `script-src` (or `default-src`) lacks `'wasm-unsafe-eval'` (or `'unsafe-eval'`). A report-only policy blocks nothing and is skipped; with no policy found there is no finding.
91
+
92
+ `--url <page>` also fetches the page that mounts the chart (two requests; a page it cannot fetch, or that does not answer 200, is exit 2) and checks what the site serves. The policy in its `Content-Security-Policy` header and `<meta>` tags gets the same `CSP_WASM_EVAL` check, and `CSP_CONNECT_SRC` when the engine is on another origin that `connect-src` does not allow. The engine is the `.wasm` address the page's HTML names, or `/app_bg.wasm` in a Next app. It must answer 200 (`ENGINE_FETCH`), start with `\0asm` (`ENGINE_NOT_WASM`) and have the type `application/wasm` (`ENGINE_CONTENT_TYPE`), and in a Next app it must equal the installed engine (`ENGINE_URL_STALE`). Outside Next, a page that names no `.wasm` address gets `ENGINE_URL_SKIPPED`: a Vite build names its hashed engine file only in a script, so open the page in a browser and request the address from its network panel.
93
+
94
+ `--data <file>` also runs `validate()` on a task data file, with one `DATA_INVALID` finding for each problem, or `DATA_NOT_JSON` when the file is not JSON.
95
+
96
+ `doctor` also asks a real browser which graphics path the chart will use, because a Node process has no WebGPU or WebGL2 of its own. It opens a page of its own, headless, in the first Chrome, Edge or Chromium it finds (`TACHLON_BROWSER` names one; otherwise it looks for `google-chrome`, `chromium` and `microsoft-edge` on the path, then for the Chrome and Edge apps on a Mac) and reports one finding. The chart tries WebGPU first and falls back to WebGL2, and so does the page:
97
+
98
+ - `GPU_WEBGPU` (`ok`) when the browser has a WebGPU adapter, with the adapter's name.
99
+ - `GPU_WEBGL2` (`ok`) when it has no WebGPU adapter but has WebGL2, with the renderer's name.
100
+ - `GPU_NONE` (`FAIL`) when it has neither, the case where `mount` rejects with `TACHLON_GPU_NONE`; the `fix:` line says what to try.
101
+
102
+ That is the browser on the machine that runs `doctor`, not the one a visitor uses. A headless browser on a machine with no graphics card may name a software renderer such as SwiftShader, which is the answer, not a fault. When no browser is found, or the one found fails to answer, the line reads `skip GPU_SKIPPED` with the reason, and it does not fail the run: open the page in a browser and read the error `mount` rejects with.
103
+
104
+ Exit 0 means no check failed, 1 means at least one did, and 2 means `doctor` could not run: no `package.json`, a workspace root without `--dir`, a file it could not read, or a flag it does not know. This run is from a fixture whose engine file was deleted from `node_modules`:
105
+
106
+ ```text
107
+ doctor in .
108
+ ok INSTALLED: tachlon 0.1.0 at node_modules/tachlon
109
+ ok ONE_COPY: one copy is installed
110
+ FAIL ENGINE_MISSING: node_modules/tachlon/dist-lib/app_bg.wasm is missing
111
+ fix: Delete the package from node_modules and install it again with `npm install`, and stop any cleanup step that deletes .wasm files.
112
+ ok STYLESHEET_OK: a source file imports tachlon/gantt.css
113
+ ok GPU_WEBGL2: the browser has no WebGPU adapter but has WebGL2 (ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)), so the chart draws with WebGL2
114
+ 1 problem found.
115
+ ```
116
+
117
+ Do the `fix:`, then run `doctor` again; a clean run ends with `No problems found.` and exit 0. `--json` prints `{ "dir": ..., "findings": [...] }` instead, with the same exit code. Each finding has `code`, `status` (`"pass"`, `"fail"` or `"skipped"`), `what`, and `fix` (empty on a pass), so a script or an assistant can read the failures without parsing text.
118
+
119
+ ## The stylesheet
120
+
121
+ `import "tachlon/gantt.css"` once in your app. The chart's colours, bar shape, row height and type are CSS tokens, in light and dark, that your own CSS can set.
122
+
123
+ ## The schema
124
+
125
+ `tachlon/schema.json` is the JSON Schema of the task data `mount` reads (`tasks` and `links`). Read it from `node_modules/tachlon/dist-lib/schema.json`, or give it to a tool or an assistant that writes task data.
126
+
127
+ ## Stop the chart
128
+
129
+ `chart.destroy()` stops the frame loop, frees the engine, and empties the element. Call it when the page removes the chart. It is safe to call twice; every other method throws once it has run.
130
+
131
+ ## The engine file
132
+
133
+ The script loads `app_bg.wasm` from the address beside itself. Serve it with status 200 as `application/wasm`. To serve it from elsewhere, pass `wasmUrl` to `mount`. Under a Content-Security-Policy, add `'wasm-unsafe-eval'` to `script-src`.
134
+
135
+ ## The watermark and `licenseKey`
136
+
137
+ A page served from localhost or `file:` is always licensed. On any other origin the chart is in trial: a small "Unlicensed" mark shows in its corner, and the chart works as before. Pass `licenseKey` to `mount` to license a chart:
138
+
139
+ ```js
140
+ const chart = await mount(element, { tasks, licenseKey: "<your key>" });
141
+ ```
142
+
143
+ The root element's `data-tachlon-license` reads `"licensed"` or `"trial"`. The key check accepts no key yet, so the mark stays on every non-local page for now.
144
+ ## License
145
+
146
+ Copyright Inner Stack Labs. All rights reserved. Tachlon is proprietary software, not open source, and the package's `license` field is `UNLICENSED`.
147
+
148
+ - **Trial.** You may install and run Tachlon to evaluate it. Outside localhost and `file:` pages the chart shows the "Unlicensed" mark.
149
+ - **Production.** Using Tachlon in a product that real users reach needs a license key from Inner Stack Labs.
150
+ - **Everything else.** You may not copy, modify, redistribute, sublicense or reverse engineer Tachlon, or use it to build a competing product, except as a license key's terms allow.
151
+
152
+ To buy a license key, or to ask what a use needs, write to hello@tachlon.com. Tachlon is provided as is, with no warranty.
153
+
154
+ ## Types and API reference
155
+
156
+ The declarations (`dist-lib/types`, the package's `types` entry) cover the whole API, and every method carries its documentation comment. The API reference is part of the Tachlon guide, which is invite-only for now: <https://app.tachlon.com/book/>. There is no public documentation site yet.
@@ -0,0 +1,159 @@
1
+ // `validate(data)` (tl#1249): check plain task data before `fromTasks` reads it, and name every problem: the path in the
2
+ // plain field names, what was expected, what was there, and one sentence for the fix. It states the same shape as
3
+ // `plain.schema.json`, plus what a schema cannot: ids that repeat, and a parent or link that names a missing task. A pure
4
+ // function of its input.
5
+ const MAX = 50;
6
+ const DATE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/;
7
+ const TYPES = ["FS", "SS", "FF", "SF"];
8
+ /** What `fromTasks` throws for data `validate` refuses; `issues` is the list, `message` is the list as lines. */
9
+ export class PlainDataError extends Error {
10
+ issues;
11
+ constructor(issues) {
12
+ const count = issues.length >= MAX ? `${MAX} or more problems (the first ${MAX})` : `${issues.length} problem${issues.length === 1 ? "" : "s"}`;
13
+ super(`Task data has ${count}:\n${issues.map((i) => ` ${i.path || "data"}: expected ${i.expected}, got ${i.got}. ${i.fix}`).join("\n")}`);
14
+ this.name = "PlainDataError";
15
+ this.issues = issues;
16
+ }
17
+ }
18
+ const TASK = {
19
+ id: { kind: "id", required: true },
20
+ name: { kind: "text", required: true },
21
+ start: { kind: "date", required: true },
22
+ end: { kind: "date" },
23
+ duration: { kind: "number", min: 0 },
24
+ parent: { kind: "id" },
25
+ progress: { kind: "number", min: 0, max: 100 },
26
+ };
27
+ const LINK = {
28
+ from: { kind: "id", required: true },
29
+ to: { kind: "id", required: true },
30
+ type: { kind: "type" },
31
+ lag: { kind: "number" },
32
+ };
33
+ const EXAMPLE = { id: '"site" or 7', text: '"Site works"', date: '"2026-03-02" or "2026-03-02T08:00"', number: "5", type: '"FS"' };
34
+ function show(v) {
35
+ if (v === undefined)
36
+ return "nothing";
37
+ if (v === null)
38
+ return "null";
39
+ if (Array.isArray(v))
40
+ return "an array";
41
+ if (typeof v === "object")
42
+ return "an object";
43
+ const s = JSON.stringify(v);
44
+ return `${typeof v} ${s.length > 40 ? `${s.slice(0, 37)}...` : s}`;
45
+ }
46
+ const isId = (v) => typeof v === "string" || typeof v === "number";
47
+ /** Whether `v` is a good value for a field of this kind: the cheap test, so a good value allocates nothing. */
48
+ function fine(f, v) {
49
+ switch (f.kind) {
50
+ case "id":
51
+ return typeof v === "string" || typeof v === "number";
52
+ case "text":
53
+ return typeof v === "string";
54
+ case "date":
55
+ return typeof v === "string" && DATE.test(v);
56
+ case "type":
57
+ return typeof v === "string" && TYPES.includes(v);
58
+ case "number":
59
+ return typeof v === "number" && Number.isFinite(v) && (f.min === undefined || v >= f.min) && (f.max === undefined || v <= f.max);
60
+ }
61
+ }
62
+ /** The problem with one field's value. */
63
+ function problem(name, f, v) {
64
+ const as = (expected) => ({ expected, fix: `Write ${name} as ${expected}, for example ${EXAMPLE[f.kind]}.` });
65
+ switch (f.kind) {
66
+ case "id":
67
+ return as("text or a number");
68
+ case "text":
69
+ return as("text");
70
+ case "date":
71
+ return as("a date as YYYY-MM-DD or YYYY-MM-DDTHH:mm");
72
+ case "type":
73
+ return { expected: 'one of "FS", "SS", "FF", "SF"', fix: 'Use "FS" (finish to start), "SS", "FF" or "SF", or leave type out for FS.' };
74
+ case "number": {
75
+ if (typeof v !== "number" || !Number.isFinite(v))
76
+ return as("a number");
77
+ const range = f.max === undefined ? `${f.min} or more` : `${f.min} to ${f.max}`;
78
+ return { expected: `a number from ${range}`, fix: `Use a value of ${range} for ${name}.` };
79
+ }
80
+ }
81
+ }
82
+ const TASK_ENTRIES = Object.entries(TASK);
83
+ const LINK_ENTRIES = Object.entries(LINK);
84
+ /** Check plain task data and name every problem, up to 50. */
85
+ export function validate(data) {
86
+ const { issues } = inspect(data);
87
+ return issues.length === 0 ? { ok: true } : { ok: false, issues };
88
+ }
89
+ /** `validate`'s one walk of the data: the problems, and the index of each task by `String(id)` for the reader to use. */
90
+ export function inspect(data) {
91
+ const issues = [];
92
+ const ids = new Map();
93
+ const add = (path, expected, got, fix) => {
94
+ if (issues.length < MAX)
95
+ issues.push({ path, expected, got, fix });
96
+ };
97
+ const fields = (item, path, table) => {
98
+ if (typeof item !== "object" || item === null || Array.isArray(item))
99
+ return add(path, "an object", show(item), "Write each entry as an object in braces, with its fields.");
100
+ const rec = item;
101
+ for (const [name, f] of table) {
102
+ const v = rec[name];
103
+ if (v === undefined) {
104
+ if (f.required)
105
+ add(`${path}.${name}`, `a ${name}`, "nothing", `Add ${name}, for example ${EXAMPLE[f.kind]}.`);
106
+ }
107
+ else if (!fine(f, v)) {
108
+ const p = problem(name, f, v);
109
+ add(`${path}.${name}`, p.expected, show(v), p.fix);
110
+ }
111
+ }
112
+ };
113
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
114
+ add("", 'an object with a "tasks" array', show(data), 'Pass { "tasks": [...], "links": [...] }.');
115
+ return { issues, ids };
116
+ }
117
+ const { tasks, links } = data;
118
+ if (!Array.isArray(tasks))
119
+ add("tasks", "an array of tasks", show(tasks), 'Pass the tasks as an array: { "tasks": [{ "id": 1, "name": "Task", "start": "2026-03-02" }] }.');
120
+ if (links !== undefined && !Array.isArray(links))
121
+ add("links", "an array of links", show(links), 'Pass links as an array: [{ "from": 1, "to": 2 }], or leave links out.');
122
+ if (Array.isArray(tasks)) {
123
+ for (let i = 0; i < tasks.length; i++) {
124
+ if (!(i in tasks))
125
+ continue;
126
+ const t = tasks[i];
127
+ fields(t, `tasks[${i}]`, TASK_ENTRIES);
128
+ const id = t?.id;
129
+ if (!isId(id))
130
+ continue;
131
+ const first = ids.get(String(id));
132
+ if (first === undefined)
133
+ ids.set(String(id), i);
134
+ else
135
+ add(`tasks[${i}].id`, "an id no other task has", `${show(id)}, also tasks[${first}].id`, "Give each task its own id.");
136
+ }
137
+ for (let i = 0; i < tasks.length; i++) {
138
+ if (!(i in tasks))
139
+ continue;
140
+ const parent = tasks[i]?.parent;
141
+ if (isId(parent) && !ids.has(String(parent)))
142
+ add(`tasks[${i}].parent`, "the id of a task in tasks", show(parent), "Use the id of a task in tasks, or leave parent out.");
143
+ }
144
+ }
145
+ if (Array.isArray(links)) {
146
+ for (let i = 0; i < links.length; i++) {
147
+ if (!(i in links))
148
+ continue;
149
+ const l = links[i];
150
+ fields(l, `links[${i}]`, LINK_ENTRIES);
151
+ for (const end of ["from", "to"]) {
152
+ const v = l?.[end];
153
+ if (Array.isArray(tasks) && isId(v) && !ids.has(String(v)))
154
+ add(`links[${i}].${end}`, "the id of a task in tasks", show(v), `Use the id of a task in tasks for ${end}, or remove this link.`);
155
+ }
156
+ }
157
+ }
158
+ return { issues, ids };
159
+ }
Binary file
@@ -0,0 +1,36 @@
1
+ // The command line's arguments: one command, and flags only (no prompts: the caller is usually an assistant).
2
+ /** The flags that take a value: the field each fills and what it needs, for the sentence when the value is missing. */
3
+ const VALUED = { "--dir": ["dir", "a folder"], "--spec": ["spec", "a package spec"], "--data": ["data", "a data file"], "--url": ["url", "a page address"] };
4
+ /** The arguments `argv` holds, or the sentence that says why they are not valid. */
5
+ export function parseArgs(argv) {
6
+ const args = { command: undefined, dryRun: false, json: false, dir: undefined, spec: undefined, data: undefined, url: undefined, install: true, plain: false, help: false, version: false };
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const [flag, inline] = argv[i].split(/=(.*)/s, 2);
9
+ if (flag === "--dry-run")
10
+ args.dryRun = true;
11
+ else if (flag === "--json")
12
+ args.json = true;
13
+ else if (flag === "--no-install")
14
+ args.install = false;
15
+ else if (flag === "--plain")
16
+ args.plain = true;
17
+ else if (flag === "--help" || flag === "-h")
18
+ args.help = true;
19
+ else if (flag === "--version" || flag === "-v")
20
+ args.version = true;
21
+ else if (Object.hasOwn(VALUED, flag)) {
22
+ const [key, needs] = VALUED[flag];
23
+ const value = inline ?? argv[++i];
24
+ if (!value || value.startsWith("-"))
25
+ return { error: `${flag} needs ${needs}` };
26
+ args[key] = value;
27
+ }
28
+ else if (flag.startsWith("-"))
29
+ return { error: `unknown flag ${flag}` };
30
+ else if (args.command === undefined)
31
+ args.command = flag;
32
+ else
33
+ return { error: `unexpected argument ${flag}` };
34
+ }
35
+ return args;
36
+ }
@@ -0,0 +1,68 @@
1
+ // The Content-Security-Policy a page needs for the engine to load: where `doctor` finds a policy (the project's config, a served page) and whether it lets the engine compile and connect.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /** The files that carry a policy in the frameworks and hosts the command line knows, and how each holds it: Next's headers, Vercel and Netlify (a string), Netlify's `_headers` (a line), a page (a meta tag). */
5
+ const CONFIG_FILES = [["next.config.js", "string"], ["next.config.mjs", "string"], ["next.config.cjs", "string"], ["next.config.ts", "string"], ["vercel.json", "string"], ["netlify.toml", "string"], ["public/_headers", "headers"], ["index.html", "html"], ["public/index.html", "html"]];
6
+ /** A string that holds (a piece of) a policy: it names a `*-src` directive. The key beside it does not matter, so a policy kept in a constant is found too. Only a template literal runs over lines. */
7
+ const STRING = /(["'])((?:(?!\1)[^\n])*?\b[a-z]+-src\s(?:(?!\1)[^\n])*)\1|`([^`]*?\b[a-z]+-src\s[^`]*)`/g;
8
+ const HEADERS_LINE = /^[ \t]+Content-Security-Policy:[ \t]*(.+)$/gim;
9
+ const META = /<meta\b[^>]*?http-?equiv=["']Content-Security-Policy["'][^>]*>/gi;
10
+ const flat = (s) => s.replace(/\s+/g, " ").trim();
11
+ /** The policies in the `<meta http-equiv>` tags of `html`. */
12
+ export function metaPolicies(html, source) {
13
+ return [...html.matchAll(META)].flatMap(([tag]) => {
14
+ const content = /\bcontent=(["'])(.*?)\1/is.exec(tag);
15
+ return content ? [{ source, text: flat(content[2]) }] : [];
16
+ });
17
+ }
18
+ /** The policies the project's config files hold as an enforced header or meta tag; a report-only one blocks nothing and is skipped. */
19
+ export function configPolicies(dir) {
20
+ return CONFIG_FILES.filter(([f]) => existsSync(join(dir, f))).flatMap(([file, kind]) => {
21
+ const text = readFileSync(join(dir, file), "utf8");
22
+ if (kind === "html")
23
+ return metaPolicies(text, file);
24
+ if (kind === "headers")
25
+ return [...text.matchAll(HEADERS_LINE)].map((m) => ({ source: file, text: flat(m[1]) }));
26
+ // The strings of one file are the pieces of one policy (an array joined with "; ", strings added across lines), so they are read as one.
27
+ const pieces = [...text.matchAll(STRING)].filter((m) => !/report-only/i.test(text.slice(Math.max(0, m.index - 80), m.index))).map((m) => flat(m[2] ?? m[3]));
28
+ return pieces.length > 0 ? [{ source: file, text: pieces.join("; ") }] : [];
29
+ });
30
+ }
31
+ /** The source list `policy` sets for `directive`, else for `default-src`; undefined when it sets neither (the policy puts no limit on it). */
32
+ function sources(policy, directive) {
33
+ const set = new Map();
34
+ for (const part of policy.split(";")) {
35
+ const [name, ...list] = part.trim().split(/\s+/);
36
+ if (name && !set.has(name.toLowerCase()))
37
+ set.set(name.toLowerCase(), list);
38
+ }
39
+ return set.get(directive) ?? set.get("default-src");
40
+ }
41
+ /** Whether the browser may compile WebAssembly under `policy`: `script-src` (else `default-src`) holds `'wasm-unsafe-eval'` or `'unsafe-eval'`, or the policy sets neither. */
42
+ export function allowsWasm(policy) {
43
+ const list = sources(policy, "script-src");
44
+ return list === undefined || list.some((s) => ["'wasm-unsafe-eval'", "'unsafe-eval'"].includes(s.toLowerCase()));
45
+ }
46
+ /** Whether `connect-src` (else `default-src`) lets a page at `page` request `target`, or the policy sets neither. */
47
+ export function allowsConnect(policy, target, page) {
48
+ const list = sources(policy, "connect-src");
49
+ return list === undefined || list.some((s) => matches(s.toLowerCase(), target, page));
50
+ }
51
+ /** One source expression of a source list against `target`: `*`, `'self'`, a scheme (`https:`), or a host with an optional scheme, `*.` prefix and port. */
52
+ function matches(source, target, page) {
53
+ if (source === "*")
54
+ return true;
55
+ if (source === "'self'")
56
+ return target.origin === page.origin;
57
+ if (source.endsWith(":"))
58
+ return target.protocol === source;
59
+ const m = /^(?:([a-z][a-z0-9+.-]*):\/\/)?(\*\.)?([^/:*]+)(?::(\d+|\*))?(?:\/.*)?$/.exec(source);
60
+ if (!m)
61
+ return false;
62
+ const [, scheme, wild, host, port] = m;
63
+ if (scheme !== undefined ? `${scheme}:` !== target.protocol : target.protocol !== page.protocol && target.protocol !== "https:")
64
+ return false;
65
+ if (port === undefined ? target.port !== "" : port !== "*" && port !== target.port)
66
+ return false;
67
+ return wild ? target.hostname.endsWith(`.${host}`) : target.hostname === host;
68
+ }
@@ -0,0 +1,86 @@
1
+ // What the command line reads from a project folder: where the project is, its package manager and its framework.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ const OTHER_BUNDLERS = ["webpack", "parcel", "rollup", "esbuild", "@angular/cli", "astro", "@sveltejs/kit", "nuxt", "gatsby", "@remix-run/dev", "react-scripts"];
5
+ const LOCKFILES = [
6
+ ["pnpm-lock.yaml", "pnpm"],
7
+ ["yarn.lock", "yarn"],
8
+ ["bun.lock", "bun"],
9
+ ["bun.lockb", "bun"],
10
+ ["package-lock.json", "npm"],
11
+ ];
12
+ /** A reason the command cannot run, in one sentence; `run` turns it into exit 2. */
13
+ export class Refusal extends Error {
14
+ }
15
+ /** The JSON object in `file`, or a `Refusal` saying it cannot be read, is not JSON, or is not an object. */
16
+ export function readJsonObject(file) {
17
+ const why = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ");
18
+ let text;
19
+ try {
20
+ text = readFileSync(file, "utf8");
21
+ }
22
+ catch (e) {
23
+ throw new Refusal(`${file} cannot be read: ${why(e)}`);
24
+ }
25
+ let value;
26
+ try {
27
+ value = JSON.parse(text);
28
+ }
29
+ catch (e) {
30
+ throw new Refusal(`${file} is not valid JSON: ${why(e)}`);
31
+ }
32
+ if (typeof value !== "object" || value === null || Array.isArray(value))
33
+ throw new Refusal(`${file} is not a JSON object`);
34
+ return value;
35
+ }
36
+ /** The nearest folder at or above `start` that holds a package.json, or undefined; with `exact`, only `start` itself. */
37
+ export function findProjectDir(start, exact = false) {
38
+ for (let dir = resolve(start);; dir = dirname(dir)) {
39
+ if (existsSync(join(dir, "package.json")))
40
+ return dir;
41
+ if (exact || dirname(dir) === dir)
42
+ return undefined;
43
+ }
44
+ }
45
+ /** The manager `dir` names by a lockfile, else by its package.json's `packageManager`; undefined if neither. */
46
+ function managerHere(dir, pkg) {
47
+ const locked = LOCKFILES.find(([file]) => existsSync(join(dir, file)));
48
+ if (locked)
49
+ return locked[1];
50
+ const named = pkg.packageManager?.split("@")[0];
51
+ return LOCKFILES.find(([, m]) => m === named)?.[1];
52
+ }
53
+ /** The manager of the nearest workspace root above `dir` (a `pnpm-workspace.yaml`, or a package.json with `workspaces`): its members share its lockfile. */
54
+ function managerOfWorkspaceRoot(dir) {
55
+ for (let up = dirname(dir); up !== dir; dir = up, up = dirname(up)) {
56
+ let pkg = {};
57
+ try {
58
+ pkg = readJsonObject(join(up, "package.json"));
59
+ }
60
+ catch {
61
+ // no package.json here, or one that is not JSON: it is not a workspace root we can read
62
+ }
63
+ if (existsSync(join(up, "pnpm-workspace.yaml")) || pkg.workspaces !== undefined)
64
+ return managerHere(up, pkg);
65
+ }
66
+ return undefined;
67
+ }
68
+ function managerOf(dir, pkg) {
69
+ return managerHere(dir, pkg) ?? managerOfWorkspaceRoot(dir) ?? "npm";
70
+ }
71
+ /** What `dir` (a folder with a package.json) is: its manager, framework and where Next keeps its routes. */
72
+ export function detect(dir) {
73
+ const pkg = readJsonObject(join(dir, "package.json"));
74
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
75
+ const routerRoot = ["", "src"].find((r) => ["app", "pages"].some((f) => existsSync(join(dir, r, f))));
76
+ return {
77
+ dir,
78
+ manager: managerOf(dir, pkg),
79
+ framework: "next" in deps ? "next" : "react" in deps ? "react" : "none",
80
+ bundler: "next" in deps ? "next" : "vite" in deps ? "vite" : undefined,
81
+ otherBundler: OTHER_BUNDLERS.find((b) => b in deps),
82
+ routerRoot,
83
+ typescript: "typescript" in deps || existsSync(join(dir, "tsconfig.json")),
84
+ workspace: pkg.workspaces !== undefined || existsSync(join(dir, "pnpm-workspace.yaml")),
85
+ };
86
+ }