import type {
JsonRecord,
ListingSort,
SearchSort,
ShapedComment,
ShapedListing,
ShapedPost,
ShapedSubreddit,
ShapedUser,
SubmitKind,
TimeFilter,
UserListing,
VoteDirection,
} from './types.ts'
import { asRecord, booleanValue, numberValue, stringValue } from './types.ts'
const SELFTEXT_LIMIT = 2000
const COMMENT_BODY_LIMIT = 2000
export function normalizeSubreddit(value: unknown, label = 'subreddit'): string {
const raw = requireString(value, label + ' is required.')
const trimmed = raw.replace(/^\/?(r\/)?/i, '').replace(/\/+$/, '')
if (!trimmed) {
throw new Error(label + ' is required.')
}
if (!/^[A-Za-z0-9_]+$/.test(trimmed)) {
throw new Error(label + ' must be a Reddit community name.')
}
return trimmed
}
export function normalizeUsername(value: unknown): string {
const raw = requireString(value, 'username is required.')
const trimmed = raw.replace(/^\/?(u\/|user\/)/i, '').replace(/\/+$/, '')
if (!trimmed) {
throw new Error('username is required.')
}
if (!/^[A-Za-z0-9_-]+$/.test(trimmed)) {
throw new Error('username must be a Reddit username.')
}
return trimmed
}
export function normalizeThingId(value: unknown, prefix: 't1' | 't3', label: string): string {
const raw = requireString(value, label + ' is required.')
if (raw.startsWith(prefix + '_')) return raw
if (/^[A-Za-z0-9]+$/.test(raw)) return prefix + '_' + raw
throw new Error(label + ' must be a Reddit ' + prefix + ' fullname or id.')
}
export function optionalLimit(value: unknown, fallback = 25): number {
if (value === undefined || value === null || value === '') return fallback
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > 100) {
throw new Error('limit must be an integer from 1 through 100.')
}
return value
}
export function listingSort(value: unknown, fallback: ListingSort = 'hot'): ListingSort {
const sort = value === undefined || value === null || value === '' ? fallback : value
switch (sort) {
case 'best':
case 'hot':
case 'new':
case 'top':
case 'rising':
case 'controversial':
return sort
default:
throw new Error('sort must be best, hot, new, top, rising, or controversial.')
}
}
export function searchSort(value: unknown, fallback: SearchSort = 'relevance'): SearchSort {
const sort = value === undefined || value === null || value === '' ? fallback : value
switch (sort) {
case 'relevance':
case 'hot':
case 'top':
case 'new':
case 'comments':
return sort
default:
throw new Error('sort must be relevance, hot, top, new, or comments.')
}
}
export function timeFilter(value: unknown): TimeFilter | undefined {
if (value === undefined || value === null || value === '') return undefined
switch (value) {
case 'hour':
case 'day':
case 'week':
case 'month':
case 'year':
case 'all':
return value
default:
throw new Error('t must be hour, day, week, month, year, or all.')
}
}
export function userListing(value: unknown, fallback: UserListing = 'about'): UserListing {
const listing = value === undefined || value === null || value === '' ? fallback : value
switch (listing) {
case 'about':
case 'submitted':
case 'comments':
case 'overview':
return listing
default:
throw new Error('listing must be about, submitted, comments, or overview.')
}
}
export function submitKind(value: unknown, fallback: SubmitKind = 'self'): SubmitKind {
const kind = value === undefined || value === null || value === '' ? fallback : value
switch (kind) {
case 'self':
case 'link':
return kind
default:
throw new Error('kind must be self or link.')
}
}
export function voteDirection(value: unknown): VoteDirection {
switch (value) {
case 1:
case '1':
case 'up':
return 1
case 0:
case '0':
case 'clear':
return 0
case -1:
case '-1':
case 'down':
return -1
default:
throw new Error("direction must be 1, 0, -1, 'up', 'down', or 'clear'.")
}
}
export function requireString(value: unknown, message: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(message)
}
return value.trim()
}
export function requireConfirm(confirm: unknown, message: string): void {
if (confirm !== true) {
throw new Error(message)
}
}
export function truncateText(value: string | null, limit: number): { text: string | null; truncated: boolean } {
if (value === null) return { text: null, truncated: false }
if (value.length <= limit) return { text: value, truncated: false }
return { text: value.slice(0, limit), truncated: true }
}
export function listingPath(subreddit: string | undefined, sort: ListingSort): string {
if (subreddit) return '/r/' + encodeURIComponent(subreddit) + '/' + sort
return '/' + sort
}
export function shapeListing(body: unknown): ShapedListing {
const listing = unwrapListing(body)
return {
kind: 'listing',
after: stringValue(listing.after) ?? null,
before: stringValue(listing.before) ?? null,
posts: childrenOf(listing).flatMap((child) => {
if (child.kind !== 't3') return []
const post = shapePost(child.data)
return post ? [post] : []
}),
}
}
export function shapeCommentListing(body: unknown): ShapedComment[] {
const listing = unwrapListing(body)
return flattenComments(childrenOf(listing))
}
export function shapePost(value: unknown): ShapedPost | null {
const data = asRecord(value)
if (!data) return null
const selftext = truncateText(stringValue(data.selftext) ?? null, SELFTEXT_LIMIT)
return {
id: stringValue(data.id) ?? '',
name: stringValue(data.name) ?? '',
title: stringValue(data.title) ?? '',
author: stringValue(data.author) ?? '',
subreddit: stringValue(data.subreddit) ?? '',
score: numberValue(data.score),
upvoteRatio: numberValue(data.upvote_ratio),
numComments: numberValue(data.num_comments),
url: stringValue(data.url) ?? null,
permalink: permalinkOf(data.permalink),
createdUtc: numberValue(data.created_utc),
over18: booleanValue(data.over_18),
stickied: booleanValue(data.stickied),
isSelf: booleanValue(data.is_self),
selftext: selftext.text,
selftextTruncated: selftext.truncated,
linkFlairText: stringValue(data.link_flair_text) ?? null,
}
}
export function shapeComment(value: unknown): ShapedComment | null {
const data = asRecord(value)
if (!data) return null
const body = truncateText(stringValue(data.body) ?? null, COMMENT_BODY_LIMIT)
return {
id: stringValue(data.id) ?? '',
name: stringValue(data.name) ?? '',
author: stringValue(data.author) ?? '',
body: body.text,
bodyTruncated: body.truncated,
score: numberValue(data.score),
permalink: permalinkOf(data.permalink),
createdUtc: numberValue(data.created_utc),
parentId: stringValue(data.parent_id) ?? null,
}
}
export function shapeSubreddit(body: unknown): ShapedSubreddit {
const data = unwrapThingData(body)
return {
id: stringValue(data.id) ?? null,
name: stringValue(data.name) ?? null,
displayName: stringValue(data.display_name) ?? null,
title: stringValue(data.title) ?? null,
publicDescription: stringValue(data.public_description) ?? null,
subscribers: numberValue(data.subscribers),
accountsActive: numberValue(data.accounts_active) ?? numberValue(data.active_user_count),
over18: booleanValue(data.over18) || booleanValue(data.over_18),
url: stringValue(data.url) ?? null,
createdUtc: numberValue(data.created_utc),
submissionType: stringValue(data.submission_type) ?? null,
}
}
export function shapeUser(body: unknown): ShapedUser {
const data = unwrapThingData(body)
return {
id: stringValue(data.id) ?? null,
name: stringValue(data.name) ?? null,
createdUtc: numberValue(data.created_utc) ?? numberValue(data.created),
commentKarma: numberValue(data.comment_karma),
linkKarma: numberValue(data.link_karma),
totalKarma: numberValue(data.total_karma),
isGold: booleanValue(data.is_gold),
isMod: booleanValue(data.is_mod),
verified: booleanValue(data.verified),
hasVerifiedEmail:
typeof data.has_verified_email === 'boolean' ? data.has_verified_email : null,
}
}
export function shapeMe(body: unknown): ShapedUser & { hasMail: boolean | null } {
const data = asRecord(body) ?? unwrapThingData(body)
return {
...shapeUser({ kind: 't2', data }),
hasMail: typeof data.has_mail === 'boolean' ? data.has_mail : null,
}
}
function unwrapThingData(body: unknown): JsonRecord {
const record = asRecord(body)
if (!record) {
throw new Error('Reddit response was not an object.')
}
const nested = asRecord(record.data)
return nested ?? record
}
function unwrapListing(body: unknown): JsonRecord {
const record = asRecord(body)
if (!record) {
throw new Error('Reddit listing response was not an object.')
}
if (record.kind === 'Listing') {
return asRecord(record.data) ?? {}
}
return asRecord(record.data) ?? record
}
function childrenOf(listing: JsonRecord): Array<JsonRecord & { kind?: string; data?: unknown }> {
if (!Array.isArray(listing.children)) return []
return listing.children.filter((child): child is JsonRecord & { kind?: string; data?: unknown } => {
return Boolean(child && typeof child === 'object' && !Array.isArray(child))
})
}
function flattenComments(children: Array<JsonRecord & { kind?: string; data?: unknown }>): ShapedComment[] {
const comments: ShapedComment[] = []
for (const child of children) {
if (child.kind === 'more') continue
if (child.kind === 't1') {
const comment = shapeComment(child.data)
if (comment) comments.push(comment)
const replies = asRecord(child.data)?.replies
if (replies && typeof replies === 'object') {
comments.push(...shapeCommentListing(replies))
}
}
}
return comments
}
function permalinkOf(value: unknown): string | null {
const permalink = stringValue(value)
if (!permalink) return null
if (permalink.startsWith('http')) return permalink
return 'https://www.reddit.com' + (permalink.startsWith('/') ? permalink : '/' + permalink)
}