@sanoma-ai/workflows 0.7.1 → 0.8.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.
- package/README.md +34 -7
- package/dist/apps-list.d.ts +90 -0
- package/dist/apps-list.js +109 -0
- package/dist/apps-list.js.map +1 -0
- package/dist/apps.d.ts +42 -0
- package/dist/apps.js +137 -0
- package/dist/apps.js.map +1 -0
- package/dist/call-workflow.js +5 -6
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.js +6 -3
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +1 -1
- package/dist/cedar.js +6 -4
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.d.ts +10 -0
- package/dist/change-steps.js +204 -0
- package/dist/change-steps.js.map +1 -0
- package/dist/change-workflow.d.ts +54 -0
- package/dist/change-workflow.js +161 -0
- package/dist/change-workflow.js.map +1 -0
- package/dist/change.d.ts +121 -24
- package/dist/change.js +196 -17
- package/dist/change.js.map +1 -1
- package/dist/claims.d.ts +58 -0
- package/dist/claims.js +27 -0
- package/dist/claims.js.map +1 -0
- package/dist/client.d.ts +92 -0
- package/dist/client.js +268 -6
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +6 -1
- package/dist/collection.js +11 -3
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +25 -2
- package/dist/config.js +36 -20
- package/dist/config.js.map +1 -1
- package/dist/db.d.ts +12 -0
- package/dist/db.js +15 -0
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +5 -2
- package/dist/deadlines.js.map +1 -1
- package/dist/define.d.ts +30 -14
- package/dist/define.js +9 -1
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +15 -4
- package/dist/demo.js +27 -12
- package/dist/demo.js.map +1 -1
- package/dist/describe.d.ts +16 -2
- package/dist/describe.js +39 -4
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +4 -3
- package/dist/direct.js +7 -4
- package/dist/direct.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/lint.js +19 -5
- package/dist/lint.js.map +1 -1
- package/dist/outline.js +13 -6
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +54 -2
- package/dist/paths.js.map +1 -1
- package/dist/resources.d.ts +7 -2
- package/dist/resources.js +23 -8
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +7 -0
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +2 -2
- package/dist/scenario.js +10 -4
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +1 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +19 -5
- package/dist/sdk.js.map +1 -1
- package/dist/shared.d.ts +43 -2
- package/dist/shared.js +46 -4
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +26 -5
- package/dist/sites.js +56 -20
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +55 -5
- package/dist/source.js +4 -0
- package/dist/source.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.js +21 -13
- package/dist/version.js.map +1 -1
- package/dist/worker.js +50 -3
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -2
- package/package.json +1 -1
- package/src/apps-list.ts +119 -0
- package/src/apps.ts +162 -0
- package/src/call-workflow.ts +10 -2
- package/src/call.ts +6 -3
- package/src/cedar.ts +9 -7
- package/src/change-steps.ts +274 -0
- package/src/change-workflow.ts +198 -0
- package/src/change.ts +257 -27
- package/src/claims.ts +79 -0
- package/src/client.ts +320 -4
- package/src/collection.ts +22 -3
- package/src/config.ts +60 -22
- package/src/db.ts +23 -0
- package/src/deadlines.ts +5 -2
- package/src/define.ts +31 -12
- package/src/demo.ts +55 -24
- package/src/describe.ts +67 -4
- package/src/direct.ts +9 -5
- package/src/errors.ts +3 -0
- package/src/index.ts +14 -0
- package/src/lint.ts +20 -5
- package/src/outline.ts +15 -6
- package/src/paths.ts +57 -2
- package/src/resources.ts +27 -9
- package/src/run.ts +5 -0
- package/src/scenario.ts +14 -4
- package/src/scenarios.ts +1 -1
- package/src/sdk.ts +21 -7
- package/src/shared.ts +60 -5
- package/src/sites.ts +70 -24
- package/src/source.ts +50 -5
- package/src/version.ts +25 -16
- package/src/worker.ts +46 -3
package/src/paths.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { APP_NAME } from "./shared.ts";
|
|
3
4
|
|
|
4
5
|
// Where an import may reach, as the lints and the data-file reader all decide it, the one walk up
|
|
5
6
|
// the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only, no parser:
|
|
@@ -45,3 +46,57 @@ export function filesUnder(dir: string, keep: (name: string) => boolean): string
|
|
|
45
46
|
.filter((path) => !relative(dir, path).split(sep).includes("node_modules"))
|
|
46
47
|
.toSorted();
|
|
47
48
|
}
|
|
49
|
+
|
|
50
|
+
/** Where a company's apps are, under its root: `apps/<app>/`. */
|
|
51
|
+
export const APPS_DIR = "apps";
|
|
52
|
+
|
|
53
|
+
/** Each directory's company root (the nearest one with a `sanoma.config.ts`, short of a `node_modules`), once found. */
|
|
54
|
+
const roots = new Map<string, string>();
|
|
55
|
+
|
|
56
|
+
/** Whether `dir` is a company's root: it holds a `sanoma.config.ts`. */
|
|
57
|
+
const isCompany = (dir: string) => existsSync(join(dir, "sanoma.config.ts"));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The app a file is in: `<app>` for a file under `<root>/apps/<app>/`, where the root is the
|
|
61
|
+
* nearest directory above it with a `sanoma.config.ts` (an app keeps its own `package.json`,
|
|
62
|
+
* and its own config when merged from a company's repo: then the company is the one whose
|
|
63
|
+
* `apps/` holds it; a `node_modules` ends the search: the file is a package's, not a company's);
|
|
64
|
+
* undefined for a file at the root or outside any company. `defineWorkflow` and `collection()` name what they declare by it.
|
|
65
|
+
*/
|
|
66
|
+
export function appOfFile(file: string | undefined): string | undefined {
|
|
67
|
+
if (file === undefined) return undefined;
|
|
68
|
+
const dir = dirname(resolve(file));
|
|
69
|
+
let root: string | null | undefined = roots.get(dir);
|
|
70
|
+
if (root === undefined) {
|
|
71
|
+
root =
|
|
72
|
+
upFrom<string | null>(dir, (d) => (basename(d) === "node_modules" ? null : isCompany(d) ? d : undefined)) ?? null;
|
|
73
|
+
// An app merged from a company's repo keeps that repo's config: the company is the one above.
|
|
74
|
+
if (root !== null && basename(dirname(root)) === APPS_DIR && isCompany(dirname(dirname(root)))) {
|
|
75
|
+
root = dirname(dirname(root));
|
|
76
|
+
}
|
|
77
|
+
// Found roots only: a directory that is no company's yet may become one (`sanoma` in an empty folder).
|
|
78
|
+
if (root !== null) roots.set(dir, root);
|
|
79
|
+
}
|
|
80
|
+
if (root === null) return undefined;
|
|
81
|
+
const [top, app, ...rest] = relative(root, file).split(sep);
|
|
82
|
+
return top === APPS_DIR && app !== undefined && rest.length && APP_NAME.test(app) ? app : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The apps' folders under `root` whose names are an `APP_NAME`, sorted: `apps/<app>/`, absolute. */
|
|
86
|
+
export function appFolders(root: string): string[] {
|
|
87
|
+
const dir = join(root, APPS_DIR);
|
|
88
|
+
if (!isDirectory(dir)) return [];
|
|
89
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
90
|
+
.filter((d) => d.isDirectory() && APP_NAME.test(d.name))
|
|
91
|
+
.map((d) => join(dir, d.name))
|
|
92
|
+
.toSorted();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Where a kind of file is under `root`: `<root>/<kind>`, then each app's `apps/<app>/<kind>`, whether or not they exist. */
|
|
96
|
+
export const kindDirs = (root: string, kind: string): string[] => [
|
|
97
|
+
join(root, kind),
|
|
98
|
+
...appFolders(root).map((app) => join(app, kind)),
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/** Where a company's scenarios are, under its root and each app's folder. */
|
|
102
|
+
export const SCENARIOS_DIR = "scenarios";
|
package/src/resources.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { dirname,
|
|
2
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { parsed } from "./ast.ts";
|
|
4
4
|
import {
|
|
5
5
|
type DataFile,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./datafile.ts";
|
|
12
12
|
import { type Binding, type Bindings, LOCKFILE, readLockfile } from "./lockfile.ts";
|
|
13
13
|
import { type Connector, VENDOR } from "./op.ts";
|
|
14
|
-
import { filesUnder } from "./paths.ts";
|
|
14
|
+
import { APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
|
|
15
15
|
import { peopleConnector, sanoma } from "./person.ts";
|
|
16
16
|
import { wakeupOps } from "./wakeup-type.ts";
|
|
17
17
|
import type { Span } from "./outline.ts";
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
personOf,
|
|
29
29
|
principalOf,
|
|
30
30
|
problemLine,
|
|
31
|
+
qualified,
|
|
31
32
|
RUNTIME_AGENTS,
|
|
32
33
|
VISITORS,
|
|
33
34
|
} from "./shared.ts";
|
|
@@ -65,9 +66,9 @@ export interface DeclaredResource {
|
|
|
65
66
|
export interface DeclaredCollection {
|
|
66
67
|
/** `<file>#<export>`, as a resource's: `resources/data.ts#outbound`. */
|
|
67
68
|
id: string;
|
|
68
|
-
/** `resources/data.ts`. */
|
|
69
|
+
/** `resources/data.ts`, or an app's `apps/<app>/resources/data.ts`. */
|
|
69
70
|
file: string;
|
|
70
|
-
/** The collection's name, its operations' middle
|
|
71
|
+
/** The collection's name, its operations' middle segments (`data.<name>.get`): an app's with its app, `crm.contacts`. */
|
|
71
72
|
name: string;
|
|
72
73
|
/** Its `export const` statement, as UTF-16 offsets into the file's text. */
|
|
73
74
|
span: Span;
|
|
@@ -119,7 +120,8 @@ const peopleRead = new Map<string, { stamp: string; read: DeclaredPeople }>();
|
|
|
119
120
|
|
|
120
121
|
/** Each data file under `root` with its size and modification time: unchanged while the files are. */
|
|
121
122
|
const filesStamp = (root: string) =>
|
|
122
|
-
|
|
123
|
+
kindDirs(root, "resources")
|
|
124
|
+
.flatMap(dataFilesUnder)
|
|
123
125
|
.map((path) => {
|
|
124
126
|
// Gone since it was listed (an editor's save by rename): stamped as such, and read next time.
|
|
125
127
|
const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
|
|
@@ -134,6 +136,13 @@ const RESERVED_GROUPS: ReadonlyMap<string, string> = new Map([
|
|
|
134
136
|
[ANONYMOUS, "who a public page runs as when nobody is signed in"],
|
|
135
137
|
]);
|
|
136
138
|
|
|
139
|
+
/**
|
|
140
|
+
* A person, agent or group declared in an app's data file: refused, because the people are the
|
|
141
|
+
* company's, so neither the policy (`readPeople`) nor `describeConfig` counts it.
|
|
142
|
+
*/
|
|
143
|
+
export const inAppPeople = (r: DeclaredResource): boolean =>
|
|
144
|
+
r.vendor === "sanoma" && Object.hasOwn(sanoma, r.type) && r.file.startsWith(`${APPS_DIR}/`);
|
|
145
|
+
|
|
137
146
|
/**
|
|
138
147
|
* The people, agents and groups the data files under `root` declare (`sanoma.person`, `.agent`,
|
|
139
148
|
* `.group`), read without running them, as `principalOf` takes them, with what is wrong in their
|
|
@@ -147,9 +156,16 @@ export function readPeople(root: string | undefined): DeclaredPeople {
|
|
|
147
156
|
if (cached?.stamp === stamp) return cached.read;
|
|
148
157
|
// With the wakeup's type, which the same constructors declare: read, and only the people's kept.
|
|
149
158
|
const read = readDataFiles(root, [peopleConnector, wakeupOps], undefined, (source) => DECLARES_PEOPLE.test(source));
|
|
150
|
-
const
|
|
159
|
+
const ofPeople = read.resources.filter((r) => Object.hasOwn(sanoma, r.type));
|
|
160
|
+
const people = ofPeople.filter((r) => !inAppPeople(r));
|
|
151
161
|
// The people are never bound, so the lockfile's problems are not theirs.
|
|
152
162
|
const own = read.problems.filter((p) => p.file !== LOCKFILE);
|
|
163
|
+
for (const r of ofPeople.filter(inAppPeople)) {
|
|
164
|
+
own.push({
|
|
165
|
+
file: r.file,
|
|
166
|
+
message: `${r.id} declares a ${r.type} in an app: the people are the company's, declared in resources/ at the root`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
153
169
|
const ids = new Map<string, DeclaredResource>();
|
|
154
170
|
for (const r of people) {
|
|
155
171
|
if (r.type === "group") {
|
|
@@ -262,7 +278,8 @@ export function readDataFiles(
|
|
|
262
278
|
|
|
263
279
|
const files = new Map<string, FileEntry>();
|
|
264
280
|
const left = new Set<string>();
|
|
265
|
-
|
|
281
|
+
// The root's, then each app's: a data file imports only from its own `resources/`.
|
|
282
|
+
for (const [dir, path] of kindDirs(root, "resources").flatMap((d) => dataFilesUnder(d).map((p) => [d, p] as const))) {
|
|
266
283
|
const rel = relative(root, path).split(sep).join("/");
|
|
267
284
|
const source = readFileSync(path, "utf8");
|
|
268
285
|
if (partial && !partial(source)) {
|
|
@@ -270,7 +287,7 @@ export function readDataFiles(
|
|
|
270
287
|
continue;
|
|
271
288
|
}
|
|
272
289
|
const { program, problems: syntax, at } = parsed(source, path);
|
|
273
|
-
const data = readDataFile(program, path,
|
|
290
|
+
const data = readDataFile(program, path, dir);
|
|
274
291
|
const problem = (offset: number, message: string) => problems.push({ file: rel, ...at(offset), message });
|
|
275
292
|
for (const p of syntax) problems.push({ file: rel, ...p });
|
|
276
293
|
for (const p of data.problems) problem(p.start, p.message);
|
|
@@ -465,7 +482,8 @@ export function readDataFiles(
|
|
|
465
482
|
data.collections.map((c) => ({
|
|
466
483
|
id: `${rel}#${c.export}`,
|
|
467
484
|
file: rel,
|
|
468
|
-
|
|
485
|
+
// An app's, `apps/<app>/resources/data.ts`, is named with its app, as `collection()` names it.
|
|
486
|
+
name: qualified(rel.startsWith(`${APPS_DIR}/`) ? rel.split("/")[1] : undefined, c.name),
|
|
469
487
|
span: [c.start, c.end] as Span,
|
|
470
488
|
})),
|
|
471
489
|
);
|
package/src/run.ts
CHANGED
|
@@ -71,6 +71,11 @@ export interface WorkerState {
|
|
|
71
71
|
workflows: Map<string, WorkflowDefinition<any, any>>;
|
|
72
72
|
/** Each workflow's outline, by name: read once when the worker starts, every run is held to it (`call_not_in_outline`). */
|
|
73
73
|
outlines: Map<string, WorkflowOutline>;
|
|
74
|
+
/**
|
|
75
|
+
* Starts a run of the workflow `name` as `runId` (an id the run already has returns it): how a
|
|
76
|
+
* built-in starts another as its child, as the change run starts apply and drift.
|
|
77
|
+
*/
|
|
78
|
+
startRun(name: string, runId: string, args: RunArgs): Promise<{ getResult(): Promise<unknown> }>;
|
|
74
79
|
/**
|
|
75
80
|
* Set when the worker stops, before DBOS shuts down. DBOS abandons a stopped worker's run
|
|
76
81
|
* functions, which then fail as their next DBOS call finds the database closed. Such a failure,
|
package/src/scenario.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import type { ISupportCodeLibrary } from "@cucumber/cucumber/api";
|
|
@@ -33,6 +33,7 @@ import { fill, isBug, objectUnder, restOf, seedFrom, shapeOf } from "./fill.ts";
|
|
|
33
33
|
import type { LedgerRecord } from "./ledger.ts";
|
|
34
34
|
import type { Driver, Op } from "./op.ts";
|
|
35
35
|
import { type OutlineNode, outlineWorkflow } from "./outline.ts";
|
|
36
|
+
import { isDirectory, kindDirs, SCENARIOS_DIR } from "./paths.ts";
|
|
36
37
|
import { isObject } from "./resource.ts";
|
|
37
38
|
import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
|
|
38
39
|
import { memorySecrets } from "./secret.ts";
|
|
@@ -1231,6 +1232,15 @@ function featureFiles(dir: string): string[] {
|
|
|
1231
1232
|
.toSorted();
|
|
1232
1233
|
}
|
|
1233
1234
|
|
|
1235
|
+
/**
|
|
1236
|
+
* A company's scenarios: the `.feature` files under `scenarios/` in `root`, then under each app's
|
|
1237
|
+
* `apps/<app>/scenarios/`, relative to `root`, with `/` between directories.
|
|
1238
|
+
*/
|
|
1239
|
+
const scenarioFiles = (root: string): string[] =>
|
|
1240
|
+
kindDirs(root, SCENARIOS_DIR).flatMap((dir) =>
|
|
1241
|
+
isDirectory(dir) ? featureFiles(dir).map((file) => `${relative(root, dir).split(sep).join("/")}/${file}`) : [],
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1234
1244
|
/** What `loadScenarios` returns. */
|
|
1235
1245
|
export interface LoadedScenarios {
|
|
1236
1246
|
scenarios: Scenario[];
|
|
@@ -1383,8 +1393,8 @@ const loadedByScope = new WeakMap<
|
|
|
1383
1393
|
>();
|
|
1384
1394
|
|
|
1385
1395
|
/**
|
|
1386
|
-
* Every scenario in the `.feature` files under `scenarios
|
|
1387
|
-
* compiled by Cucumber, with an error for each file that does not compile and each name used
|
|
1396
|
+
* Every scenario in the `.feature` files under `scenarios/` and each app's `apps/<app>/scenarios/`
|
|
1397
|
+
* in `scenarios` (a `file:` URL to the root), each named by its path from there, compiled by Cucumber, with an error for each file that does not compile and each name used
|
|
1388
1398
|
* twice. No directory, or none given, is no scenarios. Rejects when an operation's phrase names
|
|
1389
1399
|
* a field it lacks. The files are compiled again only once one is added, removed or written
|
|
1390
1400
|
* (its name, `mtimeMs` or size changes), or the people's data files change: until then each
|
|
@@ -1396,7 +1406,7 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
|
1396
1406
|
if (!existsSync(dir)) return { scenarios: [], errors: [] };
|
|
1397
1407
|
// Built first, so a phrase naming a field its input lacks rejects even with no feature files.
|
|
1398
1408
|
builtFor(scope);
|
|
1399
|
-
const files =
|
|
1409
|
+
const files = scenarioFiles(dir);
|
|
1400
1410
|
const stamp = JSON.stringify([
|
|
1401
1411
|
dir,
|
|
1402
1412
|
files.map((file) => {
|
package/src/scenarios.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { type Check, check, drive, loadScenarios, type Scenario, type Scope } fr
|
|
|
6
6
|
import { errorMessage } from "./shared.ts";
|
|
7
7
|
import { startWorker } from "./worker.ts";
|
|
8
8
|
|
|
9
|
-
export { type DemoState, demoState, reseed } from "./demo.ts";
|
|
9
|
+
export { type DemoState, demoState, reseed, type SeedHalf } from "./demo.ts";
|
|
10
10
|
|
|
11
11
|
/*
|
|
12
12
|
* `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
|
package/src/sdk.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import type { CollectionEntry, ConfigDescription, OpEntry, WorkflowEntry } from "./describe.ts";
|
|
4
4
|
import { isObject } from "./resource.ts";
|
|
5
5
|
import { type CedarSchemaJson, SCHEMA_FILE, schemaText } from "./schema.ts";
|
|
6
|
-
import { apiNameOf, DATA_VENDOR, problemLine } from "./shared.ts";
|
|
6
|
+
import { apiNameOf, DATA_VENDOR, problemLine, writtenName } from "./shared.ts";
|
|
7
7
|
|
|
8
8
|
// A company's generated SDK, `.sanoma/api.ts`: the types of a site's `api` from `sanoma/site`,
|
|
9
9
|
// written from `describeConfig` (types and JSDoc only: `api` itself is one generic client). It
|
|
@@ -24,7 +24,7 @@ type Schema = Record<string, unknown>;
|
|
|
24
24
|
* `.sanoma/api.ts` for a description: one record type per collection, and `CompanyApi` (not `Api`,
|
|
25
25
|
* which inside `declare module "sanoma/site"` is the module's own), one member per
|
|
26
26
|
* workflow (`apiNameOf`), per vendor of the operations a person may call directly (`OpEntry.direct`,
|
|
27
|
-
* by resource) and `data` for the collections, each with its JSDoc; then `Register`'s
|
|
27
|
+
* by resource) and `data` for the collections (an app's under its name), each with its JSDoc; then `Register`'s
|
|
28
28
|
* augmentation. Pure: the same description gives the same text. A workflow whose api name another
|
|
29
29
|
* has, `describeConfig`'s problem on its entry, is left out, and its problem is in `problems`.
|
|
30
30
|
*/
|
|
@@ -46,9 +46,16 @@ export function generateApi(description: ConfigDescription): { source: string; p
|
|
|
46
46
|
members.push(member(`${title}: the operations a person may call directly.`, vendor, block(resources)));
|
|
47
47
|
}
|
|
48
48
|
if (description.collections.length) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
// The root's by name, then each app's under its name: `api.data.crm.contacts`.
|
|
50
|
+
const collectionMember = (c: CollectionEntry) =>
|
|
51
|
+
member(collectionDoc(c), writtenName(c), `CollectionApi<${recordName(c)}>`);
|
|
52
|
+
const byApp = Map.groupBy(description.collections, (c) => c.app);
|
|
53
|
+
const data = [
|
|
54
|
+
...(byApp.get(undefined) ?? []).map(collectionMember),
|
|
55
|
+
...[...byApp]
|
|
56
|
+
.filter(([app]) => app !== undefined)
|
|
57
|
+
.map(([app, own]) => member(`The ${app} app's collections.`, app!, block(own.map(collectionMember)))),
|
|
58
|
+
];
|
|
52
59
|
members.push(member("The company's collections.", DATA_VENDOR, block(data)));
|
|
53
60
|
}
|
|
54
61
|
const source = [
|
|
@@ -127,8 +134,15 @@ const opMember = (op: OpEntry) =>
|
|
|
127
134
|
const collectionDoc = (c: CollectionEntry) =>
|
|
128
135
|
`${c.title ?? c.name}: the collection \`${c.name}\`, keyed by \`${c.key}\`.`;
|
|
129
136
|
|
|
130
|
-
/**
|
|
131
|
-
|
|
137
|
+
/**
|
|
138
|
+
* A collection's record type's name: `outbound` gives `OutboundRecord`, an app's `crm.contacts`
|
|
139
|
+
* `CrmContactsRecord`; never `CompanyApi` nor an imported type's.
|
|
140
|
+
*/
|
|
141
|
+
const recordName = (c: CollectionEntry) =>
|
|
142
|
+
`${c.name
|
|
143
|
+
.split(".")
|
|
144
|
+
.map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`)
|
|
145
|
+
.join("")}Record`;
|
|
132
146
|
|
|
133
147
|
/**
|
|
134
148
|
* A JSON Schema as a TypeScript type, for the kinds a start form draws (`fieldsOf` in the app):
|
package/src/shared.ts
CHANGED
|
@@ -22,12 +22,16 @@ export type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
|
22
22
|
export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
|
|
23
23
|
export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
|
|
24
24
|
export type { CredentialStatus } from "./env.ts";
|
|
25
|
-
export type { ChangeEvent, ChangeFacts, ChangeReport, ChangeState } from "./change.ts";
|
|
25
|
+
export type { ChangeDeploy, ChangeEvent, ChangeFacts, ChangeReport, ChangeState, ChangeVerified } from "./change.ts";
|
|
26
|
+
export type { AvailableApp, AvailableApps } from "./apps-list.ts";
|
|
26
27
|
|
|
27
28
|
// What a UI in the browser may use from the runtime, besides types: `@sanoma-ai/workflows/shared`.
|
|
28
29
|
// Nothing here imports DBOS, Node or zod, so a browser bundle can carry it; the runtime uses the
|
|
29
30
|
// same functions, so a page answers these questions the way a run does.
|
|
30
31
|
|
|
32
|
+
// Sanoma's claims, data (`sanoma/claims` re-exports them).
|
|
33
|
+
export * from "./claims.ts";
|
|
34
|
+
|
|
31
35
|
/**
|
|
32
36
|
* What a `ctx.sleep` without a `{ name }` is called, in its outline node and its ledger record
|
|
33
37
|
* (`name`); a name given may not be it.
|
|
@@ -76,6 +80,36 @@ export function previewRequests(
|
|
|
76
80
|
/** A site's folder name under `sites/`, its URL segment: what the app mounts and `readSites` reads. */
|
|
77
81
|
export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
78
82
|
|
|
83
|
+
/**
|
|
84
|
+
* An app's folder name under `apps/`: a lowercase letter, then lowercase letters and digits. It
|
|
85
|
+
* prefixes the names of what the folder declares (`qualified`), and is a segment of `ctx.data`,
|
|
86
|
+
* `api.data` and a site's path, so it is an identifier and a URL segment alike.
|
|
87
|
+
*/
|
|
88
|
+
export const APP_NAME = /^[a-z][a-z0-9]*$/;
|
|
89
|
+
|
|
90
|
+
/** A name declared under `apps/<app>/` as the runtime has it, `<app>.<name>`; one declared at the root as it is. */
|
|
91
|
+
export const qualified = (app: string | undefined, name: string): string =>
|
|
92
|
+
app === undefined ? name : `${app}.${name}`;
|
|
93
|
+
|
|
94
|
+
/** A workflow's or collection's name as its file writes it, `qualified`'s undone: without its app. */
|
|
95
|
+
export const writtenName = ({ name, app }: { name: string; app?: string }): string =>
|
|
96
|
+
app === undefined ? name : name.slice(app.length + 1);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A site's key: its folder's name at the root, `<app>/<site>` in an app, so its pages are under
|
|
100
|
+
* `/sites/<key>`. What `readSites` names a site by, and the app's build mounts it at.
|
|
101
|
+
*/
|
|
102
|
+
export const siteKey = (app: string | undefined, site: string): string => (app === undefined ? site : `${app}/${site}`);
|
|
103
|
+
|
|
104
|
+
/** A site's key (`siteKey`): a `SITE_NAME`, or an `APP_NAME`, `/` and a `SITE_NAME`, as a host's `domains` names a site. */
|
|
105
|
+
export const SITE_KEY = /^([a-z][a-z0-9]*\/)?[a-z][a-z0-9-]*$/;
|
|
106
|
+
|
|
107
|
+
/** A site's folder, relative to the root, by its key: `sites/<name>`, or an app's `apps/<app>/sites/<name>`. */
|
|
108
|
+
export function siteDir(key: string): string {
|
|
109
|
+
const [app, site] = key.split("/");
|
|
110
|
+
return site === undefined ? `sites/${key}` : `apps/${app}/sites/${site}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
79
113
|
/**
|
|
80
114
|
* A public site's language, as `defineSite({ locales })` names it: a BCP 47 language tag of a
|
|
81
115
|
* language and subtags (`en`, `fr`, `pt-BR`), which is also its URL segment (`/pt-BR/…`) and a
|
|
@@ -101,7 +135,7 @@ export function domainsOf(text: string): Record<string, string> {
|
|
|
101
135
|
const domains: Record<string, string> = {};
|
|
102
136
|
for (const pair of text.split(",")) {
|
|
103
137
|
const [site = "", domain = "", ...rest] = pair.trim().split("=");
|
|
104
|
-
if (!
|
|
138
|
+
if (!SITE_KEY.test(site) || !DOMAIN_NAME.test(domain) || rest.length || Object.hasOwn(domains, site)) {
|
|
105
139
|
throw new Error(
|
|
106
140
|
`SANOMA_DOMAINS has "${pair}": write <site>=<hostname>, each site once, such as www=sanoma.example`,
|
|
107
141
|
);
|
|
@@ -211,6 +245,16 @@ export const CALL_WORKFLOW = "call";
|
|
|
211
245
|
*/
|
|
212
246
|
export const CHANGE_WORKFLOW = "change";
|
|
213
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The draft a change's run is of: `change:<id>` (its first run) or `change:<id>~<n>` (a later
|
|
250
|
+
* one); undefined for any other run id.
|
|
251
|
+
*/
|
|
252
|
+
export const changeIdOf = (runId: string): string | undefined => CHANGE_RUN.exec(runId)?.[1];
|
|
253
|
+
|
|
254
|
+
/** What a draft's id may be (`ChangeInput.id`), the part of its branch and its change's run ids after `draft/` and `change:`. */
|
|
255
|
+
export const DRAFT_ID = /^[\w-]{1,64}$/;
|
|
256
|
+
const CHANGE_RUN = new RegExp(`^change:(${DRAFT_ID.source.slice(1, -1)})(?:~\\d+)?$`);
|
|
257
|
+
|
|
214
258
|
/** Opens a change: its draft's branch pushed and a pull request opened for it. */
|
|
215
259
|
export const CHANGE_OPEN = "sanoma.change.open";
|
|
216
260
|
/** Reports Sanoma's review of a change to its host (the `sanoma` commit status). */
|
|
@@ -221,6 +265,14 @@ export const CHANGE_MERGE = "sanoma.change.merge";
|
|
|
221
265
|
export const CHANGE_CLOSE = "sanoma.change.close";
|
|
222
266
|
/** The DBOS event a change run publishes its `ChangeState` on. */
|
|
223
267
|
export const CHANGE_EVENT = "change";
|
|
268
|
+
/**
|
|
269
|
+
* The `recv` topic a change run is told what happened to its change on (a `ChangeEvent`): never an
|
|
270
|
+
* approval's, whose topic is its id (`approval-<n>`).
|
|
271
|
+
*/
|
|
272
|
+
export const CHANGE_TOPIC = "change:event";
|
|
273
|
+
|
|
274
|
+
/** Lists the apps on GitHub with Sanoma's claims: the one runtime operation a page may call (`isDirect`). */
|
|
275
|
+
export const APPS_LIST = "sanoma.apps.list";
|
|
224
276
|
|
|
225
277
|
/** The workflows the runtime adds itself, which a config's own may not be named: drift, apply, call and change. */
|
|
226
278
|
export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([
|
|
@@ -324,8 +376,10 @@ export const ANONYMOUS_PRINCIPAL: Principal = Object.freeze({ id: ANONYMOUS, gro
|
|
|
324
376
|
export const WAKEUP_AGENT = "wakeup";
|
|
325
377
|
/** The runtime's agent a scheduled run is started by (`trigger: { schedule }`): the policy decides its starts. */
|
|
326
378
|
export const SCHEDULE_AGENT = "schedule";
|
|
379
|
+
/** The runtime's agent that checks and deploys the hosts: drift's, and apply's after a push to `main` (`SanomaClient.remoteEvent`). */
|
|
380
|
+
export const HOST_CHECK_AGENT = "host-check";
|
|
327
381
|
/** The runtime's own agents (scenarios, the host check, scheduled runs, the wakeup's registration), declared whether a data file names them or not. */
|
|
328
|
-
export const RUNTIME_AGENTS: readonly string[] = ["scenarios",
|
|
382
|
+
export const RUNTIME_AGENTS: readonly string[] = ["scenarios", HOST_CHECK_AGENT, SCHEDULE_AGENT, WAKEUP_AGENT];
|
|
329
383
|
|
|
330
384
|
/** The declared resources of one of the runtime's people types, by `desired`. */
|
|
331
385
|
const peopleOfType = <T>(resources: readonly PeopleResource[], type: "person" | "agent" | "group") =>
|
|
@@ -467,10 +521,11 @@ export const credentialReady = ({ status, optional }: CredentialStatus): boolean
|
|
|
467
521
|
|
|
468
522
|
/**
|
|
469
523
|
* A workflow's name in a site's `api`: kebab-case made camelCase, `outbound-next` as
|
|
470
|
-
* `api.outboundNext
|
|
524
|
+
* `api.outboundNext`, and an app's workflow one name with its app, `crm.follow-up` as
|
|
525
|
+
* `api.crmFollowUp`. `describeConfig` reports two workflows that come out the same, or one that
|
|
471
526
|
* comes out as a vendor's id or `data`, as a problem on the workflow.
|
|
472
527
|
*/
|
|
473
|
-
export const apiNameOf = (name: string): string => name.replace(
|
|
528
|
+
export const apiNameOf = (name: string): string => name.replace(/[-.]([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
474
529
|
|
|
475
530
|
/**
|
|
476
531
|
* A workflow as a site's `api` has it (`api.<apiNameOf(name)>`), started as the person on the
|
package/src/sites.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { join, relative, sep } from "node:path";
|
|
2
|
+
import { basename, join, relative, sep } from "node:path";
|
|
3
3
|
import { childrenOf, type LintProblem, type Node, readSource, type SourceFile } from "./ast.ts";
|
|
4
4
|
import { type CedarPolicySource, visitorPermits } from "./cedar.ts";
|
|
5
5
|
import { keyName } from "./datafile.ts";
|
|
6
6
|
import type { ConfigDescription } from "./describe.ts";
|
|
7
7
|
import { stringValue, unwrap } from "./outline.ts";
|
|
8
|
-
import { filesUnder, isDirectory } from "./paths.ts";
|
|
8
|
+
import { APPS_DIR, appFolders, filesUnder, isDirectory } from "./paths.ts";
|
|
9
9
|
import { START } from "./schema.ts";
|
|
10
10
|
import {
|
|
11
11
|
ANONYMOUS,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
DATA_VENDOR,
|
|
15
15
|
LOCALE,
|
|
16
16
|
SITE_NAME,
|
|
17
|
+
siteKey,
|
|
17
18
|
VISITORS,
|
|
18
19
|
WORKFLOW_ACTION,
|
|
19
20
|
} from "./shared.ts";
|
|
@@ -64,12 +65,16 @@ export interface SitePage {
|
|
|
64
65
|
content?: string;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
/** A site, `sites/<name>/`, and what it calls. */
|
|
68
|
+
/** A site, `sites/<name>/` or an app's `apps/<app>/sites/<name>/`, and what it calls. */
|
|
68
69
|
export interface SiteEntry {
|
|
69
|
-
/** Its folder's name, where it is mounted
|
|
70
|
+
/** Its folder's name, `<app>/<name>` for an app's (`siteKey`): where it is mounted, `/sites/<name>`. */
|
|
70
71
|
name: string;
|
|
72
|
+
/** The app whose folder holds it (`apps/<app>/sites/`); absent for one at the root. */
|
|
73
|
+
app?: string;
|
|
71
74
|
/** `defineSite({ title })` in its `site.ts`, when that is a string literal. */
|
|
72
75
|
title?: string;
|
|
76
|
+
/** `defineSite({ icon })` in its `site.ts`: a Lucide icon's name, as written. */
|
|
77
|
+
icon?: string;
|
|
73
78
|
/** `defineSite({ public: true })`: served to visitors, not people. */
|
|
74
79
|
public?: true;
|
|
75
80
|
/** `defineSite({ login })`, the connector whose login half signs visitors in, as written: one that names none is among `problems`. */
|
|
@@ -109,12 +114,14 @@ const PARAMS: ReadonlySet<string> = new Set(["params"]);
|
|
|
109
114
|
const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLazyFileRoute"]);
|
|
110
115
|
|
|
111
116
|
/**
|
|
112
|
-
* The sites under `root
|
|
113
|
-
*
|
|
117
|
+
* The sites under `root` (`sitesUnder`): each folder of `sites/`, then of each app's
|
|
118
|
+
* `apps/<app>/sites/`, with a `routes/` whose name is a URL segment, by key (as
|
|
119
|
+
* `@sanoma-ai/app/build`'s `sitesOf` mounts them). Reads `routes/**` and
|
|
114
120
|
* `components/**` (`.ts` and `.tsx`, less tests and declarations) of each with oxc, never running
|
|
115
121
|
* them: each page (`SitePage`, from a route's id), each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`,
|
|
116
122
|
* called or passed on (`queryFn: api.data.leads.list`):
|
|
117
|
-
* `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>`
|
|
123
|
+
* `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>` (an app's
|
|
124
|
+
* `api.data.<app>.<collection>.<op>`) a collection,
|
|
118
125
|
* and `api.<vendor>.<resource>.<op>` an operation. A name that is not written out (a variable, a
|
|
119
126
|
* template with holes, a computed member) is skipped, as is a use through a namespace import or a
|
|
120
127
|
* destructured `api`. A call to what `description` does not have, or to an operation a page may
|
|
@@ -133,9 +140,11 @@ export function readSites(
|
|
|
133
140
|
policies?: readonly CedarPolicySource[],
|
|
134
141
|
): SiteEntry[] {
|
|
135
142
|
if (root === undefined) return [];
|
|
136
|
-
const dir = join(root, "sites");
|
|
137
143
|
const workflows = new Set(description.workflows.map((wf) => wf.name));
|
|
138
144
|
const collections = new Set(description.collections.map((c) => c.name));
|
|
145
|
+
// `api.data.<app>.<collection>` passed on as a value reads as a call of `<app>`'s method: it is none.
|
|
146
|
+
const isAppCollection = ({ kind, name, method }: Written) =>
|
|
147
|
+
kind === "collection" && collections.has(`${name}.${method}`);
|
|
139
148
|
const ops = new Map(description.ops.map((op) => [op.id, op]));
|
|
140
149
|
// A workflow's api name to its name: two of one api name are a problem on the workflows.
|
|
141
150
|
const byApiName = new Map(description.workflows.map((wf) => [apiNameOf(wf.name), wf.name]));
|
|
@@ -193,11 +202,11 @@ export function readSites(
|
|
|
193
202
|
return { call: { kind, target, via, ...at }, ...(problem && { problem }) };
|
|
194
203
|
};
|
|
195
204
|
|
|
196
|
-
return
|
|
197
|
-
const read = readSiteFiles(root,
|
|
198
|
-
const resolved = read.flatMap(({ uses }) => uses.map(resolve));
|
|
205
|
+
return sitesUnder(root).map(({ name, app, dir }) => {
|
|
206
|
+
const read = readSiteFiles(root, dir);
|
|
207
|
+
const resolved = read.flatMap(({ uses }) => uses.filter((use) => !isAppCollection(use)).map(resolve));
|
|
199
208
|
const pages = pagesOf(read);
|
|
200
|
-
const siteFile =
|
|
209
|
+
const siteFile = `${dir}/site.ts`;
|
|
201
210
|
const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
|
|
202
211
|
const calls = resolved.map((r) => r.call);
|
|
203
212
|
const problems = [
|
|
@@ -212,7 +221,7 @@ export function readSites(
|
|
|
212
221
|
const warnings = options.public
|
|
213
222
|
? read.flatMap(({ route, warning }) => (warning && !pages.get(route!.path)?.content ? [warning] : []))
|
|
214
223
|
: [];
|
|
215
|
-
return { name, ...options, pages: sorted, calls, problems, warnings };
|
|
224
|
+
return { name, ...(app && { app }), ...options, pages: sorted, calls, problems, warnings };
|
|
216
225
|
});
|
|
217
226
|
}
|
|
218
227
|
|
|
@@ -225,9 +234,9 @@ function pagesOf(read: readonly { route?: SitePage }[]): Map<string, SitePage> {
|
|
|
225
234
|
return pages;
|
|
226
235
|
}
|
|
227
236
|
|
|
228
|
-
/** Each of site `
|
|
229
|
-
const readSiteFiles = (root: string,
|
|
230
|
-
SITE_DIRS.flatMap((sub) => filesUnder(join(root,
|
|
237
|
+
/** Each file of the site in `dir` (relative to `root`), as `readSiteFile` reads it, by path. */
|
|
238
|
+
const readSiteFiles = (root: string, dir: string) =>
|
|
239
|
+
SITE_DIRS.flatMap((sub) => filesUnder(join(root, dir, sub), isSource))
|
|
231
240
|
.toSorted()
|
|
232
241
|
.flatMap((path) => {
|
|
233
242
|
const file = readSource(path);
|
|
@@ -241,8 +250,8 @@ const readSiteFiles = (root: string, name: string) =>
|
|
|
241
250
|
*/
|
|
242
251
|
export function siteContentPages(root: string): Record<string, string> {
|
|
243
252
|
return Object.fromEntries(
|
|
244
|
-
|
|
245
|
-
[...pagesOf(readSiteFiles(root,
|
|
253
|
+
sitesUnder(root).flatMap(({ dir }) =>
|
|
254
|
+
[...pagesOf(readSiteFiles(root, dir)).values()].flatMap(({ path, content }) =>
|
|
246
255
|
content ? [[path, content]] : [],
|
|
247
256
|
),
|
|
248
257
|
),
|
|
@@ -257,13 +266,44 @@ export function siteContentPages(root: string): Record<string, string> {
|
|
|
257
266
|
export function siteLocales(root: string): Record<string, string[]> {
|
|
258
267
|
const none = new Set<string>();
|
|
259
268
|
return Object.fromEntries(
|
|
260
|
-
|
|
261
|
-
const { locales } = siteOptionsOf(join(root,
|
|
269
|
+
sitesUnder(root).flatMap(({ name, dir }) => {
|
|
270
|
+
const { locales } = siteOptionsOf(join(root, dir, "site.ts"), { vendors: none, logins: none });
|
|
262
271
|
return locales ? [[name, locales]] : [];
|
|
263
272
|
}),
|
|
264
273
|
);
|
|
265
274
|
}
|
|
266
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Each site's icon (`defineSite({ icon })`, a Lucide icon's name as written) under `root`, by name,
|
|
278
|
+
* for the sites whose `site.ts` gives one: the icons the app's build bundles (`virtual:sanoma/icons`).
|
|
279
|
+
* Read as `readSites` reads it, never run.
|
|
280
|
+
*/
|
|
281
|
+
export function siteIcons(root: string): Record<string, string> {
|
|
282
|
+
const none = new Set<string>();
|
|
283
|
+
return Object.fromEntries(
|
|
284
|
+
sitesUnder(root).flatMap(({ name, dir }) => {
|
|
285
|
+
const { icon } = siteOptionsOf(join(root, dir, "site.ts"), { vendors: none, logins: none });
|
|
286
|
+
return icon === undefined ? [] : [[name, icon]];
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The sites under `root`, by key (`siteKey`): each folder of `sites/` with a `routes/` whose name
|
|
293
|
+
* is a URL segment, then each app's `apps/<app>/sites/`, with its folder relative to `root`.
|
|
294
|
+
*/
|
|
295
|
+
export const sitesUnder = (root: string): { name: string; app?: string; dir: string }[] => [
|
|
296
|
+
...siteNames(join(root, "sites")).map((name) => ({ name, dir: `sites/${name}` })),
|
|
297
|
+
...appFolders(root).flatMap((folder) => {
|
|
298
|
+
const app = basename(folder);
|
|
299
|
+
return siteNames(join(folder, "sites")).map((name) => ({
|
|
300
|
+
name: siteKey(app, name),
|
|
301
|
+
app,
|
|
302
|
+
dir: `${APPS_DIR}/${app}/sites/${name}`,
|
|
303
|
+
}));
|
|
304
|
+
}),
|
|
305
|
+
];
|
|
306
|
+
|
|
267
307
|
/** The sites in `dir`: each folder with a `routes/` whose name is a URL segment, sorted. */
|
|
268
308
|
const siteNames = (dir: string): string[] =>
|
|
269
309
|
isDirectory(dir)
|
|
@@ -379,9 +419,12 @@ function contentKind(callee: Node, content: ReadonlySet<string>): string | undef
|
|
|
379
419
|
function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name" | "method"> | undefined {
|
|
380
420
|
const path = memberPath(member, api);
|
|
381
421
|
if (!path?.length) return undefined;
|
|
382
|
-
const [first, second
|
|
422
|
+
const [first, second] = path as [string, ...string[]];
|
|
423
|
+
// `api.data.<collection>.<op>`, or an app's, `api.data.<app>.<collection>.<op>`.
|
|
383
424
|
if (first === DATA_VENDOR)
|
|
384
|
-
return path.length === 3
|
|
425
|
+
return path.length === 3 || path.length === 4
|
|
426
|
+
? { kind: "collection", name: path.slice(1, -1).join("."), method: path.at(-1) }
|
|
427
|
+
: undefined;
|
|
385
428
|
if (path.length === 3) return { kind: "op", name: path.join(".") };
|
|
386
429
|
if (path.length > 2) return undefined;
|
|
387
430
|
return { kind: "workflow", name: first, ...(second !== undefined && { method: second }) };
|
|
@@ -414,14 +457,14 @@ const unread = (what: string, as: string) => `${what} is read without running si
|
|
|
414
457
|
/**
|
|
415
458
|
* What `site.ts` says of its site, read without running it, and what is wrong there: its default
|
|
416
459
|
* export must be `defineSite({ … })` written out, so it can be read at all; then `title` when it is
|
|
417
|
-
* a string, `public`, `login` and `locales`, which must be literals. `login` is kept as written; one on a
|
|
460
|
+
* a string, `icon`, `public`, `login` and `locales`, which must be literals. `login` is kept as written; one on a
|
|
418
461
|
* site that is not public, or naming no connector of the config with a login half, is a problem.
|
|
419
462
|
* `locales` is kept only when it has no problem.
|
|
420
463
|
*/
|
|
421
464
|
function siteOptionsOf(
|
|
422
465
|
path: string,
|
|
423
466
|
connectors: SiteConnectors,
|
|
424
|
-
): Pick<SiteEntry, "title" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
|
|
467
|
+
): Pick<SiteEntry, "title" | "icon" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
|
|
425
468
|
const file = readSource(path);
|
|
426
469
|
if (typeof file === "string") return { problems: [] };
|
|
427
470
|
const problems: LintProblem[] = [...file.problems];
|
|
@@ -453,6 +496,8 @@ function siteOptionsOf(
|
|
|
453
496
|
options?.properties.findLast((p: Node) => isWritten(p) && keyName(p.key) === name);
|
|
454
497
|
|
|
455
498
|
const title = stringValue(property("title")?.value);
|
|
499
|
+
// Cosmetic: one that is not a string (tsc refuses it) is shown as none, never a problem that stops a site.
|
|
500
|
+
const icon = stringValue(property("icon")?.value);
|
|
456
501
|
const publicAt = property("public");
|
|
457
502
|
const flag = unwrap(publicAt?.value);
|
|
458
503
|
const isPublic = flag?.type === "Literal" && flag.value === true;
|
|
@@ -495,6 +540,7 @@ function siteOptionsOf(
|
|
|
495
540
|
if (localesMessage) problem(localesAt, localesMessage);
|
|
496
541
|
return {
|
|
497
542
|
...(title !== undefined && { title }),
|
|
543
|
+
...(icon !== undefined && { icon }),
|
|
498
544
|
...(isPublic && { public: true as const }),
|
|
499
545
|
...(login !== undefined && { login }),
|
|
500
546
|
...(localesAt && !localesMessage && isPublic && !loginAt && { locales }),
|