diff --git a/.changeset/pre.json b/.changeset/pre.json index 95bb130..62fbf32 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -11,6 +11,7 @@ "light-ligers-exist", "quick-pets-itch", "shy-falcons-sip", + "three-lamps-develop", "two-pigs-do" ] } diff --git a/.changeset/three-lamps-develop.md b/.changeset/three-lamps-develop.md new file mode 100644 index 0000000..e6e6af4 --- /dev/null +++ b/.changeset/three-lamps-develop.md @@ -0,0 +1,5 @@ +--- +'@lens-protocol/metadata': patch +--- + +**Added** support for legacy Publication Metadata v1 and v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d4135..916107e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @lens-protocol/metadata +## 0.1.0-alpha.6 + +### Patch Changes + +- cf7bfaa: **Added** support for legacy Publication Metadata v1 and v2 + ## 0.1.0-alpha.5 ### Patch Changes diff --git a/README.md b/README.md index 8750c7d..77198c8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,25 @@ PublicationMetadataSchema.safeParse(invalid); // => { success: false, error: ZodError } ``` +You can also parse legacy Publication Metadata v2 and v1 via: + +```typescript +import { legacy } from '@lens-protocol/metadata'; + +legacy.PublicationMetadataSchema.parse(valid); // => legacy.PublicationMetadata +legacy.PublicationMetadataSchema.parse(invalid); // => throws ZodError + +// OR + +legacy.PublicationMetadataSchema.safeParse(valid); +// => { success: true, data: legacy.PublicationMetadata } +legacy.PublicationMetadataSchema.safeParse(invalid); +// => { success: false, error: ZodError } +``` + +> [!WARNING] +> When working with the `legacy` namespace make sure to use enums from tha same namespace, e.g. `legacy.PublicationMainFocus` instead of `PublicationMainFocus` to avoid confusion. + ### Profile metadata ```typescript @@ -87,7 +106,7 @@ if (!result.success) { Every time you have a discriminated union, you can use the discriminant to narrow the type. See few examples below. -**PublicationMetadata** +**`PublicationMetadata`** ```typescript import { @@ -116,7 +135,49 @@ switch (publicationMetadata.$schema) { } ``` -**MetadataAttribute** +**`legacy.PublicationMetadata`** + +`legacy.PublicationMetadata` is a discriminated union of `legacy.PublicationMetadataV1` and `legacy.PublicationMetadataV2` where the `version` property is the discriminant. + +In turn `legacy.PublicationMetadataV2` is a discriminated union of: + +- `legacy.PublicationMetadataV2Article` +- `legacy.PublicationMetadataV2Audio` +- `legacy.PublicationMetadataV2Embed` +- `legacy.PublicationMetadataV2Image` +- `legacy.PublicationMetadataV2Link` +- `legacy.PublicationMetadataV2TextOnly` +- `legacy.PublicationMetadataV2Video` + +where the `mainContentFocus` property is the discriminant. + +```typescript +import { legacy } from '@lens-protocol/metadata'; + +const publicationMetadata = legacy.PublicationMetadataSchema.parse(valid); + +switch (publicationMetadata.version) { + case legacy.PublicationMetadataVersion.V1: + // publicationMetadata is legacy.PublicationMetadataV1 + break; + case legacy.PublicationMetadataVersion.V2: + // publicationMetadata is legacy.PublicationMetadataV2 + + switch (publicationMetadata.mainContentFocus) { + case legacy.PublicationMainFocus.ARTICLE: + // publicationMetadata is legacy.PublicationMetadataV2Article + break; + case legacy.PublicationMainFocus.VIDEO: + // publicationMetadata is legacy.PublicationMetadataV2Video + break; + + // ... + } + break; +} +``` + +**`MetadataAttribute`** ```typescript import { MetadataAttribute, MetadataAttributeType } from '@lens-protocol/metadata'; diff --git a/package.json b/package.json index b88a61b..9a3341d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lens-protocol/metadata", - "version": "0.1.0-alpha.5", + "version": "0.1.0-alpha.6", "description": "Lens Protocol Metadata Standards", "type": "module", "main": "./dist/index.cjs", diff --git a/scripts/build.ts b/scripts/build.ts index b4de7c0..851a1b1 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -42,6 +42,7 @@ import { AccessConditionSchema, PublicationIdSchema, AmountSchema, + TagSchema, } from '../src'; const outputDir = 'jsonschemas'; @@ -97,6 +98,7 @@ for (const [path, Schema] of schemas) { ProfileOwnershipCondition: ProfileOwnershipConditionSchema, PublicationEncryptionStrategy: PublicationEncryptionStrategySchema, PublicationId: PublicationIdSchema, + Tag: TagSchema, }, }); diff --git a/src/primitives.ts b/src/primitives.ts index 3b80060..a61a494 100644 --- a/src/primitives.ts +++ b/src/primitives.ts @@ -32,6 +32,17 @@ export const LocaleSchema: z.Schema = z .max(5) .transform((value) => value as Locale); +/** + * An arbitrary tag. + */ +export type Tag = Brand; +/** + * @internal + */ +export const TagSchema: z.Schema = notEmptyString('An arbitrary tag.') + .max(50) + .transform((tag) => tag.toLowerCase() as Tag); + /** * A Lens App identifier. */ diff --git a/src/publication/__tests__/LegacyPublicationMetadataSchema.spec.ts b/src/publication/__tests__/LegacyPublicationMetadataSchema.spec.ts new file mode 100644 index 0000000..1f29b17 --- /dev/null +++ b/src/publication/__tests__/LegacyPublicationMetadataSchema.spec.ts @@ -0,0 +1,324 @@ +import { describe, it } from '@jest/globals'; + +import { expectSchema } from '../../__helpers__/assertions.js'; +import { legacy } from '../index.js'; + +describe(`Given the legacy.PublicationMetadataSchema`, () => { + describe(`when parsing an empty object`, () => { + it(`then should complain about the missing version`, () => { + expectSchema(() => legacy.PublicationMetadataSchema.safeParse({})).toMatchInlineSnapshot(` + "fix the following issues + · "version": Required" + `); + }); + + it(`then should complain about invalid version`, () => { + expectSchema(() => legacy.PublicationMetadataSchema.safeParse({ version: '42' })) + .toMatchInlineSnapshot(` + "fix the following issues + · "version": Invalid enum value. Expected '1.0.0' | '2.0.0', received '42'" + `); + }); + }); + + describe(`when parsing an invalid v1 object`, () => { + it(`then should complain about missing basic mandatory fields`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '1.0.0', + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "metadata_id": Required + · "name": Required + · "attributes": Required" + `); + }); + + it(`then should check at least one between content, image, and media is present`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '1.0.0', + metadata_id: '123', + name: '123', + attributes: [], + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "content": At least one between content, image, and media must be present. Content must be over 1 character. + · "image": At least one between content, image, and media must be present. + · "media": At least one between content, image, and media must be present." + `); + }); + + it('then should complain about empty content', () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '1.0.0', + metadata_id: '123', + name: '123', + attributes: [], + content: '', + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "content": At least one between content, image, and media must be present. Content must be over 1 character. + · "image": At least one between content, image, and media must be present. + · "media": At least one between content, image, and media must be present." + `); + }); + + it('then should complain about too long content', () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '1.0.0', + metadata_id: '123', + name: '123', + attributes: [], + content: 'a'.repeat(30001), + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "content": String must contain at most 30000 character(s)" + `); + }); + + it('then should complain about invalid media items', () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '1.0.0', + metadata_id: '123', + name: '123', + attributes: [], + media: [ + {}, + { + item: 'https://example.com/image.png', + cover: 'not valid URI', + }, + ], + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "media[0].item": Required + · "media[1].cover": Invalid url" + `); + }); + }); + + describe(`when parsing an invalid v2 object`, () => { + it(`then should complain about missing basic mandatory fields`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + mainContentFocus: legacy.PublicationMainFocus.TEXT_ONLY, + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "metadata_id": Required + · "name": Required + · "attributes": Required + · "content": Required + · "locale": Required" + `); + }); + + it('then should complain about invalid v2 fields', () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + metadata_id: '123', + name: '123', + attributes: [], + content: 'a', + locale: '', + contentWarning: 'NOVALID', + mainContentFocus: legacy.PublicationMainFocus.TEXT_ONLY, + tags: ['a'.repeat(51), '', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "locale": String must contain at least 2 character(s) + · "contentWarning": Invalid enum value. Expected 'NSFW' | 'SENSITIVE' | 'SPOILER', received 'NOVALID' + · "tags": Array must contain at most 10 element(s) + · "tags[0]": String must contain at most 50 character(s) + · "tags[1]": String must contain at least 1 character(s)" + `); + }); + + it(`then should complain about invalid ${legacy.PublicationMainFocus.ARTICLE} metadata`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + metadata_id: '123', + name: '123', + attributes: [], + locale: 'en', + mainContentFocus: legacy.PublicationMainFocus.ARTICLE, + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "content": Required" + `); + }); + + it(`then should complain about invalid ${legacy.PublicationMainFocus.AUDIO} metadata`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + metadata_id: '123', + name: '123', + attributes: [], + locale: 'en', + mainContentFocus: legacy.PublicationMainFocus.AUDIO, + media: [ + { + item: 'https://example.com/image.png', + type: 'image/png', + }, + ], + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "media": Metadata AUDIO requires an audio to be attached." + `); + }); + + it(`then should complain about invalid ${legacy.PublicationMainFocus.EMBED} metadata`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + metadata_id: '123', + name: '123', + attributes: [], + locale: 'en', + mainContentFocus: legacy.PublicationMainFocus.EMBED, + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "animation_url": Required" + `); + }); + + it(`then should complain about invalid ${legacy.PublicationMainFocus.IMAGE} metadata`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + metadata_id: '123', + name: '123', + attributes: [], + locale: 'en', + mainContentFocus: legacy.PublicationMainFocus.IMAGE, + media: [ + { + item: 'https://example.com/dunno.42', + }, + ], + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "media": Metadata IMAGE requires an image to be attached." + `); + }); + + it(`then should complain about invalid ${legacy.PublicationMainFocus.LINK} metadata`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + metadata_id: '123', + name: '123', + attributes: [], + locale: 'en', + content: 'somehting without a URL', + mainContentFocus: legacy.PublicationMainFocus.LINK, + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "content": Metadata LINK requires a valid https link" + `); + }); + + it(`then should complain about invalid ${legacy.PublicationMainFocus.TEXT_ONLY} metadata`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + metadata_id: '123', + name: '123', + attributes: [], + locale: 'en', + mainContentFocus: legacy.PublicationMainFocus.TEXT_ONLY, + media: [ + { + item: 'https://example.com/image.png', + }, + ], + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "content": Required + · "media": Metadata TEXT cannot have media" + `); + }); + + it(`then should complain about invalid ${legacy.PublicationMainFocus.VIDEO} metadata`, () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + metadata_id: '123', + name: '123', + attributes: [], + locale: 'en', + mainContentFocus: legacy.PublicationMainFocus.VIDEO, + media: [ + { + item: 'https://example.com/dunno.42', + }, + ], + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "media": Metadata VIDEO requires an image to be attached." + `); + }); + + it('then should complain about invalid encryptionParams', () => { + expectSchema(() => + legacy.PublicationMetadataSchema.safeParse({ + version: '2.0.0', + metadata_id: '123', + name: '123', + attributes: [], + locale: 'en', + mainContentFocus: legacy.PublicationMainFocus.TEXT_ONLY, + content: 'a', + encryptionParams: { + accessCondition: { + and: { + criteria: [], + }, + or: { + criteria: [ + { + collect: { + publicationId: '0x01-0x01', + }, + profile: { + profileId: '0x01', + }, + }, + ], + }, + }, + encryptionKey: '0x...', + }, + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "encryptionParams.accessCondition.or.criteria": Invalid OR condition: should have at least 2 conditions + · "encryptionParams.accessCondition.or.criteria[0]": Unrecognized key(s) in object: 'collect' + · "encryptionParams.accessCondition": Unrecognized key(s) in object: 'and' + · "encryptionParams.encryptionKey": Encryption key should be 368 characters long." + `); + }); + }); +}); diff --git a/src/publication/common/index.ts b/src/publication/common/index.ts index 4236688..54fc235 100644 --- a/src/publication/common/index.ts +++ b/src/publication/common/index.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { PublicationEncryptionStrategySchema } from './encryption.js'; import { MarketplaceMetadataSchema } from './marketplace.js'; import { MetadataAttributeSchema } from '../../MetadataAttribute.js'; -import { AppIdSchema, LocaleSchema, markdown, notEmptyString } from '../../primitives'; +import { AppIdSchema, LocaleSchema, TagSchema, markdown, notEmptyString } from '../../primitives'; import { SignatureSchema } from '../../primitives.js'; import { PublicationMainFocus } from '../PublicationMainFocus.js'; @@ -42,7 +42,7 @@ export const MetadataCommonSchema = z.object( encryptedWith: PublicationEncryptionStrategySchema.optional(), - tags: notEmptyString().array().optional().describe('An arbitrary list of tags.'), + tags: TagSchema.array().max(10).optional().describe('An arbitrary list of tags.'), contentWarning: z .nativeEnum(PublicationContentWarning, { description: 'Specify a content warning.' }) diff --git a/src/publication/common/media.ts b/src/publication/common/media.ts index 9763cd3..0df65ec 100644 --- a/src/publication/common/media.ts +++ b/src/publication/common/media.ts @@ -54,12 +54,13 @@ export const MediaAudioSchema = MediaCommonSchema.extend({ export type MediaAudio = z.infer; export enum MediaImageMimeType { + BMP = 'image/x-ms-bmp', GIF = 'image/gif', + HEIC = 'image/heic', JPEG = 'image/jpeg', PNG = 'image/png', - TIFF = 'image/tiff', - BMP = 'image/x-ms-bmp', SVG = 'image/svg+xml', + TIFF = 'image/tiff', WEBP = 'image/webp', } @@ -72,13 +73,14 @@ export type MediaImage = z.infer; export enum MediaVideoMimeType { GLTF = 'model/gltf+json', GLTF_BINARY = 'model/gltf-binary', - WEBM = 'video/webm', - MP4 = 'video/mp4', M4V = 'video/x-m4v', - OGV = 'video/ogv', - OGG = 'video/ogg', + MOV = 'video/mov', + MP4 = 'video/mp4', MPEG = 'video/mpeg', + OGG = 'video/ogg', + OGV = 'video/ogv', QUICKTIME = 'video/quicktime', + WEBM = 'video/webm', } export const MediaVideoSchema = MediaCommonSchema.extend({ diff --git a/src/publication/index.ts b/src/publication/index.ts index 2d47d13..0840d22 100644 --- a/src/publication/index.ts +++ b/src/publication/index.ts @@ -8,6 +8,7 @@ export * from './CheckingInSchema.js'; export * from './EmbedSchema.js'; export * from './EventSchema.js'; export * from './ImageSchema.js'; +export * as legacy from './legacy.js'; export * from './LinkSchema.js'; export * from './LivestreamSchema.js'; export * from './MintSchema.js'; diff --git a/src/publication/legacy.ts b/src/publication/legacy.ts new file mode 100644 index 0000000..4eb30b0 --- /dev/null +++ b/src/publication/legacy.ts @@ -0,0 +1,466 @@ +/* eslint-disable no-case-declarations */ +import { z } from 'zod'; + +import { PublicationMainFocus as ExtendedPublicationMainFocus } from './PublicationMainFocus'; +import { + ConditionComparisonOperator, + MarketplaceMetadataAttributeSchema, + NftContractType, + PublicationContentWarning, +} from './common'; +import { + AppIdSchema, + LocaleSchema, + Markdown, + TagSchema, + markdown, + notEmptyString, + uri, +} from '../primitives'; + +export enum PublicationMetadataVersion { + V1 = '1.0.0', + V2 = '2.0.0', +} + +export enum PublicationMainFocus { + ARTICLE = ExtendedPublicationMainFocus.ARTICLE, + AUDIO = ExtendedPublicationMainFocus.AUDIO, + EMBED = ExtendedPublicationMainFocus.EMBED, + IMAGE = ExtendedPublicationMainFocus.IMAGE, + LINK = ExtendedPublicationMainFocus.LINK, + TEXT_ONLY = ExtendedPublicationMainFocus.TEXT_ONLY, + VIDEO = ExtendedPublicationMainFocus.VIDEO, +} + +export enum AudioMimeType { + WAV = 'audio/wav', + WAV_VND = 'audio/vnd.wave', + MP3 = 'audio/mpeg', + OGG_AUDIO = 'audio/ogg', + MP4_AUDIO = 'audio/mp4', + AAC = 'audio/aac', + WEBM_AUDIO = 'audio/webm', + FLAC = 'audio/flac', +} + +export enum ImageMimeType { + GIF = 'image/gif', + JPEG = 'image/jpeg', + JPG = 'image/jpg', + PNG = 'image/png', + TIFF = 'image/tiff', + X_MS_BMP = 'image/x-ms-bmp', + SVG_XML = 'image/svg+xml', + WEBP = 'image/webp', + HEIC = 'image/heic', +} + +export enum VideoMimeType { + GLTF = 'model/gltf+json', + GLTF_BINARY = 'model/gltf-binary', + WEBM = 'video/webm', + MP4 = 'video/mp4', + M4V = 'video/x-m4v', + OGV = 'video/ogv', + OGG = 'video/ogg', + MPEG = 'video/mpeg', + QUICKTIME = 'video/quicktime', + MOV = 'video/mov', +} + +const AnimationUrlSchema = uri( + 'In spec for OpenSea and other providers - also used when using EMBED main publication focus' + + 'A URL to a multi-media attachment for the item. The file extensions GLTF, GLB, WEBM, MP4, M4V, OGV, ' + + 'and OGG are supported, along with the audio-only extensions MP3, WAV, and OGA. ' + + 'Animation_url also supports HTML pages, allowing you to build rich experiences and interactive NFTs using JavaScript canvas, ' + + 'WebGL, and more. Scripts and relative paths within the HTML page are now supported. However, access to browser extensions is not supported.', +); + +const OpenSeaSchema = z.object({ + metadata_id: z.string({ + description: + 'The metadata lens_id can be anything but if your uploading to ipfs ' + + 'you will want it to be random.. using uuid could be an option!', + }), + + description: markdown( + 'A human-readable description of the item. It could be plain text or markdown.', + ) + .optional() + .nullable(), + + external_url: uri( + `This is the URL that will appear below the asset's image on OpenSea and others etc. ` + + 'and will allow users to leave OpenSea and view the item on the site.', + ).optional(), + + name: notEmptyString('Name of the NFT item.'), + + attributes: MarketplaceMetadataAttributeSchema.array().describe( + 'These are the attributes for the item, which will show up on the OpenSea and others NFT trading websites on the item.', + ), + + image: uri('Marketplaces will store any NFT image here.').optional().nullable(), + + animation_url: AnimationUrlSchema.optional().nullable(), + + version: z.nativeEnum(PublicationMetadataVersion), +}); + +const PublicationMetadataMediaSchema = z.object({ + item: uri('Marketplaces will store any NFT image here.'), + altTag: z.string().optional().nullable().describe('The alt tag for accessibility.'), + cover: uri('The cover for any video or audio media.').optional().nullable(), + type: z.string().optional().nullable().describe('This is the mime type of the media.'), +}); +export type PublicationMetadataMedia = z.infer; + +const ContentSchema = z + .string({ + description: 'The content of a publication.', + }) + .max(30000); + +function isNullish(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +function isEmptyArray(value: T[] | null | undefined): value is [] | null | undefined { + return isNullish(value) || value.length === 0; +} + +function isEmptyString(value: string | null | undefined): value is '' | null | undefined { + return isNullish(value) || value.length === 0; +} + +const PublicationCommonSchema = OpenSeaSchema.extend({ + content: ContentSchema.transform((value) => value as Markdown) + .optional() + .nullable(), + + media: PublicationMetadataMediaSchema.array() + .optional() + .nullable() + .describe('This is lens supported attached media items to the publication.'), + + appId: AppIdSchema.optional().nullable().describe('The App Id that this publication belongs to.'), +}); + +/** + * @internal + */ +export const PublicationMetadataV1Schema = PublicationCommonSchema.extend({ + version: z.literal(PublicationMetadataVersion.V1, { description: 'The metadata version.' }), +}).superRefine((data, ctx) => { + if (isEmptyString(data.content) && isNullish(data.image) && isEmptyArray(data.media)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: + 'At least one between content, image, and media must be present. ' + + 'Content must be over 1 character.', + }); + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['image'], + message: 'At least one between content, image, and media must be present.', + }); + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['media'], + message: 'At least one between content, image, and media must be present.', + }); + } +}); +export type PublicationMetadataV1 = z.infer; + +const CollectConditionSchema = z + .object({ + collect: z.object({ + publicationId: z.string().nullable(), + thisPublication: z.boolean().nullable(), + }), + }) + .strict(); +export type CollectCondition = z.infer; + +const EoaOwnershipSchema = z + .object({ + eoa: z.object({ + address: z.string(), + }), + }) + .strict(); +export type EoaOwnership = z.infer; + +const FollowConditionSchema = z + .object({ + follow: z.object({ + profileId: z.string(), + }), + }) + .strict(); +export type FollowCondition = z.infer; + +const NftOwnershipSchema = z + .object({ + nft: z.object({ + contractAddress: z.string(), + chainID: z.number(), + contractType: z.nativeEnum(NftContractType), + tokenIds: z.string().array().nonempty().nullable(), + }), + }) + .strict(); +export type NftOwnership = z.infer; + +const ProfileOwnershipSchema = z + .object({ + profile: z.object({ + profileId: z.string(), + }), + }) + .strict(); +export type ProfileOwnership = z.infer; + +const Erc20OwnershipSchema = z + .object({ + token: z.object({ + amount: z.string(), + chainID: z.number(), + condition: z.nativeEnum(ConditionComparisonOperator), + contractAddress: z.string(), + decimals: z.number(), + }), + }) + .strict(); +export type Erc20Ownership = z.infer; + +function andCondition(options: [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]) { + return z + .object({ + and: z.object({ + criteria: z + .union(options) + .array() + .min(2, 'Invalid AND condition: should have at least 2 conditions') + .max(5, 'Invalid AND condition: should have at most 5 conditions'), + }), + }) + .strict(); +} + +function orCondition(options: [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]) { + return z + .object({ + or: z.object({ + criteria: z + .union(options) + .array() + .min(2, 'Invalid OR condition: should have at least 2 conditions') + .max(5, 'Invalid OR condition: should have at most 5 conditions'), + }), + }) + .strict(); +} + +const AccessConditionSchema = orCondition([ + CollectConditionSchema, + EoaOwnershipSchema, + FollowConditionSchema, + NftOwnershipSchema, + ProfileOwnershipSchema, + Erc20OwnershipSchema, + andCondition([ + CollectConditionSchema, + EoaOwnershipSchema, + FollowConditionSchema, + NftOwnershipSchema, + ProfileOwnershipSchema, + Erc20OwnershipSchema, + ]), + orCondition([ + CollectConditionSchema, + EoaOwnershipSchema, + FollowConditionSchema, + NftOwnershipSchema, + ProfileOwnershipSchema, + Erc20OwnershipSchema, + ]), +]); +export type AccessCondition = z.infer; + +const PublicationMetadataV2CommonSchema = PublicationCommonSchema.extend({ + version: z.literal(PublicationMetadataVersion.V2, { description: 'The metadata version.' }), + + locale: LocaleSchema, + + contentWarning: z + .nativeEnum(PublicationContentWarning, { description: 'Specify a content warning.' }) + .optional(), + + mainContentFocus: z.nativeEnum(PublicationMainFocus, { + description: 'Main content focus that for this publication.', + }), + + tags: TagSchema.array() + .max(10) + .optional() + .nullable() + .describe('Ability to tag your publication.'), + + encryptionParams: z + .object({ + accessCondition: AccessConditionSchema, + encryptionKey: z.string().length(368, 'Encryption key should be 368 characters long.'), + }) + .optional(), +}); + +const PublicationMetadataV2ArticleSchema = PublicationMetadataV2CommonSchema.extend({ + mainContentFocus: z.literal(PublicationMainFocus.ARTICLE), + + content: ContentSchema.min(1).transform((value) => value as Markdown), +}); +export type PublicationMetadataV2Article = z.infer; + +const PublicationMetadataV2AudioSchema = PublicationMetadataV2CommonSchema.extend({ + mainContentFocus: z.literal(PublicationMainFocus.AUDIO), + + media: PublicationMetadataMediaSchema.array() + .min(1) + .refine( + (value) => value.some((media) => media.type && media.type in AudioMimeType), + `Metadata ${PublicationMainFocus.AUDIO} requires an audio to be attached.`, + ), +}); +export type PublicationMetadataV2Audio = z.infer; + +const PublicationMetadataV2EmbedSchema = PublicationMetadataV2CommonSchema.extend({ + mainContentFocus: z.literal(PublicationMainFocus.EMBED), + + animation_url: AnimationUrlSchema, +}); +export type PublicationMetadataV2Embed = z.infer; + +const PublicationMetadataV2ImageSchema = PublicationMetadataV2CommonSchema.extend({ + mainContentFocus: z.literal(PublicationMainFocus.IMAGE), + + media: PublicationMetadataMediaSchema.array() + .min(1) + .refine( + (value) => value.some((media) => media.type && media.type in ImageMimeType), + `Metadata ${PublicationMainFocus.IMAGE} requires an image to be attached.`, + ), +}); +export type PublicationMetadataV2Image = z.infer; + +const PublicationMetadataV2LinkSchema = PublicationMetadataV2CommonSchema.extend({ + mainContentFocus: z.literal(PublicationMainFocus.LINK), + + content: ContentSchema.min(1) + .refine( + (value) => value.includes('https://'), + `Metadata ${PublicationMainFocus.LINK} requires a valid https link`, + ) + .transform((value) => value as Markdown), +}); +export type PublicationMetadataV2Link = z.infer; + +const PublicationMetadataV2TextOnlySchema = PublicationMetadataV2CommonSchema.extend({ + mainContentFocus: z.literal(PublicationMainFocus.TEXT_ONLY), + + content: ContentSchema.min(1).transform((value) => value as Markdown), + + media: z + .any() + .array() + .max(0, { message: 'Metadata TEXT cannot have media' }) + .optional() + .nullable(), +}); +export type PublicationMetadataV2TextOnly = z.infer; + +const PublicationMetadataV2VideoSchema = PublicationMetadataV2CommonSchema.extend({ + mainContentFocus: z.literal(PublicationMainFocus.VIDEO), + + media: PublicationMetadataMediaSchema.array() + .min(1) + .refine( + (value) => value.some((media) => media.type && media.type in VideoMimeType), + `Metadata ${PublicationMainFocus.VIDEO} requires an image to be attached.`, + ), +}); +export type PublicationMetadataV2Video = z.infer; + +/** + * @internal + */ +export const PublicationMetadataV2Schema = z.discriminatedUnion('mainContentFocus', [ + PublicationMetadataV2ArticleSchema, + PublicationMetadataV2AudioSchema, + PublicationMetadataV2EmbedSchema, + PublicationMetadataV2ImageSchema, + PublicationMetadataV2LinkSchema, + PublicationMetadataV2TextOnlySchema, + PublicationMetadataV2VideoSchema, +]); +export type PublicationMetadataV2 = z.infer; + +export type PublicationMetadata = PublicationMetadataV1 | PublicationMetadataV2; + +/** + * A union of Publication Metadata v1 ad v2. + * + * @example + * with `parse`: + * ```typescript + * legacy.PublicationMetadataSchema.parse(valid); // => legacy.PublicationMetadata + * + * legacy.PublicationMetadataSchema.parse(invalid); // => throws ZodError + * ``` + * + * @example + * with `safeParse`: + * ```typescript + * legacy.PublicationMetadataSchema.safeParse(valid); + * // => { success: true, data: legacy.PublicationMetadata } + * + * legacy.PublicationMetadataSchema.safeParse(invalid); + * // => { success: false, error: ZodError } + * ``` + */ +export const PublicationMetadataSchema = z + .object({ + // although not optional it will allow the refine function to provide better error message + version: z.nativeEnum(PublicationMetadataVersion), + }) + .passthrough() + .superRefine((data, ctx): data is PublicationMetadata => { + switch (data.version) { + case PublicationMetadataVersion.V1: + const v1Result = PublicationMetadataV1Schema.safeParse(data); + + if (!v1Result.success) { + v1Result.error.issues.forEach((issue) => { + ctx.addIssue(issue); + }); + } + break; + + case PublicationMetadataVersion.V2: + const v2Result = PublicationMetadataV2Schema.safeParse(data); + + if (!v2Result.success) { + v2Result.error.issues.forEach((issue) => { + ctx.addIssue(issue); + }); + } + break; + } + + return z.NEVER; + });