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 @@
|
||||||
/**
|
import { cardSchema, type Card } from 'soapbox/schemas/card';
|
||||||
* Card normalizer:
|
|
||||||
* Converts API cards into our internal format.
|
|
||||||
* @see {@link https://docs.joinmastodon.org/entities/card/}
|
|
||||||
*/
|
|
||||||
import punycode from 'punycode';
|
|
||||||
|
|
||||||
import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable';
|
export const normalizeCard = (card: unknown): Card => {
|
||||||
|
|
||||||
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>) => {
|
|
||||||
try {
|
try {
|
||||||
const group = groupSchema.parse(card.get('group').toJS());
|
return cardSchema.parse(card);
|
||||||
return card.set('group', group);
|
|
||||||
} catch (_e) {
|
} 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 { AnnouncementRecord, normalizeAnnouncement } from './announcement';
|
||||||
export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement-reaction';
|
export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement-reaction';
|
||||||
export { AttachmentRecord, normalizeAttachment } from './attachment';
|
export { AttachmentRecord, normalizeAttachment } from './attachment';
|
||||||
export { CardRecord, normalizeCard } from './card';
|
export { normalizeCard } from './card';
|
||||||
export { ChatRecord, normalizeChat } from './chat';
|
export { ChatRecord, normalizeChat } from './chat';
|
||||||
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
||||||
export { EmojiRecord, normalizeEmoji } from './emoji';
|
export { EmojiRecord, normalizeEmoji } from './emoji';
|
||||||
|
|
|
@ -4,12 +4,12 @@ import {
|
||||||
fromJS,
|
fromJS,
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
|
|
||||||
import { CardRecord, normalizeCard } from '../card';
|
import { normalizeCard } from '../card';
|
||||||
|
|
||||||
import type { Ad } from 'soapbox/features/ads/providers';
|
import type { Ad } from 'soapbox/features/ads/providers';
|
||||||
|
|
||||||
export const AdRecord = ImmutableRecord<Ad>({
|
export const AdRecord = ImmutableRecord<Ad>({
|
||||||
card: CardRecord(),
|
card: normalizeCard({}),
|
||||||
impression: undefined as string | undefined,
|
impression: undefined as string | undefined,
|
||||||
expires_at: undefined as string | undefined,
|
expires_at: undefined as string | undefined,
|
||||||
reason: undefined as string | undefined,
|
reason: undefined as string | undefined,
|
||||||
|
@ -18,7 +18,7 @@ export const AdRecord = ImmutableRecord<Ad>({
|
||||||
/** Normalizes an ad from Soapbox Config. */
|
/** Normalizes an ad from Soapbox Config. */
|
||||||
export const normalizeAd = (ad: Record<string, any>) => {
|
export const normalizeAd = (ad: Record<string, any>) => {
|
||||||
const map = ImmutableMap<string, any>(fromJS(ad));
|
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');
|
const expiresAt = map.get('expires_at') || map.get('expires');
|
||||||
|
|
||||||
return AdRecord(map.merge({
|
return AdRecord(map.merge({
|
||||||
|
|
|
@ -11,10 +11,10 @@ import {
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
|
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
||||||
import { normalizeCard } from 'soapbox/normalizers/card';
|
|
||||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||||
import { normalizeMention } from 'soapbox/normalizers/mention';
|
import { normalizeMention } from 'soapbox/normalizers/mention';
|
||||||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
import { normalizePoll } from 'soapbox/normalizers/poll';
|
||||||
|
import { cardSchema } from 'soapbox/schemas/card';
|
||||||
|
|
||||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||||
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
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
|
// Normalize card
|
||||||
const normalizeStatusCard = (status: ImmutableMap<string, any>) => {
|
const normalizeStatusCard = (status: ImmutableMap<string, any>) => {
|
||||||
if (status.get('card')) {
|
try {
|
||||||
return status.update('card', ImmutableMap(), normalizeCard);
|
const card = cardSchema.parse(status.get('card').toJS());
|
||||||
} else {
|
return status.set('card', card);
|
||||||
|
} catch (e) {
|
||||||
return status.set('card', null);
|
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
|
* Entity Types
|
||||||
*/
|
*/
|
||||||
export type { Account } from './account';
|
export type { Account } from './account';
|
||||||
|
export type { Card } from './card';
|
||||||
export type { CustomEmoji } from './custom-emoji';
|
export type { CustomEmoji } from './custom-emoji';
|
||||||
export type { Group } from './group';
|
export type { Group } from './group';
|
||||||
export type { GroupMember } from './group-member';
|
export type { GroupMember } from './group-member';
|
||||||
|
|
|
@ -5,7 +5,6 @@ import {
|
||||||
AnnouncementRecord,
|
AnnouncementRecord,
|
||||||
AnnouncementReactionRecord,
|
AnnouncementReactionRecord,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
CardRecord,
|
|
||||||
ChatRecord,
|
ChatRecord,
|
||||||
ChatMessageRecord,
|
ChatMessageRecord,
|
||||||
EmojiRecord,
|
EmojiRecord,
|
||||||
|
@ -37,7 +36,6 @@ type AdminReport = ReturnType<typeof AdminReportRecord>;
|
||||||
type Announcement = ReturnType<typeof AnnouncementRecord>;
|
type Announcement = ReturnType<typeof AnnouncementRecord>;
|
||||||
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
|
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
|
||||||
type Attachment = ReturnType<typeof AttachmentRecord>;
|
type Attachment = ReturnType<typeof AttachmentRecord>;
|
||||||
type Card = ReturnType<typeof CardRecord>;
|
|
||||||
type Chat = ReturnType<typeof ChatRecord>;
|
type Chat = ReturnType<typeof ChatRecord>;
|
||||||
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
||||||
type Emoji = ReturnType<typeof EmojiRecord>;
|
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||||
|
@ -82,7 +80,6 @@ export {
|
||||||
Announcement,
|
Announcement,
|
||||||
AnnouncementReaction,
|
AnnouncementReaction,
|
||||||
Attachment,
|
Attachment,
|
||||||
Card,
|
|
||||||
Chat,
|
Chat,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Emoji,
|
Emoji,
|
||||||
|
@ -110,6 +107,7 @@ export {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
Card,
|
||||||
Group,
|
Group,
|
||||||
GroupMember,
|
GroupMember,
|
||||||
GroupRelationship,
|
GroupRelationship,
|
||||||
|
|
Loading…
Reference in a new issue