Card: normalize with zod
This commit is contained in:
parent
0351bda198
commit
741da92084
7 changed files with 100 additions and 88 deletions
|
@ -1,82 +1,25 @@
|
|||
/**
|
||||
* Card normalizer:
|
||||
* Converts API cards into our internal format.
|
||||
* @see {@link https://docs.joinmastodon.org/entities/card/}
|
||||
*/
|
||||
import punycode from 'punycode';
|
||||
import { cardSchema, type Card } from 'soapbox/schemas/card';
|
||||
|
||||
import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable';
|
||||
|
||||
import { groupSchema, type Group } from 'soapbox/schemas';
|
||||
import { mergeDefined } from 'soapbox/utils/normalizers';
|
||||
|
||||
// https://docs.joinmastodon.org/entities/card/
|
||||
export const CardRecord = ImmutableRecord({
|
||||
author_name: '',
|
||||
author_url: '',
|
||||
blurhash: null as string | null,
|
||||
description: '',
|
||||
embed_url: '',
|
||||
group: null as null | Group,
|
||||
height: 0,
|
||||
html: '',
|
||||
image: null as string | null,
|
||||
provider_name: '',
|
||||
provider_url: '',
|
||||
title: '',
|
||||
type: 'link',
|
||||
url: '',
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const IDNA_PREFIX = 'xn--';
|
||||
|
||||
const decodeIDNA = (domain: string): string => {
|
||||
return domain
|
||||
.split('.')
|
||||
.map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
|
||||
.join('.');
|
||||
};
|
||||
|
||||
const getHostname = (url: string): string => {
|
||||
const parser = document.createElement('a');
|
||||
parser.href = url;
|
||||
return parser.hostname;
|
||||
};
|
||||
|
||||
/** Fall back to Pleroma's OG data */
|
||||
const normalizePleromaOpengraph = (card: ImmutableMap<string, any>) => {
|
||||
const opengraph = ImmutableMap({
|
||||
width: card.getIn(['pleroma', 'opengraph', 'width']),
|
||||
height: card.getIn(['pleroma', 'opengraph', 'height']),
|
||||
html: card.getIn(['pleroma', 'opengraph', 'html']),
|
||||
image: card.getIn(['pleroma', 'opengraph', 'thumbnail_url']),
|
||||
});
|
||||
|
||||
return card.mergeWith(mergeDefined, opengraph);
|
||||
};
|
||||
|
||||
/** Set provider from URL if not found */
|
||||
const normalizeProviderName = (card: ImmutableMap<string, any>) => {
|
||||
const providerName = card.get('provider_name') || decodeIDNA(getHostname(card.get('url')));
|
||||
return card.set('provider_name', providerName);
|
||||
};
|
||||
|
||||
const normalizeGroup = (card: ImmutableMap<string, any>) => {
|
||||
export const normalizeCard = (card: unknown): Card => {
|
||||
try {
|
||||
const group = groupSchema.parse(card.get('group').toJS());
|
||||
return card.set('group', group);
|
||||
return cardSchema.parse(card);
|
||||
} catch (_e) {
|
||||
return card.set('group', null);
|
||||
return {
|
||||
author_name: '',
|
||||
author_url: '',
|
||||
blurhash: null,
|
||||
description: '',
|
||||
embed_url: '',
|
||||
group: null,
|
||||
height: 0,
|
||||
html: '',
|
||||
image: null,
|
||||
provider_name: '',
|
||||
provider_url: '',
|
||||
title: '',
|
||||
type: 'link',
|
||||
url: '',
|
||||
width: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeCard = (card: Record<string, any>) => {
|
||||
return CardRecord(
|
||||
ImmutableMap(fromJS(card)).withMutations(card => {
|
||||
normalizePleromaOpengraph(card);
|
||||
normalizeProviderName(card);
|
||||
normalizeGroup(card);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@ export { AdminReportRecord, normalizeAdminReport } from './admin-report';
|
|||
export { AnnouncementRecord, normalizeAnnouncement } from './announcement';
|
||||
export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement-reaction';
|
||||
export { AttachmentRecord, normalizeAttachment } from './attachment';
|
||||
export { CardRecord, normalizeCard } from './card';
|
||||
export { normalizeCard } from './card';
|
||||
export { ChatRecord, normalizeChat } from './chat';
|
||||
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
||||
export { EmojiRecord, normalizeEmoji } from './emoji';
|
||||
|
|
|
@ -4,12 +4,12 @@ import {
|
|||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
import { CardRecord, normalizeCard } from '../card';
|
||||
import { normalizeCard } from '../card';
|
||||
|
||||
import type { Ad } from 'soapbox/features/ads/providers';
|
||||
|
||||
export const AdRecord = ImmutableRecord<Ad>({
|
||||
card: CardRecord(),
|
||||
card: normalizeCard({}),
|
||||
impression: undefined as string | undefined,
|
||||
expires_at: undefined as string | undefined,
|
||||
reason: undefined as string | undefined,
|
||||
|
@ -18,7 +18,7 @@ export const AdRecord = ImmutableRecord<Ad>({
|
|||
/** Normalizes an ad from Soapbox Config. */
|
||||
export const normalizeAd = (ad: Record<string, any>) => {
|
||||
const map = ImmutableMap<string, any>(fromJS(ad));
|
||||
const card = normalizeCard(map.get('card'));
|
||||
const card = normalizeCard(map.get('card').toJS());
|
||||
const expiresAt = map.get('expires_at') || map.get('expires');
|
||||
|
||||
return AdRecord(map.merge({
|
||||
|
|
|
@ -11,10 +11,10 @@ import {
|
|||
} from 'immutable';
|
||||
|
||||
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
||||
import { normalizeCard } from 'soapbox/normalizers/card';
|
||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||
import { normalizeMention } from 'soapbox/normalizers/mention';
|
||||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
||||
import { cardSchema } from 'soapbox/schemas/card';
|
||||
|
||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||
|
@ -118,9 +118,10 @@ const normalizeStatusPoll = (status: ImmutableMap<string, any>) => {
|
|||
|
||||
// Normalize card
|
||||
const normalizeStatusCard = (status: ImmutableMap<string, any>) => {
|
||||
if (status.get('card')) {
|
||||
return status.update('card', ImmutableMap(), normalizeCard);
|
||||
} else {
|
||||
try {
|
||||
const card = cardSchema.parse(status.get('card').toJS());
|
||||
return status.set('card', card);
|
||||
} catch (e) {
|
||||
return status.set('card', null);
|
||||
}
|
||||
};
|
||||
|
|
69
app/soapbox/schemas/card.ts
Normal file
69
app/soapbox/schemas/card.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import punycode from 'punycode';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { groupSchema } from './group';
|
||||
|
||||
const IDNA_PREFIX = 'xn--';
|
||||
|
||||
/**
|
||||
* Card (aka link preview).
|
||||
* https://docs.joinmastodon.org/entities/card/
|
||||
*/
|
||||
const cardSchema = z.object({
|
||||
author_name: z.string().catch(''),
|
||||
author_url: z.string().url().catch(''),
|
||||
blurhash: z.string().nullable().catch(null),
|
||||
description: z.string().catch(''),
|
||||
embed_url: z.string().url().catch(''),
|
||||
group: groupSchema.nullable().catch(null), // TruthSocial
|
||||
height: z.number().catch(0),
|
||||
html: z.string().catch(''),
|
||||
image: z.string().nullable().catch(null),
|
||||
pleroma: z.object({
|
||||
opengraph: z.object({
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
html: z.string(),
|
||||
thumbnail_url: z.string().url(),
|
||||
}).optional().catch(undefined),
|
||||
}).optional().catch(undefined),
|
||||
provider_name: z.string().catch(''),
|
||||
provider_url: z.string().url().catch(''),
|
||||
title: z.string().catch(''),
|
||||
type: z.enum(['link', 'photo', 'video', 'rich']).catch('link'),
|
||||
url: z.string().url(),
|
||||
width: z.number().catch(0),
|
||||
}).transform((card) => {
|
||||
if (!card.provider_name) {
|
||||
card.provider_name = decodeIDNA(new URL(card.url).hostname);
|
||||
}
|
||||
|
||||
if (card.pleroma?.opengraph) {
|
||||
if (!card.width && !card.height) {
|
||||
card.width = card.pleroma.opengraph.width;
|
||||
card.height = card.pleroma.opengraph.height;
|
||||
}
|
||||
|
||||
if (!card.html) {
|
||||
card.html = card.pleroma.opengraph.html;
|
||||
}
|
||||
|
||||
if (!card.image) {
|
||||
card.image = card.pleroma.opengraph.thumbnail_url;
|
||||
}
|
||||
}
|
||||
|
||||
return card;
|
||||
});
|
||||
|
||||
const decodeIDNA = (domain: string): string => {
|
||||
return domain
|
||||
.split('.')
|
||||
.map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
|
||||
.join('.');
|
||||
};
|
||||
|
||||
type Card = z.infer<typeof cardSchema>;
|
||||
|
||||
export { cardSchema, type Card };
|
|
@ -13,6 +13,7 @@ export { relationshipSchema } from './relationship';
|
|||
* Entity Types
|
||||
*/
|
||||
export type { Account } from './account';
|
||||
export type { Card } from './card';
|
||||
export type { CustomEmoji } from './custom-emoji';
|
||||
export type { Group } from './group';
|
||||
export type { GroupMember } from './group-member';
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
AnnouncementRecord,
|
||||
AnnouncementReactionRecord,
|
||||
AttachmentRecord,
|
||||
CardRecord,
|
||||
ChatRecord,
|
||||
ChatMessageRecord,
|
||||
EmojiRecord,
|
||||
|
@ -37,7 +36,6 @@ type AdminReport = ReturnType<typeof AdminReportRecord>;
|
|||
type Announcement = ReturnType<typeof AnnouncementRecord>;
|
||||
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
|
||||
type Attachment = ReturnType<typeof AttachmentRecord>;
|
||||
type Card = ReturnType<typeof CardRecord>;
|
||||
type Chat = ReturnType<typeof ChatRecord>;
|
||||
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
||||
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||
|
@ -82,7 +80,6 @@ export {
|
|||
Announcement,
|
||||
AnnouncementReaction,
|
||||
Attachment,
|
||||
Card,
|
||||
Chat,
|
||||
ChatMessage,
|
||||
Emoji,
|
||||
|
@ -110,6 +107,7 @@ export {
|
|||
};
|
||||
|
||||
export type {
|
||||
Card,
|
||||
Group,
|
||||
GroupMember,
|
||||
GroupRelationship,
|
||||
|
|
Loading…
Reference in a new issue