Skip to content
← Public packages

@kentcdodds/linkedin

Headless helpers for LinkedIn identity, requests, and guarded posting using the saved linkedin OAuth integration.

src/create-media-post-input.ts

302 lines · 8.1 KB · TypeScript
import {
	ValidationError,
	any,
	boolean,
	object,
	optional,
	parse,
	string,
} from 'remix/data-schema'
import { formatSchemaValidationError } from './schema-input-errors.ts'
import type {
	CreateArticlePostInput,
	CreateImagePostInput,
	CreateVideoPostInput,
	LinkedInVisibility,
} from './types.ts'

const nonEmptyString = string().refine((value) => value.trim().length > 0, 'Expected non-empty string')

function firstNonEmpty(...values: Array<unknown>): string | undefined {
	for (const value of values) {
		if (typeof value === 'string' && value.trim().length > 0) return value.trim()
	}
	return undefined
}

const TEXT_ALIASES = {
	text: optional(string()),
	commentary: optional(string()),
	message: optional(string()),
	body: optional(string()),
} as const

const COMMON_POST = {
	author: optional(string()),
	visibility: optional(string()),
	isReshareDisabledByAuthor: optional(boolean()),
	dryRun: optional(boolean()),
	confirm: optional(boolean()),
} as const

function mapText(raw: Record<string, unknown>): string | undefined {
	return firstNonEmpty(raw.text, raw.commentary, raw.message, raw.body)
}

const ARTICLE_KNOWN_KEYS = [
	'text',
	'commentary',
	'message',
	'body',
	'url',
	'link',
	'articleUrl',
	'href',
	'title',
	'description',
	'thumbnail',
	'thumbnailUrl',
	'thumbnail_url',
	'imageUrl',
	'author',
	'visibility',
	'isReshareDisabledByAuthor',
	'dryRun',
	'confirm',
] as const

const IMAGE_KNOWN_KEYS = [
	'text',
	'commentary',
	'message',
	'body',
	'imageUrn',
	'image_urn',
	'asset',
	'title',
	'description',
	'altText',
	'alt_text',
	'author',
	'visibility',
	'isReshareDisabledByAuthor',
	'dryRun',
	'confirm',
] as const

const VIDEO_KNOWN_KEYS = [
	'text',
	'commentary',
	'message',
	'body',
	'videoUrn',
	'video_urn',
	'uploadFromBytes',
	'uploadFromUrl',
	'upload_from_url',
	'videoUrl',
	'fileSizeBytes',
	'file_size_bytes',
	'uploadCaptions',
	'uploadThumbnail',
	'title',
	'author',
	'visibility',
	'isReshareDisabledByAuthor',
	'dryRun',
	'confirm',
] as const

const ArticleSchema = object(
	{
		...TEXT_ALIASES,
		url: optional(nonEmptyString),
		link: optional(nonEmptyString),
		articleUrl: optional(nonEmptyString),
		href: optional(nonEmptyString),
		title: optional(nonEmptyString),
		description: optional(nonEmptyString),
		thumbnail: optional(nonEmptyString),
		thumbnailUrl: optional(nonEmptyString),
		thumbnail_url: optional(nonEmptyString),
		imageUrl: optional(nonEmptyString),
		...COMMON_POST,
	},
	{ unknownKeys: 'error' },
).transform((raw): CreateArticlePostInput => {
	const text = mapText(raw as Record<string, unknown>)
	const url = firstNonEmpty(raw.url, raw.link, raw.articleUrl, raw.href)
	const thumbnailUrl = firstNonEmpty(raw.thumbnailUrl, raw.thumbnail_url, raw.imageUrl)
	return {
		text: text as string,
		url: url as string,
		title: raw.title as string,
		description: raw.description as string,
		thumbnail: raw.thumbnail,
		thumbnailUrl,
		author: raw.author,
		visibility: raw.visibility as LinkedInVisibility | undefined,
		isReshareDisabledByAuthor: raw.isReshareDisabledByAuthor,
		dryRun: raw.dryRun,
		confirm: raw.confirm,
	}
})

const ImageSchema = object(
	{
		...TEXT_ALIASES,
		imageUrn: optional(nonEmptyString),
		image_urn: optional(nonEmptyString),
		asset: optional(nonEmptyString),
		title: optional(string()),
		description: optional(string()),
		altText: optional(string()),
		alt_text: optional(string()),
		...COMMON_POST,
	},
	{ unknownKeys: 'error' },
).transform((raw): CreateImagePostInput => {
	const text = mapText(raw as Record<string, unknown>)
	const imageUrn = firstNonEmpty(raw.imageUrn, raw.image_urn, raw.asset)
	return {
		text: text as string,
		imageUrn,
		asset: raw.asset,
		title: raw.title,
		description: raw.description,
		altText: firstNonEmpty(raw.altText, raw.alt_text),
		author: raw.author,
		visibility: raw.visibility as LinkedInVisibility | undefined,
		isReshareDisabledByAuthor: raw.isReshareDisabledByAuthor,
		dryRun: raw.dryRun,
		confirm: raw.confirm,
	}
})

const VideoSchema = object(
	{
		...TEXT_ALIASES,
		videoUrn: optional(nonEmptyString),
		video_urn: optional(nonEmptyString),
		uploadFromBytes: optional(any()),
		uploadFromUrl: optional(nonEmptyString),
		upload_from_url: optional(nonEmptyString),
		videoUrl: optional(nonEmptyString),
		fileSizeBytes: optional(any()),
		file_size_bytes: optional(any()),
		uploadCaptions: optional(boolean()),
		uploadThumbnail: optional(boolean()),
		title: optional(string()),
		...COMMON_POST,
	},
	{ unknownKeys: 'error' },
).transform((raw): CreateVideoPostInput => {
	const text = mapText(raw as Record<string, unknown>)
	const fileSizeRaw = raw.fileSizeBytes ?? raw.file_size_bytes
	const fileSizeBytes =
		typeof fileSizeRaw === 'number' && Number.isFinite(fileSizeRaw)
			? fileSizeRaw
			: typeof fileSizeRaw === 'string' && fileSizeRaw.trim() && Number.isFinite(Number(fileSizeRaw))
				? Number(fileSizeRaw)
				: undefined
	return {
		text: text as string,
		videoUrn: firstNonEmpty(raw.videoUrn, raw.video_urn),
		uploadFromBytes: raw.uploadFromBytes,
		uploadFromUrl: firstNonEmpty(raw.uploadFromUrl, raw.upload_from_url, raw.videoUrl),
		fileSizeBytes,
		uploadCaptions: raw.uploadCaptions,
		uploadThumbnail: raw.uploadThumbnail,
		title: raw.title,
		author: raw.author,
		visibility: raw.visibility as LinkedInVisibility | undefined,
		isReshareDisabledByAuthor: raw.isReshareDisabledByAuthor,
		dryRun: raw.dryRun,
		confirm: raw.confirm,
	}
})

function wrapParse<T>(
	surface: string,
	knownKeys: readonly string[],
	hint: string,
	schema: unknown,
	input: unknown,
): T {
	try {
		return parse(schema as never, input ?? {}) as T
	} catch (error) {
		if (error instanceof ValidationError) {
			throw formatSchemaValidationError(error, { surface, knownKeys, hint, input })
		}
		throw error
	}
}

export function parseCreateArticlePostInput(input: unknown): CreateArticlePostInput {
	const normalized = wrapParse<CreateArticlePostInput>(
		'create-article-post',
		ARTICLE_KNOWN_KEYS,
		`Pass text (aliases: commentary/message/body), url (aliases: link/articleUrl/href), title, description, and thumbnail or thumbnailUrl (aliases: thumbnail_url/imageUrl). Unknown keys are rejected.`,
		ArticleSchema,
		input,
	)
	if (!normalized.text?.trim()) {
		throw new Error('create-article-post requires text (aliases: commentary, message, body).')
	}
	if (!normalized.url?.trim()) {
		throw new Error('create-article-post requires url (aliases: link, articleUrl, href).')
	}
	if (!normalized.title?.trim()) {
		throw new Error('create-article-post requires title.')
	}
	if (!normalized.description?.trim()) {
		throw new Error('create-article-post requires description.')
	}
	if (!normalized.thumbnail?.trim() && !normalized.thumbnailUrl?.trim()) {
		throw new Error(
			'create-article-post requires thumbnail (image URN) or thumbnailUrl (aliases: thumbnail_url, imageUrl).',
		)
	}
	return normalized
}

export function parseCreateImagePostInput(input: unknown): CreateImagePostInput {
	const normalized = wrapParse<CreateImagePostInput>(
		'create-image-post',
		IMAGE_KNOWN_KEYS,
		`Pass text (aliases: commentary/message/body) and imageUrn (aliases: image_urn, asset). Unknown keys are rejected.`,
		ImageSchema,
		input,
	)
	if (!normalized.text?.trim()) {
		throw new Error('create-image-post requires text (aliases: commentary, message, body).')
	}
	if (!normalized.imageUrn?.trim() && !normalized.asset?.trim()) {
		throw new Error('create-image-post requires imageUrn (aliases: image_urn, asset).')
	}
	return normalized
}

export function parseCreateVideoPostInput(input: unknown): CreateVideoPostInput {
	const normalized = wrapParse<CreateVideoPostInput>(
		'create-video-post',
		VIDEO_KNOWN_KEYS,
		`Pass text (aliases: commentary/message/body) plus videoUrn or uploadFromUrl/videoUrl/uploadFromBytes. Unknown keys are rejected.`,
		VideoSchema,
		input,
	)
	if (!normalized.text?.trim()) {
		throw new Error('create-video-post requires text (aliases: commentary, message, body).')
	}
	if (
		!normalized.videoUrn?.trim() &&
		!normalized.uploadFromUrl?.trim() &&
		normalized.uploadFromBytes === undefined
	) {
		throw new Error(
			'create-video-post requires videoUrn (alias: video_urn) or uploadFromUrl (aliases: upload_from_url, videoUrl) or uploadFromBytes.',
		)
	}
	return normalized
}