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,156 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ createChangelogModule,
5
+ createInMemoryChangelogStorage,
6
+ type ChangelogDate,
7
+ type StoredChangelogEntry,
8
+ } from ".";
9
+
10
+ describe("Changelog module", () => {
11
+ it("returns only Published Changelog Entries in deterministic Date order", async () => {
12
+ const changelog = createChangelogModule({
13
+ storage: createInMemoryChangelogStorage([
14
+ entry({ id: "z", slug: "draft", published: false, date: "2026-08-30" }),
15
+ entry({ id: "b", slug: "second", date: "2026-08-20" }),
16
+ entry({ id: "a", slug: "first", date: "2026-08-20" }),
17
+ entry({ id: "c", slug: "older", date: "2026-08-10" }),
18
+ ]),
19
+ });
20
+
21
+ await expect(changelog.listPublishedEntries()).resolves.toMatchObject({
22
+ items: [{ slug: "first" }, { slug: "second" }, { slug: "older" }],
23
+ });
24
+ });
25
+
26
+ it("filters Labels and continues in stable batches of 20", async () => {
27
+ const changelog = createChangelogModule({
28
+ storage: createInMemoryChangelogStorage(
29
+ Array.from({ length: 22 }, (_, index) =>
30
+ entry({
31
+ id: String(index).padStart(2, "0"),
32
+ slug: `entry-${index}`,
33
+ date: `2026-08-${String(22 - index).padStart(2, "0")}` as ChangelogDate,
34
+ labels: index === 21 ? ["Fixed"] : ["Improved"],
35
+ }),
36
+ ),
37
+ ),
38
+ });
39
+
40
+ const first = await changelog.listPublishedEntries({ label: "Improved" });
41
+ expect(first.items).toHaveLength(20);
42
+ expect(first.nextCursor).toBeTruthy();
43
+ const second = await changelog.listPublishedEntries({
44
+ label: "Improved",
45
+ cursor: first.nextCursor,
46
+ });
47
+ expect(second.items.map(({ slug }) => slug)).toEqual(["entry-20"]);
48
+ await expect(
49
+ changelog.listPublishedEntries({ label: "Fixed" }),
50
+ ).resolves.toMatchObject({ items: [{ slug: "entry-21" }] });
51
+ });
52
+
53
+ it("starts empty and projects at most one image", async () => {
54
+ await expect(
55
+ createChangelogModule({
56
+ storage: createInMemoryChangelogStorage(),
57
+ }).listPublishedEntries(),
58
+ ).resolves.toEqual({ items: [] });
59
+
60
+ const changelog = createChangelogModule({
61
+ storage: createInMemoryChangelogStorage([
62
+ entry({
63
+ images: [
64
+ { src: "https://files.example/first", alt: "First" },
65
+ { src: "https://files.example/second", alt: "Second" },
66
+ ],
67
+ }),
68
+ ]),
69
+ });
70
+ await expect(changelog.listPublishedEntries()).resolves.toMatchObject({
71
+ items: [{ image: { src: "https://files.example/first", alt: "First" } }],
72
+ });
73
+ });
74
+
75
+ it("preserves Changelog text while discarding an image before cache freshness could outlive it", async () => {
76
+ const changelog = createChangelogModule({
77
+ storage: createInMemoryChangelogStorage([
78
+ entry({
79
+ title: "Safe release",
80
+ body: "The release text remains available.",
81
+ images: [
82
+ {
83
+ src: "https://files.example/expiring",
84
+ alt: "Safe release image",
85
+ expiresAt: new Date("2026-08-26T12:00:30Z"),
86
+ },
87
+ ],
88
+ }),
89
+ ]),
90
+ now: () => new Date("2026-08-26T12:00:00Z"),
91
+ });
92
+
93
+ const page = await changelog.listPublishedEntries();
94
+ expect(page.items[0]).toMatchObject({
95
+ title: "Safe release",
96
+ body: "The release text remains available.",
97
+ });
98
+ expect(page.items[0]).not.toHaveProperty("image");
99
+ });
100
+
101
+ it("falls back to a bounded text-only projection when an upstream refresh fails", async () => {
102
+ let available = true;
103
+ let currentTime = new Date("2026-08-26T12:00:00Z");
104
+ const changelog = createChangelogModule({
105
+ storage: {
106
+ async listPublished() {
107
+ if (!available) throw new Error("Notion is unavailable");
108
+ return {
109
+ items: [
110
+ {
111
+ ...entry({ title: "Release remains readable" }),
112
+ image: {
113
+ src: "https://files.example/temporary",
114
+ alt: "Release image",
115
+ expiresAt: new Date("2026-08-26T12:02:00Z"),
116
+ },
117
+ },
118
+ ],
119
+ };
120
+ },
121
+ },
122
+ now: () => currentTime,
123
+ });
124
+
125
+ expect((await changelog.listPublishedEntries()).items[0]).toHaveProperty(
126
+ "image",
127
+ );
128
+ available = false;
129
+ currentTime = new Date("2026-08-26T12:03:00Z");
130
+ await expect(changelog.listPublishedEntries()).resolves.toMatchObject({
131
+ items: [{ title: "Release remains readable" }],
132
+ });
133
+ expect(
134
+ (await changelog.listPublishedEntries()).items[0],
135
+ ).not.toHaveProperty("image");
136
+ });
137
+ });
138
+
139
+ function entry(
140
+ overrides: Partial<StoredChangelogEntry> & { date?: ChangelogDate } = {},
141
+ ): StoredChangelogEntry {
142
+ return {
143
+ id: "entry",
144
+ slug: "entry",
145
+ date: "2026-08-01",
146
+ title: "Entry",
147
+ summary: "Summary",
148
+ body: "Body",
149
+ labels: ["Improved"],
150
+ images: [],
151
+ published: true,
152
+ createdAt: new Date("2026-08-01T00:00:00Z"),
153
+ updatedAt: new Date("2026-08-01T00:00:00Z"),
154
+ ...overrides,
155
+ };
156
+ }
@@ -0,0 +1,206 @@
1
+ export type ChangelogDate = `${number}-${number}-${number}`;
2
+
3
+ export interface ChangelogImage {
4
+ src: string;
5
+ alt: string;
6
+ expiresAt?: Date;
7
+ }
8
+
9
+ export interface StoredChangelogEntry {
10
+ id: string;
11
+ slug: string;
12
+ date: ChangelogDate;
13
+ title: string;
14
+ summary: string;
15
+ body: string;
16
+ labels: string[];
17
+ images: ChangelogImage[];
18
+ published: boolean;
19
+ createdAt: Date;
20
+ updatedAt: Date;
21
+ }
22
+
23
+ export type PublicChangelogEntry = Pick<
24
+ StoredChangelogEntry,
25
+ "slug" | "date" | "title" | "summary" | "body" | "labels" | "updatedAt"
26
+ > & { image?: ChangelogImage };
27
+
28
+ export interface ChangelogPage {
29
+ items: PublicChangelogEntry[];
30
+ nextCursor?: string;
31
+ }
32
+
33
+ export interface ChangelogQuery {
34
+ cursor?: string;
35
+ label?: string;
36
+ }
37
+
38
+ export interface ChangelogStorage {
39
+ listPublished(query: ChangelogQuery): Promise<ChangelogPage>;
40
+ }
41
+
42
+ export interface ChangelogModule {
43
+ listPublishedEntries(query?: ChangelogQuery): Promise<ChangelogPage>;
44
+ }
45
+
46
+ export {
47
+ createNotionChangelogDataSource,
48
+ createNotionChangelogStorage,
49
+ validateNotionChangelogDataSource,
50
+ type ChangelogDataSourceConfiguration,
51
+ type ChangelogPropertyIds,
52
+ type NotionChangelogStorageOptions,
53
+ } from "./notion-changelog";
54
+
55
+ export function createChangelogModule({
56
+ storage,
57
+ now = () => new Date(),
58
+ }: {
59
+ storage: ChangelogStorage;
60
+ now?: () => Date;
61
+ }): ChangelogModule {
62
+ const textFallbacks = new Map<
63
+ string,
64
+ { cachedAt: number; page: ChangelogPage }
65
+ >();
66
+ return {
67
+ async listPublishedEntries(query = {}) {
68
+ const key = fallbackKey(query);
69
+ try {
70
+ const page = await storage.listPublished(query);
71
+ const projected = projectSafeImages(page, now());
72
+ rememberTextFallback(textFallbacks, key, page, now());
73
+ return projected;
74
+ } catch (error) {
75
+ const fallback = textFallbacks.get(key);
76
+ if (fallback && fallback.cachedAt + 86_400_000 > now().getTime()) {
77
+ return fallback.page;
78
+ }
79
+ throw error;
80
+ }
81
+ },
82
+ };
83
+ }
84
+
85
+ function projectSafeImages(page: ChangelogPage, now: Date): ChangelogPage {
86
+ return {
87
+ ...page,
88
+ items: page.items.map((entry) =>
89
+ entry.image && !isSafeForProjection(entry.image, now)
90
+ ? withoutImage(entry)
91
+ : entry,
92
+ ),
93
+ };
94
+ }
95
+
96
+ function rememberTextFallback(
97
+ fallbacks: Map<string, { cachedAt: number; page: ChangelogPage }>,
98
+ key: string,
99
+ page: ChangelogPage,
100
+ now: Date,
101
+ ): void {
102
+ if (!fallbacks.has(key) && fallbacks.size >= 100) {
103
+ const oldestKey = fallbacks.keys().next().value as string | undefined;
104
+ if (oldestKey) fallbacks.delete(oldestKey);
105
+ }
106
+ fallbacks.set(key, {
107
+ cachedAt: now.getTime(),
108
+ page: {
109
+ ...page,
110
+ items: page.items.map(withoutImage),
111
+ },
112
+ });
113
+ }
114
+
115
+ function fallbackKey(query: ChangelogQuery): string {
116
+ return JSON.stringify({
117
+ cursor: query.cursor ?? null,
118
+ label: query.label ?? null,
119
+ });
120
+ }
121
+
122
+ function isSafeForProjection(image: ChangelogImage, now: Date): boolean {
123
+ if (!image.expiresAt) return true;
124
+ return image.expiresAt.getTime() > now.getTime() + 60_000;
125
+ }
126
+
127
+ function withoutImage(entry: PublicChangelogEntry): PublicChangelogEntry {
128
+ const textEntry = { ...entry };
129
+ delete textEntry.image;
130
+ return textEntry;
131
+ }
132
+
133
+ export function createInMemoryChangelogStorage(
134
+ initialEntries: StoredChangelogEntry[] = [],
135
+ ): ChangelogStorage {
136
+ const entries = structuredClone(initialEntries);
137
+ return {
138
+ async listPublished(query) {
139
+ const offset = decodeCursor(query.cursor, query.label);
140
+ const matching = entries
141
+ .filter(
142
+ (entry) =>
143
+ entry.published &&
144
+ (!query.label || entry.labels.includes(query.label)),
145
+ )
146
+ .sort(compareEntries);
147
+ const page = matching.slice(offset, offset + 20);
148
+ const nextOffset = offset + page.length;
149
+ return {
150
+ items: page.map(toPublicEntry),
151
+ ...(nextOffset < matching.length
152
+ ? { nextCursor: encodeCursor(nextOffset, query.label) }
153
+ : {}),
154
+ };
155
+ },
156
+ };
157
+ }
158
+
159
+ function compareEntries(
160
+ left: StoredChangelogEntry,
161
+ right: StoredChangelogEntry,
162
+ ): number {
163
+ return right.date.localeCompare(left.date) || left.id.localeCompare(right.id);
164
+ }
165
+
166
+ function toPublicEntry(entry: StoredChangelogEntry): PublicChangelogEntry {
167
+ const [image] = entry.images;
168
+ return {
169
+ slug: entry.slug,
170
+ date: entry.date,
171
+ title: entry.title,
172
+ summary: entry.summary,
173
+ body: entry.body,
174
+ labels: [...entry.labels],
175
+ updatedAt: new Date(entry.updatedAt),
176
+ ...(image ? { image: structuredClone(image) } : {}),
177
+ };
178
+ }
179
+
180
+ function encodeCursor(offset: number, label?: string): string {
181
+ return Buffer.from(JSON.stringify({ offset, label: label ?? null })).toString(
182
+ "base64url",
183
+ );
184
+ }
185
+
186
+ function decodeCursor(cursor: string | undefined, label?: string): number {
187
+ if (!cursor) return 0;
188
+ try {
189
+ const value: unknown = JSON.parse(
190
+ Buffer.from(cursor, "base64url").toString("utf8"),
191
+ );
192
+ if (!value || typeof value !== "object") throw new Error();
193
+ const offset = Reflect.get(value, "offset");
194
+ const cursorLabel = Reflect.get(value, "label");
195
+ if (
196
+ !Number.isSafeInteger(offset) ||
197
+ offset < 0 ||
198
+ cursorLabel !== (label ?? null)
199
+ ) {
200
+ throw new Error();
201
+ }
202
+ return offset as number;
203
+ } catch {
204
+ throw new Error("Changelog cursor does not match this timeline view.");
205
+ }
206
+ }