← 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 · TypeScriptimport {
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
}