create-ailk 0.31.0 → 0.33.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 (86) hide show
  1. package/README.md +50 -8
  2. package/component-catalog.json +12 -1
  3. package/component-catalog.md +6 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cutover.d.ts +6 -1
  7. package/dist/cutover.js +23 -6
  8. package/dist/export-content.d.ts +6 -3
  9. package/dist/export-content.js +79 -10
  10. package/dist/language-import.d.ts +16 -3
  11. package/dist/language-import.js +27 -11
  12. package/dist/lib/import-mdx.d.ts +42 -0
  13. package/dist/lib/import-mdx.js +584 -65
  14. package/dist/lib/render-comparison.d.ts +51 -0
  15. package/dist/lib/render-comparison.js +205 -6
  16. package/dist/mdx-braces.d.ts +73 -0
  17. package/dist/mdx-braces.js +285 -0
  18. package/dist/migrate-content.d.ts +1 -1
  19. package/dist/migrate-content.js +4 -2
  20. package/dist/programmatic.js +2 -8
  21. package/dist/seed-flows.d.ts +1 -1
  22. package/dist/seed-flows.js +9 -4
  23. package/dist/site-manifest.js +6 -38
  24. package/dist/site-roster.d.ts +46 -3
  25. package/dist/site-roster.js +124 -4
  26. package/dist/surfaces.d.ts +8 -0
  27. package/dist/surfaces.js +17 -0
  28. package/dist/update.d.ts +21 -1
  29. package/dist/update.js +142 -11
  30. package/package.json +3 -3
  31. package/templates/.github/workflows/ci.yml +2 -0
  32. package/templates/apps/api/CLAUDE.md +7 -5
  33. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  34. package/templates/apps/mcp/.env.example +7 -1
  35. package/templates/apps/web/__tests__/setup.ts +9 -0
  36. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
  37. package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
  38. package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
  39. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
  40. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
  43. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  44. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  45. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  46. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  47. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
  48. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
  49. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
  50. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
  51. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
  52. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
  53. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
  54. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
  55. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
  56. package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
  57. package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
  58. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
  59. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  60. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  61. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
  67. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
  68. package/templates/apps/web/auth.ts +22 -6
  69. package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
  70. package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
  71. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
  72. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
  73. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
  74. package/templates/apps/web/lib/account-config.ts +52 -14
  75. package/templates/apps/web/lib/auth-email.ts +87 -5
  76. package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
  77. package/templates/apps/web/messages/en.json +26 -1
  78. package/templates/apps/web/src/lib/routes.ts +5 -3
  79. package/templates/database/seed.ts +7 -7
  80. package/templates/package.json +2 -1
  81. package/templates/tools/body-heading.ts +440 -0
  82. package/templates/tools/check-mdx-body.ts +123 -0
  83. package/templates/tools/mdx-braces.ts +303 -0
  84. package/versions.json +48 -48
  85. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
  86. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Bare `{…}` braces on mdx-body pages (#10773, spec
3
+ * docs/specs/10773-update-never-refreshes-0-19-spec.md D3, D4).
4
+ *
5
+ * MDX reads every `{` in body text as the start of a JavaScript expression.
6
+ * Under the renderer's `next-mdx-remote` defaults (`blockJS`), an expression
7
+ * that does not parse fails the page, and one that parses is deleted — so on
8
+ * an mdx-body page every `{` in text other than a `{/* … *\/}` comment is a
9
+ * defect. This module is the one lexical rule for that brace:
10
+ *
11
+ * - `findBareBraces` names every bare brace in a body, by offset, line and
12
+ * column, in one left-to-right pass;
13
+ * - `escapeBareBraces` inserts one `\` before each, and keeps the result only
14
+ * when the site's own MDX parser reads it with no expression left but
15
+ * comments — the lexer proposes, the parser disposes.
16
+ *
17
+ * Used by `create-ailk update` and, through a byte-identical copy in the
18
+ * scaffold's `tools/`, by `pnpm mdx-body:check`. Pure: no filesystem and no
19
+ * imports. It must never contain a double open brace, which the scaffold copy
20
+ * substitutes as a marker.
21
+ */
22
+
23
+ export interface BareBrace {
24
+ /** Offset of the `{` in the body. */
25
+ offset: number;
26
+ /** 1-based line within the body. */
27
+ line: number;
28
+ /** 1-based column within the line. */
29
+ column: number;
30
+ /** `comment-like` when the next non-whitespace character is `/`. */
31
+ kind: "placeholder" | "comment-like";
32
+ }
33
+
34
+ /** A minimal mdast node: what `escapeBareBraces` reads from the parser's tree. */
35
+ export interface MdastNode {
36
+ type: string;
37
+ value?: unknown;
38
+ position?: { start?: { line?: number; offset?: number }; end?: { offset?: number } };
39
+ children?: MdastNode[];
40
+ }
41
+
42
+ /** The site's MDX parser: throws the parser's message (carrying `line` and `reason`) on failure. */
43
+ export type MdxParse = (body: string) => MdastNode;
44
+
45
+ export type EscapeResult =
46
+ | { kind: "clean" }
47
+ | { kind: "escaped"; text: string; count: number }
48
+ | { kind: "refused"; reason: string };
49
+
50
+ const ESM_RE = /^(?:import|export)[ {*]/;
51
+ // MDX has no indented code, so a fence opens at any indentation — and inside
52
+ // a list item or a blockquote, after its markers.
53
+ const FENCE_RE = /^[ \t]*(?:(?:>|[-*+]|\d{1,9}[.)])[ \t]*)*(`{3,}|~{3,})/;
54
+ const COMMENT_CLOSE_RE = /\*\/\s*\}/g;
55
+ const WHITESPACE_RE = /\s/;
56
+ const TAG_START_RE = /[A-Za-z/>]/;
57
+
58
+ const isBlank = (line: string): boolean => line.trim() === "";
59
+
60
+ /**
61
+ * Every bare `{` in an MDX body. Skips, in this order of precedence: fenced
62
+ * code, ESM blocks, backslash escapes, inline code, JSX tags (attribute
63
+ * expressions included) and `{/* … *\/}` comments. Linear in the body.
64
+ */
65
+ export function findBareBraces(body: string): BareBrace[] {
66
+ const found: Array<Omit<BareBrace, "line" | "column">> = [];
67
+ const lineEnd = (from: number): number => {
68
+ const end = body.indexOf("\n", from);
69
+ return end === -1 ? body.length : end;
70
+ };
71
+ // A code span or a tag never crosses a blank line: true at a `\n` that ends
72
+ // the body or starts a blank line.
73
+ const paragraphBreak = (at: number): boolean => {
74
+ let j = at + 1;
75
+ while (body[j] === " " || body[j] === "\t" || body[j] === "\r") j += 1;
76
+ return j >= body.length || body[j] === "\n";
77
+ };
78
+ // Memo so a failed search is never repeated over the same text.
79
+ const spanFailedUntil = new Map<number, number>();
80
+ let tagFailedUntil = -1;
81
+ let commentClose: { from: number; at: number } | undefined;
82
+
83
+ let i = 0;
84
+ while (i < body.length) {
85
+ if (i === 0 || body[i - 1] === "\n") {
86
+ const end = lineEnd(i);
87
+ const line = body.slice(i, end);
88
+ const fence = FENCE_RE.exec(line)?.[1];
89
+ if (fence !== undefined) {
90
+ // Skip to after the closing fence line, or to the end of the body.
91
+ let at = end;
92
+ while (at < body.length) {
93
+ const next = lineEnd(at + 1);
94
+ const candidate = body.slice(at + 1, next);
95
+ const run = FENCE_RE.exec(candidate);
96
+ at = next;
97
+ if (
98
+ run !== null &&
99
+ run[1]![0] === fence[0] &&
100
+ run[1]!.length >= fence.length &&
101
+ candidate.slice(run[0].length).trim() === ""
102
+ ) {
103
+ break;
104
+ }
105
+ }
106
+ i = at + 1;
107
+ continue;
108
+ }
109
+ if (ESM_RE.test(line)) {
110
+ let at = end;
111
+ while (at < body.length) {
112
+ const next = lineEnd(at + 1);
113
+ if (isBlank(body.slice(at + 1, next))) break;
114
+ at = next;
115
+ }
116
+ i = at + 1;
117
+ continue;
118
+ }
119
+ }
120
+
121
+ const char = body[i]!;
122
+ if (char === "\\") {
123
+ // A backslash before a line break is a hard break; the next line is read as a line.
124
+ i += body[i + 1] === "\n" ? 1 : 2;
125
+ continue;
126
+ }
127
+ if (char === "`") {
128
+ let run = 1;
129
+ while (body[i + run] === "`") run += 1;
130
+ const close = spanFailedUntil.get(run) ?? -1;
131
+ let closed = -1;
132
+ if (close < i) {
133
+ let at = i + run;
134
+ for (; at < body.length; at += 1) {
135
+ if (body[at] === "\n" && (paragraphBreak(at) || FENCE_RE.test(body.slice(at + 1, lineEnd(at + 1))))) break;
136
+ if (body[at] !== "`") continue;
137
+ let length = 1;
138
+ while (body[at + length] === "`") length += 1;
139
+ if (length === run) {
140
+ closed = at + length;
141
+ break;
142
+ }
143
+ at += length - 1;
144
+ }
145
+ if (closed === -1) spanFailedUntil.set(run, at);
146
+ }
147
+ i = closed === -1 ? i + run : closed;
148
+ continue;
149
+ }
150
+ if (char === "<" && TAG_START_RE.test(body[i + 1] ?? "") && i >= tagFailedUntil) {
151
+ let depth = 0;
152
+ let quote = "";
153
+ let closed = -1;
154
+ let at = i + 1;
155
+ for (; at < body.length; at += 1) {
156
+ const c = body[at]!;
157
+ if (c === "\n" && paragraphBreak(at)) break;
158
+ if (quote !== "") {
159
+ if (c === quote) quote = "";
160
+ } else if (depth > 0) {
161
+ if (c === "{") depth += 1;
162
+ else if (c === "}") depth -= 1;
163
+ } else if (c === '"' || c === "'") {
164
+ quote = c;
165
+ } else if (c === "{") {
166
+ depth = 1;
167
+ } else if (c === ">") {
168
+ closed = at + 1;
169
+ break;
170
+ }
171
+ }
172
+ if (closed !== -1) {
173
+ i = closed;
174
+ continue;
175
+ }
176
+ // An unclosed tag already breaks the page; no later `<` before `at` is
177
+ // read as a tag, which keeps the scan linear.
178
+ tagFailedUntil = at;
179
+ i += 1;
180
+ continue;
181
+ }
182
+ if (char === "{") {
183
+ let next = i + 1;
184
+ while (next < body.length && WHITESPACE_RE.test(body[next]!)) next += 1;
185
+ if (body[next] === "/" && body[next + 1] === "*") {
186
+ if (commentClose === undefined || commentClose.from > i || (commentClose.at !== -1 && commentClose.at < i)) {
187
+ COMMENT_CLOSE_RE.lastIndex = next + 2;
188
+ const match = COMMENT_CLOSE_RE.exec(body);
189
+ commentClose = { from: i, at: match === null ? -1 : match.index + match[0].length };
190
+ }
191
+ if (commentClose.at !== -1) {
192
+ i = commentClose.at;
193
+ continue;
194
+ }
195
+ }
196
+ found.push({ offset: i, kind: body[next] === "/" ? "comment-like" : "placeholder" });
197
+ }
198
+ i += 1;
199
+ }
200
+
201
+ // Offsets to 1-based line and column, in one pass.
202
+ const braces: BareBrace[] = [];
203
+ let line = 1;
204
+ let lineStart = 0;
205
+ let scanned = 0;
206
+ for (const brace of found) {
207
+ for (; scanned < brace.offset; scanned += 1) {
208
+ if (body[scanned] === "\n") {
209
+ line += 1;
210
+ lineStart = scanned + 1;
211
+ }
212
+ }
213
+ braces.push({ ...brace, line, column: brace.offset - lineStart + 1 });
214
+ }
215
+ return braces;
216
+ }
217
+
218
+ const COMMENT_VALUE_RE = /^\s*\/\*[\s\S]*\*\/\s*$/;
219
+
220
+ /** The first expression node left in the tree that is not a comment. */
221
+ export function firstExpression(node: MdastNode): MdastNode | undefined {
222
+ if (
223
+ (node.type === "mdxFlowExpression" || node.type === "mdxTextExpression") &&
224
+ !(typeof node.value === "string" && COMMENT_VALUE_RE.test(node.value))
225
+ ) {
226
+ return node;
227
+ }
228
+ for (const child of node.children ?? []) {
229
+ const hit = firstExpression(child);
230
+ if (hit !== undefined) return hit;
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ /** The first `code` or `inlineCode` node whose span holds one of `offsets` (sorted). */
236
+ function codeNodeHolding(node: MdastNode, offsets: number[]): MdastNode | undefined {
237
+ if (node.type === "code" || node.type === "inlineCode") {
238
+ const start = node.position?.start?.offset;
239
+ const end = node.position?.end?.offset;
240
+ if (typeof start === "number" && typeof end === "number") {
241
+ let lo = 0;
242
+ let hi = offsets.length;
243
+ while (lo < hi) {
244
+ const mid = (lo + hi) >> 1;
245
+ if (offsets[mid]! < start) lo = mid + 1;
246
+ else hi = mid;
247
+ }
248
+ if (lo < offsets.length && offsets[lo]! < end) return node;
249
+ }
250
+ }
251
+ for (const child of node.children ?? []) {
252
+ const hit = codeNodeHolding(child, offsets);
253
+ if (hit !== undefined) return hit;
254
+ }
255
+ return undefined;
256
+ }
257
+
258
+ /**
259
+ * Escape every bare brace in `body`, all or nothing: `clean` when there is
260
+ * none (`parse` is not called), `refused` when a brace is a mangled comment,
261
+ * the escaped text does not parse, or an expression the escape did not reach
262
+ * remains; otherwise `escaped`.
263
+ */
264
+ export function escapeBareBraces(body: string, parse: MdxParse): EscapeResult {
265
+ const braces = findBareBraces(body);
266
+ if (braces.length === 0) return { kind: "clean" };
267
+ const mangled = braces.find((brace) => brace.kind === "comment-like");
268
+ if (mangled !== undefined) {
269
+ return { kind: "refused", reason: `{/ at line ${mangled.line} is not a {/* … */} comment` };
270
+ }
271
+ const parts: string[] = [];
272
+ let from = 0;
273
+ for (const { offset } of braces) {
274
+ parts.push(body.slice(from, offset), "\\");
275
+ from = offset;
276
+ }
277
+ parts.push(body.slice(from));
278
+ const text = parts.join("");
279
+ let tree: MdastNode;
280
+ try {
281
+ tree = parse(text);
282
+ } catch (err) {
283
+ const { line, reason } = (err ?? {}) as { line?: unknown; reason?: unknown };
284
+ const why = typeof reason === "string" ? reason : err instanceof Error ? err.message : String(err);
285
+ return { kind: "refused", reason: `line ${typeof line === "number" ? line : "?"}: ${why}` };
286
+ }
287
+ const escapes = braces.map((brace, k) => brace.offset + k);
288
+ const inCode = codeNodeHolding(tree, escapes);
289
+ if (inCode !== undefined) {
290
+ return {
291
+ kind: "refused",
292
+ reason: `a brace at line ${inCode.position?.start?.line ?? "?"} is inside code the lexer did not see`,
293
+ };
294
+ }
295
+ const left = firstExpression(tree);
296
+ if (left !== undefined) {
297
+ return {
298
+ kind: "refused",
299
+ reason: `an expression at line ${left.position?.start?.line ?? "?"} the escape did not reach`,
300
+ };
301
+ }
302
+ return { kind: "escaped", text, count: braces.length };
303
+ }
package/versions.json CHANGED
@@ -1,66 +1,66 @@
1
1
  {
2
- "@working-theory/admin-ui": "0.54.0",
3
- "@working-theory/aeo": "0.54.0",
4
- "@working-theory/agent-context": "0.54.0",
2
+ "@working-theory/admin-ui": "0.56.0",
3
+ "@working-theory/aeo": "0.56.0",
4
+ "@working-theory/agent-context": "0.56.0",
5
5
  "@working-theory/agent-contract": "0.1.0",
6
6
  "@working-theory/agent-memory": "0.0.0",
7
7
  "@working-theory/ai": "0.1.0",
8
- "@working-theory/analytics": "0.54.0",
9
- "@working-theory/api": "0.54.0",
10
- "@working-theory/api-client": "0.54.0",
8
+ "@working-theory/analytics": "0.56.0",
9
+ "@working-theory/api": "0.56.0",
10
+ "@working-theory/api-client": "0.56.0",
11
11
  "@working-theory/cda-filesystem": "0.0.0",
12
- "@working-theory/cli": "0.54.0",
13
- "@working-theory/common": "0.54.0",
12
+ "@working-theory/cli": "0.56.0",
13
+ "@working-theory/common": "0.56.0",
14
14
  "@working-theory/consent": "0.1.0",
15
- "@working-theory/content-adapters": "0.54.0",
16
- "@working-theory/content-projection": "0.54.0",
17
- "@working-theory/email": "0.54.0",
15
+ "@working-theory/content-adapters": "0.56.0",
16
+ "@working-theory/content-projection": "0.56.0",
17
+ "@working-theory/email": "0.56.0",
18
18
  "@working-theory/env": "0.0.0",
19
- "@working-theory/eslint-config": "0.54.0",
20
- "@working-theory/fumadocs-schema": "0.54.0",
21
- "@working-theory/ids": "0.19.0",
22
- "@working-theory/jsonld": "0.54.0",
23
- "@working-theory/license": "0.54.0",
24
- "@working-theory/mcp-server": "0.54.0",
25
- "@working-theory/metadata": "0.54.0",
26
- "@working-theory/observability": "0.54.0",
27
- "@working-theory/page-renderer": "0.54.0",
19
+ "@working-theory/eslint-config": "0.56.0",
20
+ "@working-theory/fumadocs-schema": "0.56.0",
21
+ "@working-theory/ids": "0.20.0",
22
+ "@working-theory/jsonld": "0.56.0",
23
+ "@working-theory/license": "0.56.0",
24
+ "@working-theory/mcp-server": "0.56.0",
25
+ "@working-theory/metadata": "0.56.0",
26
+ "@working-theory/observability": "0.56.0",
27
+ "@working-theory/page-renderer": "0.56.0",
28
28
  "@working-theory/plugin-mcp": "0.1.3",
29
- "@working-theory/schema": "0.54.0",
30
- "@working-theory/schema-pack-registry": "0.54.0",
31
- "@working-theory/schema-validator": "0.54.0",
32
- "@working-theory/sections-registry": "0.54.0",
33
- "@working-theory/site-config": "0.54.0",
34
- "@working-theory/spoke-checkout": "0.7.10",
35
- "@working-theory/stripe-client": "0.54.0",
36
- "@working-theory/templates": "0.54.0",
37
- "@working-theory/theme": "0.54.0",
38
- "@working-theory/ui": "0.54.0",
39
- "@working-theory/validation": "0.54.0",
40
- "@working-theory/web-i18n": "0.54.0",
41
- "@working-theory/admin": "0.10.0",
42
- "@working-theory/api-commerce": "0.1.8",
43
- "@working-theory/api-content-write": "0.5.0",
44
- "@working-theory/api-hub": "0.4.0",
45
- "@working-theory/api-multi-tenant": "0.2.3",
46
- "@working-theory/api-scheduling": "0.4.5",
47
- "@working-theory/api-signal-collectors": "0.3.1",
48
- "@working-theory/api-workspace": "0.5.0",
29
+ "@working-theory/schema": "0.56.0",
30
+ "@working-theory/schema-pack-registry": "0.56.0",
31
+ "@working-theory/schema-validator": "0.56.0",
32
+ "@working-theory/sections-registry": "0.56.0",
33
+ "@working-theory/site-config": "0.56.0",
34
+ "@working-theory/spoke-checkout": "0.7.12",
35
+ "@working-theory/stripe-client": "0.56.0",
36
+ "@working-theory/templates": "0.56.0",
37
+ "@working-theory/theme": "0.56.0",
38
+ "@working-theory/ui": "0.56.0",
39
+ "@working-theory/validation": "0.56.0",
40
+ "@working-theory/web-i18n": "0.56.0",
41
+ "@working-theory/admin": "0.11.0",
42
+ "@working-theory/api-commerce": "0.1.10",
43
+ "@working-theory/api-content-write": "0.6.0",
44
+ "@working-theory/api-hub": "0.4.2",
45
+ "@working-theory/api-multi-tenant": "0.2.5",
46
+ "@working-theory/api-scheduling": "0.4.7",
47
+ "@working-theory/api-signal-collectors": "0.4.0",
48
+ "@working-theory/api-workspace": "0.6.0",
49
49
  "@working-theory/billing-client": "0.0.0",
50
50
  "@working-theory/branding": "0.0.0",
51
51
  "@working-theory/license-client": "0.0.0",
52
52
  "@working-theory/license-manifest": "0.0.0",
53
- "@working-theory/mcp-scheduling": "0.3.7",
54
- "@working-theory/mcp-signal-collectors": "0.2.8",
55
- "@working-theory/mcp-workspace": "0.6.0",
53
+ "@working-theory/mcp-scheduling": "0.3.9",
54
+ "@working-theory/mcp-signal-collectors": "0.2.10",
55
+ "@working-theory/mcp-workspace": "0.6.2",
56
56
  "@working-theory/partner-session": "0.0.0",
57
57
  "@working-theory/preview-protocol": "0.0.0",
58
- "@working-theory/ui-commerce": "0.23.32",
59
- "@working-theory/ui-docs-blog": "0.23.32",
60
- "@working-theory/ui-scheduling": "0.25.6",
61
- "@working-theory/ui-signal-collectors": "0.27.10",
58
+ "@working-theory/ui-commerce": "0.23.34",
59
+ "@working-theory/ui-docs-blog": "0.23.34",
60
+ "@working-theory/ui-scheduling": "0.25.8",
61
+ "@working-theory/ui-signal-collectors": "0.28.0",
62
62
  "@working-theory/web": "0.0.0",
63
63
  "@working-theory/app-api": "1.0.0",
64
64
  "@working-theory/mcp": "0.0.0",
65
- "@working-theory/database": "0.54.0"
65
+ "@working-theory/database": "0.56.0"
66
66
  }
@@ -1,89 +0,0 @@
1
- "use client"; // Reads sessionStorage (a browser API) once on mount — a server page cannot.
2
-
3
- /**
4
- * AccountInviteLink — the /thanks page's "Track this in your account" link
5
- * (#8771 D6). The magnet walk writes one `sessionStorage` item,
6
- * `ailk:account-invite`, before it navigates here; this shows the link only
7
- * for the thank-you page that walk was headed to, and only if the stored href
8
- * is a same-site sign-in path. It reads the item once and removes it, so the
9
- * email in the href does not stay in the tab. A refresh shows no link (the
10
- * invite email still carries it).
11
- *
12
- * Base tree: it imports nothing from `flows/**`, so a build without the walk
13
- * still compiles this page.
14
- */
15
- import { Button, Container, Link } from "@working-theory/ui";
16
- import type { ReactElement } from "react";
17
- import { useEffect, useState } from "react";
18
-
19
- /** Must equal `ACCOUNT_INVITE_STORAGE_KEY` in the walk's `FlowStepperClient`. */
20
- const STORAGE_KEY = "ailk:account-invite";
21
- /** A reserved name that never resolves: only used to test an href stays on-site. */
22
- const PLACEHOLDER_ORIGIN = "https://placeholder.invalid";
23
- const HUB_SIGN_IN_PATH = "/api/hub-session/sign-in";
24
-
25
- interface Props {
26
- /** The route's `[slug]` — the hand-off must have been written for this page. */
27
- thanksSlug: string;
28
- /** `contact.trackInAccount`, read on the server. */
29
- label: string;
30
- }
31
-
32
- /** The stored href when it is a same-site sign-in path, else `null`. */
33
- function safeSignInHref(raw: unknown, thanks: unknown, thanksSlug: string): string | null {
34
- if (typeof raw !== "string" || thanks !== thanksSlug) return null;
35
- let url: URL;
36
- try {
37
- url = new URL(raw, PLACEHOLDER_ORIGIN);
38
- } catch {
39
- return null;
40
- }
41
- if (url.origin !== PLACEHOLDER_ORIGIN) return null;
42
- if (url.pathname !== HUB_SIGN_IN_PATH && !url.pathname.endsWith("/sign-in")) {
43
- return null;
44
- }
45
- return raw;
46
- }
47
-
48
- /** Read the hand-off once, remove it, and answer the link to show (or `null`). */
49
- function takeHandOff(thanksSlug: string): string | null {
50
- try {
51
- const stored = window.sessionStorage.getItem(STORAGE_KEY);
52
- if (stored === null) return null;
53
- window.sessionStorage.removeItem(STORAGE_KEY);
54
- const parsed: unknown = JSON.parse(stored);
55
- if (typeof parsed !== "object" || parsed === null) return null;
56
- const { thanks, href } = parsed as { thanks?: unknown; href?: unknown };
57
- return safeSignInHref(href, thanks, thanksSlug);
58
- } catch {
59
- return null;
60
- }
61
- }
62
-
63
- export function AccountInviteLink({ thanksSlug, label }: Props): ReactElement | null {
64
- const [href, setHref] = useState<string | null>(null);
65
-
66
- useEffect(() => {
67
- // Only ever sets the link: React's development double-mount runs this
68
- // twice, and the second run finds the item already removed.
69
- const found = takeHandOff(thanksSlug);
70
- if (found !== null) setHref(found);
71
- }, [thanksSlug]);
72
-
73
- if (href === null) return null;
74
- return (
75
- // Session Replay records the DOM, so the link (it carries the email) is
76
- // blocked from it; `Link` does not forward an undeclared data-* attribute.
77
- // The page shell sets no gutters, so the link sits in its own Container,
78
- // lined up with the thank-you content above it.
79
- <Container className="pb-section">
80
- <div data-sentry-block>
81
- <Button asChild variant="outline" className="min-h-11">
82
- <Link href={href} variant="bare" data-testid="thanks-account-invite">
83
- {label}
84
- </Link>
85
- </Button>
86
- </div>
87
- </Container>
88
- );
89
- }
@@ -1,104 +0,0 @@
1
- /**
2
- * #8771 AC-5 — /thanks shows only its own hand-off: the walk's sessionStorage
3
- * item, for this page's slug, when its href is a same-site sign-in path.
4
- */
5
- import { existsSync, readFileSync } from "node:fs";
6
- import { join } from "node:path";
7
-
8
- import { render, screen } from "@testing-library/react";
9
- import React, { StrictMode } from "react";
10
-
11
- import { AccountInviteLink } from "../AccountInviteLink";
12
-
13
- const KEY = "ailk:account-invite";
14
- const LABEL = "Track this in your account";
15
- const SITE_HREF = "/sign-in?next=%2Faccount&email=jane%40example.com";
16
- const HUB_HREF = "/api/hub-session/sign-in?next=%2Faccount&email=x%40y.example";
17
- const WALK = join(__dirname, "..", "..", "..", "flows", "[slug]", "FlowStepperClient.tsx");
18
- // A Free scaffold ships no magnet walk, and the scaffold matrix runs this suite
19
- // inside one (#8799), so the key check skips where the walk does not exist.
20
- const itWithWalk = existsSync(WALK) ? it : it.skip;
21
-
22
- function store(value: unknown): void {
23
- window.sessionStorage.setItem(KEY, typeof value === "string" ? value : JSON.stringify(value));
24
- }
25
-
26
- function show(slug = "flow-thanks") {
27
- return render(<AccountInviteLink thanksSlug={slug} label={LABEL} />);
28
- }
29
-
30
- beforeEach(() => window.sessionStorage.clear());
31
-
32
- describe("AccountInviteLink", () => {
33
- it("renders the stored link for its own slug, blocked from Replay, and removes the item", () => {
34
- store({ thanks: "flow-thanks", href: SITE_HREF });
35
-
36
- show();
37
-
38
- const link = screen.getByTestId("thanks-account-invite");
39
- expect(link).toHaveAttribute("href", SITE_HREF);
40
- expect(link).toHaveTextContent(LABEL);
41
- expect(link.closest("[data-sentry-block]")).not.toBeNull();
42
- expect(window.sessionStorage.getItem(KEY)).toBeNull();
43
- });
44
-
45
- it("renders a hub spoke's sign-in path under the matching slug", () => {
46
- store({ thanks: "flow-thanks", href: HUB_HREF });
47
- show();
48
- expect(screen.getByTestId("thanks-account-invite")).toHaveAttribute("href", HUB_HREF);
49
- });
50
-
51
- it("renders a locale-prefixed site sign-in path", () => {
52
- store({ thanks: "flow-thanks", href: "/fr/sign-in?email=a%40b.example" });
53
- show();
54
- expect(screen.getByTestId("thanks-account-invite")).toBeInTheDocument();
55
- });
56
-
57
- it.each([
58
- ["another slug", { thanks: "other", href: SITE_HREF }, "flow-thanks"],
59
- ["an off-site href", { thanks: "flow-thanks", href: "https://evil.example/sign-in" }, "flow-thanks"],
60
- ["a protocol-relative href", { thanks: "flow-thanks", href: "//evil.example/sign-in" }, "flow-thanks"],
61
- ["a non-sign-in path", { thanks: "flow-thanks", href: "/account" }, "flow-thanks"],
62
- ["a hub href under another thanks", { thanks: "flow-thanks-qualified", href: HUB_HREF }, "flow-thanks"],
63
- ["a value that is not JSON", "not json", "flow-thanks"],
64
- ["a JSON null", "null", "flow-thanks"],
65
- ])("renders nothing for %s", (_name, value, slug) => {
66
- store(value);
67
- show(slug);
68
- expect(screen.queryByTestId("thanks-account-invite")).toBeNull();
69
- });
70
-
71
- it("renders nothing when sessionStorage throws", () => {
72
- const spy = jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
73
- throw new Error("denied");
74
- });
75
- show();
76
- expect(screen.queryByTestId("thanks-account-invite")).toBeNull();
77
- spy.mockRestore();
78
- });
79
-
80
- it("shows the link once: a fresh mount after it (a refresh) renders nothing", () => {
81
- store({ thanks: "flow-thanks", href: SITE_HREF });
82
- const first = show();
83
- expect(screen.getByTestId("thanks-account-invite")).toBeInTheDocument();
84
- first.unmount();
85
-
86
- show();
87
- expect(screen.queryByTestId("thanks-account-invite")).toBeNull();
88
- });
89
-
90
- it("keeps the link under React.StrictMode's double effect", () => {
91
- store({ thanks: "flow-thanks", href: SITE_HREF });
92
- render(
93
- <StrictMode>
94
- <AccountInviteLink thanksSlug="flow-thanks" label={LABEL} />
95
- </StrictMode>,
96
- );
97
- expect(screen.getByTestId("thanks-account-invite")).toBeInTheDocument();
98
- });
99
-
100
- itWithWalk("reads the same storage key the walk writes (the two constants are tied only by this test)", () => {
101
- const walk = readFileSync(WALK, "utf8");
102
- expect(walk).toContain(`ACCOUNT_INVITE_STORAGE_KEY = "${KEY}"`);
103
- });
104
- });