WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { UnicodeString } from "@atproto/api";
2
3/**
4 * Parse a route parameter as BigInt.
5 * Returns null if the value cannot be parsed.
6 */
7export function parseBigIntParam(value: string): bigint | null {
8 try {
9 return BigInt(value);
10 } catch (error) {
11 // BigInt throws RangeError or SyntaxError for invalid input
12 if (error instanceof RangeError || error instanceof SyntaxError) {
13 return null;
14 }
15 // Re-throw unexpected errors
16 throw error;
17 }
18}
19
20/**
21 * Validate post text according to lexicon constraints.
22 * - Max 300 graphemes (user-perceived characters)
23 * - Non-empty after trimming whitespace
24 */
25export function validatePostText(text: unknown): {
26 valid: boolean;
27 trimmed?: string;
28 error?: string;
29} {
30 // Type guard: ensure text is a string
31 if (typeof text !== "string") {
32 return { valid: false, error: "Text is required and must be a string" };
33 }
34
35 const trimmed = text.trim();
36
37 if (trimmed.length === 0) {
38 return { valid: false, error: "Text cannot be empty" };
39 }
40
41 const graphemeLength = new UnicodeString(trimmed).graphemeLength;
42 if (graphemeLength > 300) {
43 return {
44 valid: false,
45 error: "Text must be 300 characters or less",
46 };
47 }
48
49 return { valid: true, trimmed };
50}
51
52/**
53 * Validate topic title according to lexicon constraints.
54 * - Max 120 graphemes (user-perceived characters)
55 * - Non-empty after trimming whitespace
56 */
57export function validateTopicTitle(title: unknown): {
58 valid: boolean;
59 trimmed?: string;
60 error?: string;
61} {
62 if (typeof title !== "string") {
63 return { valid: false, error: "Title is required and must be a string" };
64 }
65
66 const trimmed = title.trim();
67
68 if (trimmed.length === 0) {
69 return { valid: false, error: "Title cannot be empty" };
70 }
71
72 const graphemeLength = new UnicodeString(trimmed).graphemeLength;
73 if (graphemeLength > 120) {
74 return {
75 valid: false,
76 error: "Title must be 120 characters or less",
77 };
78 }
79
80 return { valid: true, trimmed };
81}
82
83/**
84 * Validate that a parent post belongs to the same thread as the root.
85 *
86 * Rules:
87 * - Parent can BE the root (replying directly to topic)
88 * - Parent can be a reply in the same thread (parent.rootPostId === rootId)
89 * - Parent cannot belong to a different thread
90 */
91export function validateReplyParent(
92 root: { id: bigint; rootPostId: bigint | null },
93 parent: { id: bigint; rootPostId: bigint | null },
94 rootId: bigint
95): { valid: boolean; error?: string } {
96 // Parent IS the root (replying to topic)
97 if (parent.id === rootId && parent.rootPostId === null) {
98 return { valid: true };
99 }
100
101 // Parent is a reply in the same thread
102 if (parent.rootPostId === rootId) {
103 return { valid: true };
104 }
105
106 // Parent belongs to a different thread
107 return {
108 valid: false,
109 error: "Parent post does not belong to this thread",
110 };
111}