@sanoma-ai/workflows 0.4.2 → 0.6.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 +18 -9
- package/dist/cedar.d.ts +23 -6
- package/dist/cedar.js +151 -44
- package/dist/cedar.js.map +1 -1
- package/dist/client.d.ts +3 -2
- package/dist/client.js +15 -6
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +6 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/describe.d.ts +1 -1
- package/dist/describe.js +38 -4
- package/dist/describe.js.map +1 -1
- package/dist/host.d.ts +2 -0
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/op.d.ts +55 -3
- package/dist/op.js +6 -0
- package/dist/op.js.map +1 -1
- package/dist/policy.d.ts +9 -1
- package/dist/policy.js +18 -7
- package/dist/policy.js.map +1 -1
- package/dist/resources.d.ts +4 -2
- package/dist/resources.js +23 -11
- package/dist/resources.js.map +1 -1
- package/dist/scenario.d.ts +1 -1
- package/dist/scenario.js +4 -2
- package/dist/scenario.js.map +1 -1
- package/dist/schema.js +1 -1
- package/dist/shared.d.ts +35 -2
- package/dist/shared.js +60 -3
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +72 -6
- package/dist/sites.js +277 -46
- package/dist/sites.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/src/cedar.ts +152 -41
- package/src/client.ts +17 -4
- package/src/config.ts +8 -2
- package/src/describe.ts +47 -4
- package/src/host.ts +2 -0
- package/src/index.ts +10 -0
- package/src/op.ts +56 -3
- package/src/policy.ts +29 -10
- package/src/resources.ts +26 -10
- package/src/scenario.ts +5 -2
- package/src/schema.ts +1 -1
- package/src/shared.ts +72 -3
- package/src/sites.ts +346 -49
- package/src/version.ts +4 -2
package/src/sites.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join, relative, sep } from "node:path";
|
|
3
3
|
import { childrenOf, type LintProblem, type Node, readSource, type SourceFile } from "./ast.ts";
|
|
4
|
+
import { type CedarPolicySource, visitorPermits } from "./cedar.ts";
|
|
4
5
|
import { keyName } from "./datafile.ts";
|
|
5
6
|
import type { ConfigDescription } from "./describe.ts";
|
|
6
7
|
import { stringValue, unwrap } from "./outline.ts";
|
|
7
8
|
import { filesUnder, isDirectory } from "./paths.ts";
|
|
8
|
-
import {
|
|
9
|
+
import { START } from "./schema.ts";
|
|
10
|
+
import {
|
|
11
|
+
ANONYMOUS,
|
|
12
|
+
apiNameOf,
|
|
13
|
+
BUILT_IN_WORKFLOWS,
|
|
14
|
+
DATA_VENDOR,
|
|
15
|
+
LOCALE,
|
|
16
|
+
SITE_NAME,
|
|
17
|
+
VISITORS,
|
|
18
|
+
WORKFLOW_ACTION,
|
|
19
|
+
} from "./shared.ts";
|
|
9
20
|
|
|
10
21
|
// What a company's sites call, read from their source with oxc and never run: the reverse index
|
|
11
22
|
// a workflow's page shows as Used by, and the names `sanoma check` holds to the config.
|
|
@@ -19,7 +30,13 @@ export interface SiteCall {
|
|
|
19
30
|
* `api` gives; that name itself when none is), the collection's name, or the operation's id.
|
|
20
31
|
*/
|
|
21
32
|
target: string;
|
|
22
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* What it calls of the workflow (`start`, or a read of its runs: `result`, `latest`, …) or the
|
|
35
|
+
* collection (`list`, `save`, …), as written; absent for a workflow called as itself or by
|
|
36
|
+
* `RunForm`, which starts it, and for an operation.
|
|
37
|
+
*/
|
|
38
|
+
method?: string;
|
|
39
|
+
/** `<RunForm workflow="…" />` or a call on `api`, from `sanoma/site`. */
|
|
23
40
|
via: "RunForm" | "api";
|
|
24
41
|
/** The file, relative to the config's root, with `/`: `sites/sales/routes/index.tsx`. */
|
|
25
42
|
file: string;
|
|
@@ -35,24 +52,59 @@ export interface SiteCall {
|
|
|
35
52
|
page?: string;
|
|
36
53
|
}
|
|
37
54
|
|
|
55
|
+
/** A page of a site: a route file's path, and for a page per content entry the kind it reads. */
|
|
56
|
+
export interface SitePage {
|
|
57
|
+
/** As `SiteCall.page` has it: `/sites/www`, `/sites/www/blog/$slug`. */
|
|
58
|
+
path: string;
|
|
59
|
+
/**
|
|
60
|
+
* For a path with one parameter (`$slug`), the content kind whose entries are its pages: the one
|
|
61
|
+
* kind its file reads by that parameter, `content.<kind>.get(params.slug)` (or `slug`,
|
|
62
|
+
* destructured) with `content` from `sanoma/site`.
|
|
63
|
+
*/
|
|
64
|
+
content?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
/** A site, `sites/<name>/`, and what it calls. */
|
|
39
68
|
export interface SiteEntry {
|
|
40
69
|
/** Its folder's name, where it is mounted: `/sites/<name>`. */
|
|
41
70
|
name: string;
|
|
42
71
|
/** `defineSite({ title })` in its `site.ts`, when that is a string literal. */
|
|
43
72
|
title?: string;
|
|
73
|
+
/** `defineSite({ public: true })`: served to visitors, not people. */
|
|
74
|
+
public?: true;
|
|
75
|
+
/** `defineSite({ login })`, the connector whose login half signs visitors in, as written: one that names none is among `problems`. */
|
|
76
|
+
login?: string;
|
|
77
|
+
/**
|
|
78
|
+
* `defineSite({ locales })`, a public site's languages, the first its default: kept only when
|
|
79
|
+
* every one is a `LOCALE`, written once, on a public site with no login; else a problem.
|
|
80
|
+
*/
|
|
81
|
+
locales?: string[];
|
|
82
|
+
/** The host a public site answers on: set by `startApp` from its `domains`, never by `readSites`. */
|
|
83
|
+
domain?: string;
|
|
84
|
+
/** Its pages, by path: one for each path its `routes/` files give (a layout and its index are one). */
|
|
85
|
+
pages: SitePage[];
|
|
44
86
|
/** Its calls, by file and then in the file's order. */
|
|
45
87
|
calls: SiteCall[];
|
|
46
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Each call to a workflow, a collection or an operation the config does not have, or to an
|
|
90
|
+
* operation a page may not call directly, or, on a public site, that no policy lets a visitor
|
|
91
|
+
* make, at its file; then what is wrong in its `site.ts`.
|
|
92
|
+
*/
|
|
47
93
|
problems: (LintProblem & { file: string })[];
|
|
94
|
+
/**
|
|
95
|
+
* On a public site, each page with one parameter whose file reads content the sitemap cannot
|
|
96
|
+
* tie to it (`SitePage.content`), so the sitemap lists none of its pages: at the first read.
|
|
97
|
+
*/
|
|
98
|
+
warnings: (LintProblem & { file: string })[];
|
|
48
99
|
}
|
|
49
100
|
|
|
50
101
|
/** Where a site's code is: its pages and its components. */
|
|
51
102
|
const SITE_DIRS = ["routes", "components"];
|
|
52
|
-
/** Where `api` and `defineSite` come from: `sanoma/site`, which re-exports the app's. */
|
|
103
|
+
/** Where `api`, `RunForm` and `defineSite` come from: `sanoma/site`, which re-exports the app's. */
|
|
53
104
|
const SITE_SOURCES: ReadonlySet<string> = new Set(["sanoma/site", "@sanoma-ai/app/site"]);
|
|
54
|
-
const UI_SOURCES: ReadonlySet<string> = new Set(["@sanoma-ai/app/ui"]);
|
|
55
105
|
const KIND_NAMES = { workflow: "workflow", collection: "collection", op: "operation" } as const;
|
|
106
|
+
/** What a route's loader and head name its path's parameters by. */
|
|
107
|
+
const PARAMS: ReadonlySet<string> = new Set(["params"]);
|
|
56
108
|
/** What makes a route from its id, whose first argument is the id. */
|
|
57
109
|
const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLazyFileRoute"]);
|
|
58
110
|
|
|
@@ -60,26 +112,67 @@ const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLaz
|
|
|
60
112
|
* The sites under `root`: each folder of `sites/` with a `routes/` whose name is a URL segment,
|
|
61
113
|
* by name (as `@sanoma-ai/app/build`'s `sitesOf` mounts them). Reads `routes/**` and
|
|
62
114
|
* `components/**` (`.ts` and `.tsx`, less tests and declarations) of each with oxc, never running
|
|
63
|
-
* them: each `<RunForm workflow="…" />`
|
|
64
|
-
*
|
|
115
|
+
* them: each page (`SitePage`, from a route's id), each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`,
|
|
116
|
+
* called or passed on (`queryFn: api.data.leads.list`):
|
|
65
117
|
* `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>` a collection,
|
|
66
118
|
* and `api.<vendor>.<resource>.<op>` an operation. A name that is not written out (a variable, a
|
|
67
119
|
* template with holes, a computed member) is skipped, as is a use through a namespace import or a
|
|
68
120
|
* destructured `api`. A call to what `description` does not have, or to an operation a page may
|
|
69
|
-
* not call directly, is a problem.
|
|
121
|
+
* not call directly, is a problem. So is a `login` in `site.ts` that does not name one of
|
|
122
|
+
* `connectors.logins` (the vendors whose connector has a login half) on a public site. Given the
|
|
123
|
+
* company's `policies` (undefined while they have problems, which refuse every call), so is a
|
|
124
|
+
* public site's start or direct call that no company `permit` could let a visitor make
|
|
125
|
+
* (`visitorPermits`): one for `anonymous`, or on a site with a login, one for `anonymous` or the
|
|
126
|
+
* `visitors`, whatever its conditions. Whatever the policies, so is a public site's call of a
|
|
127
|
+
* built-in workflow, and its read of a workflow's runs but `.result`, which `callApi` refuses a visitor.
|
|
70
128
|
*/
|
|
71
129
|
export function readSites(
|
|
72
130
|
root: string | undefined,
|
|
73
131
|
description: Pick<ConfigDescription, "workflows" | "collections" | "ops">,
|
|
132
|
+
connectors: SiteConnectors,
|
|
133
|
+
policies?: readonly CedarPolicySource[],
|
|
74
134
|
): SiteEntry[] {
|
|
75
135
|
if (root === undefined) return [];
|
|
76
136
|
const dir = join(root, "sites");
|
|
77
|
-
if (!isDirectory(dir)) return [];
|
|
78
137
|
const workflows = new Set(description.workflows.map((wf) => wf.name));
|
|
79
138
|
const collections = new Set(description.collections.map((c) => c.name));
|
|
80
139
|
const ops = new Map(description.ops.map((op) => [op.id, op]));
|
|
81
140
|
// A workflow's api name to its name: two of one api name are a problem on the workflows.
|
|
82
141
|
const byApiName = new Map(description.workflows.map((wf) => [apiNameOf(wf.name), wf.name]));
|
|
142
|
+
const permits = policies && visitorPermits(policies);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Why a visitor's `call`, which exists, is certainly refused on a public site that has `login`:
|
|
146
|
+
* as `callApi` refuses it whatever the policy says, or as no permit could allow it.
|
|
147
|
+
*/
|
|
148
|
+
const refused = (call: SiteCall, login: string | undefined): string | undefined => {
|
|
149
|
+
const why = `a visitor's call to ${KIND_NAMES[call.kind]} "${call.target}" is refused`;
|
|
150
|
+
let action: string;
|
|
151
|
+
let group: string;
|
|
152
|
+
if (call.kind === "workflow") {
|
|
153
|
+
if (BUILT_IN_WORKFLOWS.has(call.target)) return `${why}: a visitor may not start the runtime's own workflows`;
|
|
154
|
+
if (call.method === "result") return undefined;
|
|
155
|
+
if (call.method !== undefined && call.method !== "start") {
|
|
156
|
+
return `${why}: a visitor reads only the result of a run it started`;
|
|
157
|
+
}
|
|
158
|
+
[action, group] = [`${WORKFLOW_ACTION}${call.target}`, START];
|
|
159
|
+
} else {
|
|
160
|
+
action = call.kind === "op" ? call.target : `${DATA_VENDOR}.${call.target}.${call.method}`;
|
|
161
|
+
const op = ops.get(action);
|
|
162
|
+
if (!op) return undefined;
|
|
163
|
+
group = `effect:${op.effect}`;
|
|
164
|
+
}
|
|
165
|
+
if (!permits) return undefined;
|
|
166
|
+
const [scopes, principal] =
|
|
167
|
+
login === undefined
|
|
168
|
+
? [[ANONYMOUS], `== Sanoma::Principal::"${ANONYMOUS}"`]
|
|
169
|
+
: [[ANONYMOUS, VISITORS], `in Sanoma::Group::"${VISITORS}"`];
|
|
170
|
+
if (permits(new Set(scopes), action, group)) return undefined;
|
|
171
|
+
return (
|
|
172
|
+
`${why}: no permit in policies/ names ${scopes.join(" or ")} for ${action}. ` +
|
|
173
|
+
`Add: permit (principal ${principal}, action == Sanoma::Action::"${action}", resource);`
|
|
174
|
+
);
|
|
175
|
+
};
|
|
83
176
|
|
|
84
177
|
/** A call as written, resolved as the app's `callApi` resolves it, with why it would fail. */
|
|
85
178
|
const resolve = ({ kind, name, via, ...at }: Written): { call: SiteCall; problem?: string } => {
|
|
@@ -100,34 +193,105 @@ export function readSites(
|
|
|
100
193
|
return { call: { kind, target, via, ...at }, ...(problem && { problem }) };
|
|
101
194
|
};
|
|
102
195
|
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
.
|
|
196
|
+
return siteNames(dir).map((name) => {
|
|
197
|
+
const read = readSiteFiles(root, name);
|
|
198
|
+
const resolved = read.flatMap(({ uses }) => uses.map(resolve));
|
|
199
|
+
const pages = pagesOf(read);
|
|
200
|
+
const siteFile = `sites/${name}/site.ts`;
|
|
201
|
+
const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
|
|
202
|
+
const calls = resolved.map((r) => r.call);
|
|
203
|
+
const problems = [
|
|
204
|
+
...resolved.flatMap(({ call, problem }) => {
|
|
205
|
+
const message = problem ?? (options.public ? refused(call, options.login) : undefined);
|
|
206
|
+
return message === undefined ? [] : [{ file: call.file, line: call.line, column: call.column, message }];
|
|
207
|
+
}),
|
|
208
|
+
...optionProblems.map((p) => ({ file: siteFile, ...p })),
|
|
209
|
+
];
|
|
210
|
+
const sorted = [...pages.keys()].toSorted().map((path) => pages.get(path)!);
|
|
211
|
+
// A page's file it warns of, unless another of its files (a lazy one, its layout) gives its kind.
|
|
212
|
+
const warnings = options.public
|
|
213
|
+
? read.flatMap(({ route, warning }) => (warning && !pages.get(route!.path)?.content ? [warning] : []))
|
|
214
|
+
: [];
|
|
215
|
+
return { name, ...options, pages: sorted, calls, problems, warnings };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A site's pages, by path, from its files: a layout's and its index's (one path) merged. */
|
|
220
|
+
function pagesOf(read: readonly { route?: SitePage }[]): Map<string, SitePage> {
|
|
221
|
+
const pages = new Map<string, SitePage>();
|
|
222
|
+
for (const { route } of read) {
|
|
223
|
+
if (route) pages.set(route.path, { ...pages.get(route.path), ...route });
|
|
224
|
+
}
|
|
225
|
+
return pages;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Each of site `name`'s files under `root`, as `readSiteFile` reads it, by path. */
|
|
229
|
+
const readSiteFiles = (root: string, name: string) =>
|
|
230
|
+
SITE_DIRS.flatMap((sub) => filesUnder(join(root, "sites", name, sub), isSource))
|
|
106
231
|
.toSorted()
|
|
107
|
-
.
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const file = readSource(path);
|
|
111
|
-
return typeof file === "string" ? [] : usesIn(file, relative(root, path).split(sep).join("/")).map(resolve);
|
|
112
|
-
});
|
|
113
|
-
const title = titleOf(join(dir, name, "site.ts"));
|
|
114
|
-
const calls = resolved.map((r) => r.call);
|
|
115
|
-
const problems = resolved.flatMap(({ call: { file, line, column }, problem }) =>
|
|
116
|
-
problem === undefined ? [] : [{ file, line, column, message: problem }],
|
|
117
|
-
);
|
|
118
|
-
return { name, ...(title !== undefined && { title }), calls, problems };
|
|
232
|
+
.flatMap((path) => {
|
|
233
|
+
const file = readSource(path);
|
|
234
|
+
return typeof file === "string" ? [] : [readSiteFile(file, relative(root, path).split(sep).join("/"))];
|
|
119
235
|
});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Each site's pages under `root` whose entries are a content kind's (`SitePage.content`), path to
|
|
239
|
+
* kind: what the app's build gives its router (`virtual:sanoma/locales`), so a page lists only the
|
|
240
|
+
* languages its entry is in. Read as `readSites` reads them, never run.
|
|
241
|
+
*/
|
|
242
|
+
export function siteContentPages(root: string): Record<string, string> {
|
|
243
|
+
return Object.fromEntries(
|
|
244
|
+
siteNames(join(root, "sites")).flatMap((name) =>
|
|
245
|
+
[...pagesOf(readSiteFiles(root, name)).values()].flatMap(({ path, content }) =>
|
|
246
|
+
content ? [[path, content]] : [],
|
|
247
|
+
),
|
|
248
|
+
),
|
|
249
|
+
);
|
|
120
250
|
}
|
|
121
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Each site's languages (`defineSite({ locales })`, the first its default) under `root`, by name,
|
|
254
|
+
* for the sites whose `site.ts` gives them without a problem there: what the app's build gives
|
|
255
|
+
* its router (`virtual:sanoma/locales`). Read as `readSites` reads them, never run.
|
|
256
|
+
*/
|
|
257
|
+
export function siteLocales(root: string): Record<string, string[]> {
|
|
258
|
+
const none = new Set<string>();
|
|
259
|
+
return Object.fromEntries(
|
|
260
|
+
siteNames(join(root, "sites")).flatMap((name) => {
|
|
261
|
+
const { locales } = siteOptionsOf(join(root, "sites", name, "site.ts"), { vendors: none, logins: none });
|
|
262
|
+
return locales ? [[name, locales]] : [];
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The sites in `dir`: each folder with a `routes/` whose name is a URL segment, sorted. */
|
|
268
|
+
const siteNames = (dir: string): string[] =>
|
|
269
|
+
isDirectory(dir)
|
|
270
|
+
? readdirSync(dir, { withFileTypes: true })
|
|
271
|
+
.filter((d) => d.isDirectory() && SITE_NAME.test(d.name) && existsSync(join(dir, d.name, "routes")))
|
|
272
|
+
.map((d) => d.name)
|
|
273
|
+
.toSorted()
|
|
274
|
+
: [];
|
|
275
|
+
|
|
122
276
|
/** A use of `api` or a `RunForm`, as written: the name it gives, before it is resolved. */
|
|
123
277
|
type Written = Omit<SiteCall, "target"> & { name: string };
|
|
124
278
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
279
|
+
/**
|
|
280
|
+
* What one file holds: its uses, in its order, and the page it is, from its route's id, with the
|
|
281
|
+
* content kind whose entry its one parameter names (`SitePage.content`). A file under `routes/` is
|
|
282
|
+
* read whole; any other only when it imports `api` or `RunForm`.
|
|
283
|
+
*/
|
|
284
|
+
function readSiteFile(
|
|
285
|
+
{ program, at }: SourceFile,
|
|
286
|
+
file: string,
|
|
287
|
+
): { uses: Written[]; route?: SitePage; warning?: LintProblem & { file: string } } {
|
|
127
288
|
const api = importedAs(program, SITE_SOURCES, "api");
|
|
128
|
-
const runForm = importedAs(program,
|
|
129
|
-
|
|
289
|
+
const runForm = importedAs(program, SITE_SOURCES, "RunForm");
|
|
290
|
+
const content = importedAs(program, SITE_SOURCES, "content");
|
|
291
|
+
if (!api.size && !runForm.size && !file.includes("/routes/")) return { uses: [] };
|
|
130
292
|
const found: Omit<Written, "page">[] = [];
|
|
293
|
+
/** Each `content.<kind>.get(arg)`: its kind, its argument and where it is. */
|
|
294
|
+
const gets: { kind: string; arg: Node; start: number }[] = [];
|
|
131
295
|
let page: string | undefined;
|
|
132
296
|
const visit = (node: Node) => {
|
|
133
297
|
if (!node) return;
|
|
@@ -135,12 +299,14 @@ function usesIn({ program, at }: SourceFile, file: string): Written[] {
|
|
|
135
299
|
const named = apiPath(node, api);
|
|
136
300
|
// The outermost member of a path on `api`: what it names, called or not; its inner members are not visited.
|
|
137
301
|
if (named) return void found.push({ ...named, via: "api", file, ...at(node.start) });
|
|
138
|
-
} else if (node.type === "CallExpression"
|
|
302
|
+
} else if (node.type === "CallExpression") {
|
|
139
303
|
const callee = unwrap(node.callee);
|
|
140
|
-
if (callee?.type === "Identifier" && ROUTE_MAKERS.has(callee.name)) {
|
|
304
|
+
if (callee?.type === "Identifier" && ROUTE_MAKERS.has(callee.name) && page === undefined) {
|
|
141
305
|
const id = stringValue(node.arguments[0]);
|
|
142
306
|
if (id !== undefined) page = pageOf(id);
|
|
143
307
|
}
|
|
308
|
+
const kind = contentKind(callee, content);
|
|
309
|
+
if (kind !== undefined) gets.push({ kind, arg: unwrap(node.arguments[0]), start: node.start });
|
|
144
310
|
} else if (node.type === "JSXOpeningElement" && node.name.type === "JSXIdentifier" && runForm.has(node.name.name)) {
|
|
145
311
|
const attribute = node.attributes.find(
|
|
146
312
|
(a: Node) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === "workflow",
|
|
@@ -152,11 +318,46 @@ function usesIn({ program, at }: SourceFile, file: string): Written[] {
|
|
|
152
318
|
for (const child of childrenOf(node)) visit(child);
|
|
153
319
|
};
|
|
154
320
|
visit(program);
|
|
155
|
-
|
|
321
|
+
const uses = found.map((use) => (page === undefined ? use : { ...use, page }));
|
|
322
|
+
if (page === undefined) return { uses };
|
|
323
|
+
// The one parameter's name (not a splat's), and the kinds whose `get` is given it (`params.slug`, or `slug` destructured).
|
|
324
|
+
const params = page.split("/").filter((segment) => segment.startsWith("$"));
|
|
325
|
+
if (params.length !== 1 || params[0] === "$") return { uses, route: { path: page } };
|
|
326
|
+
const param = params[0]!.slice(1);
|
|
327
|
+
const kinds = new Set(
|
|
328
|
+
gets
|
|
329
|
+
.filter(({ arg }) =>
|
|
330
|
+
arg?.type === "Identifier"
|
|
331
|
+
? arg.name === param
|
|
332
|
+
: arg?.type === "MemberExpression" && memberPath(arg, PARAMS)?.join(".") === param,
|
|
333
|
+
)
|
|
334
|
+
.map(({ kind }) => kind),
|
|
335
|
+
);
|
|
336
|
+
if (kinds.size === 1) return { uses, route: { path: page, content: [...kinds][0]! } };
|
|
337
|
+
// A read by the parameter in a form the sitemap does not follow (`params.id.toLowerCase()`, two kinds).
|
|
338
|
+
const unfollowed = gets.find(({ arg }) => mentions(arg, param));
|
|
339
|
+
if (!unfollowed) return { uses, route: { path: page } };
|
|
340
|
+
const message =
|
|
341
|
+
`the sitemap lists none of this page's entries: it reads content by the page's parameter in a way it cannot follow. ` +
|
|
342
|
+
`Read one kind by it as written, \`content.<kind>.get(params.${param})\` (or \`${param}\` destructured)`;
|
|
343
|
+
return { uses, route: { path: page }, warning: { file, ...at(unfollowed.start), message } };
|
|
156
344
|
}
|
|
157
345
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
346
|
+
/** Whether `node` reads the parameter `param` anywhere in it: `params.<param>`, or `param` destructured (not another parameter, nor a property's name, `post.slug` or `{ slug: x }`). */
|
|
347
|
+
const mentions = (node: Node, param: string): boolean => {
|
|
348
|
+
if (!node) return false;
|
|
349
|
+
if (node.type === "Identifier") return node.name === param;
|
|
350
|
+
if (node.type === "Property" && !node.computed) return mentions(node.value, param);
|
|
351
|
+
if (node.type === "MemberExpression") {
|
|
352
|
+
const path = memberPath(node, PARAMS);
|
|
353
|
+
if (path) return path[0] === param;
|
|
354
|
+
if (!node.computed) return mentions(node.object, param);
|
|
355
|
+
}
|
|
356
|
+
return childrenOf(node).some((child) => mentions(child, param));
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/** The names of a member path on one of `roots` (`api.data.leads.list` is `data`, `leads`, `list`); undefined for a path on anything else or a name not written out. */
|
|
360
|
+
function memberPath(member: Node, roots: ReadonlySet<string>): string[] | undefined {
|
|
160
361
|
const path: string[] = [];
|
|
161
362
|
let node = member;
|
|
162
363
|
while (node?.type === "MemberExpression") {
|
|
@@ -165,11 +366,25 @@ function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" |
|
|
|
165
366
|
path.unshift(key);
|
|
166
367
|
node = unwrap(node.object);
|
|
167
368
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
369
|
+
return node?.type === "Identifier" && roots.has(node.name) ? path : undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The kind of `content.<kind>.get`, a callee on `content` imported from `sanoma/site`; undefined for any other. */
|
|
373
|
+
function contentKind(callee: Node, content: ReadonlySet<string>): string | undefined {
|
|
374
|
+
const path = callee?.type === "MemberExpression" ? memberPath(callee, content) : undefined;
|
|
375
|
+
return path?.length === 2 && path[1] === "get" ? path[0] : undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** What a member path on `api` names; undefined for a path on anything else, a name not written out, or no name. */
|
|
379
|
+
function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name" | "method"> | undefined {
|
|
380
|
+
const path = memberPath(member, api);
|
|
381
|
+
if (!path?.length) return undefined;
|
|
382
|
+
const [first, second, third] = path as [string, ...string[]];
|
|
383
|
+
if (first === DATA_VENDOR)
|
|
384
|
+
return path.length === 3 ? { kind: "collection", name: second!, method: third } : undefined;
|
|
171
385
|
if (path.length === 3) return { kind: "op", name: path.join(".") };
|
|
172
|
-
|
|
386
|
+
if (path.length > 2) return undefined;
|
|
387
|
+
return { kind: "workflow", name: first, ...(second !== undefined && { method: second }) };
|
|
173
388
|
}
|
|
174
389
|
|
|
175
390
|
/** The local names `imported` has in the file, imported as a value from one of `sources`. */
|
|
@@ -187,22 +402,104 @@ function importedAs(program: Node, sources: ReadonlySet<string>, imported: strin
|
|
|
187
402
|
return names;
|
|
188
403
|
}
|
|
189
404
|
|
|
190
|
-
/**
|
|
191
|
-
|
|
405
|
+
/** The connectors a site's `login` is checked against: the config's vendors, and those with a login half. */
|
|
406
|
+
type SiteConnectors = { vendors: ReadonlySet<string>; logins: ReadonlySet<string> };
|
|
407
|
+
|
|
408
|
+
/** A property of an object literal that is written out: not a spread, its name not computed. */
|
|
409
|
+
const isWritten = (p: Node) => p.type === "Property" && !p.computed;
|
|
410
|
+
|
|
411
|
+
/** What `site.ts` that `sanoma` cannot read without running it is told. */
|
|
412
|
+
const unread = (what: string, as: string) => `${what} is read without running site.ts: write it as ${as}`;
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* What `site.ts` says of its site, read without running it, and what is wrong there: its default
|
|
416
|
+
* 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
|
|
418
|
+
* site that is not public, or naming no connector of the config with a login half, is a problem.
|
|
419
|
+
* `locales` is kept only when it has no problem.
|
|
420
|
+
*/
|
|
421
|
+
function siteOptionsOf(
|
|
422
|
+
path: string,
|
|
423
|
+
connectors: SiteConnectors,
|
|
424
|
+
): Pick<SiteEntry, "title" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
|
|
192
425
|
const file = readSource(path);
|
|
193
|
-
if (typeof file === "string") return
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const
|
|
426
|
+
if (typeof file === "string") return { problems: [] };
|
|
427
|
+
const problems: LintProblem[] = [...file.problems];
|
|
428
|
+
const problem = (at: Node, message: string) => void problems.push({ ...file.at(at.start), message });
|
|
429
|
+
const exported = file.program.body.find((s: Node) => s.type === "ExportDefaultDeclaration");
|
|
430
|
+
if (!exported) return { problems };
|
|
431
|
+
const call = unwrap(exported.declaration);
|
|
197
432
|
const callee = unwrap(call?.callee);
|
|
198
433
|
const options = unwrap(call?.arguments?.[0]);
|
|
199
|
-
|
|
434
|
+
const defineSite = importedAs(file.program, SITE_SOURCES, "defineSite");
|
|
435
|
+
// Anything not written out (a variable, a spread, a computed key) could make the site public unseen.
|
|
436
|
+
const written =
|
|
437
|
+
call?.type === "CallExpression" &&
|
|
438
|
+
callee?.type === "Identifier" &&
|
|
439
|
+
defineSite.has(callee.name) &&
|
|
440
|
+
(options === undefined || options.type === "ObjectExpression");
|
|
441
|
+
if (!written) {
|
|
442
|
+
problem(
|
|
443
|
+
exported,
|
|
444
|
+
unread("its default export", '`defineSite({ … })` from "sanoma/site", its options an object literal'),
|
|
445
|
+
);
|
|
446
|
+
return { problems };
|
|
447
|
+
}
|
|
448
|
+
for (const p of options?.properties ?? []) {
|
|
449
|
+
if (!isWritten(p)) problem(p, unread("each of `defineSite`'s options", "a property with its name"));
|
|
450
|
+
}
|
|
451
|
+
// The last of a name, as the object it makes has.
|
|
452
|
+
const property = (name: string): Node =>
|
|
453
|
+
options?.properties.findLast((p: Node) => isWritten(p) && keyName(p.key) === name);
|
|
454
|
+
|
|
455
|
+
const title = stringValue(property("title")?.value);
|
|
456
|
+
const publicAt = property("public");
|
|
457
|
+
const flag = unwrap(publicAt?.value);
|
|
458
|
+
const isPublic = flag?.type === "Literal" && flag.value === true;
|
|
459
|
+
const publicUnread = publicAt !== undefined && !(flag?.type === "Literal" && typeof flag.value === "boolean");
|
|
460
|
+
if (publicUnread) problem(publicAt, unread("`public`", "`true` or `false`"));
|
|
461
|
+
|
|
462
|
+
const loginAt = property("login");
|
|
463
|
+
const login = stringValue(loginAt?.value);
|
|
464
|
+
const loginProblem = (): string | undefined => {
|
|
465
|
+
if (login === undefined) return unread("`login`", "a string");
|
|
466
|
+
// When `public` is what is wrong, it is said there.
|
|
467
|
+
if (!isPublic && !publicUnread) return "a login is for a public site: add `public: true`, or remove `login`";
|
|
468
|
+
if (!connectors.vendors.has(login)) return `login names "${login}", which is not a connector in sanoma.config.ts`;
|
|
469
|
+
if (!connectors.logins.has(login)) return `login names "${login}", whose connector has no login half`;
|
|
200
470
|
return undefined;
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
471
|
+
};
|
|
472
|
+
const message = loginAt && loginProblem();
|
|
473
|
+
if (message) problem(loginAt, message);
|
|
474
|
+
|
|
475
|
+
const localesAt = property("locales");
|
|
476
|
+
const list = unwrap(localesAt?.value);
|
|
477
|
+
// A hole or a spread is no string.
|
|
478
|
+
const tags: (string | undefined)[] | undefined =
|
|
479
|
+
list?.type === "ArrayExpression" ? list.elements.map((e: Node) => stringValue(e)) : undefined;
|
|
480
|
+
const locales = tags?.every((tag): tag is string => tag !== undefined) ? tags : undefined;
|
|
481
|
+
const localesProblem = (): string | undefined => {
|
|
482
|
+
if (locales === undefined) return unread("`locales`", "an array of strings");
|
|
483
|
+
if (!locales.length) return "`locales` names a site's languages, its default first: give one or more, or remove it";
|
|
484
|
+
const bad = locales.find((locale) => !LOCALE.test(locale));
|
|
485
|
+
if (bad !== undefined) return `"${bad}" is not a language tag such as "en", "fr" or "pt-BR"`;
|
|
486
|
+
const twice = locales.find((locale, i) => locales.indexOf(locale) !== i);
|
|
487
|
+
if (twice !== undefined) return `"${twice}" is in \`locales\` twice`;
|
|
488
|
+
// When `public` is what is wrong, it is said there.
|
|
489
|
+
if ((!isPublic && !publicUnread) || loginAt) {
|
|
490
|
+
return "`locales` are for a public site with no login: add `public: true`, or remove `locales`";
|
|
491
|
+
}
|
|
492
|
+
return undefined;
|
|
493
|
+
};
|
|
494
|
+
const localesMessage = localesAt && localesProblem();
|
|
495
|
+
if (localesMessage) problem(localesAt, localesMessage);
|
|
496
|
+
return {
|
|
497
|
+
...(title !== undefined && { title }),
|
|
498
|
+
...(isPublic && { public: true as const }),
|
|
499
|
+
...(login !== undefined && { login }),
|
|
500
|
+
...(localesAt && !localesMessage && isPublic && !loginAt && { locales }),
|
|
501
|
+
problems,
|
|
502
|
+
};
|
|
206
503
|
}
|
|
207
504
|
|
|
208
505
|
/**
|
package/src/version.ts
CHANGED
|
@@ -5,8 +5,10 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { jsonSchemaOf, opIdsOf, type WorkflowDefinition } from "./define.ts";
|
|
6
6
|
import { upFrom } from "./paths.ts";
|
|
7
7
|
|
|
8
|
-
/** This package's version.
|
|
9
|
-
export const RUNTIME_VERSION =
|
|
8
|
+
/** This package's version, from its package.json: one level above `src/` and `dist/` alike. */
|
|
9
|
+
export const RUNTIME_VERSION = (
|
|
10
|
+
JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }
|
|
11
|
+
).version;
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* The sequence of DBOS calls the runtime makes around each `ctx` call: policy step, approval
|