From fa40d01fcec1bcc87c095ba825221e7b115754d6 Mon Sep 17 00:00:00 2001 From: kualta Date: Sat, 31 May 2025 11:05:27 +0400 Subject: [PATCH 1/3] feat: add custom post metadata type --- jsonschemas/schema.json | 9 +++++- scripts/build.ts | 3 ++ src/builders/posts.ts | 43 +++++++++++++++++++++++++++ src/post/CustomSchema.ts | 50 ++++++++++++++++++++++++++++++++ src/post/PostMainFocus.ts | 1 + src/post/PostMetadataSchemaId.ts | 1 + src/post/index.ts | 4 +++ 7 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/post/CustomSchema.ts diff --git a/jsonschemas/schema.json b/jsonschemas/schema.json index 915093e5..7b660cc8 100644 --- a/jsonschemas/schema.json +++ b/jsonschemas/schema.json @@ -27,6 +27,9 @@ { "$ref": "#/$defs/MintMetadata" }, + { + "$ref": "#/$defs/CustomMetadata" + }, { "$ref": "#/$defs/SpaceMetadata" }, @@ -78,7 +81,8 @@ "SHORT_VIDEO", "3D", "STORY", - "SPACE" + "SPACE", + "CUSTOM" ] }, "AnyMedia": { @@ -2306,6 +2310,9 @@ ], "additionalProperties": true }, + "CustomMetadata": { + "$ref": "posts/custom/3.0.0.json" + }, "SpaceMetadata": { "type": "object", "properties": { diff --git a/scripts/build.ts b/scripts/build.ts index 99ad0f1f..fff5eab8 100755 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -39,6 +39,7 @@ import { MetadataIdSchema, MetadataLicenseTypeSchema, MintSchema, + CustomSchema, NamespaceMetadataSchema, NamespaceRuleMetadataSchema, Nft721MetadataAttributeSchema, @@ -77,6 +78,7 @@ const schemas = new Map>([ ['posts/link/3.0.0.json', LinkSchema], ['posts/livestream/3.0.0.json', LiveStreamSchema], ['posts/mint/3.0.0.json', MintSchema], + ['posts/custom/3.0.0.json', CustomSchema], ['posts/space/3.0.0.json', SpaceSchema], ['posts/story/3.0.0.json', StorySchema], ['posts/text-only/3.0.0.json', TextOnlySchema], @@ -216,6 +218,7 @@ async function generateUmbrellaSchema() { LinkMetadata: LinkSchema, LiveStreamMetadata: LiveStreamSchema, MintMetadata: MintSchema, + CustomMetadata: CustomSchema, SpaceMetadata: SpaceSchema, TextOnlyMetadata: TextOnlySchema, StoryMetadata: StorySchema, diff --git a/src/builders/posts.ts b/src/builders/posts.ts index 580addb0..e06014a0 100644 --- a/src/builders/posts.ts +++ b/src/builders/posts.ts @@ -28,6 +28,9 @@ import { type MintMetadata, type MintMetadataDetails, MintSchema, + type CustomMetadata, + type CustomMetadataDetails, + CustomSchema, PostMainFocus, PostMetadataSchemaId, type SpaceMetadata, @@ -652,6 +655,46 @@ export function mint({ ); } +/** + * @private + * @privateRemarks MUST stay very @private to produce usable docs + */ +type CustomDetails = InputForPostMetadataDetails; +/** + * All {@link CustomMetadataDetails} fields with: + * - `id` defaults to a UUID + * - `locale` defaults to `en` + * - `mainContentFocus` automatically set to `PostSchemaId.CUSTOM_LATEST` + */ +export type CustomOptions = CustomDetails & { + /** + * All the {@link NftMetadata} fields. + */ + nft?: NftDetails; +}; +/** + * Creates a valid CustomMetadata. + */ +export function custom({ + nft, + locale = DEFAULT_LOCALE, + id = v4(), + ...others +}: CustomOptions): CustomMetadata { + return evaluate( + CustomSchema.safeParse({ + $schema: PostMetadataSchemaId.CUSTOM_LATEST, + ...nft, + lens: { + id, + locale, + mainContentFocus: PostMainFocus.CUSTOM, + ...others, + }, + }), + ); +} + /** * @private * @privateRemarks MUST stay very @private to produce usable docs diff --git a/src/post/CustomSchema.ts b/src/post/CustomSchema.ts new file mode 100644 index 00000000..921fef9f --- /dev/null +++ b/src/post/CustomSchema.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { NonEmptyStringSchema, type Signature } from '../primitives.js'; +import type { NftMetadata } from '../tokens/eip721.js'; +import { PostMainFocus } from './PostMainFocus.js'; +import { PostMetadataSchemaId } from './PostMetadataSchemaId.js'; +import { + mainContentFocus, + metadataDetailsWith, + postWith, + type PostMetadataCommon, +} from './common'; + +export type CustomMetadataDetails = PostMetadataCommon & { + /** + * The main focus of the post. + */ + mainContentFocus: PostMainFocus.CUSTOM; + /** + * A JSON string containing any custom data. + */ + value: string; +}; + +const CustomMetadataDetailsSchema: z.ZodType = + metadataDetailsWith({ + mainContentFocus: mainContentFocus(PostMainFocus.CUSTOM), + + value: NonEmptyStringSchema.describe('A JSON string containing any custom data.'), + }); + +export type CustomMetadata = NftMetadata & { + /** + * The schema id. + */ + $schema: PostMetadataSchemaId.CUSTOM_LATEST; + /** + * The metadata details. + */ + lens: CustomMetadataDetails; + /** + * A cryptographic signature of the `lens` data. + */ + signature?: Signature; +}; + +export const CustomSchema = postWith({ + $schema: z.literal(PostMetadataSchemaId.CUSTOM_LATEST), + lens: CustomMetadataDetailsSchema, +}); diff --git a/src/post/PostMainFocus.ts b/src/post/PostMainFocus.ts index 3f57407d..4bbb5a90 100644 --- a/src/post/PostMainFocus.ts +++ b/src/post/PostMainFocus.ts @@ -20,6 +20,7 @@ export enum PostMainFocus { THREE_D = '3D', STORY = 'STORY', SPACE = 'SPACE', + CUSTOM = 'CUSTOM', } export const PostMainFocusSchema = z.nativeEnum(PostMainFocus); diff --git a/src/post/PostMetadataSchemaId.ts b/src/post/PostMetadataSchemaId.ts index a5ee6a85..198effe0 100644 --- a/src/post/PostMetadataSchemaId.ts +++ b/src/post/PostMetadataSchemaId.ts @@ -21,4 +21,5 @@ export enum PostMetadataSchemaId { TRANSACTION_LATEST = `${location}/transaction/3.0.0.json`, TEXT_ONLY_LATEST = `${location}/text-only/3.0.0.json`, VIDEO_LATEST = `${location}/video/3.0.0.json`, + CUSTOM_LATEST = `${location}/custom/3.0.0.json`, } diff --git a/src/post/index.ts b/src/post/index.ts index b2f274ee..3bf2e9f4 100644 --- a/src/post/index.ts +++ b/src/post/index.ts @@ -11,6 +11,7 @@ export * from './ImageSchema.js'; export * from './LinkSchema.js'; export * from './LiveStreamSchema.js'; export * from './MintSchema.js'; +export * from './CustomSchema.js'; export * from './PostMainFocus.js'; export * from './PostMetadataSchemaId.js'; export * from './SpaceSchema.js'; @@ -30,6 +31,7 @@ import { type ImageMetadata, ImageSchema } from './ImageSchema.js'; import { type LinkMetadata, LinkSchema } from './LinkSchema.js'; import { type LiveStreamMetadata, LiveStreamSchema } from './LiveStreamSchema.js'; import { type MintMetadata, MintSchema } from './MintSchema.js'; +import { type CustomMetadata, CustomSchema } from './CustomSchema.js'; import { type SpaceMetadata, SpaceSchema } from './SpaceSchema.js'; import { type StoryMetadata, StorySchema } from './StorySchema.js'; import { type TextOnlyMetadata, TextOnlySchema } from './TextOnlySchema.js'; @@ -82,6 +84,7 @@ export type PostMetadata = ShapeCheck< | LinkMetadata | LiveStreamMetadata | MintMetadata + | CustomMetadata | SpaceMetadata | TextOnlyMetadata | StoryMetadata @@ -124,6 +127,7 @@ export const PostMetadataSchema: z.ZodType = LinkSchema, LiveStreamSchema, MintSchema, + CustomSchema, SpaceSchema, TextOnlySchema, StorySchema, From ca485f717ada798764d49e559d77300887ca1b08 Mon Sep 17 00:00:00 2001 From: kualta Date: Sat, 31 May 2025 11:21:14 +0400 Subject: [PATCH 2/3] add human-readable name, update tests, generate schema --- jsonschemas/posts/custom/3.0.0.json | 592 ++++++++++++++++++ jsonschemas/schema.json | 130 +++- src/builders/posts.ts | 16 + src/post/CustomSchema.ts | 6 + src/post/__tests__/PostMetadataSchema.spec.ts | 20 +- 5 files changed, 762 insertions(+), 2 deletions(-) create mode 100644 jsonschemas/posts/custom/3.0.0.json diff --git a/jsonschemas/posts/custom/3.0.0.json b/jsonschemas/posts/custom/3.0.0.json new file mode 100644 index 00000000..973e10c2 --- /dev/null +++ b/jsonschemas/posts/custom/3.0.0.json @@ -0,0 +1,592 @@ +{ + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "$ref": "#/$defs/NonEmptyString", + "description": "A human-readable description of the item. It could be plain text or markdown." + }, + { + "type": "null" + } + ], + "description": "A human-readable description of the item. It could be plain text or markdown." + }, + "external_url": { + "anyOf": [ + { + "$ref": "#/$defs/URI", + "description": "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." + }, + { + "type": "null" + } + ], + "description": "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." + }, + "name": { + "type": "string", + "description": "Name of the NFT item." + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/$defs/MarketplaceMetadataAttribute" + }, + "description": "These are the attributes for the item, which will show up on the OpenSea and others NFT trading websites on the item." + }, + "image": { + "anyOf": [ + { + "$ref": "#/$defs/URI", + "description": "NFT will store any image here." + }, + { + "type": "null" + } + ], + "description": "NFT will store any image here." + }, + "animation_url": { + "anyOf": [ + { + "$ref": "#/$defs/URI", + "description": "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." + }, + { + "type": "null" + } + ], + "description": "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." + }, + "signature": { + "type": "string", + "minLength": 1, + "description": "A cryptographic signature of the Lens metadata." + }, + "$schema": { + "type": "string", + "const": "https://json-schemas.lens.dev/posts/custom/3.0.0.json" + }, + "lens": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/NonEmptyString", + "description": "A unique identifier that in storages like IPFS ensures the uniqueness of the metadata URI. Use a UUID if unsure." + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/$defs/MetadataAttribute" + }, + "minItems": 1, + "maxItems": 20, + "description": "A bag of attributes that can be used to store any kind of metadata that is not currently supported by the standard. Over time, common attributes will be added to the standard and their usage as arbitrary attributes will be discouraged." + }, + "locale": { + "type": "string", + "pattern": "^[a-z]{2}(?:-[a-zA-Z]{2})?$", + "description": "A Locale Identifier in the `[language]` OR `[language]-[region]` format (e.g. `en`, `en-GB`, `it`). [language] MUST be in the ISO 639-1 format. [region], if provided, MUST be in the ISO 3166-1 alpha-2 format." + }, + "tags": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/Tag" + }, + "maxItems": 20, + "description": "An arbitrary list of tags." + }, + "contentWarning": { + "type": "string", + "enum": [ + "NSFW", + "SENSITIVE", + "SPOILER" + ], + "description": "Specify a content warning." + }, + "mainContentFocus": { + "type": "string", + "const": "CUSTOM", + "description": "The main focus of the post." + }, + "name": { + "$ref": "#/$defs/NonEmptyString", + "description": "A human-readable name for the custom post type." + }, + "value": { + "$ref": "#/$defs/NonEmptyString", + "description": "A JSON string containing any custom data." + } + }, + "required": [ + "id", + "locale", + "mainContentFocus", + "name", + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "$schema", + "lens" + ], + "additionalProperties": true, + "$defs": { + "NonEmptyString": { + "type": "string", + "minLength": 1 + }, + "Markdown": { + "$ref": "#/$defs/NonEmptyString" + }, + "URI": { + "type": "string", + "minLength": 6, + "format": "uri", + "description": "A Uniform Resource Identifier." + }, + "AnyMedia": { + "anyOf": [ + { + "$ref": "#/$defs/MediaAudio" + }, + { + "$ref": "#/$defs/MediaImage" + }, + { + "$ref": "#/$defs/MediaVideo" + } + ] + }, + "ChainId": { + "type": "number", + "exclusiveMinimum": 0 + }, + "EvmAddress": { + "type": "string", + "minLength": 42, + "maxLength": 42, + "description": "An EVM compatible address." + }, + "GeoURI": { + "type": "string", + "pattern": "^geo:(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)$", + "description": "A Geographic coordinate as subset of Geo URI (RFC 5870). Currently only supports the `geo:lat,lng` format." + }, + "MarketplaceMetadataAttribute": { + "type": "object", + "properties": { + "display_type": { + "type": "string", + "enum": [ + "number", + "string", + "date" + ] + }, + "trait_type": { + "$ref": "#/$defs/NonEmptyString", + "description": "The name of the trait." + }, + "value": { + "type": [ + "string", + "number" + ] + } + }, + "additionalProperties": true + }, + "MediaAudio": { + "type": "object", + "properties": { + "item": { + "$ref": "#/$defs/URI" + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/$defs/MetadataAttribute" + }, + "minItems": 1, + "description": "A bag of attributes that can be used to store any kind of metadata that is not currently supported by the standard." + }, + "type": { + "type": "string", + "enum": [ + "audio/wav", + "audio/vnd.wave", + "audio/mpeg", + "audio/ogg", + "audio/mp4", + "audio/aac", + "audio/webm", + "audio/flac" + ], + "description": "The mime type of the audio file." + }, + "cover": { + "$ref": "#/$defs/URI", + "description": "A Uniform Resource Identifier." + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "How long the the audio is in seconds." + }, + "license": { + "$ref": "#/$defs/MetadataLicenseType", + "description": "The license for the audio." + }, + "credits": { + "$ref": "#/$defs/NonEmptyString", + "description": "The credits for the audio." + }, + "artist": { + "$ref": "#/$defs/NonEmptyString", + "description": "The name of the artist." + }, + "genre": { + "$ref": "#/$defs/NonEmptyString", + "description": "The genre of the audio" + }, + "recordLabel": { + "$ref": "#/$defs/NonEmptyString", + "description": "The record label for the audio." + }, + "kind": { + "type": "string", + "enum": [ + "MUSIC", + "PODCAST", + "AUDIOBOOK", + "VOICE_NOTE", + "SOUND", + "OTHER" + ], + "description": "The type of audio." + }, + "lyrics": { + "$ref": "#/$defs/URI", + "description": "A Uniform Resource Identifier." + } + }, + "required": [ + "item", + "type" + ], + "additionalProperties": false + }, + "MediaImage": { + "type": "object", + "properties": { + "item": { + "$ref": "#/$defs/URI" + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/$defs/MetadataAttribute" + }, + "minItems": 1, + "description": "A bag of attributes that can be used to store any kind of metadata that is not currently supported by the standard." + }, + "type": { + "type": "string", + "enum": [ + "image/avif", + "image/bmp", + "image/gif", + "image/heic", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/tiff", + "image/webp", + "image/x-ms-bmp" + ], + "description": "The mime type of the image" + }, + "altTag": { + "$ref": "#/$defs/NonEmptyString", + "description": "The alt tag for accessibility" + }, + "license": { + "$ref": "#/$defs/MetadataLicenseType", + "description": "The license for the image" + } + }, + "required": [ + "item", + "type" + ], + "additionalProperties": false + }, + "MediaVideo": { + "type": "object", + "properties": { + "item": { + "$ref": "#/$defs/URI" + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/$defs/MetadataAttribute" + }, + "minItems": 1, + "description": "A bag of attributes that can be used to store any kind of metadata that is not currently supported by the standard." + }, + "type": { + "type": "string", + "enum": [ + "model/gltf+json", + "model/gltf-binary", + "video/x-m4v", + "video/mov", + "video/mp4", + "video/mpeg", + "video/ogg", + "video/ogv", + "video/quicktime", + "video/webm" + ], + "description": "The mime type of the video" + }, + "altTag": { + "$ref": "#/$defs/NonEmptyString", + "description": "The alt tag for accessibility" + }, + "cover": { + "$ref": "#/$defs/URI", + "description": "A Uniform Resource Identifier." + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "How long the the video is in seconds" + }, + "license": { + "$ref": "#/$defs/MetadataLicenseType", + "description": "The license for the video" + } + }, + "required": [ + "item", + "type" + ], + "additionalProperties": false + }, + "MetadataAttribute": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Boolean" + }, + "key": { + "$ref": "#/$defs/NonEmptyString", + "description": "The attribute's unique identifier." + }, + "value": { + "type": "string", + "enum": [ + "true", + "false" + ], + "description": "A JS boolean value serialized as string. It's consumer responsibility to parse it." + } + }, + "required": [ + "type", + "key", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Date" + }, + "key": { + "$ref": "#/$defs/NonEmptyString", + "description": "The attribute's unique identifier." + }, + "value": { + "type": "string", + "format": "date-time", + "description": "A valid ISO 8601 date string. It's consumer responsibility to parse it." + } + }, + "required": [ + "type", + "key", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Number" + }, + "key": { + "$ref": "#/$defs/NonEmptyString", + "description": "The attribute's unique identifier." + }, + "value": { + "$ref": "#/$defs/NonEmptyString", + "description": "A valid JS number serialized as string. It's consumer responsibility to parse it." + } + }, + "required": [ + "type", + "key", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "String" + }, + "key": { + "$ref": "#/$defs/NonEmptyString", + "description": "The attribute's unique identifier." + }, + "value": { + "$ref": "#/$defs/NonEmptyString", + "description": "Any string value." + } + }, + "required": [ + "type", + "key", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "JSON" + }, + "key": { + "$ref": "#/$defs/NonEmptyString", + "description": "The attribute's unique identifier." + }, + "value": { + "$ref": "#/$defs/NonEmptyString", + "description": "A JSON string. It's consumer responsibility to validate and parse it." + } + }, + "required": [ + "type", + "key", + "value" + ], + "additionalProperties": false + } + ] + }, + "MetadataLicenseType": { + "type": "string", + "enum": [ + "CCO", + "CC BY", + "CC BY-ND", + "CC BY-NC", + "TBNL-C-D-PL-Legal", + "TBNL-C-DT-PL-Legal", + "TBNL-C-ND-PL-Legal", + "TBNL-C-D-NPL-Legal", + "TBNL-C-DT-NPL-Legal", + "TBNL-C-DTSA-PL-Legal", + "TBNL-C-DTSA-NPL-Legal", + "TBNL-C-ND-NPL-Legal", + "TBNL-C-D-PL-Ledger", + "TBNL-C-DT-PL-Ledger", + "TBNL-C-ND-PL-Ledger", + "TBNL-C-D-NPL-Ledger", + "TBNL-C-DT-NPL-Ledger", + "TBNL-C-DTSA-PL-Ledger", + "TBNL-C-DTSA-NPL-Ledger", + "TBNL-C-ND-NPL-Ledger", + "TBNL-NC-D-PL-Legal", + "TBNL-NC-DT-PL-Legal", + "TBNL-NC-ND-PL-Legal", + "TBNL-NC-D-NPL-Legal", + "TBNL-NC-DT-NPL-Legal", + "TBNL-NC-DTSA-PL-Legal", + "TBNL-NC-DTSA-NPL-Legal", + "TBNL-NC-ND-NPL-Legal", + "TBNL-NC-D-PL-Ledger", + "TBNL-NC-DT-PL-Ledger", + "TBNL-NC-ND-PL-Ledger", + "TBNL-NC-D-NPL-Ledger", + "TBNL-NC-DT-NPL-Ledger", + "TBNL-NC-DTSA-PL-Ledger", + "TBNL-NC-DTSA-NPL-Ledger", + "TBNL-NC-ND-NPL-Ledger" + ] + }, + "PhysicalAddress": { + "type": "object", + "properties": { + "formatted": { + "$ref": "#/$defs/NonEmptyString", + "description": "The full mailing address formatted for display." + }, + "streetAddress": { + "$ref": "#/$defs/NonEmptyString", + "description": "The street address including house number, street name, P.O. Box, apartment or unit number and extended multi-line address information." + }, + "locality": { + "$ref": "#/$defs/NonEmptyString", + "description": "The city or locality." + }, + "region": { + "$ref": "#/$defs/NonEmptyString", + "description": "The state or region." + }, + "postalCode": { + "$ref": "#/$defs/NonEmptyString", + "description": "The zip or postal code." + }, + "country": { + "$ref": "#/$defs/NonEmptyString", + "description": "The country name component." + } + }, + "required": [ + "locality", + "country" + ], + "additionalProperties": false + }, + "Tag": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "description": "An arbitrary tag." + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/jsonschemas/schema.json b/jsonschemas/schema.json index 7b660cc8..0cf6af69 100644 --- a/jsonschemas/schema.json +++ b/jsonschemas/schema.json @@ -2311,7 +2311,135 @@ "additionalProperties": true }, "CustomMetadata": { - "$ref": "posts/custom/3.0.0.json" + "type": "object", + "properties": { + "description": { + "anyOf": [ + { + "$ref": "#/$defs/NonEmptyString", + "description": "A human-readable description of the item. It could be plain text or markdown." + }, + { + "type": "null" + } + ], + "description": "A human-readable description of the item. It could be plain text or markdown." + }, + "external_url": { + "anyOf": [ + { + "$ref": "#/$defs/URI", + "description": "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." + }, + { + "type": "null" + } + ], + "description": "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." + }, + "name": { + "type": "string", + "description": "Name of the NFT item." + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/$defs/MarketplaceMetadataAttribute" + }, + "description": "These are the attributes for the item, which will show up on the OpenSea and others NFT trading websites on the item." + }, + "image": { + "anyOf": [ + { + "$ref": "#/$defs/URI", + "description": "NFT will store any image here." + }, + { + "type": "null" + } + ], + "description": "NFT will store any image here." + }, + "animation_url": { + "anyOf": [ + { + "$ref": "#/$defs/URI", + "description": "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." + }, + { + "type": "null" + } + ], + "description": "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." + }, + "signature": { + "$ref": "#/$defs/Signature", + "description": "A cryptographic signature of the Lens metadata." + }, + "$schema": { + "type": "string", + "const": "https://json-schemas.lens.dev/posts/custom/3.0.0.json" + }, + "lens": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/MetadataId" + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/$defs/MetadataAttribute" + }, + "minItems": 1, + "maxItems": 20, + "description": "A bag of attributes that can be used to store any kind of metadata that is not currently supported by the standard. Over time, common attributes will be added to the standard and their usage as arbitrary attributes will be discouraged." + }, + "locale": { + "$ref": "#/$defs/Locale" + }, + "tags": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/Tag" + }, + "maxItems": 20, + "description": "An arbitrary list of tags." + }, + "contentWarning": { + "$ref": "#/$defs/ContentWarning", + "description": "Specify a content warning." + }, + "mainContentFocus": { + "type": "string", + "const": "CUSTOM", + "description": "The main focus of the post." + }, + "name": { + "$ref": "#/$defs/NonEmptyString", + "description": "A human-readable name for the custom post type." + }, + "value": { + "$ref": "#/$defs/NonEmptyString", + "description": "A JSON string containing any custom data." + } + }, + "required": [ + "id", + "locale", + "mainContentFocus", + "name", + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "$schema", + "lens" + ], + "additionalProperties": true }, "SpaceMetadata": { "type": "object", diff --git a/src/builders/posts.ts b/src/builders/posts.ts index e06014a0..6727c0a5 100644 --- a/src/builders/posts.ts +++ b/src/builders/posts.ts @@ -674,6 +674,22 @@ export type CustomOptions = CustomDetails & { }; /** * Creates a valid CustomMetadata. + * + * @category Compose + * @param input - Use your IDE suggestions for an enhanced development experience + * + * @example + * ```ts + * const metadata = custom({ + * name: 'My Custom Post Type', + * value: JSON.stringify({ + * customField1: 'value1', + * customField2: 42, + * customField3: ['item1', 'item2'] + * }), + * tags: ['custom', 'special'], + * }); + * ``` */ export function custom({ nft, diff --git a/src/post/CustomSchema.ts b/src/post/CustomSchema.ts index 921fef9f..fb9d9ffa 100644 --- a/src/post/CustomSchema.ts +++ b/src/post/CustomSchema.ts @@ -16,6 +16,10 @@ export type CustomMetadataDetails = PostMetadataCommon & { * The main focus of the post. */ mainContentFocus: PostMainFocus.CUSTOM; + /** + * A human-readable name for the custom post type. + */ + name: string; /** * A JSON string containing any custom data. */ @@ -26,6 +30,8 @@ const CustomMetadataDetailsSchema: z.ZodType { it('then it should complain about the missing $schema', () => { expectResult(() => PostMetadataSchema.safeParse({})).toMatchInlineSnapshot(` "fix the following issues - · "$schema": Invalid discriminator value. Expected 'https://json-schemas.lens.dev/posts/article/3.0.0.json' | 'https://json-schemas.lens.dev/posts/audio/3.0.0.json' | 'https://json-schemas.lens.dev/posts/checking-in/3.0.0.json' | 'https://json-schemas.lens.dev/posts/embed/3.0.0.json' | 'https://json-schemas.lens.dev/posts/event/3.0.0.json' | 'https://json-schemas.lens.dev/posts/image/3.0.0.json' | 'https://json-schemas.lens.dev/posts/link/3.0.0.json' | 'https://json-schemas.lens.dev/posts/livestream/3.0.0.json' | 'https://json-schemas.lens.dev/posts/mint/3.0.0.json' | 'https://json-schemas.lens.dev/posts/space/3.0.0.json' | 'https://json-schemas.lens.dev/posts/text-only/3.0.0.json' | 'https://json-schemas.lens.dev/posts/story/3.0.0.json' | 'https://json-schemas.lens.dev/posts/transaction/3.0.0.json' | 'https://json-schemas.lens.dev/posts/3d/3.0.0.json' | 'https://json-schemas.lens.dev/posts/video/3.0.0.json'" + · "$schema": Invalid discriminator value. Expected 'https://json-schemas.lens.dev/posts/article/3.0.0.json' | 'https://json-schemas.lens.dev/posts/audio/3.0.0.json' | 'https://json-schemas.lens.dev/posts/checking-in/3.0.0.json' | 'https://json-schemas.lens.dev/posts/embed/3.0.0.json' | 'https://json-schemas.lens.dev/posts/event/3.0.0.json' | 'https://json-schemas.lens.dev/posts/image/3.0.0.json' | 'https://json-schemas.lens.dev/posts/link/3.0.0.json' | 'https://json-schemas.lens.dev/posts/livestream/3.0.0.json' | 'https://json-schemas.lens.dev/posts/mint/3.0.0.json' | 'https://json-schemas.lens.dev/posts/custom/3.0.0.json' | 'https://json-schemas.lens.dev/posts/space/3.0.0.json' | 'https://json-schemas.lens.dev/posts/text-only/3.0.0.json' | 'https://json-schemas.lens.dev/posts/story/3.0.0.json' | 'https://json-schemas.lens.dev/posts/transaction/3.0.0.json' | 'https://json-schemas.lens.dev/posts/3d/3.0.0.json' | 'https://json-schemas.lens.dev/posts/video/3.0.0.json'" `); }); }); @@ -262,4 +262,22 @@ describe('Given the PostMetadataSchema', () => { `); }); }); + + describe(`when parsing an invalid ${PostMetadataSchemaId.CUSTOM_LATEST}`, () => { + it('then it should flag the missing fields', () => { + expectResult(() => + PostMetadataSchema.safeParse({ + $schema: PostMetadataSchemaId.CUSTOM_LATEST, + lens: {}, + }), + ).toMatchInlineSnapshot(` + "fix the following issues + · "lens.id": Required + · "lens.locale": Required + · "lens.mainContentFocus": Invalid literal value, expected "CUSTOM" + · "lens.name": Required + · "lens.value": Required" + `); + }); + }); }); From d90e002622a448cb5b6b7e027e4ccc858e0a494e Mon Sep 17 00:00:00 2001 From: Cesare Naldi Date: Tue, 17 Jun 2025 15:16:40 +0200 Subject: [PATCH 3/3] feat: formalize an experimental post type --- scripts/build.ts | 6 ++-- src/builders/posts.ts | 14 ++++---- src/post/CustomSchema.ts | 56 ------------------------------ src/post/ExperimentalPostSchema.ts | 51 +++++++++++++++++++++++++++ src/post/PostMainFocus.ts | 2 +- src/post/PostMetadataSchemaId.ts | 2 +- src/post/common/index.ts | 3 +- src/post/index.ts | 8 ++--- 8 files changed, 69 insertions(+), 73 deletions(-) delete mode 100644 src/post/CustomSchema.ts create mode 100644 src/post/ExperimentalPostSchema.ts diff --git a/scripts/build.ts b/scripts/build.ts index fff5eab8..6556f376 100755 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -20,6 +20,7 @@ import { EmbedSchema, EventSchema, EvmAddressSchema, + ExperimentalPostSchema, FeedMetadataSchema, FeedRuleMetadataSchema, GeoURISchema, @@ -39,7 +40,6 @@ import { MetadataIdSchema, MetadataLicenseTypeSchema, MintSchema, - CustomSchema, NamespaceMetadataSchema, NamespaceRuleMetadataSchema, Nft721MetadataAttributeSchema, @@ -78,7 +78,7 @@ const schemas = new Map>([ ['posts/link/3.0.0.json', LinkSchema], ['posts/livestream/3.0.0.json', LiveStreamSchema], ['posts/mint/3.0.0.json', MintSchema], - ['posts/custom/3.0.0.json', CustomSchema], + ['posts/experimental/3.0.0.json', ExperimentalPostSchema], ['posts/space/3.0.0.json', SpaceSchema], ['posts/story/3.0.0.json', StorySchema], ['posts/text-only/3.0.0.json', TextOnlySchema], @@ -218,7 +218,7 @@ async function generateUmbrellaSchema() { LinkMetadata: LinkSchema, LiveStreamMetadata: LiveStreamSchema, MintMetadata: MintSchema, - CustomMetadata: CustomSchema, + ExperimentalPostMetadata: ExperimentalPostSchema, SpaceMetadata: SpaceSchema, TextOnlyMetadata: TextOnlySchema, StoryMetadata: StorySchema, diff --git a/src/builders/posts.ts b/src/builders/posts.ts index 6727c0a5..73dc3295 100644 --- a/src/builders/posts.ts +++ b/src/builders/posts.ts @@ -16,6 +16,9 @@ import { type EventMetadata, type EventMetadataDetails, EventSchema, + type ExperimentalPostMetadata, + type ExperimentalPostMetadataDetails, + ExperimentalPostSchema, type ImageMetadata, type ImageMetadataDetails, ImageSchema, @@ -28,9 +31,6 @@ import { type MintMetadata, type MintMetadataDetails, MintSchema, - type CustomMetadata, - type CustomMetadataDetails, - CustomSchema, PostMainFocus, PostMetadataSchemaId, type SpaceMetadata, @@ -659,9 +659,9 @@ export function mint({ * @private * @privateRemarks MUST stay very @private to produce usable docs */ -type CustomDetails = InputForPostMetadataDetails; +type CustomDetails = InputForPostMetadataDetails; /** - * All {@link CustomMetadataDetails} fields with: + * All {@link ExperimentalPostMetadataDetails} fields with: * - `id` defaults to a UUID * - `locale` defaults to `en` * - `mainContentFocus` automatically set to `PostSchemaId.CUSTOM_LATEST` @@ -696,9 +696,9 @@ export function custom({ locale = DEFAULT_LOCALE, id = v4(), ...others -}: CustomOptions): CustomMetadata { +}: CustomOptions): ExperimentalPostMetadata { return evaluate( - CustomSchema.safeParse({ + ExperimentalPostSchema.safeParse({ $schema: PostMetadataSchemaId.CUSTOM_LATEST, ...nft, lens: { diff --git a/src/post/CustomSchema.ts b/src/post/CustomSchema.ts deleted file mode 100644 index fb9d9ffa..00000000 --- a/src/post/CustomSchema.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { z } from 'zod'; - -import { NonEmptyStringSchema, type Signature } from '../primitives.js'; -import type { NftMetadata } from '../tokens/eip721.js'; -import { PostMainFocus } from './PostMainFocus.js'; -import { PostMetadataSchemaId } from './PostMetadataSchemaId.js'; -import { - mainContentFocus, - metadataDetailsWith, - postWith, - type PostMetadataCommon, -} from './common'; - -export type CustomMetadataDetails = PostMetadataCommon & { - /** - * The main focus of the post. - */ - mainContentFocus: PostMainFocus.CUSTOM; - /** - * A human-readable name for the custom post type. - */ - name: string; - /** - * A JSON string containing any custom data. - */ - value: string; -}; - -const CustomMetadataDetailsSchema: z.ZodType = - metadataDetailsWith({ - mainContentFocus: mainContentFocus(PostMainFocus.CUSTOM), - - name: NonEmptyStringSchema.describe('A human-readable name for the custom post type.'), - - value: NonEmptyStringSchema.describe('A JSON string containing any custom data.'), - }); - -export type CustomMetadata = NftMetadata & { - /** - * The schema id. - */ - $schema: PostMetadataSchemaId.CUSTOM_LATEST; - /** - * The metadata details. - */ - lens: CustomMetadataDetails; - /** - * A cryptographic signature of the `lens` data. - */ - signature?: Signature; -}; - -export const CustomSchema = postWith({ - $schema: z.literal(PostMetadataSchemaId.CUSTOM_LATEST), - lens: CustomMetadataDetailsSchema, -}); diff --git a/src/post/ExperimentalPostSchema.ts b/src/post/ExperimentalPostSchema.ts new file mode 100644 index 00000000..e7eeb9bd --- /dev/null +++ b/src/post/ExperimentalPostSchema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { NonEmptyStringSchema, type Signature } from '../primitives.js'; +import type { NftMetadata } from '../tokens/eip721.js'; +import { PostMainFocus } from './PostMainFocus.js'; +import { PostMetadataSchemaId } from './PostMetadataSchemaId.js'; +import { type PostMetadataCommon, metadataDetailsWith, postWith } from './common/index.js'; + +export type ExperimentalPostMetadataDetails = PostMetadataCommon & { + /** + * The main focus of the post. + */ + mainContentFocus: PostMainFocus; + /** + * A sub-identifier for the custom post type. + * This is used to differentiate between different custom post types. + */ + name: string; +}; + +const ExperimentalPostMetadataDetailsSchema: z.ZodType< + ExperimentalPostMetadataDetails, + z.ZodTypeDef, + object +> = metadataDetailsWith({ + mainContentFocus: z.nativeEnum(PostMainFocus), + + name: NonEmptyStringSchema.describe( + 'A sub-identifier for the custom post type. This is used to differentiate between different custom post types.', + ), +}); + +export type ExperimentalPostMetadata = NftMetadata & { + /** + * The schema id. + */ + $schema: PostMetadataSchemaId.EXPERIMENTAL_LATEST; + /** + * The metadata details. + */ + lens: ExperimentalPostMetadataDetails; + /** + * A cryptographic signature of the `lens` data. + */ + signature?: Signature; +}; + +export const ExperimentalPostSchema = postWith({ + $schema: z.literal(PostMetadataSchemaId.EXPERIMENTAL_LATEST), + lens: ExperimentalPostMetadataDetailsSchema, +}); diff --git a/src/post/PostMainFocus.ts b/src/post/PostMainFocus.ts index 4bbb5a90..62ffea07 100644 --- a/src/post/PostMainFocus.ts +++ b/src/post/PostMainFocus.ts @@ -20,7 +20,7 @@ export enum PostMainFocus { THREE_D = '3D', STORY = 'STORY', SPACE = 'SPACE', - CUSTOM = 'CUSTOM', + OTHER = 'OTHER', } export const PostMainFocusSchema = z.nativeEnum(PostMainFocus); diff --git a/src/post/PostMetadataSchemaId.ts b/src/post/PostMetadataSchemaId.ts index 198effe0..fd9bd7c8 100644 --- a/src/post/PostMetadataSchemaId.ts +++ b/src/post/PostMetadataSchemaId.ts @@ -21,5 +21,5 @@ export enum PostMetadataSchemaId { TRANSACTION_LATEST = `${location}/transaction/3.0.0.json`, TEXT_ONLY_LATEST = `${location}/text-only/3.0.0.json`, VIDEO_LATEST = `${location}/video/3.0.0.json`, - CUSTOM_LATEST = `${location}/custom/3.0.0.json`, + EXPERIMENTAL_LATEST = `${location}/experimental/3.0.0.json`, } diff --git a/src/post/common/index.ts b/src/post/common/index.ts index 7e247d70..3e052a76 100644 --- a/src/post/common/index.ts +++ b/src/post/common/index.ts @@ -69,7 +69,8 @@ export function metadataDetailsWith< Augmentation extends { mainContentFocus: | z.ZodLiteral - | z.ZodUnion<[z.ZodLiteral, ...z.ZodLiteral[]]>; + | z.ZodUnion<[z.ZodLiteral, ...z.ZodLiteral[]]> + | z.ZodNativeEnum; }, >(augmentation: Augmentation) { return z diff --git a/src/post/index.ts b/src/post/index.ts index 3bf2e9f4..70e93317 100644 --- a/src/post/index.ts +++ b/src/post/index.ts @@ -11,7 +11,7 @@ export * from './ImageSchema.js'; export * from './LinkSchema.js'; export * from './LiveStreamSchema.js'; export * from './MintSchema.js'; -export * from './CustomSchema.js'; +export * from './ExperimentalPostSchema.js'; export * from './PostMainFocus.js'; export * from './PostMetadataSchemaId.js'; export * from './SpaceSchema.js'; @@ -27,11 +27,11 @@ import { type AudioMetadata, AudioSchema } from './AudioSchema.js'; import { type CheckingInMetadata, CheckingInSchema } from './CheckingInSchema.js'; import { type EmbedMetadata, EmbedSchema } from './EmbedSchema'; import { type EventMetadata, EventSchema } from './EventSchema.js'; +import { type ExperimentalPostMetadata, ExperimentalPostSchema } from './ExperimentalPostSchema.js'; import { type ImageMetadata, ImageSchema } from './ImageSchema.js'; import { type LinkMetadata, LinkSchema } from './LinkSchema.js'; import { type LiveStreamMetadata, LiveStreamSchema } from './LiveStreamSchema.js'; import { type MintMetadata, MintSchema } from './MintSchema.js'; -import { type CustomMetadata, CustomSchema } from './CustomSchema.js'; import { type SpaceMetadata, SpaceSchema } from './SpaceSchema.js'; import { type StoryMetadata, StorySchema } from './StorySchema.js'; import { type TextOnlyMetadata, TextOnlySchema } from './TextOnlySchema.js'; @@ -84,7 +84,7 @@ export type PostMetadata = ShapeCheck< | LinkMetadata | LiveStreamMetadata | MintMetadata - | CustomMetadata + | ExperimentalPostMetadata | SpaceMetadata | TextOnlyMetadata | StoryMetadata @@ -127,7 +127,7 @@ export const PostMetadataSchema: z.ZodType = LinkSchema, LiveStreamSchema, MintSchema, - CustomSchema, + ExperimentalPostSchema, SpaceSchema, TextOnlySchema, StorySchema,