@shware/workflow 0.4.0 → 2.0.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 (162) hide show
  1. package/README.md +90 -49
  2. package/dist/client.d.cts +1 -1
  3. package/dist/client.d.mts +1 -1
  4. package/dist/cloudflare/bindings.cjs.map +1 -1
  5. package/dist/cloudflare/bindings.d.cts +1 -1
  6. package/dist/cloudflare/bindings.d.mts +1 -1
  7. package/dist/cloudflare/bindings.mjs.map +1 -1
  8. package/dist/cloudflare/channels.cjs +13 -18
  9. package/dist/cloudflare/channels.cjs.map +1 -1
  10. package/dist/cloudflare/channels.d.cts +3 -3
  11. package/dist/cloudflare/channels.d.cts.map +1 -1
  12. package/dist/cloudflare/channels.d.mts +3 -3
  13. package/dist/cloudflare/channels.d.mts.map +1 -1
  14. package/dist/cloudflare/channels.mjs +14 -19
  15. package/dist/cloudflare/channels.mjs.map +1 -1
  16. package/dist/cloudflare/index.d.cts +4 -4
  17. package/dist/cloudflare/index.d.mts +4 -4
  18. package/dist/cloudflare/router.d.cts +1 -1
  19. package/dist/cloudflare/router.d.mts +1 -1
  20. package/dist/cloudflare/runner.d.cts +2 -2
  21. package/dist/cloudflare/runner.d.mts +2 -2
  22. package/dist/cloudflare/senders.cjs +15 -8
  23. package/dist/cloudflare/senders.cjs.map +1 -1
  24. package/dist/cloudflare/senders.d.cts +15 -11
  25. package/dist/cloudflare/senders.d.cts.map +1 -1
  26. package/dist/cloudflare/senders.d.mts +15 -11
  27. package/dist/cloudflare/senders.d.mts.map +1 -1
  28. package/dist/cloudflare/senders.mjs +15 -8
  29. package/dist/cloudflare/senders.mjs.map +1 -1
  30. package/dist/cloudflare/worker.cjs +7 -4
  31. package/dist/cloudflare/worker.cjs.map +1 -1
  32. package/dist/cloudflare/worker.d.cts +2 -2
  33. package/dist/cloudflare/worker.d.cts.map +1 -1
  34. package/dist/cloudflare/worker.d.mts +2 -2
  35. package/dist/cloudflare/worker.d.mts.map +1 -1
  36. package/dist/cloudflare/worker.mjs +8 -5
  37. package/dist/cloudflare/worker.mjs.map +1 -1
  38. package/dist/config.cjs +66 -42
  39. package/dist/config.cjs.map +1 -1
  40. package/dist/config.d.cts +26 -45
  41. package/dist/config.d.cts.map +1 -1
  42. package/dist/config.d.mts +26 -45
  43. package/dist/config.d.mts.map +1 -1
  44. package/dist/config.mjs +67 -42
  45. package/dist/config.mjs.map +1 -1
  46. package/dist/dsl/action.cjs +1 -1
  47. package/dist/dsl/action.cjs.map +1 -1
  48. package/dist/dsl/action.d.cts +1 -1
  49. package/dist/dsl/action.d.mts +1 -1
  50. package/dist/dsl/action.mjs +1 -1
  51. package/dist/dsl/action.mjs.map +1 -1
  52. package/dist/dsl/bundle.cjs +3 -2
  53. package/dist/dsl/bundle.cjs.map +1 -1
  54. package/dist/dsl/bundle.d.cts +3 -2
  55. package/dist/dsl/bundle.d.cts.map +1 -1
  56. package/dist/dsl/bundle.d.mts +3 -2
  57. package/dist/dsl/bundle.d.mts.map +1 -1
  58. package/dist/dsl/bundle.mjs +3 -2
  59. package/dist/dsl/bundle.mjs.map +1 -1
  60. package/dist/dsl/condition.cjs +1 -1
  61. package/dist/dsl/condition.cjs.map +1 -1
  62. package/dist/dsl/condition.d.cts +1 -1
  63. package/dist/dsl/condition.d.mts +1 -1
  64. package/dist/dsl/condition.mjs +1 -1
  65. package/dist/dsl/condition.mjs.map +1 -1
  66. package/dist/dsl/flow.cjs +45 -17
  67. package/dist/dsl/flow.cjs.map +1 -1
  68. package/dist/dsl/flow.d.cts +30 -17
  69. package/dist/dsl/flow.d.cts.map +1 -1
  70. package/dist/dsl/flow.d.mts +30 -17
  71. package/dist/dsl/flow.d.mts.map +1 -1
  72. package/dist/dsl/flow.mjs +45 -18
  73. package/dist/dsl/flow.mjs.map +1 -1
  74. package/dist/dsl/index.cjs +0 -1
  75. package/dist/dsl/index.d.cts +2 -2
  76. package/dist/dsl/index.d.mts +2 -2
  77. package/dist/dsl/index.mjs +2 -2
  78. package/dist/dsl/template.cjs +2 -47
  79. package/dist/dsl/template.cjs.map +1 -1
  80. package/dist/dsl/template.d.cts +4 -54
  81. package/dist/dsl/template.d.cts.map +1 -1
  82. package/dist/dsl/template.d.mts +4 -54
  83. package/dist/dsl/template.d.mts.map +1 -1
  84. package/dist/dsl/template.mjs +3 -47
  85. package/dist/dsl/template.mjs.map +1 -1
  86. package/dist/dsl/workflow.cjs +1 -1
  87. package/dist/dsl/workflow.cjs.map +1 -1
  88. package/dist/dsl/workflow.d.cts +1 -1
  89. package/dist/dsl/workflow.d.mts +1 -1
  90. package/dist/dsl/workflow.mjs +1 -1
  91. package/dist/dsl/workflow.mjs.map +1 -1
  92. package/dist/email-context.cjs +29 -0
  93. package/dist/email-context.cjs.map +1 -0
  94. package/dist/email-context.d.cts +11 -0
  95. package/dist/email-context.d.cts.map +1 -0
  96. package/dist/email-context.d.mts +11 -0
  97. package/dist/email-context.d.mts.map +1 -0
  98. package/dist/email-context.mjs +27 -0
  99. package/dist/email-context.mjs.map +1 -0
  100. package/dist/email.cjs +72 -0
  101. package/dist/email.cjs.map +1 -0
  102. package/dist/email.d.cts +63 -0
  103. package/dist/email.d.cts.map +1 -0
  104. package/dist/email.d.mts +63 -0
  105. package/dist/email.d.mts.map +1 -0
  106. package/dist/email.mjs +65 -0
  107. package/dist/email.mjs.map +1 -0
  108. package/dist/engine/index.d.cts +2 -2
  109. package/dist/engine/index.d.mts +2 -2
  110. package/dist/engine/ports.d.cts +7 -2
  111. package/dist/engine/ports.d.cts.map +1 -1
  112. package/dist/engine/ports.d.mts +7 -2
  113. package/dist/engine/ports.d.mts.map +1 -1
  114. package/dist/index.cjs +13 -3
  115. package/dist/index.d.cts +5 -4
  116. package/dist/index.d.mts +5 -4
  117. package/dist/index.mjs +5 -4
  118. package/dist/push.cjs +3 -5
  119. package/dist/push.cjs.map +1 -1
  120. package/dist/push.d.cts +2 -2
  121. package/dist/push.d.cts.map +1 -1
  122. package/dist/push.d.mts +2 -2
  123. package/dist/push.d.mts.map +1 -1
  124. package/dist/push.mjs +4 -6
  125. package/dist/push.mjs.map +1 -1
  126. package/dist/react-email.cjs +16 -8
  127. package/dist/react-email.cjs.map +1 -1
  128. package/dist/react-email.d.cts +2 -1
  129. package/dist/react-email.d.cts.map +1 -1
  130. package/dist/react-email.d.mts +2 -1
  131. package/dist/react-email.d.mts.map +1 -1
  132. package/dist/react-email.mjs +17 -9
  133. package/dist/react-email.mjs.map +1 -1
  134. package/dist/reporting/classify.cjs.map +1 -1
  135. package/dist/reporting/classify.d.cts +1 -1
  136. package/dist/reporting/classify.d.mts +1 -1
  137. package/dist/reporting/classify.mjs.map +1 -1
  138. package/dist/reporting/observer.cjs +5 -4
  139. package/dist/reporting/observer.cjs.map +1 -1
  140. package/dist/reporting/observer.d.cts +3 -2
  141. package/dist/reporting/observer.d.cts.map +1 -1
  142. package/dist/reporting/observer.d.mts +3 -2
  143. package/dist/reporting/observer.d.mts.map +1 -1
  144. package/dist/reporting/observer.mjs +5 -4
  145. package/dist/reporting/observer.mjs.map +1 -1
  146. package/dist/reporting/tracking.cjs +3 -12
  147. package/dist/reporting/tracking.cjs.map +1 -1
  148. package/dist/reporting/tracking.d.cts +18 -7
  149. package/dist/reporting/tracking.d.cts.map +1 -1
  150. package/dist/reporting/tracking.d.mts +18 -7
  151. package/dist/reporting/tracking.d.mts.map +1 -1
  152. package/dist/reporting/tracking.mjs +3 -12
  153. package/dist/reporting/tracking.mjs.map +1 -1
  154. package/dist/templates.cjs +90 -30
  155. package/dist/templates.cjs.map +1 -1
  156. package/dist/templates.d.cts +70 -13
  157. package/dist/templates.d.cts.map +1 -1
  158. package/dist/templates.d.mts +70 -13
  159. package/dist/templates.d.mts.map +1 -1
  160. package/dist/templates.mjs +87 -31
  161. package/dist/templates.mjs.map +1 -1
  162. package/package.json +11 -1
@@ -1,76 +1,26 @@
1
- import { DiscordTemplate, EmailTemplate, PropsSchema, PushTemplate, SlackTemplate } from "../templates.mjs";
1
+ import { DiscordTemplate, EmailTemplate, PushTemplate, SlackTemplate } from "../templates.mjs";
2
2
  import * as core from "zod/v4/core";
3
3
  //#region src/dsl/template.d.ts
4
4
  export type Channel = 'email' | 'sms' | 'push' | 'in_app' | 'slack' | 'discord' | 'survey';
5
5
  export type EmptyProps = Record<never, never>;
6
- /**
7
- * Template reference: a top-level named asset, reusable across workflows and
8
- * versioned on its own. The props shape is declared here and checked at use.
9
- */
6
+ /** A template by key, its props typed by hand: for the channels without template definitions. */
10
7
  export interface TemplateRef<C extends Channel = Channel, P extends object = EmptyProps> {
11
8
  readonly channel: C;
12
9
  readonly key: string;
13
10
  readonly __props?: P;
14
11
  }
15
12
  export interface TemplateFactory {
16
- email<P extends object = EmptyProps>(key: string): TemplateRef<'email', P>;
17
13
  sms<P extends object = EmptyProps>(key: string): TemplateRef<'sms', P>;
18
- push<P extends object = EmptyProps>(key: string): TemplateRef<'push', P>;
19
14
  inApp<P extends object = EmptyProps>(key: string): TemplateRef<'in_app', P>;
20
- slack<P extends object = EmptyProps>(key: string): TemplateRef<'slack', P>;
21
- discord<P extends object = EmptyProps>(key: string): TemplateRef<'discord', P>;
22
15
  survey<P extends object = EmptyProps>(key: string): TemplateRef<'survey', P>;
23
16
  }
24
17
  /**
25
- * Unbound template references, props typed by hand. Email and push refs come
26
- * from the registries (`templates<Emails, Pushes>()`), which type both key and
27
- * props; this is the low-level form for channels without a registry (sms,
28
- * slack, …) and for tests.
18
+ * Template references for the channels an app-supplied sender renders:
29
19
  *
30
- * const alert = template.slack<{ plan: string }>('cs_churn_alert');
20
+ * const code = template.sms<{ code: string }>('login_code');
31
21
  */
32
22
  export declare const template: TemplateFactory;
33
23
  /** Template props: the input type of the template's Zod `props` schema — what the DSL passes. */
34
24
  export type PropsOf<M> = M extends EmailTemplate<infer S> | PushTemplate<infer S> | DiscordTemplate<infer S, never> | SlackTemplate<infer S, never> ? core.input<S> extends object ? core.input<S> : EmptyProps : never;
35
- /**
36
- * The channel registries: what the config registers as `templates` and what
37
- * `templates()` derives the references from — one type for both. Leave it
38
- * inferred, or check it with `satisfies TemplateRegistry`: annotating a value
39
- * as the bare type widens the keys to `string`.
40
- */
41
- export interface TemplateRegistry<E extends Readonly<Record<string, EmailTemplate>> = Readonly<Record<string, EmailTemplate>>, P extends Readonly<Record<string, PushTemplate>> = Readonly<Record<string, PushTemplate>>> {
42
- readonly email?: E;
43
- readonly push?: P;
44
- /** Discord webhook templates, each typed by the host's Env its `webhook` reads. */
45
- readonly discord?: Readonly<Record<string, DiscordTemplate<PropsSchema, never>>>;
46
- readonly slack?: Readonly<Record<string, SlackTemplate<PropsSchema, never>>>;
47
- }
48
- /** One channel's registry in `R`, without the `undefined` of an optional field; `never` when absent. */
49
- type ChannelRegistry<R, K extends 'email' | 'push' | 'discord' | 'slack'> = K extends keyof R ? Exclude<R[K], undefined> : never;
50
- /** One reference per registered key, each typed by its own template's `props`. */
51
- export type TemplateRefTable<C extends Channel, R> = { readonly [K in keyof R & string]: TemplateRef<C, PropsOf<R[K]>>; };
52
- /** `t.email.<key>` / `t.push.<key>`; a channel the registry leaves out is an empty table. */
53
- export interface TemplateRefs<R extends TemplateRegistry> {
54
- readonly email: [ChannelRegistry<R, 'email'>] extends [never] ? EmptyProps : TemplateRefTable<'email', ChannelRegistry<R, 'email'>>;
55
- readonly push: [ChannelRegistry<R, 'push'>] extends [never] ? EmptyProps : TemplateRefTable<'push', ChannelRegistry<R, 'push'>>;
56
- readonly discord: [ChannelRegistry<R, 'discord'>] extends [never] ? EmptyProps : TemplateRefTable<'discord', ChannelRegistry<R, 'discord'>>;
57
- readonly slack: [ChannelRegistry<R, 'slack'>] extends [never] ? EmptyProps : TemplateRefTable<'slack', ChannelRegistry<R, 'slack'>>;
58
- }
59
- /**
60
- * Template references derived from the registries themselves: **the
61
- * registries type the keys** — an unregistered key fails to compile, and a
62
- * push template is not accepted where an email is — and each template's
63
- * `props` schema types its use sites. Templates are registered once; the
64
- * config takes the registries, the workflows take the references:
65
- *
66
- * export const registry = { email, push };
67
- * export const t = templates(registry);
68
- * flow.email(t.email.welcome, { name: u.name });
69
- *
70
- * Each reference is a plain `{ channel, key }` built from the registry's own
71
- * keys; no template is rendered, and the registries are left untouched.
72
- */
73
- export declare function templates<const R extends TemplateRegistry>(registry: R): TemplateRefs<R>;
74
25
  //#endregion
75
- export {};
76
26
  //# sourceMappingURL=template.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"template.d.mts","names":[],"sources":["../../src/dsl/template.ts"],"mappings":";;;YAyBY;YAEA,aAAa;;;;;iBAMR,YAAY,UAAU,UAAU,SAAS,mBAAmB;WAClE,SAAS;WACT;WACA,UAAU;;iBAGJ;EACf,MAAM,mBAAmB,YAAY,cAAc,qBAAqB;EACxE,IAAI,mBAAmB,YAAY,cAAc,mBAAmB;EACpE,KAAK,mBAAmB,YAAY,cAAc,oBAAoB;EACtE,MAAM,mBAAmB,YAAY,cAAc,sBAAsB;EACzE,MAAM,mBAAmB,YAAY,cAAc,qBAAqB;EACxE,QAAQ,mBAAmB,YAAY,cAAc,uBAAuB;EAC5E,OAAO,mBAAmB,YAAY,cAAc,sBAAsB;;;;;;;;;;qBAe/D,UAAU;;YAaX,QAAQ,KAAK,UACrB,oBAAoB,KACpB,mBAAmB,KACnB,sBAAsB,YACtB,oBAAoB,YACpB,KAAK,MAAM,oBACT,KAAK,MAAM,KACX;;;;;;;iBASW,iBACf,UAAU,SAAS,eAAe,kBAAkB,SAAS,eAAe,iBAC5E,UAAU,SAAS,eAAe,iBAAiB,SAAS,eAAe;WAElE,QAAQ;WACR,OAAO;;WAEP,UAAU,SAAS,eAAe,gBAAgB;WAClD,QAAQ,SAAS,eAAe,cAAc;;;KAIpD,gBAAgB,GAAG,oDAAoD,gBAAgB,IACxF,QAAQ,EAAE;;YAIF,iBAAiB,UAAU,SAAS,iBACpC,WAAW,aAAa,YAAY,GAAG,QAAQ,EAAE;;iBAI5C,aAAa,UAAU;WAC7B,QAAQ,gBAAgB,+BAC7B,aACA,0BAA0B,gBAAgB;WACrC,OAAO,gBAAgB,8BAC5B,aACA,yBAAyB,gBAAgB;WACpC,UAAU,gBAAgB,iCAC/B,aACA,4BAA4B,gBAAgB;WACvC,QAAQ,gBAAgB,+BAC7B,aACA,0BAA0B,gBAAgB;;;;;;;;;;;;;;;;wBAiBhC,gBAAgB,UAAU,kBAAkB,UAAU,IAAI,aAAa"}
1
+ {"version":3,"file":"template.d.mts","names":[],"sources":["../../src/dsl/template.ts"],"mappings":";;;YAUY;YAEA,aAAa;;iBAGR,YAAY,UAAU,UAAU,SAAS,mBAAmB;WAClE,SAAS;WACT;WACA,UAAU;;iBAGJ;EACf,IAAI,mBAAmB,YAAY,cAAc,mBAAmB;EACpE,MAAM,mBAAmB,YAAY,cAAc,sBAAsB;EACzE,OAAO,mBAAmB,YAAY,cAAc,sBAAsB;;;;;;;qBAY/D,UAAU;;YAOX,QAAQ,KAAK,UACrB,oBAAoB,KACpB,mBAAmB,KACnB,sBAAsB,YACtB,oBAAoB,YACpB,KAAK,MAAM,oBACT,KAAK,MAAM,KACX"}
@@ -1,63 +1,19 @@
1
- import { isDiscordTemplate, isEmailTemplate, isPushTemplate, isSlackTemplate } from "../templates.mjs";
2
1
  //#region src/dsl/template.ts
3
2
  const makeTemplate = (channel) => (key) => ({
4
3
  channel,
5
4
  key
6
5
  });
7
6
  /**
8
- * Unbound template references, props typed by hand. Email and push refs come
9
- * from the registries (`templates<Emails, Pushes>()`), which type both key and
10
- * props; this is the low-level form for channels without a registry (sms,
11
- * slack, …) and for tests.
7
+ * Template references for the channels an app-supplied sender renders:
12
8
  *
13
- * const alert = template.slack<{ plan: string }>('cs_churn_alert');
9
+ * const code = template.sms<{ code: string }>('login_code');
14
10
  */
15
11
  const template = {
16
- email: makeTemplate("email"),
17
12
  sms: makeTemplate("sms"),
18
- push: makeTemplate("push"),
19
13
  inApp: makeTemplate("in_app"),
20
- slack: makeTemplate("slack"),
21
- discord: makeTemplate("discord"),
22
14
  survey: makeTemplate("survey")
23
15
  };
24
- /**
25
- * Template references derived from the registries themselves: **the
26
- * registries type the keys** — an unregistered key fails to compile, and a
27
- * push template is not accepted where an email is — and each template's
28
- * `props` schema types its use sites. Templates are registered once; the
29
- * config takes the registries, the workflows take the references:
30
- *
31
- * export const registry = { email, push };
32
- * export const t = templates(registry);
33
- * flow.email(t.email.welcome, { name: u.name });
34
- *
35
- * Each reference is a plain `{ channel, key }` built from the registry's own
36
- * keys; no template is rendered, and the registries are left untouched.
37
- */
38
- function templates(registry) {
39
- return {
40
- email: refTable("email", registry.email, isEmailTemplate),
41
- push: refTable("push", registry.push, isPushTemplate),
42
- discord: refTable("discord", registry.discord, isDiscordTemplate),
43
- slack: refTable("slack", registry.slack, isSlackTemplate)
44
- };
45
- }
46
- const ARTICLE = {
47
- email: "an email",
48
- push: "a push",
49
- discord: "a Discord",
50
- slack: "a Slack"
51
- };
52
- function refTable(channel, entries, is) {
53
- const table = Object.create(null);
54
- for (const [key, entry] of Object.entries(entries ?? {})) {
55
- if (!is(entry)) throw new TypeError(`templates(): '${key}' in the ${channel} registry is not ${ARTICLE[channel]} template`);
56
- table[key] = template[channel](key);
57
- }
58
- return Object.freeze(table);
59
- }
60
16
  //#endregion
61
- export { template, templates };
17
+ export { template };
62
18
 
63
19
  //# sourceMappingURL=template.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"template.mjs","names":[],"sources":["../../src/dsl/template.ts"],"sourcesContent":["/**\n * Templates — named message-content assets. The DSL only ever holds a\n * reference ({ channel, key, phantom props }); the content itself (react-email\n * components) lives in the app and meets the reference again in the studio and\n * at send time via the registry.\n *\n * Type technique: `templates(registry)` derives the references from the\n * registries so **the registries type the keys** — an unregistered key fails\n * to compile — while `PropsOf<M>` reads the props contract off each template's\n * Zod schema, so it is written exactly once.\n */\n\nimport type * as core from 'zod/v4/core';\nimport {\n type DiscordTemplate,\n type EmailTemplate,\n type PropsSchema,\n type PushTemplate,\n type SlackTemplate,\n isDiscordTemplate,\n isEmailTemplate,\n isPushTemplate,\n isSlackTemplate,\n} from '../templates';\n\nexport type Channel = 'email' | 'sms' | 'push' | 'in_app' | 'slack' | 'discord' | 'survey';\n\nexport type EmptyProps = Record<never, never>;\n\n/**\n * Template reference: a top-level named asset, reusable across workflows and\n * versioned on its own. The props shape is declared here and checked at use.\n */\nexport interface TemplateRef<C extends Channel = Channel, P extends object = EmptyProps> {\n readonly channel: C;\n readonly key: string;\n readonly __props?: P;\n}\n\nexport interface TemplateFactory {\n email<P extends object = EmptyProps>(key: string): TemplateRef<'email', P>;\n sms<P extends object = EmptyProps>(key: string): TemplateRef<'sms', P>;\n push<P extends object = EmptyProps>(key: string): TemplateRef<'push', P>;\n inApp<P extends object = EmptyProps>(key: string): TemplateRef<'in_app', P>;\n slack<P extends object = EmptyProps>(key: string): TemplateRef<'slack', P>;\n discord<P extends object = EmptyProps>(key: string): TemplateRef<'discord', P>;\n survey<P extends object = EmptyProps>(key: string): TemplateRef<'survey', P>;\n}\n\nconst makeTemplate =\n <C extends Channel>(channel: C) =>\n <P extends object = EmptyProps>(key: string): TemplateRef<C, P> => ({ channel, key });\n\n/**\n * Unbound template references, props typed by hand. Email and push refs come\n * from the registries (`templates<Emails, Pushes>()`), which type both key and\n * props; this is the low-level form for channels without a registry (sms,\n * slack, …) and for tests.\n *\n * const alert = template.slack<{ plan: string }>('cs_churn_alert');\n */\nexport const template: TemplateFactory = {\n email: makeTemplate('email'),\n sms: makeTemplate('sms'),\n push: makeTemplate('push'),\n inApp: makeTemplate('in_app'),\n slack: makeTemplate('slack'),\n discord: makeTemplate('discord'),\n survey: makeTemplate('survey'),\n};\n\n/* ----------------------------- Template registry ---------------------------- */\n\n/** Template props: the input type of the template's Zod `props` schema — what the DSL passes. */\nexport type PropsOf<M> = M extends\n | EmailTemplate<infer S>\n | PushTemplate<infer S>\n | DiscordTemplate<infer S, never>\n | SlackTemplate<infer S, never>\n ? core.input<S> extends object\n ? core.input<S>\n : EmptyProps\n : never;\n\n/**\n * The channel registries: what the config registers as `templates` and what\n * `templates()` derives the references from — one type for both. Leave it\n * inferred, or check it with `satisfies TemplateRegistry`: annotating a value\n * as the bare type widens the keys to `string`.\n */\nexport interface TemplateRegistry<\n E extends Readonly<Record<string, EmailTemplate>> = Readonly<Record<string, EmailTemplate>>,\n P extends Readonly<Record<string, PushTemplate>> = Readonly<Record<string, PushTemplate>>,\n> {\n readonly email?: E;\n readonly push?: P;\n /** Discord webhook templates, each typed by the host's Env its `webhook` reads. */\n readonly discord?: Readonly<Record<string, DiscordTemplate<PropsSchema, never>>>;\n readonly slack?: Readonly<Record<string, SlackTemplate<PropsSchema, never>>>;\n}\n\n/** One channel's registry in `R`, without the `undefined` of an optional field; `never` when absent. */\ntype ChannelRegistry<R, K extends 'email' | 'push' | 'discord' | 'slack'> = K extends keyof R\n ? Exclude<R[K], undefined>\n : never;\n\n/** One reference per registered key, each typed by its own template's `props`. */\nexport type TemplateRefTable<C extends Channel, R> = {\n readonly [K in keyof R & string]: TemplateRef<C, PropsOf<R[K]>>;\n};\n\n/** `t.email.<key>` / `t.push.<key>`; a channel the registry leaves out is an empty table. */\nexport interface TemplateRefs<R extends TemplateRegistry> {\n readonly email: [ChannelRegistry<R, 'email'>] extends [never]\n ? EmptyProps\n : TemplateRefTable<'email', ChannelRegistry<R, 'email'>>;\n readonly push: [ChannelRegistry<R, 'push'>] extends [never]\n ? EmptyProps\n : TemplateRefTable<'push', ChannelRegistry<R, 'push'>>;\n readonly discord: [ChannelRegistry<R, 'discord'>] extends [never]\n ? EmptyProps\n : TemplateRefTable<'discord', ChannelRegistry<R, 'discord'>>;\n readonly slack: [ChannelRegistry<R, 'slack'>] extends [never]\n ? EmptyProps\n : TemplateRefTable<'slack', ChannelRegistry<R, 'slack'>>;\n}\n\n/**\n * Template references derived from the registries themselves: **the\n * registries type the keys** — an unregistered key fails to compile, and a\n * push template is not accepted where an email is — and each template's\n * `props` schema types its use sites. Templates are registered once; the\n * config takes the registries, the workflows take the references:\n *\n * export const registry = { email, push };\n * export const t = templates(registry);\n * flow.email(t.email.welcome, { name: u.name });\n *\n * Each reference is a plain `{ channel, key }` built from the registry's own\n * keys; no template is rendered, and the registries are left untouched.\n */\nexport function templates<const R extends TemplateRegistry>(registry: R): TemplateRefs<R> {\n return {\n email: refTable('email', registry.email, isEmailTemplate),\n push: refTable('push', registry.push, isPushTemplate),\n discord: refTable('discord', registry.discord, isDiscordTemplate),\n slack: refTable('slack', registry.slack, isSlackTemplate),\n } as unknown as TemplateRefs<R>;\n}\n\nconst ARTICLE: Record<'email' | 'push' | 'discord' | 'slack', string> = {\n email: 'an email',\n push: 'a push',\n discord: 'a Discord',\n slack: 'a Slack',\n};\n\nfunction refTable(\n channel: 'email' | 'push' | 'discord' | 'slack',\n entries: Readonly<Record<string, unknown>> | undefined,\n is: (value: unknown) => boolean\n): Readonly<Record<string, TemplateRef>> {\n const table: Record<string, TemplateRef> = Object.create(null);\n for (const [key, entry] of Object.entries(entries ?? {})) {\n if (!is(entry)) {\n throw new TypeError(\n `templates(): '${key}' in the ${channel} registry is not ${ARTICLE[channel]} template`\n );\n }\n table[key] = template[channel](key);\n }\n return Object.freeze(table);\n}\n"],"mappings":";;AAiDA,MAAM,gBACgB,aACY,SAAoC;CAAE;CAAS;AAAI;;;;;;;;;AAUrF,MAAa,WAA4B;CACvC,OAAO,aAAa,OAAO;CAC3B,KAAK,aAAa,KAAK;CACvB,MAAM,aAAa,MAAM;CACzB,OAAO,aAAa,QAAQ;CAC5B,OAAO,aAAa,OAAO;CAC3B,SAAS,aAAa,SAAS;CAC/B,QAAQ,aAAa,QAAQ;AAC/B;;;;;;;;;;;;;;;AAwEA,SAAgB,UAA4C,UAA8B;CACxF,OAAO;EACL,OAAO,SAAS,SAAS,SAAS,OAAO,eAAe;EACxD,MAAM,SAAS,QAAQ,SAAS,MAAM,cAAc;EACpD,SAAS,SAAS,WAAW,SAAS,SAAS,iBAAiB;EAChE,OAAO,SAAS,SAAS,SAAS,OAAO,eAAe;CAC1D;AACF;AAEA,MAAM,UAAkE;CACtE,OAAO;CACP,MAAM;CACN,SAAS;CACT,OAAO;AACT;AAEA,SAAS,SACP,SACA,SACA,IACuC;CACvC,MAAM,QAAqC,OAAO,OAAO,IAAI;CAC7D,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,WAAW,CAAC,CAAC,GAAG;EACxD,IAAI,CAAC,GAAG,KAAK,GACX,MAAM,IAAI,UACR,iBAAiB,IAAI,WAAW,QAAQ,mBAAmB,QAAQ,SAAS,UAC9E;EAEF,MAAM,OAAO,SAAS,QAAQ,CAAC,GAAG;CACpC;CACA,OAAO,OAAO,OAAO,KAAK;AAC5B"}
1
+ {"version":3,"file":"template.mjs","names":[],"sources":["../../src/dsl/template.ts"],"sourcesContent":["/**\n * Templates in the DSL. The four channels with template definitions (email, push, discord,\n * slack) take the definition itself — `.email(welcome, props)` — so its Zod `props` schema types\n * the call (`PropsOf`) and its `id` lands in the IR. The channels rendered by app-supplied\n * senders (sms, in-app, survey) have no definition; they take a hand-typed reference by key.\n */\n\nimport type * as core from 'zod/v4/core';\nimport type { DiscordTemplate, EmailTemplate, PushTemplate, SlackTemplate } from '../templates';\n\nexport type Channel = 'email' | 'sms' | 'push' | 'in_app' | 'slack' | 'discord' | 'survey';\n\nexport type EmptyProps = Record<never, never>;\n\n/** A template by key, its props typed by hand: for the channels without template definitions. */\nexport interface TemplateRef<C extends Channel = Channel, P extends object = EmptyProps> {\n readonly channel: C;\n readonly key: string;\n readonly __props?: P;\n}\n\nexport interface TemplateFactory {\n sms<P extends object = EmptyProps>(key: string): TemplateRef<'sms', P>;\n inApp<P extends object = EmptyProps>(key: string): TemplateRef<'in_app', P>;\n survey<P extends object = EmptyProps>(key: string): TemplateRef<'survey', P>;\n}\n\nconst makeTemplate =\n <C extends Channel>(channel: C) =>\n <P extends object = EmptyProps>(key: string): TemplateRef<C, P> => ({ channel, key });\n\n/**\n * Template references for the channels an app-supplied sender renders:\n *\n * const code = template.sms<{ code: string }>('login_code');\n */\nexport const template: TemplateFactory = {\n sms: makeTemplate('sms'),\n inApp: makeTemplate('in_app'),\n survey: makeTemplate('survey'),\n};\n\n/** Template props: the input type of the template's Zod `props` schema — what the DSL passes. */\nexport type PropsOf<M> = M extends\n | EmailTemplate<infer S>\n | PushTemplate<infer S>\n | DiscordTemplate<infer S, never>\n | SlackTemplate<infer S, never>\n ? core.input<S> extends object\n ? core.input<S>\n : EmptyProps\n : never;\n"],"mappings":";AA2BA,MAAM,gBACgB,aACY,SAAoC;CAAE;CAAS;AAAI;;;;;;AAOrF,MAAa,WAA4B;CACvC,KAAK,aAAa,KAAK;CACvB,OAAO,aAAa,QAAQ;CAC5B,QAAQ,aAAa,QAAQ;AAC/B"}
@@ -110,7 +110,7 @@ var WorkflowBuilderImpl = class extends require_dsl_flow.FlowBuilderImpl {
110
110
  * id: 'onboarding',
111
111
  * name: 'Onboarding',
112
112
  * trigger: trigger.event(e.sign_up),
113
- * }).email(t.email.welcome);
113
+ * }).email(email.welcome);
114
114
  */
115
115
  function workflow(definition) {
116
116
  return new WorkflowBuilderImpl(definition, require_provenance.captureLoc(workflow));
@@ -1 +1 @@
1
- {"version":3,"file":"workflow.cjs","names":["FlowBuilderImpl","condIR","durationIR","WorkflowIRSchema","semanticHash","captureLoc"],"sources":["../../src/dsl/workflow.ts"],"sourcesContent":["import { semanticHash } from '../hash';\nimport {\n type GoalIR,\n IR_VERSION,\n type MetaIR,\n type NodeIR,\n type SourceLocIR,\n type WorkflowIR,\n WorkflowIR as WorkflowIRSchema,\n} from '../ir';\nimport { captureLoc } from '../provenance';\nimport { type Duration, durationIR } from './base';\nimport { type Condition, condIR } from './condition';\nimport { type FlowBuilder, FlowBuilderImpl } from './flow';\nimport type { TriggerInternal, TriggerRef } from './trigger';\n\n/**\n * The workflow — a named flow chain plus its configuration (options =\n * configuration, the chain = steps), and the compiler back half: `toIR()`\n * deep-clones the collected nodes, assigns structural-path ids, computes the\n * contentHash (metadata stripped, see hash.ts) and self-checks the result\n * against the authoritative IR schema.\n */\n\n/** Full conversion-goal configuration (customer.io's Goal & Exit semantics). */\nexport interface GoalOptions {\n condition: Condition;\n /** Attribution window: how long after entry a match still counts as a conversion. Unbounded by default. */\n within?: Duration;\n /** Exit on conversion. Defaults to true — this is what absorbs all those True→Exit arms in a UI canvas. */\n exitOnMatch?: boolean;\n}\n\nexport interface WorkflowDefinition {\n /**\n * Wire identity — engine instances, the entry ledger and stats keys are all\n * built on it (`WorkflowIR.id`), so it is not something to rename for\n * readability; `name` is.\n */\n id: string;\n\n /** Trigger: a trigger.xxx() asset. TODO: multiple triggers, entry frequency / re-entry policy. */\n trigger: TriggerRef;\n\n /**\n * Conversion goal: feeds reporting (conversion rate / attribution) and by\n * default exits on match. Checked before each node runs. Pass a condition\n * for the shorthand, or GoalOptions for the full form.\n */\n goal?: Condition | GoalOptions;\n\n /** Plain exit condition: leaving without counting as a conversion (unsubscribed, no longer eligible). Coexists with goal. */\n exitWhen?: Condition;\n\n /*\n * Everything below is human-facing metadata: it lands in IR's `meta` field\n * and is **excluded from contentHash**. Editing it neither invalidates the\n * version in-flight users are pinned to nor shows up as a change in plan.\n */\n\n /** Human label: what the studio shows instead of the id. */\n name?: string;\n\n /** One line on what this flow does (UI lists, plan output). */\n description?: string;\n}\n\nexport interface WorkflowBuilder extends FlowBuilder {\n toIR(): WorkflowIR;\n}\n\n/**\n * Assign node ids from the structural path (rules in ir.ts's header). Written\n * in place into the tree toIR deep-cloned, never into the builder's own nodes.\n */\nfunction assignIds(nodes: NodeIR[], prefix: string): void {\n nodes.forEach((node, i) => {\n const id = prefix === '' ? String(i) : `${prefix}.${i}`;\n node.id = id;\n switch (node.type) {\n case 'branch':\n node.cases.forEach((c, ci) => assignIds(c.flow, `${id}.c${ci}`));\n if (node.otherwise) assignIds(node.otherwise, `${id}.o`);\n break;\n case 'cohort':\n node.arms.forEach((arm) => assignIds(arm.flow, `${id}.${arm.name}`));\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) assignIds(node.onTimeout, `${id}.t`);\n break;\n default:\n break;\n }\n });\n}\n\n/**\n * Ids are derived, never authored, so a duplicate is always a compiler bug —\n * and a silent one: assignIds writes in place, so the second write simply wins\n * and two nodes ship as one. What that costs is concrete, because an id is the\n * durable step name (`step.do('{id}:send')`), the message idempotency key\n * (`{instanceId}:{id}`) and the cohort bucket seed: the twins share a\n * checkpoint, and the second send is suppressed as a duplicate of the first.\n * Cheap to check once at the end of the compile, so it is checked.\n */\nfunction assertUniqueIds(nodes: readonly NodeIR[], seen = new Set<string>()): void {\n for (const node of nodes) {\n if (seen.has(node.id)) {\n throw new Error(\n `toIR(): node id '${node.id}' is used twice — ids are durable step names and message idempotency keys, so they must be unique`\n );\n }\n seen.add(node.id);\n switch (node.type) {\n case 'branch':\n for (const branchCase of node.cases) assertUniqueIds(branchCase.flow, seen);\n if (node.otherwise) assertUniqueIds(node.otherwise, seen);\n break;\n case 'cohort':\n for (const arm of node.arms) assertUniqueIds(arm.flow, seen);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) assertUniqueIds(node.onTimeout, seen);\n break;\n default:\n break;\n }\n }\n}\n\nclass WorkflowBuilderImpl extends FlowBuilderImpl implements WorkflowBuilder {\n constructor(\n private readonly options: WorkflowDefinition,\n /** Callsite of the workflow() call (provenance; lands in meta.loc). */\n private readonly loc: SourceLocIR | undefined\n ) {\n super();\n }\n\n private goalIR(): GoalIR | undefined {\n const goal = this.options.goal;\n if (goal === undefined) return undefined;\n if ('__condition' in goal) {\n return { condition: condIR(goal), exitOnMatch: true };\n }\n return {\n condition: condIR(goal.condition),\n ...(goal.within !== undefined ? { within: durationIR(goal.within) } : {}),\n exitOnMatch: goal.exitOnMatch ?? true,\n };\n }\n\n private metaIR(): MetaIR | undefined {\n const { name, description } = this.options;\n if (name === undefined && description === undefined && this.loc === undefined) {\n return undefined;\n }\n return {\n ...(name !== undefined ? { name } : {}),\n ...(description !== undefined ? { description } : {}),\n ...(this.loc !== undefined ? { loc: this.loc } : {}),\n };\n }\n\n toIR(): WorkflowIR {\n const flowNodes = structuredClone(this.nodes);\n assignIds(flowNodes, '');\n assertUniqueIds(flowNodes);\n const goal = this.goalIR();\n const meta = this.metaIR();\n const body = {\n irVersion: IR_VERSION,\n id: this.options.id,\n ...(meta !== undefined ? { meta } : {}),\n trigger: (this.options.trigger as TriggerInternal).ir,\n ...(goal !== undefined ? { goal } : {}),\n ...(this.options.exitWhen !== undefined ? { exitWhen: condIR(this.options.exitWhen) } : {}),\n flow: flowNodes,\n };\n // semanticHash strips meta / label / reason, so metadata edits leave contentHash alone\n // Self-check: the compiler's output must pass IR's authoritative schema\n return WorkflowIRSchema.parse({ ...body, contentHash: semanticHash(body) });\n }\n}\n\n/**\n * Workflow definition: an id, configuration (trigger / goal / exitWhen),\n * optional labels, and chained steps.\n *\n * export const onboarding = workflow({\n * id: 'onboarding',\n * name: 'Onboarding',\n * trigger: trigger.event(e.sign_up),\n * }).email(t.email.welcome);\n */\nexport function workflow(definition: WorkflowDefinition): WorkflowBuilder {\n return new WorkflowBuilderImpl(definition, captureLoc(workflow));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"workflow.cjs","names":["FlowBuilderImpl","condIR","durationIR","WorkflowIRSchema","semanticHash","captureLoc"],"sources":["../../src/dsl/workflow.ts"],"sourcesContent":["import { semanticHash } from '../hash';\nimport {\n type GoalIR,\n IR_VERSION,\n type MetaIR,\n type NodeIR,\n type SourceLocIR,\n type WorkflowIR,\n WorkflowIR as WorkflowIRSchema,\n} from '../ir';\nimport { captureLoc } from '../provenance';\nimport { type Duration, durationIR } from './base';\nimport { type Condition, condIR } from './condition';\nimport { type FlowBuilder, FlowBuilderImpl } from './flow';\nimport type { TriggerInternal, TriggerRef } from './trigger';\n\n/**\n * The workflow — a named flow chain plus its configuration (options =\n * configuration, the chain = steps), and the compiler back half: `toIR()`\n * deep-clones the collected nodes, assigns structural-path ids, computes the\n * contentHash (metadata stripped, see hash.ts) and self-checks the result\n * against the authoritative IR schema.\n */\n\n/** Full conversion-goal configuration (customer.io's Goal & Exit semantics). */\nexport interface GoalOptions {\n condition: Condition;\n /** Attribution window: how long after entry a match still counts as a conversion. Unbounded by default. */\n within?: Duration;\n /** Exit on conversion. Defaults to true — this is what absorbs all those True→Exit arms in a UI canvas. */\n exitOnMatch?: boolean;\n}\n\nexport interface WorkflowDefinition {\n /**\n * Wire identity — engine instances, the entry ledger and stats keys are all\n * built on it (`WorkflowIR.id`), so it is not something to rename for\n * readability; `name` is.\n */\n id: string;\n\n /** Trigger: a trigger.xxx() asset. TODO: multiple triggers, entry frequency / re-entry policy. */\n trigger: TriggerRef;\n\n /**\n * Conversion goal: feeds reporting (conversion rate / attribution) and by\n * default exits on match. Checked before each node runs. Pass a condition\n * for the shorthand, or GoalOptions for the full form.\n */\n goal?: Condition | GoalOptions;\n\n /** Plain exit condition: leaving without counting as a conversion (unsubscribed, no longer eligible). Coexists with goal. */\n exitWhen?: Condition;\n\n /*\n * Everything below is human-facing metadata: it lands in IR's `meta` field\n * and is **excluded from contentHash**. Editing it neither invalidates the\n * version in-flight users are pinned to nor shows up as a change in plan.\n */\n\n /** Human label: what the studio shows instead of the id. */\n name?: string;\n\n /** One line on what this flow does (UI lists, plan output). */\n description?: string;\n}\n\nexport interface WorkflowBuilder extends FlowBuilder {\n toIR(): WorkflowIR;\n}\n\n/**\n * Assign node ids from the structural path (rules in ir.ts's header). Written\n * in place into the tree toIR deep-cloned, never into the builder's own nodes.\n */\nfunction assignIds(nodes: NodeIR[], prefix: string): void {\n nodes.forEach((node, i) => {\n const id = prefix === '' ? String(i) : `${prefix}.${i}`;\n node.id = id;\n switch (node.type) {\n case 'branch':\n node.cases.forEach((c, ci) => assignIds(c.flow, `${id}.c${ci}`));\n if (node.otherwise) assignIds(node.otherwise, `${id}.o`);\n break;\n case 'cohort':\n node.arms.forEach((arm) => assignIds(arm.flow, `${id}.${arm.name}`));\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) assignIds(node.onTimeout, `${id}.t`);\n break;\n default:\n break;\n }\n });\n}\n\n/**\n * Ids are derived, never authored, so a duplicate is always a compiler bug —\n * and a silent one: assignIds writes in place, so the second write simply wins\n * and two nodes ship as one. What that costs is concrete, because an id is the\n * durable step name (`step.do('{id}:send')`), the message idempotency key\n * (`{instanceId}:{id}`) and the cohort bucket seed: the twins share a\n * checkpoint, and the second send is suppressed as a duplicate of the first.\n * Cheap to check once at the end of the compile, so it is checked.\n */\nfunction assertUniqueIds(nodes: readonly NodeIR[], seen = new Set<string>()): void {\n for (const node of nodes) {\n if (seen.has(node.id)) {\n throw new Error(\n `toIR(): node id '${node.id}' is used twice — ids are durable step names and message idempotency keys, so they must be unique`\n );\n }\n seen.add(node.id);\n switch (node.type) {\n case 'branch':\n for (const branchCase of node.cases) assertUniqueIds(branchCase.flow, seen);\n if (node.otherwise) assertUniqueIds(node.otherwise, seen);\n break;\n case 'cohort':\n for (const arm of node.arms) assertUniqueIds(arm.flow, seen);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) assertUniqueIds(node.onTimeout, seen);\n break;\n default:\n break;\n }\n }\n}\n\nclass WorkflowBuilderImpl extends FlowBuilderImpl implements WorkflowBuilder {\n constructor(\n private readonly options: WorkflowDefinition,\n /** Callsite of the workflow() call (provenance; lands in meta.loc). */\n private readonly loc: SourceLocIR | undefined\n ) {\n super();\n }\n\n private goalIR(): GoalIR | undefined {\n const goal = this.options.goal;\n if (goal === undefined) return undefined;\n if ('__condition' in goal) {\n return { condition: condIR(goal), exitOnMatch: true };\n }\n return {\n condition: condIR(goal.condition),\n ...(goal.within !== undefined ? { within: durationIR(goal.within) } : {}),\n exitOnMatch: goal.exitOnMatch ?? true,\n };\n }\n\n private metaIR(): MetaIR | undefined {\n const { name, description } = this.options;\n if (name === undefined && description === undefined && this.loc === undefined) {\n return undefined;\n }\n return {\n ...(name !== undefined ? { name } : {}),\n ...(description !== undefined ? { description } : {}),\n ...(this.loc !== undefined ? { loc: this.loc } : {}),\n };\n }\n\n toIR(): WorkflowIR {\n const flowNodes = structuredClone(this.nodes);\n assignIds(flowNodes, '');\n assertUniqueIds(flowNodes);\n const goal = this.goalIR();\n const meta = this.metaIR();\n const body = {\n irVersion: IR_VERSION,\n id: this.options.id,\n ...(meta !== undefined ? { meta } : {}),\n trigger: (this.options.trigger as TriggerInternal).ir,\n ...(goal !== undefined ? { goal } : {}),\n ...(this.options.exitWhen !== undefined ? { exitWhen: condIR(this.options.exitWhen) } : {}),\n flow: flowNodes,\n };\n // semanticHash strips meta / label / reason, so metadata edits leave contentHash alone\n // Self-check: the compiler's output must pass IR's authoritative schema\n return WorkflowIRSchema.parse({ ...body, contentHash: semanticHash(body) });\n }\n}\n\n/**\n * Workflow definition: an id, configuration (trigger / goal / exitWhen),\n * optional labels, and chained steps.\n *\n * export const onboarding = workflow({\n * id: 'onboarding',\n * name: 'Onboarding',\n * trigger: trigger.event(e.sign_up),\n * }).email(email.welcome);\n */\nexport function workflow(definition: WorkflowDefinition): WorkflowBuilder {\n return new WorkflowBuilderImpl(definition, captureLoc(workflow));\n}\n"],"mappings":"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"}
@@ -52,7 +52,7 @@ export interface WorkflowBuilder extends FlowBuilder {
52
52
  * id: 'onboarding',
53
53
  * name: 'Onboarding',
54
54
  * trigger: trigger.event(e.sign_up),
55
- * }).email(t.email.welcome);
55
+ * }).email(email.welcome);
56
56
  */
57
57
  export declare function workflow(definition: WorkflowDefinition): WorkflowBuilder;
58
58
  //#endregion
@@ -52,7 +52,7 @@ export interface WorkflowBuilder extends FlowBuilder {
52
52
  * id: 'onboarding',
53
53
  * name: 'Onboarding',
54
54
  * trigger: trigger.event(e.sign_up),
55
- * }).email(t.email.welcome);
55
+ * }).email(email.welcome);
56
56
  */
57
57
  export declare function workflow(definition: WorkflowDefinition): WorkflowBuilder;
58
58
  //#endregion
@@ -109,7 +109,7 @@ var WorkflowBuilderImpl = class extends FlowBuilderImpl {
109
109
  * id: 'onboarding',
110
110
  * name: 'Onboarding',
111
111
  * trigger: trigger.event(e.sign_up),
112
- * }).email(t.email.welcome);
112
+ * }).email(email.welcome);
113
113
  */
114
114
  function workflow(definition) {
115
115
  return new WorkflowBuilderImpl(definition, captureLoc(workflow));
@@ -1 +1 @@
1
- {"version":3,"file":"workflow.mjs","names":["WorkflowIRSchema"],"sources":["../../src/dsl/workflow.ts"],"sourcesContent":["import { semanticHash } from '../hash';\nimport {\n type GoalIR,\n IR_VERSION,\n type MetaIR,\n type NodeIR,\n type SourceLocIR,\n type WorkflowIR,\n WorkflowIR as WorkflowIRSchema,\n} from '../ir';\nimport { captureLoc } from '../provenance';\nimport { type Duration, durationIR } from './base';\nimport { type Condition, condIR } from './condition';\nimport { type FlowBuilder, FlowBuilderImpl } from './flow';\nimport type { TriggerInternal, TriggerRef } from './trigger';\n\n/**\n * The workflow — a named flow chain plus its configuration (options =\n * configuration, the chain = steps), and the compiler back half: `toIR()`\n * deep-clones the collected nodes, assigns structural-path ids, computes the\n * contentHash (metadata stripped, see hash.ts) and self-checks the result\n * against the authoritative IR schema.\n */\n\n/** Full conversion-goal configuration (customer.io's Goal & Exit semantics). */\nexport interface GoalOptions {\n condition: Condition;\n /** Attribution window: how long after entry a match still counts as a conversion. Unbounded by default. */\n within?: Duration;\n /** Exit on conversion. Defaults to true — this is what absorbs all those True→Exit arms in a UI canvas. */\n exitOnMatch?: boolean;\n}\n\nexport interface WorkflowDefinition {\n /**\n * Wire identity — engine instances, the entry ledger and stats keys are all\n * built on it (`WorkflowIR.id`), so it is not something to rename for\n * readability; `name` is.\n */\n id: string;\n\n /** Trigger: a trigger.xxx() asset. TODO: multiple triggers, entry frequency / re-entry policy. */\n trigger: TriggerRef;\n\n /**\n * Conversion goal: feeds reporting (conversion rate / attribution) and by\n * default exits on match. Checked before each node runs. Pass a condition\n * for the shorthand, or GoalOptions for the full form.\n */\n goal?: Condition | GoalOptions;\n\n /** Plain exit condition: leaving without counting as a conversion (unsubscribed, no longer eligible). Coexists with goal. */\n exitWhen?: Condition;\n\n /*\n * Everything below is human-facing metadata: it lands in IR's `meta` field\n * and is **excluded from contentHash**. Editing it neither invalidates the\n * version in-flight users are pinned to nor shows up as a change in plan.\n */\n\n /** Human label: what the studio shows instead of the id. */\n name?: string;\n\n /** One line on what this flow does (UI lists, plan output). */\n description?: string;\n}\n\nexport interface WorkflowBuilder extends FlowBuilder {\n toIR(): WorkflowIR;\n}\n\n/**\n * Assign node ids from the structural path (rules in ir.ts's header). Written\n * in place into the tree toIR deep-cloned, never into the builder's own nodes.\n */\nfunction assignIds(nodes: NodeIR[], prefix: string): void {\n nodes.forEach((node, i) => {\n const id = prefix === '' ? String(i) : `${prefix}.${i}`;\n node.id = id;\n switch (node.type) {\n case 'branch':\n node.cases.forEach((c, ci) => assignIds(c.flow, `${id}.c${ci}`));\n if (node.otherwise) assignIds(node.otherwise, `${id}.o`);\n break;\n case 'cohort':\n node.arms.forEach((arm) => assignIds(arm.flow, `${id}.${arm.name}`));\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) assignIds(node.onTimeout, `${id}.t`);\n break;\n default:\n break;\n }\n });\n}\n\n/**\n * Ids are derived, never authored, so a duplicate is always a compiler bug —\n * and a silent one: assignIds writes in place, so the second write simply wins\n * and two nodes ship as one. What that costs is concrete, because an id is the\n * durable step name (`step.do('{id}:send')`), the message idempotency key\n * (`{instanceId}:{id}`) and the cohort bucket seed: the twins share a\n * checkpoint, and the second send is suppressed as a duplicate of the first.\n * Cheap to check once at the end of the compile, so it is checked.\n */\nfunction assertUniqueIds(nodes: readonly NodeIR[], seen = new Set<string>()): void {\n for (const node of nodes) {\n if (seen.has(node.id)) {\n throw new Error(\n `toIR(): node id '${node.id}' is used twice — ids are durable step names and message idempotency keys, so they must be unique`\n );\n }\n seen.add(node.id);\n switch (node.type) {\n case 'branch':\n for (const branchCase of node.cases) assertUniqueIds(branchCase.flow, seen);\n if (node.otherwise) assertUniqueIds(node.otherwise, seen);\n break;\n case 'cohort':\n for (const arm of node.arms) assertUniqueIds(arm.flow, seen);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) assertUniqueIds(node.onTimeout, seen);\n break;\n default:\n break;\n }\n }\n}\n\nclass WorkflowBuilderImpl extends FlowBuilderImpl implements WorkflowBuilder {\n constructor(\n private readonly options: WorkflowDefinition,\n /** Callsite of the workflow() call (provenance; lands in meta.loc). */\n private readonly loc: SourceLocIR | undefined\n ) {\n super();\n }\n\n private goalIR(): GoalIR | undefined {\n const goal = this.options.goal;\n if (goal === undefined) return undefined;\n if ('__condition' in goal) {\n return { condition: condIR(goal), exitOnMatch: true };\n }\n return {\n condition: condIR(goal.condition),\n ...(goal.within !== undefined ? { within: durationIR(goal.within) } : {}),\n exitOnMatch: goal.exitOnMatch ?? true,\n };\n }\n\n private metaIR(): MetaIR | undefined {\n const { name, description } = this.options;\n if (name === undefined && description === undefined && this.loc === undefined) {\n return undefined;\n }\n return {\n ...(name !== undefined ? { name } : {}),\n ...(description !== undefined ? { description } : {}),\n ...(this.loc !== undefined ? { loc: this.loc } : {}),\n };\n }\n\n toIR(): WorkflowIR {\n const flowNodes = structuredClone(this.nodes);\n assignIds(flowNodes, '');\n assertUniqueIds(flowNodes);\n const goal = this.goalIR();\n const meta = this.metaIR();\n const body = {\n irVersion: IR_VERSION,\n id: this.options.id,\n ...(meta !== undefined ? { meta } : {}),\n trigger: (this.options.trigger as TriggerInternal).ir,\n ...(goal !== undefined ? { goal } : {}),\n ...(this.options.exitWhen !== undefined ? { exitWhen: condIR(this.options.exitWhen) } : {}),\n flow: flowNodes,\n };\n // semanticHash strips meta / label / reason, so metadata edits leave contentHash alone\n // Self-check: the compiler's output must pass IR's authoritative schema\n return WorkflowIRSchema.parse({ ...body, contentHash: semanticHash(body) });\n }\n}\n\n/**\n * Workflow definition: an id, configuration (trigger / goal / exitWhen),\n * optional labels, and chained steps.\n *\n * export const onboarding = workflow({\n * id: 'onboarding',\n * name: 'Onboarding',\n * trigger: trigger.event(e.sign_up),\n * }).email(t.email.welcome);\n */\nexport function workflow(definition: WorkflowDefinition): WorkflowBuilder {\n return new WorkflowBuilderImpl(definition, captureLoc(workflow));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"workflow.mjs","names":["WorkflowIRSchema"],"sources":["../../src/dsl/workflow.ts"],"sourcesContent":["import { semanticHash } from '../hash';\nimport {\n type GoalIR,\n IR_VERSION,\n type MetaIR,\n type NodeIR,\n type SourceLocIR,\n type WorkflowIR,\n WorkflowIR as WorkflowIRSchema,\n} from '../ir';\nimport { captureLoc } from '../provenance';\nimport { type Duration, durationIR } from './base';\nimport { type Condition, condIR } from './condition';\nimport { type FlowBuilder, FlowBuilderImpl } from './flow';\nimport type { TriggerInternal, TriggerRef } from './trigger';\n\n/**\n * The workflow — a named flow chain plus its configuration (options =\n * configuration, the chain = steps), and the compiler back half: `toIR()`\n * deep-clones the collected nodes, assigns structural-path ids, computes the\n * contentHash (metadata stripped, see hash.ts) and self-checks the result\n * against the authoritative IR schema.\n */\n\n/** Full conversion-goal configuration (customer.io's Goal & Exit semantics). */\nexport interface GoalOptions {\n condition: Condition;\n /** Attribution window: how long after entry a match still counts as a conversion. Unbounded by default. */\n within?: Duration;\n /** Exit on conversion. Defaults to true — this is what absorbs all those True→Exit arms in a UI canvas. */\n exitOnMatch?: boolean;\n}\n\nexport interface WorkflowDefinition {\n /**\n * Wire identity — engine instances, the entry ledger and stats keys are all\n * built on it (`WorkflowIR.id`), so it is not something to rename for\n * readability; `name` is.\n */\n id: string;\n\n /** Trigger: a trigger.xxx() asset. TODO: multiple triggers, entry frequency / re-entry policy. */\n trigger: TriggerRef;\n\n /**\n * Conversion goal: feeds reporting (conversion rate / attribution) and by\n * default exits on match. Checked before each node runs. Pass a condition\n * for the shorthand, or GoalOptions for the full form.\n */\n goal?: Condition | GoalOptions;\n\n /** Plain exit condition: leaving without counting as a conversion (unsubscribed, no longer eligible). Coexists with goal. */\n exitWhen?: Condition;\n\n /*\n * Everything below is human-facing metadata: it lands in IR's `meta` field\n * and is **excluded from contentHash**. Editing it neither invalidates the\n * version in-flight users are pinned to nor shows up as a change in plan.\n */\n\n /** Human label: what the studio shows instead of the id. */\n name?: string;\n\n /** One line on what this flow does (UI lists, plan output). */\n description?: string;\n}\n\nexport interface WorkflowBuilder extends FlowBuilder {\n toIR(): WorkflowIR;\n}\n\n/**\n * Assign node ids from the structural path (rules in ir.ts's header). Written\n * in place into the tree toIR deep-cloned, never into the builder's own nodes.\n */\nfunction assignIds(nodes: NodeIR[], prefix: string): void {\n nodes.forEach((node, i) => {\n const id = prefix === '' ? String(i) : `${prefix}.${i}`;\n node.id = id;\n switch (node.type) {\n case 'branch':\n node.cases.forEach((c, ci) => assignIds(c.flow, `${id}.c${ci}`));\n if (node.otherwise) assignIds(node.otherwise, `${id}.o`);\n break;\n case 'cohort':\n node.arms.forEach((arm) => assignIds(arm.flow, `${id}.${arm.name}`));\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) assignIds(node.onTimeout, `${id}.t`);\n break;\n default:\n break;\n }\n });\n}\n\n/**\n * Ids are derived, never authored, so a duplicate is always a compiler bug —\n * and a silent one: assignIds writes in place, so the second write simply wins\n * and two nodes ship as one. What that costs is concrete, because an id is the\n * durable step name (`step.do('{id}:send')`), the message idempotency key\n * (`{instanceId}:{id}`) and the cohort bucket seed: the twins share a\n * checkpoint, and the second send is suppressed as a duplicate of the first.\n * Cheap to check once at the end of the compile, so it is checked.\n */\nfunction assertUniqueIds(nodes: readonly NodeIR[], seen = new Set<string>()): void {\n for (const node of nodes) {\n if (seen.has(node.id)) {\n throw new Error(\n `toIR(): node id '${node.id}' is used twice — ids are durable step names and message idempotency keys, so they must be unique`\n );\n }\n seen.add(node.id);\n switch (node.type) {\n case 'branch':\n for (const branchCase of node.cases) assertUniqueIds(branchCase.flow, seen);\n if (node.otherwise) assertUniqueIds(node.otherwise, seen);\n break;\n case 'cohort':\n for (const arm of node.arms) assertUniqueIds(arm.flow, seen);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) assertUniqueIds(node.onTimeout, seen);\n break;\n default:\n break;\n }\n }\n}\n\nclass WorkflowBuilderImpl extends FlowBuilderImpl implements WorkflowBuilder {\n constructor(\n private readonly options: WorkflowDefinition,\n /** Callsite of the workflow() call (provenance; lands in meta.loc). */\n private readonly loc: SourceLocIR | undefined\n ) {\n super();\n }\n\n private goalIR(): GoalIR | undefined {\n const goal = this.options.goal;\n if (goal === undefined) return undefined;\n if ('__condition' in goal) {\n return { condition: condIR(goal), exitOnMatch: true };\n }\n return {\n condition: condIR(goal.condition),\n ...(goal.within !== undefined ? { within: durationIR(goal.within) } : {}),\n exitOnMatch: goal.exitOnMatch ?? true,\n };\n }\n\n private metaIR(): MetaIR | undefined {\n const { name, description } = this.options;\n if (name === undefined && description === undefined && this.loc === undefined) {\n return undefined;\n }\n return {\n ...(name !== undefined ? { name } : {}),\n ...(description !== undefined ? { description } : {}),\n ...(this.loc !== undefined ? { loc: this.loc } : {}),\n };\n }\n\n toIR(): WorkflowIR {\n const flowNodes = structuredClone(this.nodes);\n assignIds(flowNodes, '');\n assertUniqueIds(flowNodes);\n const goal = this.goalIR();\n const meta = this.metaIR();\n const body = {\n irVersion: IR_VERSION,\n id: this.options.id,\n ...(meta !== undefined ? { meta } : {}),\n trigger: (this.options.trigger as TriggerInternal).ir,\n ...(goal !== undefined ? { goal } : {}),\n ...(this.options.exitWhen !== undefined ? { exitWhen: condIR(this.options.exitWhen) } : {}),\n flow: flowNodes,\n };\n // semanticHash strips meta / label / reason, so metadata edits leave contentHash alone\n // Self-check: the compiler's output must pass IR's authoritative schema\n return WorkflowIRSchema.parse({ ...body, contentHash: semanticHash(body) });\n }\n}\n\n/**\n * Workflow definition: an id, configuration (trigger / goal / exitWhen),\n * optional labels, and chained steps.\n *\n * export const onboarding = workflow({\n * id: 'onboarding',\n * name: 'Onboarding',\n * trigger: trigger.event(e.sign_up),\n * }).email(email.welcome);\n */\nexport function workflow(definition: WorkflowDefinition): WorkflowBuilder {\n return new WorkflowBuilderImpl(definition, captureLoc(workflow));\n}\n"],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let react = require("react");
3
+ //#region src/email-context.ts
4
+ /**
5
+ * `@shware/workflow/email-context` — the email being rendered, for its components: the resolved
6
+ * metadata (`resolveEmail`) of the template, so a shared layout renders the unsubscribe link the
7
+ * template resolves to instead of a URL of its own. The renderer (`@shware/workflow/react-email`)
8
+ * and the studio's preview both provide it; the props stay the template's own.
9
+ *
10
+ * const { unsubscribeUrl } = useEmailContext();
11
+ * {unsubscribeUrl !== undefined && <Link href={unsubscribeUrl}>Unsubscribe</Link>}
12
+ *
13
+ * Browser-safe: React only — no react-dom/server, no Worker bindings.
14
+ */
15
+ const EmailContext = (0, react.createContext)(void 0);
16
+ function EmailProvider({ email, children }) {
17
+ return (0, react.createElement)(EmailContext.Provider, { value: email }, children);
18
+ }
19
+ /** The email being rendered; throws outside a render the SDK or the studio provides. */
20
+ function useEmailContext() {
21
+ const email = (0, react.useContext)(EmailContext);
22
+ if (email === void 0) throw new Error("useEmailContext() is called outside an email render: render the template with @shware/workflow/react-email's registryRenderer, or wrap it in <EmailProvider email={resolveEmail(template, config.email)}>");
23
+ return email;
24
+ }
25
+ //#endregion
26
+ exports.EmailProvider = EmailProvider;
27
+ exports.useEmailContext = useEmailContext;
28
+
29
+ //# sourceMappingURL=email-context.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-context.cjs","names":["createContext","createElement","useContext"],"sources":["../src/email-context.ts"],"sourcesContent":["import { type ReactNode, createContext, createElement, useContext } from 'react';\nimport type { ResolvedEmail } from './email';\n\n/**\n * `@shware/workflow/email-context` — the email being rendered, for its components: the resolved\n * metadata (`resolveEmail`) of the template, so a shared layout renders the unsubscribe link the\n * template resolves to instead of a URL of its own. The renderer (`@shware/workflow/react-email`)\n * and the studio's preview both provide it; the props stay the template's own.\n *\n * const { unsubscribeUrl } = useEmailContext();\n * {unsubscribeUrl !== undefined && <Link href={unsubscribeUrl}>Unsubscribe</Link>}\n *\n * Browser-safe: React only — no react-dom/server, no Worker bindings.\n */\n\nconst EmailContext = createContext<ResolvedEmail | undefined>(undefined);\n\nexport function EmailProvider({\n email,\n children,\n}: {\n email: ResolvedEmail;\n children?: ReactNode;\n}): ReactNode {\n return createElement(EmailContext.Provider, { value: email }, children);\n}\n\n/** The email being rendered; throws outside a render the SDK or the studio provides. */\nexport function useEmailContext(): ResolvedEmail {\n const email = useContext(EmailContext);\n if (email === undefined) {\n throw new Error(\n 'useEmailContext() is called outside an email render: render the template with ' +\n \"@shware/workflow/react-email's registryRenderer, or wrap it in <EmailProvider email={resolveEmail(template, config.email)}>\"\n );\n }\n return email;\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,MAAM,gBAAA,GAAeA,MAAAA,cAAAA,CAAyC,KAAA,CAAS;AAEvE,SAAgB,cAAc,EAC5B,OACA,YAIY;CACZ,QAAA,GAAOC,MAAAA,cAAAA,CAAc,aAAa,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AACxE;;AAGA,SAAgB,kBAAiC;CAC/C,MAAM,SAAA,GAAQC,MAAAA,WAAAA,CAAW,YAAY;CACrC,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MACR,2MAEF;CAEF,OAAO;AACT"}
@@ -0,0 +1,11 @@
1
+ import { ResolvedEmail } from "./email.cjs";
2
+ import { ReactNode } from "react";
3
+ //#region src/email-context.d.ts
4
+ export declare function EmailProvider({ email, children }: {
5
+ email: ResolvedEmail;
6
+ children?: ReactNode;
7
+ }): ReactNode;
8
+ /** The email being rendered; throws outside a render the SDK or the studio provides. */
9
+ export declare function useEmailContext(): ResolvedEmail;
10
+ //#endregion
11
+ //# sourceMappingURL=email-context.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-context.d.cts","names":[],"sources":["../src/email-context.ts"],"mappings":";;;wBAiBgB,gBACd,OACA;EAEA,OAAO;EACP,WAAW;IACT;;wBAKY,mBAAmB"}
@@ -0,0 +1,11 @@
1
+ import { ResolvedEmail } from "./email.mjs";
2
+ import { ReactNode } from "react";
3
+ //#region src/email-context.d.ts
4
+ export declare function EmailProvider({ email, children }: {
5
+ email: ResolvedEmail;
6
+ children?: ReactNode;
7
+ }): ReactNode;
8
+ /** The email being rendered; throws outside a render the SDK or the studio provides. */
9
+ export declare function useEmailContext(): ResolvedEmail;
10
+ //#endregion
11
+ //# sourceMappingURL=email-context.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-context.d.mts","names":[],"sources":["../src/email-context.ts"],"mappings":";;;wBAiBgB,gBACd,OACA;EAEA,OAAO;EACP,WAAW;IACT;;wBAKY,mBAAmB"}
@@ -0,0 +1,27 @@
1
+ import { createContext, createElement, useContext } from "react";
2
+ //#region src/email-context.ts
3
+ /**
4
+ * `@shware/workflow/email-context` — the email being rendered, for its components: the resolved
5
+ * metadata (`resolveEmail`) of the template, so a shared layout renders the unsubscribe link the
6
+ * template resolves to instead of a URL of its own. The renderer (`@shware/workflow/react-email`)
7
+ * and the studio's preview both provide it; the props stay the template's own.
8
+ *
9
+ * const { unsubscribeUrl } = useEmailContext();
10
+ * {unsubscribeUrl !== undefined && <Link href={unsubscribeUrl}>Unsubscribe</Link>}
11
+ *
12
+ * Browser-safe: React only — no react-dom/server, no Worker bindings.
13
+ */
14
+ const EmailContext = createContext(void 0);
15
+ function EmailProvider({ email, children }) {
16
+ return createElement(EmailContext.Provider, { value: email }, children);
17
+ }
18
+ /** The email being rendered; throws outside a render the SDK or the studio provides. */
19
+ function useEmailContext() {
20
+ const email = useContext(EmailContext);
21
+ if (email === void 0) throw new Error("useEmailContext() is called outside an email render: render the template with @shware/workflow/react-email's registryRenderer, or wrap it in <EmailProvider email={resolveEmail(template, config.email)}>");
22
+ return email;
23
+ }
24
+ //#endregion
25
+ export { EmailProvider, useEmailContext };
26
+
27
+ //# sourceMappingURL=email-context.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-context.mjs","names":[],"sources":["../src/email-context.ts"],"sourcesContent":["import { type ReactNode, createContext, createElement, useContext } from 'react';\nimport type { ResolvedEmail } from './email';\n\n/**\n * `@shware/workflow/email-context` — the email being rendered, for its components: the resolved\n * metadata (`resolveEmail`) of the template, so a shared layout renders the unsubscribe link the\n * template resolves to instead of a URL of its own. The renderer (`@shware/workflow/react-email`)\n * and the studio's preview both provide it; the props stay the template's own.\n *\n * const { unsubscribeUrl } = useEmailContext();\n * {unsubscribeUrl !== undefined && <Link href={unsubscribeUrl}>Unsubscribe</Link>}\n *\n * Browser-safe: React only — no react-dom/server, no Worker bindings.\n */\n\nconst EmailContext = createContext<ResolvedEmail | undefined>(undefined);\n\nexport function EmailProvider({\n email,\n children,\n}: {\n email: ResolvedEmail;\n children?: ReactNode;\n}): ReactNode {\n return createElement(EmailContext.Provider, { value: email }, children);\n}\n\n/** The email being rendered; throws outside a render the SDK or the studio provides. */\nexport function useEmailContext(): ResolvedEmail {\n const email = useContext(EmailContext);\n if (email === undefined) {\n throw new Error(\n 'useEmailContext() is called outside an email render: render the template with ' +\n \"@shware/workflow/react-email's registryRenderer, or wrap it in <EmailProvider email={resolveEmail(template, config.email)}>\"\n );\n }\n return email;\n}\n"],"mappings":";;;;;;;;;;;;;AAeA,MAAM,eAAe,cAAyC,KAAA,CAAS;AAEvE,SAAgB,cAAc,EAC5B,OACA,YAIY;CACZ,OAAO,cAAc,aAAa,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AACxE;;AAGA,SAAgB,kBAAiC;CAC/C,MAAM,QAAQ,WAAW,YAAY;CACrC,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MACR,2MAEF;CAEF,OAAO;AACT"}
package/dist/email.cjs ADDED
@@ -0,0 +1,72 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/email.ts
3
+ /** `Name <email>`, or the bare address. */
4
+ function formatEmailAddress(address) {
5
+ if (typeof address === "string") return address;
6
+ return address.name === void 0 ? address.email : `${address.name} <${address.email}>`;
7
+ }
8
+ /** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */
9
+ function resolveEmail(template, config) {
10
+ const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;
11
+ return {
12
+ from: template.from,
13
+ ...template.replyTo === void 0 ? {} : { replyTo: template.replyTo },
14
+ ...unsubscribeUrl === void 0 ? {} : { unsubscribeUrl }
15
+ };
16
+ }
17
+ const ADDRESS = /^[^\s@<>]+@[^\s@<>]+$/;
18
+ /** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
19
+ function assertEmailAddress(address, where) {
20
+ const email = typeof address === "object" && address !== null ? address.email : address;
21
+ const name = typeof address === "object" && address !== null ? address.name : void 0;
22
+ if (typeof email !== "string" || !ADDRESS.test(email)) throw new TypeError(`${where} must be an email address or { email, name? }`);
23
+ if (name !== void 0 && typeof name !== "string") throw new TypeError(`${where}.name must be a string`);
24
+ }
25
+ /** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
26
+ function assertUnsubscribeUrl(url, where) {
27
+ let parsed;
28
+ try {
29
+ parsed = typeof url === "string" ? new URL(url) : void 0;
30
+ } catch {
31
+ parsed = void 0;
32
+ }
33
+ if (parsed === void 0 || parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);
34
+ }
35
+ /** The config's `email` and every registered template's metadata; throws on the first invalid value. */
36
+ function assertEmailConfig(config, templates) {
37
+ if (Object.hasOwn(config, "emails")) throw new TypeError("the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, and the rest moved to `email: { unsubscribeUrl, tracking }`");
38
+ const email = config.email;
39
+ if (email !== void 0) {
40
+ if (email.unsubscribeUrl !== void 0) assertUnsubscribeUrl(email.unsubscribeUrl, "email.unsubscribeUrl");
41
+ const quick = email.tracking?.quickClickMs;
42
+ if (quick !== void 0 && !(typeof quick === "number" && Number.isFinite(quick) && quick >= 0)) throw new TypeError("email.tracking.quickClickMs must be a non-negative number");
43
+ }
44
+ for (const template of templates) assertEmailMetadata(template, `email template '${template.id}'`);
45
+ }
46
+ /** A template's own `from` / `replyTo` / `unsubscribeUrl`, as `emailTemplate` checks them. */
47
+ function assertEmailMetadata(template, where) {
48
+ assertEmailAddress(template.from, `${where}: from`);
49
+ if (template.replyTo !== void 0) assertEmailAddress(template.replyTo, `${where}: replyTo`);
50
+ if (template.unsubscribeUrl !== void 0) assertUnsubscribeUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);
51
+ }
52
+ /** The unsubscribe link may carry its own query (a token, a list): the path names it. */
53
+ function isUnsubscribeLink(url, unsubscribeUrl) {
54
+ if (unsubscribeUrl === void 0) return false;
55
+ try {
56
+ const a = new URL(url);
57
+ const b = new URL(unsubscribeUrl);
58
+ return a.origin === b.origin && a.pathname === b.pathname;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+ //#endregion
64
+ exports.assertEmailAddress = assertEmailAddress;
65
+ exports.assertEmailConfig = assertEmailConfig;
66
+ exports.assertEmailMetadata = assertEmailMetadata;
67
+ exports.assertUnsubscribeUrl = assertUnsubscribeUrl;
68
+ exports.formatEmailAddress = formatEmailAddress;
69
+ exports.isUnsubscribeLink = isUnsubscribeLink;
70
+ exports.resolveEmail = resolveEmail;
71
+
72
+ //# sourceMappingURL=email.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email.cjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["import type { EmailTemplate } from './templates';\n\n/**\n * Email metadata: who a template's email comes from, where replies go, and the unsubscribe link\n * its layout carries. Each `emailTemplate` declares its own `from` (and `replyTo`, if replies should\n * go elsewhere); the unsubscribe link is the template's `unsubscribeUrl`, else the config's\n * `email.unsubscribeUrl`, else none. `resolveEmail` is that rule, and the sender, the renderer,\n * the Worker's preview and the studio all go through it. No React and no I/O here, so a browser\n * can import it.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\n/** The config's `email`: what every email template shares. */\nexport interface EmailConfig {\n /**\n * The unsubscribe link of every email template that names none of its own. The layout reads it\n * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a\n * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.\n */\n unsubscribeUrl?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** One template's email metadata, resolved against the config. */\nexport interface ResolvedEmail {\n from: EmailAddress;\n /** Absent when the template names none: the email goes out without a Reply-To. */\n replyTo?: EmailAddress;\n /** Absent when neither the template nor the config names one: the email has no unsubscribe link. */\n unsubscribeUrl?: string;\n}\n\n/** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl'>,\n config?: EmailConfig\n): ResolvedEmail {\n const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;\n return {\n from: template.from,\n ...(template.replyTo === undefined ? {} : { replyTo: template.replyTo }),\n ...(unsubscribeUrl === undefined ? {} : { unsubscribeUrl }),\n };\n}\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+$/;\n\n/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */\nexport function assertEmailAddress(address: unknown, where: string): void {\n const email =\n typeof address === 'object' && address !== null\n ? (address as { email?: unknown }).email\n : address;\n const name =\n typeof address === 'object' && address !== null\n ? (address as { name?: unknown }).name\n : undefined;\n if (typeof email !== 'string' || !ADDRESS.test(email)) {\n throw new TypeError(`${where} must be an email address or { email, name? }`);\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError(`${where}.name must be a string`);\n }\n}\n\n/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */\nexport function assertUnsubscribeUrl(url: unknown, where: string): void {\n let parsed: URL | undefined;\n try {\n parsed = typeof url === 'string' ? new URL(url) : undefined;\n } catch {\n parsed = undefined;\n }\n if (parsed === undefined || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {\n throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);\n }\n}\n\n/** The config's `email` and every registered template's metadata; throws on the first invalid value. */\nexport function assertEmailConfig(\n config: { email?: EmailConfig },\n templates: Iterable<EmailTemplate>\n): void {\n if (Object.hasOwn(config, 'emails')) {\n throw new TypeError(\n \"the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, \" +\n 'and the rest moved to `email: { unsubscribeUrl, tracking }`'\n );\n }\n const email = config.email;\n if (email !== undefined) {\n if (email.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(email.unsubscribeUrl, 'email.unsubscribeUrl');\n }\n const quick = email.tracking?.quickClickMs;\n if (\n quick !== undefined &&\n !(typeof quick === 'number' && Number.isFinite(quick) && quick >= 0)\n ) {\n throw new TypeError('email.tracking.quickClickMs must be a non-negative number');\n }\n }\n for (const template of templates)\n assertEmailMetadata(template, `email template '${template.id}'`);\n}\n\n/** A template's own `from` / `replyTo` / `unsubscribeUrl`, as `emailTemplate` checks them. */\nexport function assertEmailMetadata(\n template: { from?: unknown; replyTo?: unknown; unsubscribeUrl?: unknown },\n where: string\n): void {\n assertEmailAddress(template.from, `${where}: from`);\n if (template.replyTo !== undefined) assertEmailAddress(template.replyTo, `${where}: replyTo`);\n if (template.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);\n }\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nexport function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean {\n if (unsubscribeUrl === undefined) return false;\n try {\n const a = new URL(url);\n const b = new URL(unsubscribeUrl);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n"],"mappings":";;;AAeA,SAAgB,mBAAmB,SAA+B;CAChE,IAAI,OAAO,YAAY,UAAU,OAAO;CACxC,OAAO,QAAQ,SAAS,KAAA,IAAY,QAAQ,QAAQ,GAAG,QAAQ,KAAK,IAAI,QAAQ,MAAM;AACxF;;AA+BA,SAAgB,aACd,UACA,QACe;CACf,MAAM,iBAAiB,SAAS,kBAAkB,QAAQ;CAC1D,OAAO;EACL,MAAM,SAAS;EACf,GAAI,SAAS,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,SAAS,QAAQ;EACtE,GAAI,mBAAmB,KAAA,IAAY,CAAC,IAAI,EAAE,eAAe;CAC3D;AACF;AAEA,MAAM,UAAU;;AAGhB,SAAgB,mBAAmB,SAAkB,OAAqB;CACxE,MAAM,QACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAAgC,QACjC;CACN,MAAM,OACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAA+B,OAChC,KAAA;CACN,IAAI,OAAO,UAAU,YAAY,CAAC,QAAQ,KAAK,KAAK,GAClD,MAAM,IAAI,UAAU,GAAG,MAAM,8CAA8C;CAE7E,IAAI,SAAS,KAAA,KAAa,OAAO,SAAS,UACxC,MAAM,IAAI,UAAU,GAAG,MAAM,uBAAuB;AAExD;;AAGA,SAAgB,qBAAqB,KAAc,OAAqB;CACtE,IAAI;CACJ,IAAI;EACF,SAAS,OAAO,QAAQ,WAAW,IAAI,IAAI,GAAG,IAAI,KAAA;CACpD,QAAQ;EACN,SAAS,KAAA;CACX;CACA,IAAI,WAAW,KAAA,KAAc,OAAO,aAAa,YAAY,OAAO,aAAa,SAC/E,MAAM,IAAI,UAAU,GAAG,MAAM,gDAAgD;AAEjF;;AAGA,SAAgB,kBACd,QACA,WACM;CACN,IAAI,OAAO,OAAO,QAAQ,QAAQ,GAChC,MAAM,IAAI,UACR,qJAEF;CAEF,MAAM,QAAQ,OAAO;CACrB,IAAI,UAAU,KAAA,GAAW;EACvB,IAAI,MAAM,mBAAmB,KAAA,GAC3B,qBAAqB,MAAM,gBAAgB,sBAAsB;EAEnE,MAAM,QAAQ,MAAM,UAAU;EAC9B,IACE,UAAU,KAAA,KACV,EAAE,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,KAAK,SAAS,IAElE,MAAM,IAAI,UAAU,2DAA2D;CAEnF;CACA,KAAK,MAAM,YAAY,WACrB,oBAAoB,UAAU,mBAAmB,SAAS,GAAG,EAAE;AACnE;;AAGA,SAAgB,oBACd,UACA,OACM;CACN,mBAAmB,SAAS,MAAM,GAAG,MAAM,OAAO;CAClD,IAAI,SAAS,YAAY,KAAA,GAAW,mBAAmB,SAAS,SAAS,GAAG,MAAM,UAAU;CAC5F,IAAI,SAAS,mBAAmB,KAAA,GAC9B,qBAAqB,SAAS,gBAAgB,GAAG,MAAM,iBAAiB;AAE5E;;AAGA,SAAgB,kBAAkB,KAAa,gBAA6C;CAC1F,IAAI,mBAAmB,KAAA,GAAW,OAAO;CACzC,IAAI;EACF,MAAM,IAAI,IAAI,IAAI,GAAG;EACrB,MAAM,IAAI,IAAI,IAAI,cAAc;EAChC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE;CACnD,QAAQ;EACN,OAAO;CACT;AACF"}
@@ -0,0 +1,63 @@
1
+ import { EmailTemplate } from "./templates.cjs";
2
+ //#region src/email.d.ts
3
+ /**
4
+ * Email metadata: who a template's email comes from, where replies go, and the unsubscribe link
5
+ * its layout carries. Each `emailTemplate` declares its own `from` (and `replyTo`, if replies should
6
+ * go elsewhere); the unsubscribe link is the template's `unsubscribeUrl`, else the config's
7
+ * `email.unsubscribeUrl`, else none. `resolveEmail` is that rule, and the sender, the renderer,
8
+ * the Worker's preview and the studio all go through it. No React and no I/O here, so a browser
9
+ * can import it.
10
+ */
11
+ /** An address as the transports take it: bare, or with a display name. */
12
+ export type EmailAddress = string | {
13
+ email: string;
14
+ name?: string;
15
+ };
16
+ /** `Name <email>`, or the bare address. */
17
+ export declare function formatEmailAddress(address: EmailAddress): string;
18
+ /** The config's `email`: what every email template shares. */
19
+ export interface EmailConfig {
20
+ /**
21
+ * The unsubscribe link of every email template that names none of its own. The layout reads it
22
+ * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a
23
+ * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.
24
+ */
25
+ unsubscribeUrl?: string;
26
+ /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */
27
+ tracking?: {
28
+ /**
29
+ * An open or click this many milliseconds after delivery is a weak machine signal — machine
30
+ * only together with another (a second link within seconds); 1000 by default. A person
31
+ * tapping a mail notification can click within a few seconds, so keep it short.
32
+ */
33
+ quickClickMs?: number;
34
+ };
35
+ }
36
+ /** One template's email metadata, resolved against the config. */
37
+ export interface ResolvedEmail {
38
+ from: EmailAddress;
39
+ /** Absent when the template names none: the email goes out without a Reply-To. */
40
+ replyTo?: EmailAddress;
41
+ /** Absent when neither the template nor the config names one: the email has no unsubscribe link. */
42
+ unsubscribeUrl?: string;
43
+ }
44
+ /** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */
45
+ export declare function resolveEmail(template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl'>, config?: EmailConfig): ResolvedEmail;
46
+ /** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
47
+ export declare function assertEmailAddress(address: unknown, where: string): void;
48
+ /** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
49
+ export declare function assertUnsubscribeUrl(url: unknown, where: string): void;
50
+ /** The config's `email` and every registered template's metadata; throws on the first invalid value. */
51
+ export declare function assertEmailConfig(config: {
52
+ email?: EmailConfig;
53
+ }, templates: Iterable<EmailTemplate>): void;
54
+ /** A template's own `from` / `replyTo` / `unsubscribeUrl`, as `emailTemplate` checks them. */
55
+ export declare function assertEmailMetadata(template: {
56
+ from?: unknown;
57
+ replyTo?: unknown;
58
+ unsubscribeUrl?: unknown;
59
+ }, where: string): void;
60
+ /** The unsubscribe link may carry its own query (a token, a list): the path names it. */
61
+ export declare function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean;
62
+ //#endregion
63
+ //# sourceMappingURL=email.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email.d.cts","names":[],"sources":["../src/email.ts"],"mappings":";;;;;;;;;;;YAYY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;iBAM3B;;;;;;EAMf;;EAEA;;;;;;IAME;;;;iBAKa;EACf,MAAM;;EAEN,UAAU;;EAEV;;;wBAIc,aACd,UAAU,KAAK,uDACf,SAAS,cACR;;wBAYa,mBAAmB,kBAAkB;;wBAkBrC,qBAAqB,cAAc;;wBAanC,kBACd;EAAU,QAAQ;GAClB,WAAW,SAAS;;wBA0BN,oBACd;EAAY;EAAgB;EAAmB;GAC/C;;wBAUc,kBAAkB,aAAa"}