@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.
Files changed (55) hide show
  1. package/README.md +18 -9
  2. package/dist/cedar.d.ts +23 -6
  3. package/dist/cedar.js +151 -44
  4. package/dist/cedar.js.map +1 -1
  5. package/dist/client.d.ts +3 -2
  6. package/dist/client.js +15 -6
  7. package/dist/client.js.map +1 -1
  8. package/dist/config.d.ts +6 -1
  9. package/dist/config.js +3 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/describe.d.ts +1 -1
  12. package/dist/describe.js +38 -4
  13. package/dist/describe.js.map +1 -1
  14. package/dist/host.d.ts +2 -0
  15. package/dist/host.js.map +1 -1
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.js +3 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/op.d.ts +55 -3
  20. package/dist/op.js +6 -0
  21. package/dist/op.js.map +1 -1
  22. package/dist/policy.d.ts +9 -1
  23. package/dist/policy.js +18 -7
  24. package/dist/policy.js.map +1 -1
  25. package/dist/resources.d.ts +4 -2
  26. package/dist/resources.js +23 -11
  27. package/dist/resources.js.map +1 -1
  28. package/dist/scenario.d.ts +1 -1
  29. package/dist/scenario.js +4 -2
  30. package/dist/scenario.js.map +1 -1
  31. package/dist/schema.js +1 -1
  32. package/dist/shared.d.ts +35 -2
  33. package/dist/shared.js +60 -3
  34. package/dist/shared.js.map +1 -1
  35. package/dist/sites.d.ts +72 -6
  36. package/dist/sites.js +277 -46
  37. package/dist/sites.js.map +1 -1
  38. package/dist/version.d.ts +2 -2
  39. package/dist/version.js +2 -2
  40. package/dist/version.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/cedar.ts +152 -41
  43. package/src/client.ts +17 -4
  44. package/src/config.ts +8 -2
  45. package/src/describe.ts +47 -4
  46. package/src/host.ts +2 -0
  47. package/src/index.ts +10 -0
  48. package/src/op.ts +56 -3
  49. package/src/policy.ts +29 -10
  50. package/src/resources.ts +26 -10
  51. package/src/scenario.ts +5 -2
  52. package/src/schema.ts +1 -1
  53. package/src/shared.ts +72 -3
  54. package/src/sites.ts +346 -49
  55. 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 { apiNameOf, DATA_VENDOR, SITE_NAME } from "./shared.ts";
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
- /** `<RunForm workflow="…" />` from `@sanoma-ai/app/ui`, or a call on `api` from `sanoma/site`. */
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
- /** Each call to a workflow, a collection or an operation the config does not have, or to an operation a page may not call directly, at its file. */
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="…" />` whose `RunForm` is imported from `@sanoma-ai/app/ui`, and
64
- * each use of `api` imported from `sanoma/site`, called or passed on (`queryFn: api.data.leads.list`):
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 readdirSync(dir, { withFileTypes: true })
104
- .filter((d) => d.isDirectory() && SITE_NAME.test(d.name) && existsSync(join(dir, d.name, "routes")))
105
- .map((d) => d.name)
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
- .map((name) => {
108
- const files = SITE_DIRS.flatMap((sub) => filesUnder(join(dir, name, sub), isSource)).toSorted();
109
- const resolved = files.flatMap((path) => {
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
- /** The uses in one file, in its order. */
126
- function usesIn({ program, at }: SourceFile, file: string): Written[] {
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, UI_SOURCES, "RunForm");
129
- if (!api.size && !runForm.size) return [];
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" && page === undefined) {
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
- return found.map((use) => (page === undefined ? use : { ...use, page }));
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
- /** What a member path on `api` names; undefined for a path on anything else, a name not written out, or no name. */
159
- function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name"> | undefined {
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
- if (node?.type !== "Identifier" || !api.has(node.name)) return undefined;
169
- const [first, second] = path as [string, ...string[]];
170
- if (first === DATA_VENDOR) return path.length === 3 ? { kind: "collection", name: second! } : undefined;
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
- return path.length <= 2 ? { kind: "workflow", name: first } : undefined;
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
- /** A site's title: the string `title` its `site.ts` exports by default through `defineSite`. */
191
- function titleOf(path: string): string | undefined {
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 undefined;
194
- const defineSite = importedAs(file.program, SITE_SOURCES, "defineSite");
195
- const exported = file.program.body.find((s: Node) => s.type === "ExportDefaultDeclaration")?.declaration;
196
- const call = unwrap(exported);
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
- if (call?.type !== "CallExpression" || callee?.type !== "Identifier" || !defineSite.has(callee.name))
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
- if (options?.type !== "ObjectExpression") return undefined;
202
- const title = options.properties.find(
203
- (p: Node) => p.type === "Property" && !p.computed && keyName(p.key) === "title",
204
- );
205
- return stringValue(title?.value);
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. A unit test keeps it equal to package.json. */
9
- export const RUNTIME_VERSION = "0.4.2";
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