create-feedbax 0.1.0 → 0.2.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 (210) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +1261 -26
  4. package/dist/index.js +1402 -160
  5. package/package.json +40 -14
  6. package/template/.feedbax-template.sha256 +1 -0
  7. package/template/.gitignore.template +6 -0
  8. package/template/.npmignore.template +5 -0
  9. package/template/.prettierignore.template +4 -0
  10. package/template/LICENSE +201 -0
  11. package/template/components.json +25 -0
  12. package/template/eslint.config.mjs +25 -0
  13. package/template/package.json +70 -14
  14. package/template/public/feedbax-mark.svg +4 -0
  15. package/template/public/robots.txt +3 -0
  16. package/template/src/authorized-draft-post.test.tsx +212 -0
  17. package/template/src/authorized-draft-post.tsx +204 -0
  18. package/template/src/authorized-draft-query.ts +74 -0
  19. package/template/src/browser-post-state.test.ts +120 -0
  20. package/template/src/browser-post-state.ts +134 -0
  21. package/template/src/browser-vote-state.test.ts +14 -0
  22. package/template/src/browser-vote-state.ts +27 -0
  23. package/template/src/capability-authorization-error.ts +3 -0
  24. package/template/src/changelog-page.test.tsx +139 -0
  25. package/template/src/changelog-page.tsx +219 -0
  26. package/template/src/changelog-query.ts +13 -0
  27. package/template/src/changelog-runtime.ts +27 -0
  28. package/template/src/cloudflare-turnstile.ts +31 -0
  29. package/template/src/cloudflare-workers-test.ts +3 -0
  30. package/template/src/cloudflare-workers.d.ts +18 -0
  31. package/template/src/comment-creation.test.tsx +270 -0
  32. package/template/src/comment-profile.ts +5 -0
  33. package/template/src/comment-thread-page.ts +18 -0
  34. package/template/src/components/command-palette-context.tsx +71 -0
  35. package/template/src/components/command-palette.tsx +246 -0
  36. package/template/src/components/create-post-trigger.tsx +44 -0
  37. package/template/src/components/device-profile-provider.tsx +309 -0
  38. package/template/src/components/header.tsx +95 -0
  39. package/template/src/components/loader.tsx +10 -0
  40. package/template/src/components/profile-menu.tsx +89 -0
  41. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  42. package/template/src/components/ui/components/attachment.tsx +207 -0
  43. package/template/src/components/ui/components/avatar.tsx +107 -0
  44. package/template/src/components/ui/components/badge.tsx +52 -0
  45. package/template/src/components/ui/components/bubble.tsx +128 -0
  46. package/template/src/components/ui/components/button-group.tsx +87 -0
  47. package/template/src/components/ui/components/button.tsx +56 -0
  48. package/template/src/components/ui/components/card.tsx +103 -0
  49. package/template/src/components/ui/components/checkbox.tsx +26 -0
  50. package/template/src/components/ui/components/command.tsx +194 -0
  51. package/template/src/components/ui/components/dialog.tsx +154 -0
  52. package/template/src/components/ui/components/drawer.tsx +229 -0
  53. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  54. package/template/src/components/ui/components/empty.tsx +101 -0
  55. package/template/src/components/ui/components/field.tsx +236 -0
  56. package/template/src/components/ui/components/input-group.tsx +155 -0
  57. package/template/src/components/ui/components/input.tsx +20 -0
  58. package/template/src/components/ui/components/item.tsx +201 -0
  59. package/template/src/components/ui/components/kbd.tsx +26 -0
  60. package/template/src/components/ui/components/label.tsx +18 -0
  61. package/template/src/components/ui/components/marker.tsx +71 -0
  62. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  63. package/template/src/components/ui/components/message.tsx +92 -0
  64. package/template/src/components/ui/components/native-select.tsx +65 -0
  65. package/template/src/components/ui/components/popover.tsx +88 -0
  66. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  67. package/template/src/components/ui/components/radio-group.tsx +36 -0
  68. package/template/src/components/ui/components/scroll-area.tsx +52 -0
  69. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  70. package/template/src/components/ui/components/select.tsx +199 -0
  71. package/template/src/components/ui/components/separator.tsx +23 -0
  72. package/template/src/components/ui/components/sheet.tsx +135 -0
  73. package/template/src/components/ui/components/skeleton.tsx +13 -0
  74. package/template/src/components/ui/components/sonner.tsx +43 -0
  75. package/template/src/components/ui/components/spinner.tsx +16 -0
  76. package/template/src/components/ui/components/tabs.tsx +80 -0
  77. package/template/src/components/ui/components/textarea.tsx +18 -0
  78. package/template/src/components/ui/components/tooltip.tsx +64 -0
  79. package/template/src/components/ui/hooks/.gitkeep +0 -0
  80. package/template/src/components/ui/lib/utils.ts +6 -0
  81. package/template/src/components/ui/styles/globals.css +151 -0
  82. package/template/src/create-post-overlay.tsx +82 -0
  83. package/template/src/draft-post-list.tsx +39 -0
  84. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  85. package/template/src/feedback-cache-invalidation.ts +97 -0
  86. package/template/src/feedback-runtime.ts +38 -0
  87. package/template/src/feedbax/changelog/index.test.ts +156 -0
  88. package/template/src/feedbax/changelog/index.ts +206 -0
  89. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  90. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  91. package/template/src/feedbax/config/index.test.ts +111 -0
  92. package/template/src/feedbax/config/index.ts +271 -0
  93. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  94. package/template/src/feedbax/feedback/index.test.ts +900 -0
  95. package/template/src/feedbax/feedback/index.ts +942 -0
  96. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  97. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  98. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  99. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  100. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  101. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  102. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  103. package/template/src/feedbax.test-fixture.ts +64 -0
  104. package/template/src/feedbax.ts +69 -0
  105. package/template/src/index.css +2138 -0
  106. package/template/src/lazy-create-post-overlay.tsx +6 -0
  107. package/template/src/lazy-post-detail-overlay.tsx +10 -0
  108. package/template/src/masked-post-link.tsx +27 -0
  109. package/template/src/overlay-post-detail.test.tsx +130 -0
  110. package/template/src/overlay-post-detail.tsx +92 -0
  111. package/template/src/portal-comments.test.ts +375 -0
  112. package/template/src/portal-comments.ts +384 -0
  113. package/template/src/portal-features.test.tsx +150 -0
  114. package/template/src/portal-feedback-form.tsx +515 -0
  115. package/template/src/portal-feedback-runtime.test.ts +32 -0
  116. package/template/src/portal-feedback-runtime.ts +55 -0
  117. package/template/src/portal-feedback-server-function.ts +93 -0
  118. package/template/src/portal-feedback-submission.test.ts +307 -0
  119. package/template/src/portal-feedback-submission.ts +214 -0
  120. package/template/src/portal-head.ts +58 -0
  121. package/template/src/portal-server-entry.ts +64 -0
  122. package/template/src/portal-voting.test.ts +106 -0
  123. package/template/src/portal-voting.ts +149 -0
  124. package/template/src/post-badges.tsx +27 -0
  125. package/template/src/post-creation-flow.test.tsx +230 -0
  126. package/template/src/post-creation-flow.tsx +88 -0
  127. package/template/src/post-detail-overlay.test.tsx +114 -0
  128. package/template/src/post-detail-overlay.tsx +67 -0
  129. package/template/src/post-feed-controls.test.tsx +239 -0
  130. package/template/src/post-feed-controls.tsx +298 -0
  131. package/template/src/post-presentation.ts +37 -0
  132. package/template/src/post-queries.test.ts +158 -0
  133. package/template/src/post-queries.ts +26 -0
  134. package/template/src/post-results.tsx +165 -0
  135. package/template/src/public-cache-policy.test.ts +141 -0
  136. package/template/src/public-cache-policy.ts +119 -0
  137. package/template/src/public-cache-tags.ts +7 -0
  138. package/template/src/public-changelog-page.ts +15 -0
  139. package/template/src/public-changelog-server-function.ts +27 -0
  140. package/template/src/public-date.ts +11 -0
  141. package/template/src/public-failure-state.test.tsx +69 -0
  142. package/template/src/public-feedback-index.tsx +124 -0
  143. package/template/src/public-feedback-page.test.tsx +290 -0
  144. package/template/src/public-feedback-page.ts +75 -0
  145. package/template/src/public-feedback-server-function.ts +22 -0
  146. package/template/src/public-portal-error.tsx +27 -0
  147. package/template/src/public-post-detail.tsx +682 -0
  148. package/template/src/public-post-page.test.tsx +173 -0
  149. package/template/src/public-post-page.ts +15 -0
  150. package/template/src/public-post-route.test.tsx +214 -0
  151. package/template/src/public-post-route.tsx +171 -0
  152. package/template/src/public-post-server-function.ts +48 -0
  153. package/template/src/public-post-unavailable.tsx +10 -0
  154. package/template/src/public-roadmap-page.test.tsx +139 -0
  155. package/template/src/public-roadmap-page.ts +24 -0
  156. package/template/src/public-roadmap-route.test.tsx +218 -0
  157. package/template/src/public-roadmap-route.tsx +50 -0
  158. package/template/src/public-roadmap-server-function.ts +44 -0
  159. package/template/src/public-roadmap-view.tsx +378 -0
  160. package/template/src/public-shell.test.tsx +518 -0
  161. package/template/src/roadmap-query.ts +21 -0
  162. package/template/src/root-branding.test.ts +53 -0
  163. package/template/src/routeTree.gen.ts +131 -0
  164. package/template/src/routed-post-creation-form.tsx +28 -0
  165. package/template/src/router.tsx +25 -0
  166. package/template/src/routes/__root.tsx +116 -0
  167. package/template/src/routes/changelog.tsx +39 -0
  168. package/template/src/routes/index.tsx +61 -0
  169. package/template/src/routes/p.$slug.tsx +16 -0
  170. package/template/src/routes/roadmap.tsx +11 -0
  171. package/template/src/routes/submit.tsx +28 -0
  172. package/template/src/safe-public-failure.ts +45 -0
  173. package/template/src/server.ts +111 -0
  174. package/template/src/similar-posts.tsx +45 -0
  175. package/template/src/submit-route.test.tsx +134 -0
  176. package/template/src/trusted-feedback-handler.test.ts +425 -0
  177. package/template/src/trusted-feedback-handler.ts +181 -0
  178. package/template/src/trusted-feedback-runtime.ts +17 -0
  179. package/template/src/turnstile-configuration.test.ts +37 -0
  180. package/template/src/turnstile-configuration.ts +19 -0
  181. package/template/src/use-media-query.ts +17 -0
  182. package/template/src/vote-toggle.test.tsx +171 -0
  183. package/template/src/vote-toggle.tsx +211 -0
  184. package/template/tsconfig.json +45 -0
  185. package/template/vite.config.ts +20 -0
  186. package/template/vitest.config.ts +4 -0
  187. package/template/vitest.setup.ts +3 -0
  188. package/template/wrangler.jsonc +35 -0
  189. package/dist/cli.d.ts +0 -3
  190. package/dist/cli.d.ts.map +0 -1
  191. package/dist/cli.js.map +0 -1
  192. package/dist/index.d.ts +0 -21
  193. package/dist/index.d.ts.map +0 -1
  194. package/dist/index.js.map +0 -1
  195. package/template/.env.example +0 -4
  196. package/template/README.md +0 -7
  197. package/template/apps/portal/components.json +0 -20
  198. package/template/apps/portal/package.json +0 -31
  199. package/template/apps/portal/src/lib/utils.ts +0 -6
  200. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  201. package/template/apps/portal/src/router.tsx +0 -10
  202. package/template/apps/portal/src/routes/__root.tsx +0 -31
  203. package/template/apps/portal/src/routes/index.tsx +0 -35
  204. package/template/apps/portal/src/styles.css +0 -91
  205. package/template/apps/portal/tsconfig.json +0 -15
  206. package/template/apps/portal/vite.config.ts +0 -13
  207. package/template/feedbax.config.ts +0 -13
  208. package/template/feedbax.jsonc +0 -11
  209. package/template/pnpm-workspace.yaml +0 -3
  210. package/template/turbo.json +0 -8
@@ -0,0 +1,400 @@
1
+ import type {
2
+ ChangelogDate,
3
+ ChangelogImage,
4
+ ChangelogStorage,
5
+ PublicChangelogEntry,
6
+ } from "./index";
7
+
8
+ const NOTION_API_URL = "https://api.notion.com/v1";
9
+ const NOTION_API_VERSION = "2026-03-11";
10
+
11
+ const canonicalProperties = {
12
+ title: { name: "Title", type: "title", schema: { title: {} } },
13
+ slug: { name: "Slug", type: "rich_text", schema: { rich_text: {} } },
14
+ date: { name: "Date", type: "date", schema: { date: {} } },
15
+ summary: {
16
+ name: "Summary",
17
+ type: "rich_text",
18
+ schema: { rich_text: {} },
19
+ },
20
+ body: { name: "Body", type: "rich_text", schema: { rich_text: {} } },
21
+ labels: {
22
+ name: "Labels",
23
+ type: "multi_select",
24
+ schema: { multi_select: { options: [] } },
25
+ },
26
+ image: { name: "Image", type: "files", schema: { files: {} } },
27
+ published: {
28
+ name: "Published",
29
+ type: "checkbox",
30
+ schema: { checkbox: {} },
31
+ },
32
+ createdAt: {
33
+ name: "Created At",
34
+ type: "created_time",
35
+ schema: { created_time: {} },
36
+ },
37
+ updatedAt: {
38
+ name: "Updated At",
39
+ type: "last_edited_time",
40
+ schema: { last_edited_time: {} },
41
+ },
42
+ } as const;
43
+
44
+ export type ChangelogPropertyIds = {
45
+ [Key in keyof typeof canonicalProperties]: string;
46
+ };
47
+
48
+ export interface ChangelogDataSourceConfiguration {
49
+ databaseId: string;
50
+ dataSourceId: string;
51
+ propertyIds: ChangelogPropertyIds;
52
+ }
53
+
54
+ interface NotionOptions {
55
+ token: string;
56
+ request?: typeof fetch;
57
+ }
58
+
59
+ export interface NotionChangelogStorageOptions extends NotionOptions {
60
+ dataSourceId: string;
61
+ propertyIds: ChangelogPropertyIds;
62
+ onDiagnostic?: (message: string) => void;
63
+ }
64
+
65
+ export async function createNotionChangelogDataSource({
66
+ token,
67
+ databaseId,
68
+ request = fetch,
69
+ }: NotionOptions & {
70
+ databaseId: string;
71
+ }): Promise<ChangelogDataSourceConfiguration> {
72
+ const response = await request(`${NOTION_API_URL}/data_sources`, {
73
+ method: "POST",
74
+ headers: notionHeaders(token),
75
+ body: JSON.stringify({
76
+ parent: { database_id: databaseId },
77
+ title: [{ type: "text", text: { content: "Changelog" } }],
78
+ properties: Object.fromEntries(
79
+ Object.values(canonicalProperties).map(({ name, schema }) => [
80
+ name,
81
+ schema,
82
+ ]),
83
+ ),
84
+ }),
85
+ });
86
+ return configurationFromResponse(
87
+ await readObject(response, "Changelog Data Source"),
88
+ databaseId,
89
+ );
90
+ }
91
+
92
+ export async function validateNotionChangelogDataSource({
93
+ token,
94
+ dataSourceId,
95
+ propertyIds,
96
+ request = fetch,
97
+ }: NotionChangelogStorageOptions): Promise<ChangelogDataSourceConfiguration> {
98
+ const response = await request(
99
+ `${NOTION_API_URL}/data_sources/${dataSourceId}`,
100
+ { headers: notionHeaders(token) },
101
+ );
102
+ const dataSource = await readObject(response, "Changelog Data Source");
103
+ const properties = recordAt(dataSource, "properties");
104
+ const propertiesById = new Map(
105
+ Object.values(properties).flatMap((property) => {
106
+ if (!isRecord(property) || typeof property.id !== "string") return [];
107
+ return [[property.id, property] as const];
108
+ }),
109
+ );
110
+ const repairs: string[] = [];
111
+ for (const [key, { name, type }] of Object.entries(canonicalProperties)) {
112
+ const configuredId = propertyIds[key as keyof ChangelogPropertyIds];
113
+ const property = propertiesById.get(configuredId);
114
+ if (!property) {
115
+ repairs.push(
116
+ `Restore the "${name}" property with ID "${configuredId}" and type "${type}", then update the configured property ID if Notion assigns a new one.`,
117
+ );
118
+ } else if (property.type !== type) {
119
+ repairs.push(
120
+ `Change the property configured as "${name}" (ID "${configuredId}") from "${String(property.type)}" to "${type}", or restore a ${type} property and configure its ID.`,
121
+ );
122
+ }
123
+ }
124
+ if (repairs.length) throw new Error(repairs.join("\n"));
125
+ return {
126
+ databaseId: databaseIdFromResponse(dataSource),
127
+ dataSourceId,
128
+ propertyIds: { ...propertyIds },
129
+ };
130
+ }
131
+
132
+ export function createNotionChangelogStorage({
133
+ token,
134
+ dataSourceId,
135
+ propertyIds,
136
+ request = fetch,
137
+ onDiagnostic = console.warn,
138
+ }: NotionChangelogStorageOptions): ChangelogStorage {
139
+ return {
140
+ async listPublished(query) {
141
+ const url = new URL(
142
+ `${NOTION_API_URL}/data_sources/${dataSourceId}/query`,
143
+ );
144
+ for (const id of publicPropertyIds(propertyIds)) {
145
+ url.searchParams.append("filter_properties", id);
146
+ }
147
+ const response = await request(url, {
148
+ method: "POST",
149
+ headers: notionHeaders(token),
150
+ body: JSON.stringify({
151
+ page_size: 20,
152
+ ...(query.cursor ? { start_cursor: query.cursor } : {}),
153
+ filter: {
154
+ and: [
155
+ {
156
+ property: propertyIds.published,
157
+ checkbox: { equals: true },
158
+ },
159
+ ...(query.label
160
+ ? [
161
+ {
162
+ property: propertyIds.labels,
163
+ multi_select: { contains: query.label },
164
+ },
165
+ ]
166
+ : []),
167
+ ],
168
+ },
169
+ sorts: [
170
+ { property: propertyIds.date, direction: "descending" },
171
+ { timestamp: "created_time", direction: "ascending" },
172
+ { property: propertyIds.slug, direction: "ascending" },
173
+ ],
174
+ }),
175
+ });
176
+ const body = await readObject(response, "Changelog query");
177
+ if (!Array.isArray(body.results)) {
178
+ throw new Error("Notion returned an invalid Changelog Entry list.");
179
+ }
180
+ const items = body.results.map((page) =>
181
+ entryFromPage(record(page), propertyIds, { onDiagnostic }),
182
+ );
183
+ const nextCursor =
184
+ body.has_more === true && typeof body.next_cursor === "string"
185
+ ? body.next_cursor
186
+ : undefined;
187
+ return { items, ...(nextCursor ? { nextCursor } : {}) };
188
+ },
189
+ };
190
+ }
191
+
192
+ function publicPropertyIds(ids: ChangelogPropertyIds): string[] {
193
+ return [
194
+ ids.title,
195
+ ids.slug,
196
+ ids.date,
197
+ ids.summary,
198
+ ids.body,
199
+ ids.labels,
200
+ ids.image,
201
+ ids.updatedAt,
202
+ ];
203
+ }
204
+
205
+ function entryFromPage(
206
+ page: Record<string, unknown>,
207
+ ids: ChangelogPropertyIds,
208
+ projection: {
209
+ onDiagnostic: (message: string) => void;
210
+ },
211
+ ): PublicChangelogEntry {
212
+ const properties = recordAt(page, "properties");
213
+ const slug = richText(propertyAt(properties, ids.slug));
214
+ const title = titleText(propertyAt(properties, ids.title));
215
+ const image = firstImage(propertyAt(properties, ids.image), {
216
+ ...projection,
217
+ slug,
218
+ alt: `${title} image`,
219
+ });
220
+ return {
221
+ slug,
222
+ date: dateValue(propertyAt(properties, ids.date)),
223
+ title,
224
+ summary: richText(propertyAt(properties, ids.summary)),
225
+ body: richText(propertyAt(properties, ids.body)),
226
+ labels: multiSelect(propertyAt(properties, ids.labels)),
227
+ updatedAt: new Date(requiredString(page.last_edited_time)),
228
+ ...(image ? { image } : {}),
229
+ };
230
+ }
231
+
232
+ function configurationFromResponse(
233
+ dataSource: Record<string, unknown>,
234
+ databaseId: string,
235
+ ): ChangelogDataSourceConfiguration {
236
+ const properties = recordAt(dataSource, "properties");
237
+ return {
238
+ databaseId,
239
+ dataSourceId: requiredString(dataSource.id),
240
+ propertyIds: Object.fromEntries(
241
+ Object.entries(canonicalProperties).map(([key, { name }]) => [
242
+ key,
243
+ requiredString(recordAt(properties, name).id),
244
+ ]),
245
+ ) as ChangelogPropertyIds,
246
+ };
247
+ }
248
+
249
+ function databaseIdFromResponse(dataSource: Record<string, unknown>): string {
250
+ return requiredString(recordAt(dataSource, "parent").database_id);
251
+ }
252
+
253
+ function titleText(property: Record<string, unknown>): string {
254
+ return plainText(property.title);
255
+ }
256
+
257
+ function richText(property: Record<string, unknown>): string {
258
+ return plainText(property.rich_text);
259
+ }
260
+
261
+ function plainText(value: unknown): string {
262
+ if (!Array.isArray(value)) return "";
263
+ return value
264
+ .map((item) =>
265
+ isRecord(item) && typeof item.plain_text === "string"
266
+ ? item.plain_text
267
+ : "",
268
+ )
269
+ .join("");
270
+ }
271
+
272
+ function dateValue(property: Record<string, unknown>): ChangelogDate {
273
+ const value = recordAt(property, "date").start;
274
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
275
+ throw new Error("Notion returned a Changelog Entry without a valid Date.");
276
+ }
277
+ return value as ChangelogDate;
278
+ }
279
+
280
+ function multiSelect(property: Record<string, unknown>): string[] {
281
+ if (!Array.isArray(property.multi_select)) return [];
282
+ return property.multi_select.flatMap((option) =>
283
+ isRecord(option) && typeof option.name === "string" ? [option.name] : [],
284
+ );
285
+ }
286
+
287
+ function firstImage(
288
+ property: Record<string, unknown>,
289
+ {
290
+ slug,
291
+ alt,
292
+ onDiagnostic,
293
+ }: {
294
+ slug: string;
295
+ alt: string;
296
+ onDiagnostic: (message: string) => void;
297
+ },
298
+ ): ChangelogImage | undefined {
299
+ if (!Array.isArray(property.files) || property.files.length === 0) return;
300
+ if (property.files.length > 1) {
301
+ onDiagnostic(
302
+ `Changelog Entry "${slug}" has ${property.files.length} Image files; only the first file is in the public contract.`,
303
+ );
304
+ }
305
+ if (!isRecord(property.files[0])) {
306
+ onDiagnostic(`Changelog Entry "${slug}" has an invalid Image reference.`);
307
+ return;
308
+ }
309
+ const file = property.files[0];
310
+ const hosted = isRecord(file.file) ? file.file : undefined;
311
+ const external = isRecord(file.external) ? file.external : undefined;
312
+ const src = hosted?.url ?? external?.url;
313
+ if (typeof src !== "string" || !isHttpUrl(src)) {
314
+ onDiagnostic(`Changelog Entry "${slug}" has an invalid Image reference.`);
315
+ return;
316
+ }
317
+ const expiresAt =
318
+ hosted && typeof hosted.expiry_time === "string"
319
+ ? new Date(hosted.expiry_time)
320
+ : undefined;
321
+ if (hosted && (!expiresAt || Number.isNaN(expiresAt.getTime()))) {
322
+ onDiagnostic(
323
+ `Changelog Entry "${slug}" has a temporary Image reference without a valid expiry.`,
324
+ );
325
+ return;
326
+ }
327
+ return {
328
+ src,
329
+ alt,
330
+ ...(expiresAt ? { expiresAt } : {}),
331
+ };
332
+ }
333
+
334
+ function isHttpUrl(value: string): boolean {
335
+ try {
336
+ const protocol = new URL(value).protocol;
337
+ return protocol === "https:" || protocol === "http:";
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+
343
+ function propertyAt(
344
+ properties: Record<string, unknown>,
345
+ id: string,
346
+ ): Record<string, unknown> {
347
+ const direct = properties[id];
348
+ if (isRecord(direct)) return direct;
349
+
350
+ const matched = Object.values(properties).find(
351
+ (property) => isRecord(property) && property.id === id,
352
+ );
353
+ return record(matched);
354
+ }
355
+
356
+ function notionHeaders(token: string): Record<string, string> {
357
+ return {
358
+ Authorization: `Bearer ${token}`,
359
+ "Content-Type": "application/json",
360
+ "Notion-Version": NOTION_API_VERSION,
361
+ };
362
+ }
363
+
364
+ async function readObject(
365
+ response: Response,
366
+ subject: string,
367
+ ): Promise<Record<string, unknown>> {
368
+ const body: unknown = await response.json();
369
+ if (!response.ok) {
370
+ throw new Error(
371
+ `Notion rejected the ${subject} request (${response.status}).`,
372
+ );
373
+ }
374
+ if (!isRecord(body))
375
+ throw new Error(`Notion returned an invalid ${subject} response.`);
376
+ return body;
377
+ }
378
+
379
+ function recordAt(
380
+ value: Record<string, unknown>,
381
+ key: string,
382
+ ): Record<string, unknown> {
383
+ return record(value[key]);
384
+ }
385
+
386
+ function record(value: unknown): Record<string, unknown> {
387
+ if (!isRecord(value))
388
+ throw new Error("Notion returned an invalid Changelog response.");
389
+ return value;
390
+ }
391
+
392
+ function requiredString(value: unknown): string {
393
+ if (typeof value !== "string")
394
+ throw new Error("Notion returned an invalid Changelog response.");
395
+ return value;
396
+ }
397
+
398
+ function isRecord(value: unknown): value is Record<string, unknown> {
399
+ return typeof value === "object" && value !== null && !Array.isArray(value);
400
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ assertProductionConfiguration,
4
+ defineFeedbax,
5
+ type FeedbaxConfig,
6
+ } from "./index";
7
+
8
+ const properties = Object.fromEntries(
9
+ [
10
+ "title",
11
+ "slug",
12
+ "description",
13
+ "type",
14
+ "status",
15
+ "published",
16
+ "voteCount",
17
+ "teamResponse",
18
+ "releaseNotesUrl",
19
+ "submitterName",
20
+ "submitterEmail",
21
+ "source",
22
+ "externalId",
23
+ "editTokenHash",
24
+ "createdAt",
25
+ "updatedAt",
26
+ ].map((key) => [key, `${key}-id`]),
27
+ ) as FeedbaxConfig["notion"]["properties"];
28
+ const changelogProperties = Object.fromEntries(
29
+ [
30
+ "title",
31
+ "slug",
32
+ "date",
33
+ "summary",
34
+ "body",
35
+ "labels",
36
+ "image",
37
+ "published",
38
+ "createdAt",
39
+ "updatedAt",
40
+ ].map((key) => [key, `${key}-id`]),
41
+ ) as NonNullable<FeedbaxConfig["changelog"]>["properties"];
42
+ const input: FeedbaxConfig = {
43
+ product: {
44
+ name: "Acme",
45
+ description: "Acme feedback",
46
+ logo: "/logo.svg",
47
+ siteUrl: "https://feedback.example.com",
48
+ },
49
+ theme: { preset: "lyra-neutral" },
50
+ notion: { databaseId: "db", dataSourceId: "source", properties },
51
+ changelog: {
52
+ databaseId: "changes-db",
53
+ dataSourceId: "changes-source",
54
+ properties: changelogProperties,
55
+ },
56
+ features: { voting: true, comments: true, changelog: true },
57
+ submissions: {
58
+ moderation: "required",
59
+ maxTitleLength: 120,
60
+ maxDescriptionLength: 5000,
61
+ },
62
+ turnstile: { enabled: false },
63
+ };
64
+
65
+ describe("Feedbax configuration", () => {
66
+ it("accepts the complete generated contract", () =>
67
+ expect(defineFeedbax(input)).toEqual(input));
68
+ it("requires enabled capability dependencies", () =>
69
+ expect(() =>
70
+ defineFeedbax({
71
+ ...input,
72
+ changelog: undefined,
73
+ } as unknown as FeedbaxConfig),
74
+ ).toThrow("Changelog is enabled"));
75
+ it("rejects unknown and incomplete configuration", () => {
76
+ expect(() =>
77
+ defineFeedbax({ ...input, extra: true } as unknown as FeedbaxConfig),
78
+ ).toThrow('Unknown Feedbax configuration key "extra"');
79
+ expect(() =>
80
+ defineFeedbax({
81
+ ...input,
82
+ product: { ...input.product, logo: "http://unsafe.test" },
83
+ }),
84
+ ).toThrow("product.logo must be root-relative or an HTTPS URL");
85
+ });
86
+ it("requires an absolute site URL only for production acceptance", () => {
87
+ const local = defineFeedbax({
88
+ ...input,
89
+ product: { ...input.product, siteUrl: undefined },
90
+ });
91
+ expect(() =>
92
+ assertProductionConfiguration(local, "development"),
93
+ ).not.toThrow();
94
+ expect(() => assertProductionConfiguration(local, "production")).toThrow(
95
+ "product.siteUrl is required in production",
96
+ );
97
+ });
98
+ it("requires a Turnstile site key when enabled", () =>
99
+ expect(() =>
100
+ defineFeedbax({ ...input, turnstile: { enabled: true } }),
101
+ ).toThrow("turnstile.siteKey is missing"));
102
+ });
103
+
104
+ it.each(["/portal", "//example.com", "http://example.com"])(
105
+ "rejects a non-HTTPS absolute site URL: %s",
106
+ (siteUrl) => {
107
+ expect(() =>
108
+ defineFeedbax({ ...input, product: { ...input.product, siteUrl } }),
109
+ ).toThrow("product.siteUrl must be an HTTPS URL");
110
+ },
111
+ );