zitejs 0.9.133 → 0.9.135

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.
@@ -7,8 +7,8 @@ export type ZiteWebhook = {
7
7
  * A provider trigger: what should fire the endpoint, declared as a literal.
8
8
  * The platform registers it with the provider at publish, verifies each
9
9
  * delivery and queues it; the endpoint only sees the payload. A `poll`
10
- * trigger runs the endpoint on a cadence with `input.__poll.cursor` from the
11
- * previous run, and the endpoint returns `{ cursor }` to advance it.
10
+ * trigger runs the endpoint on a cadence. Either way `execute` gets `trigger`
11
+ * ({@link ZiteTriggerContext}) to read the cursor and record what it handled.
12
12
  */
13
13
  export type ZiteTrigger = {
14
14
  /**
@@ -86,49 +86,47 @@ export type ZiteTriggerUnsubscribeInput = {
86
86
  };
87
87
  export type ZiteTriggerSubscribe = (input: ZiteTriggerSubscribeInput) => Promise<ZiteTriggerSubscribeResult> | ZiteTriggerSubscribeResult;
88
88
  export type ZiteTriggerUnsubscribe = (input: ZiteTriggerUnsubscribeInput) => Promise<void> | void;
89
- /**
90
- * What a poll-triggered run receives as `input.__poll`. Only on a fire from
91
- * the poll's schedule; a request call to the same endpoint carries none.
92
- */
93
- export type ZitePollInput = {
94
- /** What the previous successful run returned as `cursor`; null on the first run. */
95
- cursor: string | null;
96
- /** Ids earlier runs returned as `handled`, newest last. Skip these. */
97
- seen: string[];
98
- };
99
- /**
100
- * What a provider trigger's delivery carries as `input.__webhook`, beside the
101
- * provider's own body. `cursor` and `seen` work as they do for a poll: for a
102
- * provider whose notification only says that something changed, `subscribe`
103
- * returns a starting `cursor` and each delivery returns the next one. A
104
- * trigger that keeps a cursor gets one delivery at a time.
105
- */
106
- export type ZiteTriggerDelivery = {
89
+ /** Where a provider delivery came from; app event triggers get only this. */
90
+ export type ZiteTriggerDeliveryInfo = {
91
+ /** The delivery's headers, lowercased. */
107
92
  headers: Record<string, string>;
108
93
  /** The provider's id for this delivery; redeliveries share it. */
109
94
  deliveryId?: string;
110
95
  provider?: string;
111
- /** What `subscribe` or the previous successful delivery returned as `cursor`. */
112
- cursor: string | null;
113
- /** Ids earlier deliveries returned as `handled`, newest last. Skip these. */
114
- seen: string[];
115
96
  };
116
- /** What an app event trigger's run carries as `input.__webhook`: one event, no cursor. */
117
- export type ZiteAppEventDelivery = Omit<ZiteTriggerDelivery, "cursor" | "seen">;
118
97
  /**
119
- * What a poll-triggered run, or a provider trigger's delivery, returns for
120
- * the platform to keep: its new `cursor`, and the id of every item it acted
121
- * on, so the next run's `seen` includes them even if the cursor hands them
122
- * back.
98
+ * What a poll run, or a provider trigger's delivery, gets as `trigger`. Record
99
+ * each item acted on with `handled` as you go: it is kept even if the run
100
+ * later throws, so the next run skips it. The cursor staged with `advance` is
101
+ * committed only when the run succeeds.
123
102
  */
124
- export type ZitePollResult = {
125
- cursor?: string;
126
- handled?: string[];
103
+ export type ZiteTriggerRun = {
104
+ /** The last committed cursor: what `subscribe` returned, or the last successful run's `advance`. Null when there is none yet. */
105
+ cursor: string | null;
106
+ /** Whether an earlier run, or this one, recorded `id` as handled. */
107
+ seen(id: string): boolean;
108
+ /** Record an item as acted on (1 to 512 characters). */
109
+ handled(id: string): void;
110
+ /** Stage the next cursor (at most 4096 characters). The last call wins. */
111
+ advance(cursor: string): void;
127
112
  };
113
+ export type ZiteTriggerDeliveryRun = ZiteTriggerRun & ZiteTriggerDeliveryInfo;
114
+ /** The `trigger` param for an endpoint declaring `TTrigger`. */
115
+ export type ZiteTriggerContext<TTrigger> = TTrigger extends {
116
+ every: string;
117
+ } ? ZiteTriggerRun : TTrigger extends {
118
+ events: string[];
119
+ } ? ZiteTriggerDeliveryInfo : TTrigger extends {
120
+ integration: string;
121
+ } ? ZiteTriggerDeliveryRun : never;
128
122
  export interface ZiteRequestContext {
129
123
  user: {
130
124
  id: string;
131
125
  email: string;
126
+ roles: Array<{
127
+ id: string;
128
+ name: string;
129
+ }>;
132
130
  [key: string]: unknown;
133
131
  };
134
132
  userId?: string;
@@ -217,22 +215,12 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
217
215
  */
218
216
  validatesInput?: boolean;
219
217
  execute: (params: {
220
- input: TTrigger extends {
221
- every: string;
222
- } ? TInput & {
223
- __poll?: ZitePollInput;
224
- } : TTrigger extends {
225
- events: string[];
226
- } ? TInput & {
227
- __webhook?: ZiteAppEventDelivery;
228
- } : TTrigger extends {
229
- integration: string;
230
- } ? TInput & {
231
- __webhook?: ZiteTriggerDelivery;
232
- } : TInput;
218
+ input: TInput;
233
219
  context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : TTrigger extends ZiteTrigger ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;
234
220
  } & (TStream extends true ? {
235
221
  stream: ZiteStreamInterface;
222
+ } : {}) & (TTrigger extends ZiteTrigger ? {
223
+ trigger?: ZiteTriggerContext<TTrigger>;
236
224
  } : {})) => Promise<TOutput> | TOutput;
237
225
  }
238
226
  /**
@@ -74,6 +74,10 @@ function createEndpoint(config) {
74
74
  // request callers doesn't describe them.
75
75
  if (isPlatformTriggeredInput(params.input))
76
76
  return execute(params);
77
+ // A trigger's run carries the provider's body, not a request's input.
78
+ if (params.trigger !== undefined) {
79
+ return execute(params);
80
+ }
77
81
  // Same, for a fire with no input object to hold the sentinel: the runner
78
82
  // only merges `__cron` into a plain object.
79
83
  if (firesWithoutARequest && params.input === undefined) {
@@ -4,7 +4,7 @@ const vitest_1 = require("vitest");
4
4
  const zod_1 = require("zod");
5
5
  const index_js_1 = require("./index.js");
6
6
  const index_js_2 = require("../runtime/index.js");
7
- const requestContext = { user: { id: "u1", email: "a@b.c" } };
7
+ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
8
8
  (0, vitest_1.afterEach)(() => {
9
9
  vitest_1.vi.unstubAllGlobals();
10
10
  });
@@ -133,28 +133,51 @@ const requestContext = { user: { id: "u1", email: "a@b.c" } };
133
133
  });
134
134
  await (0, vitest_1.expect)(endpoint.execute({ input: undefined, context: requestContext })).rejects.toMatchObject({ code: "BAD_REQUEST" });
135
135
  });
136
- (0, vitest_1.it)("hands a poll its cursor and seen ids, typed", async () => {
136
+ (0, vitest_1.it)("hands a poll a typed trigger and skips the input schema for it", async () => {
137
+ const handled = [];
137
138
  const endpoint = (0, index_js_1.createEndpoint)({
138
- inputSchema: zod_1.z.object({}),
139
+ inputSchema: zod_1.z.object({ required: zod_1.z.string() }),
139
140
  trigger: { every: "5m", integration: "stripe" },
140
- execute: async ({ input }) => {
141
- const seen = new Set(input.__poll?.seen);
142
- const items = ["a", "b"].filter((id) => !seen.has(id));
143
- return { cursor: input.__poll?.cursor ?? "start", handled: items };
141
+ execute: async ({ trigger }) => {
142
+ if (!trigger)
143
+ return { ran: 0 };
144
+ const fresh = ["a", "b"].filter((id) => !trigger.seen(id));
145
+ for (const id of fresh)
146
+ trigger.handled(id);
147
+ trigger.advance("c2");
148
+ return { ran: fresh.length, from: trigger.cursor };
144
149
  },
145
150
  });
151
+ const seen = new Set(["a"]);
146
152
  await (0, vitest_1.expect)(endpoint.execute({
147
- input: { __poll: { cursor: "c1", seen: ["a"] } },
153
+ input: {},
148
154
  context: { user: null },
149
- })).resolves.toEqual({ cursor: "c1", handled: ["b"] });
155
+ trigger: {
156
+ cursor: "c1",
157
+ seen: (id) => seen.has(id),
158
+ handled: (id) => void handled.push(id),
159
+ advance: () => { },
160
+ },
161
+ })).resolves.toEqual({ ran: 1, from: "c1" });
162
+ (0, vitest_1.expect)(handled).toEqual(["b"]);
150
163
  });
151
- (0, vitest_1.it)("gives __poll only to poll triggers", () => {
164
+ (0, vitest_1.it)("types trigger by the declared kind", () => {
152
165
  (0, index_js_1.createEndpoint)({
153
- inputSchema: zod_1.z.object({ action: zod_1.z.string() }),
154
166
  trigger: { integration: "github" },
155
- execute: async ({ input }) => {
156
- // @ts-expect-error a provider trigger's input has no __poll
157
- return input.__poll;
167
+ execute: async ({ trigger }) => trigger?.deliveryId ?? trigger?.cursor,
168
+ });
169
+ (0, index_js_1.createEndpoint)({
170
+ trigger: { integration: "team-slack", events: ["message.channels"] },
171
+ execute: async ({ trigger }) => {
172
+ // @ts-expect-error an app event has no cursor API
173
+ trigger?.advance("x");
174
+ return trigger?.headers;
175
+ },
176
+ });
177
+ (0, index_js_1.createEndpoint)({
178
+ execute: async (params) => {
179
+ // @ts-expect-error a plain endpoint gets no trigger
180
+ return params.trigger;
158
181
  },
159
182
  });
160
183
  });
@@ -184,40 +207,28 @@ const requestContext = { user: { id: "u1", email: "a@b.c" } };
184
207
  endpointId: "onPush",
185
208
  });
186
209
  });
187
- (0, vitest_1.it)("hands a provider trigger's delivery its cursor and seen ids, typed", async () => {
210
+ (0, vitest_1.it)("hands a provider delivery its body as input and the cursor API on trigger", async () => {
188
211
  const endpoint = (0, index_js_1.createEndpoint)({
189
- inputSchema: zod_1.z.object({}),
212
+ inputSchema: zod_1.z.object({ required: zod_1.z.string() }),
190
213
  trigger: { integration: "googleCalendar" },
191
- execute: async ({ input }) => {
192
- const seen = new Set(input.__webhook?.seen);
193
- const changed = ["e1", "e2"].filter((id) => !seen.has(id));
194
- return { cursor: `${input.__webhook?.cursor}+1`, handled: changed };
195
- },
214
+ execute: async ({ input, trigger }) => ({
215
+ body: input,
216
+ cursor: trigger?.cursor,
217
+ delivery: trigger?.deliveryId,
218
+ }),
196
219
  });
197
220
  await (0, vitest_1.expect)(endpoint.execute({
198
- input: {
199
- __webhook: { headers: {}, cursor: "sync1", seen: ["e1"] },
200
- },
221
+ input: { kind: "sync" },
201
222
  context: { user: null },
202
- })).resolves.toEqual({ cursor: "sync1+1", handled: ["e2"] });
203
- });
204
- (0, vitest_1.it)("types an app event trigger's run as one event, with no cursor to keep", async () => {
205
- const endpoint = (0, index_js_1.createEndpoint)({
206
- inputSchema: zod_1.z.object({ event: zod_1.z.object({ text: zod_1.z.string() }) }),
207
- trigger: { integration: "team-slack", events: ["message.channels"] },
208
- execute: async ({ input }) => {
209
- // @ts-expect-error an app event delivery has no cursor
210
- void input.__webhook?.cursor;
211
- return { id: input.__webhook?.deliveryId ?? null };
212
- },
213
- });
214
- await (0, vitest_1.expect)(endpoint.execute({
215
- input: {
216
- event: { text: "hi" },
217
- __webhook: { headers: {}, deliveryId: "slack:Ev1" },
223
+ trigger: {
224
+ cursor: "sync1",
225
+ seen: () => false,
226
+ handled: () => { },
227
+ advance: () => { },
228
+ headers: {},
229
+ deliveryId: "chan:2",
218
230
  },
219
- context: { user: null },
220
- })).resolves.toEqual({ id: "slack:Ev1" });
231
+ })).resolves.toEqual({ body: { kind: "sync" }, cursor: "sync1", delivery: "chan:2" });
221
232
  });
222
233
  (0, vitest_1.it)("refuses events on a trigger that also polls", () => {
223
234
  (0, index_js_1.createEndpoint)({
@@ -1232,13 +1232,13 @@ function generateBackendWrapperTs(envVarNames = []) {
1232
1232
  "// Auto-generated type-narrowing wrapper. Do not edit manually.",
1233
1233
  "// Re-exports createEndpoint with context.user typed to the app User.",
1234
1234
  "",
1235
- "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStreamInterface, ZiteTrigger, ZiteTriggerDelivery, ZiteAppEventDelivery, ZitePollInput, ZiteWebhook } from 'zitejs/backend/base';",
1235
+ "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStreamInterface, ZiteTrigger, ZiteTriggerContext, ZiteWebhook } from 'zitejs/backend/base';",
1236
1236
  "import type { User } from 'zitejs/auth';",
1237
1237
  "",
1238
1238
  "export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStreamInterface, ZiteTrigger, ZiteWebhook };",
1239
1239
  // A trigger's registration exports are typed against these, and they
1240
1240
  // only reach app code through this file.
1241
- "export type { ZiteAppEventDelivery, ZitePollInput, ZitePollResult, ZiteTriggerDelivery, ZiteTriggerSubscribe, ZiteTriggerSubscribeInput, ZiteTriggerSubscribeResult, ZiteTriggerUnsubscribe, ZiteTriggerUnsubscribeInput } from 'zitejs/backend/base';",
1241
+ "export type { ZiteTriggerContext, ZiteTriggerDeliveryInfo, ZiteTriggerDeliveryRun, ZiteTriggerRun, ZiteTriggerSubscribe, ZiteTriggerSubscribeInput, ZiteTriggerSubscribeResult, ZiteTriggerUnsubscribe, ZiteTriggerUnsubscribeInput } from 'zitejs/backend/base';",
1242
1242
  // The pre-monorepo SDK put this in scope for every endpoint, so migrated
1243
1243
  // code can name it. `createEndpoint` infers the same thing without it.
1244
1244
  "export type InferSchemaType<T> = T extends { _output: infer U } ? U : T;",
@@ -1302,7 +1302,8 @@ function generateBackendWrapperTs(envVarNames = []) {
1302
1302
  " User,",
1303
1303
  " 'name' | 'emailVerified' | 'image' | 'createdAt' | 'updatedAt'",
1304
1304
  "> &",
1305
- " Pick<User, 'id' | 'email'>;",
1305
+ " Pick<User, 'id' | 'email'> &",
1306
+ " Pick<_ZiteRequestContext['user'], 'roles'>;",
1306
1307
  "",
1307
1308
  'export interface ZiteRequestContext extends Omit<_ZiteRequestContext, "user"> {',
1308
1309
  " user: ZiteEndpointUser;",
@@ -1329,11 +1330,10 @@ function generateBackendWrapperTs(envVarNames = []) {
1329
1330
  " trigger?: TTrigger;",
1330
1331
  " execute: (",
1331
1332
  " params: {",
1332
- // Mirrors the base module: a poll's run carries `__poll`, a provider
1333
- // trigger's delivery `__webhook`.
1334
- " input: TTrigger extends { every: string } ? TInput & { __poll?: ZitePollInput } : TTrigger extends { events: string[] } ? TInput & { __webhook?: ZiteAppEventDelivery } : TTrigger extends { integration: string } ? TInput & { __webhook?: ZiteTriggerDelivery } : TInput;",
1333
+ " input: TInput;",
1335
1334
  " context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : TTrigger extends ZiteTrigger ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
1336
- " } & (TStream extends true ? { stream: ZiteStreamInterface } : {}),",
1335
+ // Mirrors the base module: a trigger's run gets `trigger`.
1336
+ " } & (TStream extends true ? { stream: ZiteStreamInterface } : {}) & (TTrigger extends ZiteTrigger ? { trigger?: ZiteTriggerContext<TTrigger> } : {}),",
1337
1337
  " ) => Promise<TOutput> | TOutput;",
1338
1338
  "}",
1339
1339
  "",
@@ -1343,6 +1343,14 @@ function generateBackendWrapperTs(envVarNames = []) {
1343
1343
  " return config;",
1344
1344
  "}",
1345
1345
  "",
1346
+ "/**",
1347
+ " * Checks if a user has a specific role. Returns true if the user has the role, false",
1348
+ " * otherwise. If the user is not logged in, returns false.",
1349
+ " */",
1350
+ "export const hasRole = (context: ZiteRequestContext, roleId: string) => {",
1351
+ " return context.user?.roles?.some((r) => r.id === roleId);",
1352
+ "};",
1353
+ "",
1346
1354
  // Declared in the wrapper (not a standalone .d.ts) so the merge is
1347
1355
  // guaranteed to be in the program — every endpoint imports this file,
1348
1356
  // while tsconfig `include` does not reliably pick up dot-directories.
@@ -390,9 +390,9 @@ const duplicateIdentifierErrorsIn = (source) => {
390
390
  (0, vitest_1.it)('passes the trigger registration types and trigger inputs through to app code', () => {
391
391
  (0, vitest_1.expect)(output).toContain('ZiteTriggerSubscribe, ZiteTriggerSubscribeInput');
392
392
  (0, vitest_1.expect)(output).toContain('ZiteTriggerUnsubscribe, ZiteTriggerUnsubscribeInput');
393
- (0, vitest_1.expect)(output).toContain("TTrigger extends { every: string } ? TInput & { __poll?: ZitePollInput }");
394
- (0, vitest_1.expect)(output).toContain('TInput & { __webhook?: ZiteTriggerDelivery }');
395
- (0, vitest_1.expect)(output).toContain("TTrigger extends { events: string[] } ? TInput & { __webhook?: ZiteAppEventDelivery }");
393
+ (0, vitest_1.expect)(output).toContain('ZiteTriggerContext, ZiteTriggerDeliveryInfo, ZiteTriggerDeliveryRun, ZiteTriggerRun');
394
+ (0, vitest_1.expect)(output).toContain('(TTrigger extends ZiteTrigger ? { trigger?: ZiteTriggerContext<TTrigger> } : {})');
395
+ (0, vitest_1.expect)(output).not.toContain('__poll');
396
396
  (0, vitest_1.expect)(output).not.toContain('src/triggers/');
397
397
  });
398
398
  (0, vitest_1.it)('exports ZiteError', () => {
@@ -11,6 +11,41 @@ const estree_walker_1 = require("estree-walker");
11
11
  const domTagNames_js_1 = require("./domTagNames.js");
12
12
  const validExtensions = new Set([".jsx", ".tsx"]);
13
13
  const fragmentNames = new Set(["Fragment", "React.Fragment"]);
14
+ // react-three-fiber reads a dashed prop as a path into the three.js object, so
15
+ // any data-zite-* prop that reaches one throws. Components from the three
16
+ // ecosystem (drei, react-spring's `animated`) spread their props onto three
17
+ // objects, and anything rendered in a scene may be a three object too. So a
18
+ // component is only tagged where it is known to render DOM: under a DOM
19
+ // element or drei's <Html>. In files that import the three ecosystem, a JSX
20
+ // tree's root is not assumed to be DOM, since scene components live there.
21
+ const threeIntrinsicDomNames = new Set(["audio", "line", "path", "source"]);
22
+ function isThreeModule(source) {
23
+ return (source.startsWith("@react-three/") || source === "@react-spring/three");
24
+ }
25
+ /** Local binding name -> imported name ("*" for a namespace import). */
26
+ function getThreeImports(ast) {
27
+ const imports = new Map();
28
+ for (const statement of ast.program.body) {
29
+ if (statement.type === "ImportDeclaration" &&
30
+ isThreeModule(statement.source.value)) {
31
+ for (const specifier of statement.specifiers) {
32
+ imports.set(specifier.local.name, specifier.type === "ImportSpecifier"
33
+ ? specifier.imported.name ?? specifier.imported.value
34
+ : "*");
35
+ }
36
+ }
37
+ }
38
+ return imports;
39
+ }
40
+ /** The three-ecosystem name an element refers to, if it comes from one. */
41
+ function getThreeName(componentName, threeImports) {
42
+ const segments = componentName.split(".");
43
+ const imported = threeImports.get(segments[0]);
44
+ if (imported === undefined) {
45
+ return null;
46
+ }
47
+ return imported === "*" ? segments[segments.length - 1] : imported;
48
+ }
14
49
  function getComponentName(elementName) {
15
50
  if (elementName.type === "JSXIdentifier") {
16
51
  return elementName.name;
@@ -51,18 +86,52 @@ function ziteId() {
51
86
  plugins: ["jsx", "typescript"],
52
87
  });
53
88
  const magicString = new magic_string_1.default(code);
89
+ const threeImports = getThreeImports(ast);
90
+ const rootContext = threeImports.size > 0 ? "unknown" : "dom";
91
+ // The render context each open JSXElement gives its children.
92
+ const contextStack = [];
54
93
  (0, estree_walker_1.walk)(ast, {
94
+ leave(_node) {
95
+ if (_node.type === "JSXElement") {
96
+ contextStack.pop();
97
+ }
98
+ },
55
99
  enter(_node) {
56
100
  const node = _node;
57
101
  if (node.type === "JSXElement") {
58
102
  const openingElement = node.openingElement;
59
103
  const children = node.children;
104
+ const context = contextStack[contextStack.length - 1] ?? rootContext;
60
105
  const componentName = getComponentName(openingElement.name);
61
- if (!componentName) {
106
+ const kind = componentName ? classifyElement(componentName) : null;
107
+ const threeName = componentName
108
+ ? getThreeName(componentName, threeImports)
109
+ : null;
110
+ let childContext = context;
111
+ if (threeName === "Html") {
112
+ childContext = "dom";
113
+ }
114
+ else if (threeName !== null) {
115
+ childContext = "scene";
116
+ }
117
+ else if (kind === "dom") {
118
+ childContext = "dom";
119
+ }
120
+ else if (componentName &&
121
+ kind === null &&
122
+ !fragmentNames.has(componentName)) {
123
+ childContext = "scene";
124
+ }
125
+ contextStack.push(childContext);
126
+ if (!componentName || !kind) {
62
127
  return;
63
128
  }
64
- const kind = classifyElement(componentName);
65
- if (!kind) {
129
+ const isTagged = kind === "component"
130
+ ? threeName === "Canvas" ||
131
+ (threeName === null && context === "dom")
132
+ : !threeIntrinsicDomNames.has(componentName) ||
133
+ context === "dom";
134
+ if (!isTagged) {
66
135
  return;
67
136
  }
68
137
  const line = openingElement.loc?.start?.line ?? 0;
@@ -42,9 +42,65 @@ async function transform(code) {
42
42
  const source = 'const a = <group><mesh position-x={1} /><ambientLight intensity={0.5} /></group>;';
43
43
  (0, vitest_1.expect)(await transform(source)).toBe(source);
44
44
  });
45
- (0, vitest_1.it)('tags DOM elements nested inside non-DOM intrinsics', async () => {
45
+ (0, vitest_1.it)('tags DOM but not components nested inside non-DOM intrinsics', async () => {
46
46
  const code = await transform('const a = <group><Html><div /></Html></group>;');
47
- (0, vitest_1.expect)(code).toBe('const a = <group><Html data-zite-id="src/App.tsx|1|17" data-zite-component="Html"><div data-zite-id="src/App.tsx|1|23" /></Html></group>;');
47
+ (0, vitest_1.expect)(code).toBe('const a = <group><Html><div data-zite-id="src/App.tsx|1|23" /></Html></group>;');
48
+ });
49
+ (0, vitest_1.describe)('react-three scenes', () => {
50
+ const scene = (jsx) => transform("import { Canvas } from '@react-three/fiber';\n" +
51
+ "import { Html, Text } from '@react-three/drei';\n" +
52
+ "import { Button } from './Button';\n" +
53
+ `const a = ${jsx};`);
54
+ (0, vitest_1.it)('skips three components, nested components and three.js intrinsics', async () => {
55
+ const code = await scene('<Canvas><Text>Hi</Text><Button /><line /></Canvas>');
56
+ (0, vitest_1.expect)(code).toContain('<Text>Hi</Text>');
57
+ (0, vitest_1.expect)(code).toContain('<Button />');
58
+ (0, vitest_1.expect)(code).toContain('<line />');
59
+ });
60
+ (0, vitest_1.it)('tags the Canvas container and UI next to it', async () => {
61
+ const code = await scene('<div><Canvas /><Button>Start</Button></div>');
62
+ (0, vitest_1.expect)(code).toMatch(/<Canvas data-zite-id="[^"]+" data-zite-component="Canvas" \/>/);
63
+ (0, vitest_1.expect)(code).toMatch(/<Button data-zite-id="[^"]+" data-zite-component="Button" data-zite-editable="true">/);
64
+ });
65
+ (0, vitest_1.it)('tags DOM and components inside an <Html> overlay again', async () => {
66
+ const code = await scene('<Canvas><Html><Button /><div /></Html></Canvas>');
67
+ (0, vitest_1.expect)(code).toContain('<Html>');
68
+ (0, vitest_1.expect)(code).toMatch(/<Button data-zite-id="[^"]+" data-zite-component="Button" \/>/);
69
+ (0, vitest_1.expect)(code).toMatch(/<div data-zite-id="[^"]+" \/>/);
70
+ });
71
+ (0, vitest_1.it)('skips the root of a scene component in the same file', async () => {
72
+ const code = await scene('<><Button position={[0, 0, 0]} /></>');
73
+ (0, vitest_1.expect)(code).toContain('<Button position={[0, 0, 0]} />');
74
+ });
75
+ (0, vitest_1.it)('skips children of three wrapper components', async () => {
76
+ const code = await transform("import { Float } from '@react-three/drei';\nimport { Box } from './Box';\nconst a = <Float><Box /></Float>;");
77
+ (0, vitest_1.expect)(code).toContain('<Float><Box /></Float>');
78
+ });
79
+ (0, vitest_1.it)('recognises aliased and namespaced three imports', async () => {
80
+ const code = await transform("import { Canvas as C } from '@react-three/fiber';\nimport * as Drei from '@react-three/drei';\n" +
81
+ "import { Box, Card } from './ui';\nconst a = <C><Box /><Drei.Html><Card /></Drei.Html></C>;");
82
+ (0, vitest_1.expect)(code).toMatch(/<C data-zite-id="[^"]+" data-zite-component="C">/);
83
+ (0, vitest_1.expect)(code).toContain('<Box />');
84
+ (0, vitest_1.expect)(code).toContain('<Drei.Html>');
85
+ (0, vitest_1.expect)(code).toMatch(/<Card data-zite-id="[^"]+" data-zite-component="Card" \/>/);
86
+ });
87
+ (0, vitest_1.it)('tags SVG lines and paths in DOM next to a scene', async () => {
88
+ const code = await scene('<div><svg><line /><path /></svg></div>');
89
+ (0, vitest_1.expect)(code).toMatch(/<line data-zite-id="[^"]+" \/>/);
90
+ (0, vitest_1.expect)(code).toMatch(/<path data-zite-id="[^"]+" \/>/);
91
+ });
92
+ (0, vitest_1.it)("skips react-spring's animated three elements", async () => {
93
+ const code = await transform("import { animated } from '@react-spring/three';\nconst a = <animated.mesh />;");
94
+ (0, vitest_1.expect)(code).toContain('<animated.mesh />');
95
+ });
96
+ (0, vitest_1.it)('skips components nested in a scene element in a file without three imports', async () => {
97
+ const code = await transform("import { Tree } from './Tree';\nconst a = <group><Tree /></group>;");
98
+ (0, vitest_1.expect)(code).toContain('<Tree />');
99
+ });
100
+ });
101
+ (0, vitest_1.it)('keeps tagging components in files that do not import react-three', async () => {
102
+ const code = await transform("import { Card } from './Card';\nconst a = <Card />;");
103
+ (0, vitest_1.expect)(code).toContain('<Card data-zite-id="src/App.tsx|2|10" data-zite-component="Card" />');
48
104
  });
49
105
  (0, vitest_1.it)('leaves fragments untouched', async () => {
50
106
  const source = 'const a = <Fragment><React.Fragment key="a" /></Fragment>;';
@@ -7,8 +7,8 @@ export type ZiteWebhook = {
7
7
  * A provider trigger: what should fire the endpoint, declared as a literal.
8
8
  * The platform registers it with the provider at publish, verifies each
9
9
  * delivery and queues it; the endpoint only sees the payload. A `poll`
10
- * trigger runs the endpoint on a cadence with `input.__poll.cursor` from the
11
- * previous run, and the endpoint returns `{ cursor }` to advance it.
10
+ * trigger runs the endpoint on a cadence. Either way `execute` gets `trigger`
11
+ * ({@link ZiteTriggerContext}) to read the cursor and record what it handled.
12
12
  */
13
13
  export type ZiteTrigger = {
14
14
  /**
@@ -86,49 +86,47 @@ export type ZiteTriggerUnsubscribeInput = {
86
86
  };
87
87
  export type ZiteTriggerSubscribe = (input: ZiteTriggerSubscribeInput) => Promise<ZiteTriggerSubscribeResult> | ZiteTriggerSubscribeResult;
88
88
  export type ZiteTriggerUnsubscribe = (input: ZiteTriggerUnsubscribeInput) => Promise<void> | void;
89
- /**
90
- * What a poll-triggered run receives as `input.__poll`. Only on a fire from
91
- * the poll's schedule; a request call to the same endpoint carries none.
92
- */
93
- export type ZitePollInput = {
94
- /** What the previous successful run returned as `cursor`; null on the first run. */
95
- cursor: string | null;
96
- /** Ids earlier runs returned as `handled`, newest last. Skip these. */
97
- seen: string[];
98
- };
99
- /**
100
- * What a provider trigger's delivery carries as `input.__webhook`, beside the
101
- * provider's own body. `cursor` and `seen` work as they do for a poll: for a
102
- * provider whose notification only says that something changed, `subscribe`
103
- * returns a starting `cursor` and each delivery returns the next one. A
104
- * trigger that keeps a cursor gets one delivery at a time.
105
- */
106
- export type ZiteTriggerDelivery = {
89
+ /** Where a provider delivery came from; app event triggers get only this. */
90
+ export type ZiteTriggerDeliveryInfo = {
91
+ /** The delivery's headers, lowercased. */
107
92
  headers: Record<string, string>;
108
93
  /** The provider's id for this delivery; redeliveries share it. */
109
94
  deliveryId?: string;
110
95
  provider?: string;
111
- /** What `subscribe` or the previous successful delivery returned as `cursor`. */
112
- cursor: string | null;
113
- /** Ids earlier deliveries returned as `handled`, newest last. Skip these. */
114
- seen: string[];
115
96
  };
116
- /** What an app event trigger's run carries as `input.__webhook`: one event, no cursor. */
117
- export type ZiteAppEventDelivery = Omit<ZiteTriggerDelivery, "cursor" | "seen">;
118
97
  /**
119
- * What a poll-triggered run, or a provider trigger's delivery, returns for
120
- * the platform to keep: its new `cursor`, and the id of every item it acted
121
- * on, so the next run's `seen` includes them even if the cursor hands them
122
- * back.
98
+ * What a poll run, or a provider trigger's delivery, gets as `trigger`. Record
99
+ * each item acted on with `handled` as you go: it is kept even if the run
100
+ * later throws, so the next run skips it. The cursor staged with `advance` is
101
+ * committed only when the run succeeds.
123
102
  */
124
- export type ZitePollResult = {
125
- cursor?: string;
126
- handled?: string[];
103
+ export type ZiteTriggerRun = {
104
+ /** The last committed cursor: what `subscribe` returned, or the last successful run's `advance`. Null when there is none yet. */
105
+ cursor: string | null;
106
+ /** Whether an earlier run, or this one, recorded `id` as handled. */
107
+ seen(id: string): boolean;
108
+ /** Record an item as acted on (1 to 512 characters). */
109
+ handled(id: string): void;
110
+ /** Stage the next cursor (at most 4096 characters). The last call wins. */
111
+ advance(cursor: string): void;
127
112
  };
113
+ export type ZiteTriggerDeliveryRun = ZiteTriggerRun & ZiteTriggerDeliveryInfo;
114
+ /** The `trigger` param for an endpoint declaring `TTrigger`. */
115
+ export type ZiteTriggerContext<TTrigger> = TTrigger extends {
116
+ every: string;
117
+ } ? ZiteTriggerRun : TTrigger extends {
118
+ events: string[];
119
+ } ? ZiteTriggerDeliveryInfo : TTrigger extends {
120
+ integration: string;
121
+ } ? ZiteTriggerDeliveryRun : never;
128
122
  export interface ZiteRequestContext {
129
123
  user: {
130
124
  id: string;
131
125
  email: string;
126
+ roles: Array<{
127
+ id: string;
128
+ name: string;
129
+ }>;
132
130
  [key: string]: unknown;
133
131
  };
134
132
  userId?: string;
@@ -217,22 +215,12 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
217
215
  */
218
216
  validatesInput?: boolean;
219
217
  execute: (params: {
220
- input: TTrigger extends {
221
- every: string;
222
- } ? TInput & {
223
- __poll?: ZitePollInput;
224
- } : TTrigger extends {
225
- events: string[];
226
- } ? TInput & {
227
- __webhook?: ZiteAppEventDelivery;
228
- } : TTrigger extends {
229
- integration: string;
230
- } ? TInput & {
231
- __webhook?: ZiteTriggerDelivery;
232
- } : TInput;
218
+ input: TInput;
233
219
  context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : TTrigger extends ZiteTrigger ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;
234
220
  } & (TStream extends true ? {
235
221
  stream: ZiteStreamInterface;
222
+ } : {}) & (TTrigger extends ZiteTrigger ? {
223
+ trigger?: ZiteTriggerContext<TTrigger>;
236
224
  } : {})) => Promise<TOutput> | TOutput;
237
225
  }
238
226
  /**
@@ -69,6 +69,10 @@ export function createEndpoint(config) {
69
69
  // request callers doesn't describe them.
70
70
  if (isPlatformTriggeredInput(params.input))
71
71
  return execute(params);
72
+ // A trigger's run carries the provider's body, not a request's input.
73
+ if (params.trigger !== undefined) {
74
+ return execute(params);
75
+ }
72
76
  // Same, for a fire with no input object to hold the sentinel: the runner
73
77
  // only merges `__cron` into a plain object.
74
78
  if (firesWithoutARequest && params.input === undefined) {
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { z } from "zod";
3
3
  import { createEndpoint, ZiteError, } from "./index.js";
4
4
  import { createTableClient } from "../runtime/index.js";
5
- const requestContext = { user: { id: "u1", email: "a@b.c" } };
5
+ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
6
6
  afterEach(() => {
7
7
  vi.unstubAllGlobals();
8
8
  });
@@ -131,28 +131,51 @@ describe("platform-triggered runs", () => {
131
131
  });
132
132
  await expect(endpoint.execute({ input: undefined, context: requestContext })).rejects.toMatchObject({ code: "BAD_REQUEST" });
133
133
  });
134
- it("hands a poll its cursor and seen ids, typed", async () => {
134
+ it("hands a poll a typed trigger and skips the input schema for it", async () => {
135
+ const handled = [];
135
136
  const endpoint = createEndpoint({
136
- inputSchema: z.object({}),
137
+ inputSchema: z.object({ required: z.string() }),
137
138
  trigger: { every: "5m", integration: "stripe" },
138
- execute: async ({ input }) => {
139
- const seen = new Set(input.__poll?.seen);
140
- const items = ["a", "b"].filter((id) => !seen.has(id));
141
- return { cursor: input.__poll?.cursor ?? "start", handled: items };
139
+ execute: async ({ trigger }) => {
140
+ if (!trigger)
141
+ return { ran: 0 };
142
+ const fresh = ["a", "b"].filter((id) => !trigger.seen(id));
143
+ for (const id of fresh)
144
+ trigger.handled(id);
145
+ trigger.advance("c2");
146
+ return { ran: fresh.length, from: trigger.cursor };
142
147
  },
143
148
  });
149
+ const seen = new Set(["a"]);
144
150
  await expect(endpoint.execute({
145
- input: { __poll: { cursor: "c1", seen: ["a"] } },
151
+ input: {},
146
152
  context: { user: null },
147
- })).resolves.toEqual({ cursor: "c1", handled: ["b"] });
153
+ trigger: {
154
+ cursor: "c1",
155
+ seen: (id) => seen.has(id),
156
+ handled: (id) => void handled.push(id),
157
+ advance: () => { },
158
+ },
159
+ })).resolves.toEqual({ ran: 1, from: "c1" });
160
+ expect(handled).toEqual(["b"]);
148
161
  });
149
- it("gives __poll only to poll triggers", () => {
162
+ it("types trigger by the declared kind", () => {
150
163
  createEndpoint({
151
- inputSchema: z.object({ action: z.string() }),
152
164
  trigger: { integration: "github" },
153
- execute: async ({ input }) => {
154
- // @ts-expect-error a provider trigger's input has no __poll
155
- return input.__poll;
165
+ execute: async ({ trigger }) => trigger?.deliveryId ?? trigger?.cursor,
166
+ });
167
+ createEndpoint({
168
+ trigger: { integration: "team-slack", events: ["message.channels"] },
169
+ execute: async ({ trigger }) => {
170
+ // @ts-expect-error an app event has no cursor API
171
+ trigger?.advance("x");
172
+ return trigger?.headers;
173
+ },
174
+ });
175
+ createEndpoint({
176
+ execute: async (params) => {
177
+ // @ts-expect-error a plain endpoint gets no trigger
178
+ return params.trigger;
156
179
  },
157
180
  });
158
181
  });
@@ -182,40 +205,28 @@ describe("platform-triggered runs", () => {
182
205
  endpointId: "onPush",
183
206
  });
184
207
  });
185
- it("hands a provider trigger's delivery its cursor and seen ids, typed", async () => {
208
+ it("hands a provider delivery its body as input and the cursor API on trigger", async () => {
186
209
  const endpoint = createEndpoint({
187
- inputSchema: z.object({}),
210
+ inputSchema: z.object({ required: z.string() }),
188
211
  trigger: { integration: "googleCalendar" },
189
- execute: async ({ input }) => {
190
- const seen = new Set(input.__webhook?.seen);
191
- const changed = ["e1", "e2"].filter((id) => !seen.has(id));
192
- return { cursor: `${input.__webhook?.cursor}+1`, handled: changed };
193
- },
212
+ execute: async ({ input, trigger }) => ({
213
+ body: input,
214
+ cursor: trigger?.cursor,
215
+ delivery: trigger?.deliveryId,
216
+ }),
194
217
  });
195
218
  await expect(endpoint.execute({
196
- input: {
197
- __webhook: { headers: {}, cursor: "sync1", seen: ["e1"] },
198
- },
219
+ input: { kind: "sync" },
199
220
  context: { user: null },
200
- })).resolves.toEqual({ cursor: "sync1+1", handled: ["e2"] });
201
- });
202
- it("types an app event trigger's run as one event, with no cursor to keep", async () => {
203
- const endpoint = createEndpoint({
204
- inputSchema: z.object({ event: z.object({ text: z.string() }) }),
205
- trigger: { integration: "team-slack", events: ["message.channels"] },
206
- execute: async ({ input }) => {
207
- // @ts-expect-error an app event delivery has no cursor
208
- void input.__webhook?.cursor;
209
- return { id: input.__webhook?.deliveryId ?? null };
210
- },
211
- });
212
- await expect(endpoint.execute({
213
- input: {
214
- event: { text: "hi" },
215
- __webhook: { headers: {}, deliveryId: "slack:Ev1" },
221
+ trigger: {
222
+ cursor: "sync1",
223
+ seen: () => false,
224
+ handled: () => { },
225
+ advance: () => { },
226
+ headers: {},
227
+ deliveryId: "chan:2",
216
228
  },
217
- context: { user: null },
218
- })).resolves.toEqual({ id: "slack:Ev1" });
229
+ })).resolves.toEqual({ body: { kind: "sync" }, cursor: "sync1", delivery: "chan:2" });
219
230
  });
220
231
  it("refuses events on a trigger that also polls", () => {
221
232
  createEndpoint({
@@ -1217,13 +1217,13 @@ export function generateBackendWrapperTs(envVarNames = []) {
1217
1217
  "// Auto-generated type-narrowing wrapper. Do not edit manually.",
1218
1218
  "// Re-exports createEndpoint with context.user typed to the app User.",
1219
1219
  "",
1220
- "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStreamInterface, ZiteTrigger, ZiteTriggerDelivery, ZiteAppEventDelivery, ZitePollInput, ZiteWebhook } from 'zitejs/backend/base';",
1220
+ "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStreamInterface, ZiteTrigger, ZiteTriggerContext, ZiteWebhook } from 'zitejs/backend/base';",
1221
1221
  "import type { User } from 'zitejs/auth';",
1222
1222
  "",
1223
1223
  "export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStreamInterface, ZiteTrigger, ZiteWebhook };",
1224
1224
  // A trigger's registration exports are typed against these, and they
1225
1225
  // only reach app code through this file.
1226
- "export type { ZiteAppEventDelivery, ZitePollInput, ZitePollResult, ZiteTriggerDelivery, ZiteTriggerSubscribe, ZiteTriggerSubscribeInput, ZiteTriggerSubscribeResult, ZiteTriggerUnsubscribe, ZiteTriggerUnsubscribeInput } from 'zitejs/backend/base';",
1226
+ "export type { ZiteTriggerContext, ZiteTriggerDeliveryInfo, ZiteTriggerDeliveryRun, ZiteTriggerRun, ZiteTriggerSubscribe, ZiteTriggerSubscribeInput, ZiteTriggerSubscribeResult, ZiteTriggerUnsubscribe, ZiteTriggerUnsubscribeInput } from 'zitejs/backend/base';",
1227
1227
  // The pre-monorepo SDK put this in scope for every endpoint, so migrated
1228
1228
  // code can name it. `createEndpoint` infers the same thing without it.
1229
1229
  "export type InferSchemaType<T> = T extends { _output: infer U } ? U : T;",
@@ -1287,7 +1287,8 @@ export function generateBackendWrapperTs(envVarNames = []) {
1287
1287
  " User,",
1288
1288
  " 'name' | 'emailVerified' | 'image' | 'createdAt' | 'updatedAt'",
1289
1289
  "> &",
1290
- " Pick<User, 'id' | 'email'>;",
1290
+ " Pick<User, 'id' | 'email'> &",
1291
+ " Pick<_ZiteRequestContext['user'], 'roles'>;",
1291
1292
  "",
1292
1293
  'export interface ZiteRequestContext extends Omit<_ZiteRequestContext, "user"> {',
1293
1294
  " user: ZiteEndpointUser;",
@@ -1314,11 +1315,10 @@ export function generateBackendWrapperTs(envVarNames = []) {
1314
1315
  " trigger?: TTrigger;",
1315
1316
  " execute: (",
1316
1317
  " params: {",
1317
- // Mirrors the base module: a poll's run carries `__poll`, a provider
1318
- // trigger's delivery `__webhook`.
1319
- " input: TTrigger extends { every: string } ? TInput & { __poll?: ZitePollInput } : TTrigger extends { events: string[] } ? TInput & { __webhook?: ZiteAppEventDelivery } : TTrigger extends { integration: string } ? TInput & { __webhook?: ZiteTriggerDelivery } : TInput;",
1318
+ " input: TInput;",
1320
1319
  " context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : TTrigger extends ZiteTrigger ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
1321
- " } & (TStream extends true ? { stream: ZiteStreamInterface } : {}),",
1320
+ // Mirrors the base module: a trigger's run gets `trigger`.
1321
+ " } & (TStream extends true ? { stream: ZiteStreamInterface } : {}) & (TTrigger extends ZiteTrigger ? { trigger?: ZiteTriggerContext<TTrigger> } : {}),",
1322
1322
  " ) => Promise<TOutput> | TOutput;",
1323
1323
  "}",
1324
1324
  "",
@@ -1328,6 +1328,14 @@ export function generateBackendWrapperTs(envVarNames = []) {
1328
1328
  " return config;",
1329
1329
  "}",
1330
1330
  "",
1331
+ "/**",
1332
+ " * Checks if a user has a specific role. Returns true if the user has the role, false",
1333
+ " * otherwise. If the user is not logged in, returns false.",
1334
+ " */",
1335
+ "export const hasRole = (context: ZiteRequestContext, roleId: string) => {",
1336
+ " return context.user?.roles?.some((r) => r.id === roleId);",
1337
+ "};",
1338
+ "",
1331
1339
  // Declared in the wrapper (not a standalone .d.ts) so the merge is
1332
1340
  // guaranteed to be in the program — every endpoint imports this file,
1333
1341
  // while tsconfig `include` does not reliably pick up dot-directories.
@@ -385,9 +385,9 @@ describe('generateBackendWrapperTs', () => {
385
385
  it('passes the trigger registration types and trigger inputs through to app code', () => {
386
386
  expect(output).toContain('ZiteTriggerSubscribe, ZiteTriggerSubscribeInput');
387
387
  expect(output).toContain('ZiteTriggerUnsubscribe, ZiteTriggerUnsubscribeInput');
388
- expect(output).toContain("TTrigger extends { every: string } ? TInput & { __poll?: ZitePollInput }");
389
- expect(output).toContain('TInput & { __webhook?: ZiteTriggerDelivery }');
390
- expect(output).toContain("TTrigger extends { events: string[] } ? TInput & { __webhook?: ZiteAppEventDelivery }");
388
+ expect(output).toContain('ZiteTriggerContext, ZiteTriggerDeliveryInfo, ZiteTriggerDeliveryRun, ZiteTriggerRun');
389
+ expect(output).toContain('(TTrigger extends ZiteTrigger ? { trigger?: ZiteTriggerContext<TTrigger> } : {})');
390
+ expect(output).not.toContain('__poll');
391
391
  expect(output).not.toContain('src/triggers/');
392
392
  });
393
393
  it('exports ZiteError', () => {
@@ -5,6 +5,41 @@ import { walk } from "estree-walker";
5
5
  import { domTagNames } from "./domTagNames.js";
6
6
  const validExtensions = new Set([".jsx", ".tsx"]);
7
7
  const fragmentNames = new Set(["Fragment", "React.Fragment"]);
8
+ // react-three-fiber reads a dashed prop as a path into the three.js object, so
9
+ // any data-zite-* prop that reaches one throws. Components from the three
10
+ // ecosystem (drei, react-spring's `animated`) spread their props onto three
11
+ // objects, and anything rendered in a scene may be a three object too. So a
12
+ // component is only tagged where it is known to render DOM: under a DOM
13
+ // element or drei's <Html>. In files that import the three ecosystem, a JSX
14
+ // tree's root is not assumed to be DOM, since scene components live there.
15
+ const threeIntrinsicDomNames = new Set(["audio", "line", "path", "source"]);
16
+ function isThreeModule(source) {
17
+ return (source.startsWith("@react-three/") || source === "@react-spring/three");
18
+ }
19
+ /** Local binding name -> imported name ("*" for a namespace import). */
20
+ function getThreeImports(ast) {
21
+ const imports = new Map();
22
+ for (const statement of ast.program.body) {
23
+ if (statement.type === "ImportDeclaration" &&
24
+ isThreeModule(statement.source.value)) {
25
+ for (const specifier of statement.specifiers) {
26
+ imports.set(specifier.local.name, specifier.type === "ImportSpecifier"
27
+ ? specifier.imported.name ?? specifier.imported.value
28
+ : "*");
29
+ }
30
+ }
31
+ }
32
+ return imports;
33
+ }
34
+ /** The three-ecosystem name an element refers to, if it comes from one. */
35
+ function getThreeName(componentName, threeImports) {
36
+ const segments = componentName.split(".");
37
+ const imported = threeImports.get(segments[0]);
38
+ if (imported === undefined) {
39
+ return null;
40
+ }
41
+ return imported === "*" ? segments[segments.length - 1] : imported;
42
+ }
8
43
  function getComponentName(elementName) {
9
44
  if (elementName.type === "JSXIdentifier") {
10
45
  return elementName.name;
@@ -45,18 +80,52 @@ export function ziteId() {
45
80
  plugins: ["jsx", "typescript"],
46
81
  });
47
82
  const magicString = new MagicString(code);
83
+ const threeImports = getThreeImports(ast);
84
+ const rootContext = threeImports.size > 0 ? "unknown" : "dom";
85
+ // The render context each open JSXElement gives its children.
86
+ const contextStack = [];
48
87
  walk(ast, {
88
+ leave(_node) {
89
+ if (_node.type === "JSXElement") {
90
+ contextStack.pop();
91
+ }
92
+ },
49
93
  enter(_node) {
50
94
  const node = _node;
51
95
  if (node.type === "JSXElement") {
52
96
  const openingElement = node.openingElement;
53
97
  const children = node.children;
98
+ const context = contextStack[contextStack.length - 1] ?? rootContext;
54
99
  const componentName = getComponentName(openingElement.name);
55
- if (!componentName) {
100
+ const kind = componentName ? classifyElement(componentName) : null;
101
+ const threeName = componentName
102
+ ? getThreeName(componentName, threeImports)
103
+ : null;
104
+ let childContext = context;
105
+ if (threeName === "Html") {
106
+ childContext = "dom";
107
+ }
108
+ else if (threeName !== null) {
109
+ childContext = "scene";
110
+ }
111
+ else if (kind === "dom") {
112
+ childContext = "dom";
113
+ }
114
+ else if (componentName &&
115
+ kind === null &&
116
+ !fragmentNames.has(componentName)) {
117
+ childContext = "scene";
118
+ }
119
+ contextStack.push(childContext);
120
+ if (!componentName || !kind) {
56
121
  return;
57
122
  }
58
- const kind = classifyElement(componentName);
59
- if (!kind) {
123
+ const isTagged = kind === "component"
124
+ ? threeName === "Canvas" ||
125
+ (threeName === null && context === "dom")
126
+ : !threeIntrinsicDomNames.has(componentName) ||
127
+ context === "dom";
128
+ if (!isTagged) {
60
129
  return;
61
130
  }
62
131
  const line = openingElement.loc?.start?.line ?? 0;
@@ -37,9 +37,65 @@ describe('ziteId', () => {
37
37
  const source = 'const a = <group><mesh position-x={1} /><ambientLight intensity={0.5} /></group>;';
38
38
  expect(await transform(source)).toBe(source);
39
39
  });
40
- it('tags DOM elements nested inside non-DOM intrinsics', async () => {
40
+ it('tags DOM but not components nested inside non-DOM intrinsics', async () => {
41
41
  const code = await transform('const a = <group><Html><div /></Html></group>;');
42
- expect(code).toBe('const a = <group><Html data-zite-id="src/App.tsx|1|17" data-zite-component="Html"><div data-zite-id="src/App.tsx|1|23" /></Html></group>;');
42
+ expect(code).toBe('const a = <group><Html><div data-zite-id="src/App.tsx|1|23" /></Html></group>;');
43
+ });
44
+ describe('react-three scenes', () => {
45
+ const scene = (jsx) => transform("import { Canvas } from '@react-three/fiber';\n" +
46
+ "import { Html, Text } from '@react-three/drei';\n" +
47
+ "import { Button } from './Button';\n" +
48
+ `const a = ${jsx};`);
49
+ it('skips three components, nested components and three.js intrinsics', async () => {
50
+ const code = await scene('<Canvas><Text>Hi</Text><Button /><line /></Canvas>');
51
+ expect(code).toContain('<Text>Hi</Text>');
52
+ expect(code).toContain('<Button />');
53
+ expect(code).toContain('<line />');
54
+ });
55
+ it('tags the Canvas container and UI next to it', async () => {
56
+ const code = await scene('<div><Canvas /><Button>Start</Button></div>');
57
+ expect(code).toMatch(/<Canvas data-zite-id="[^"]+" data-zite-component="Canvas" \/>/);
58
+ expect(code).toMatch(/<Button data-zite-id="[^"]+" data-zite-component="Button" data-zite-editable="true">/);
59
+ });
60
+ it('tags DOM and components inside an <Html> overlay again', async () => {
61
+ const code = await scene('<Canvas><Html><Button /><div /></Html></Canvas>');
62
+ expect(code).toContain('<Html>');
63
+ expect(code).toMatch(/<Button data-zite-id="[^"]+" data-zite-component="Button" \/>/);
64
+ expect(code).toMatch(/<div data-zite-id="[^"]+" \/>/);
65
+ });
66
+ it('skips the root of a scene component in the same file', async () => {
67
+ const code = await scene('<><Button position={[0, 0, 0]} /></>');
68
+ expect(code).toContain('<Button position={[0, 0, 0]} />');
69
+ });
70
+ it('skips children of three wrapper components', async () => {
71
+ const code = await transform("import { Float } from '@react-three/drei';\nimport { Box } from './Box';\nconst a = <Float><Box /></Float>;");
72
+ expect(code).toContain('<Float><Box /></Float>');
73
+ });
74
+ it('recognises aliased and namespaced three imports', async () => {
75
+ const code = await transform("import { Canvas as C } from '@react-three/fiber';\nimport * as Drei from '@react-three/drei';\n" +
76
+ "import { Box, Card } from './ui';\nconst a = <C><Box /><Drei.Html><Card /></Drei.Html></C>;");
77
+ expect(code).toMatch(/<C data-zite-id="[^"]+" data-zite-component="C">/);
78
+ expect(code).toContain('<Box />');
79
+ expect(code).toContain('<Drei.Html>');
80
+ expect(code).toMatch(/<Card data-zite-id="[^"]+" data-zite-component="Card" \/>/);
81
+ });
82
+ it('tags SVG lines and paths in DOM next to a scene', async () => {
83
+ const code = await scene('<div><svg><line /><path /></svg></div>');
84
+ expect(code).toMatch(/<line data-zite-id="[^"]+" \/>/);
85
+ expect(code).toMatch(/<path data-zite-id="[^"]+" \/>/);
86
+ });
87
+ it("skips react-spring's animated three elements", async () => {
88
+ const code = await transform("import { animated } from '@react-spring/three';\nconst a = <animated.mesh />;");
89
+ expect(code).toContain('<animated.mesh />');
90
+ });
91
+ it('skips components nested in a scene element in a file without three imports', async () => {
92
+ const code = await transform("import { Tree } from './Tree';\nconst a = <group><Tree /></group>;");
93
+ expect(code).toContain('<Tree />');
94
+ });
95
+ });
96
+ it('keeps tagging components in files that do not import react-three', async () => {
97
+ const code = await transform("import { Card } from './Card';\nconst a = <Card />;");
98
+ expect(code).toContain('<Card data-zite-id="src/App.tsx|2|10" data-zite-component="Card" />');
43
99
  });
44
100
  it('leaves fragments untouched', async () => {
45
101
  const source = 'const a = <Fragment><React.Fragment key="a" /></Fragment>;';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.9.133",
3
+ "version": "0.9.135",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "sideEffects": false,