diff --git a/README.md b/README.md index bf45f178..36100f5c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ Monorepository of npm packages we use in our projects. +## 📦 hyperdata + +[![hyperdata version][hyperdata-version-src]][hyperdata-version-href] + +Library for working with NFT metadata. + ## 📦 if-that [![if-that version][if-that-version-src]][if-that-version-href] @@ -36,6 +42,9 @@ Logical and friendly wrapper for Substrate API (@polkadot). +[hyperdata-version-src]: https://img.shields.io/npm/v/@kodadot1/hyperdata/latest.svg?style=flat&colorA=18181B&colorB=FF7AC3 +[hyperdata-version-href]: https://npmjs.com/package/@kodadot1/hyperdata + [if-that-version-src]: https://img.shields.io/npm/v/@kodadot1/if-that/latest.svg?style=flat&colorA=18181B&colorB=FF7AC3 [if-that-version-href]: https://npmjs.com/package/@kodadot1/if-that diff --git a/hyperdata/.editorconfig b/hyperdata/.editorconfig new file mode 100644 index 00000000..4f4d652c --- /dev/null +++ b/hyperdata/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.js] +indent_style = space +indent_size = 2 + +[{package.json,*.yml,*.cjson}] +indent_style = space +indent_size = 2 diff --git a/hyperdata/.eslintignore b/hyperdata/.eslintignore new file mode 100644 index 00000000..9c628283 --- /dev/null +++ b/hyperdata/.eslintignore @@ -0,0 +1,3 @@ +node_modules +coverage +dist diff --git a/hyperdata/.eslintrc b/hyperdata/.eslintrc new file mode 100644 index 00000000..dca1fe97 --- /dev/null +++ b/hyperdata/.eslintrc @@ -0,0 +1,4 @@ +{ + "extends": ["plugin:vue/recommended", "prettier"], + "rules": {} +} diff --git a/hyperdata/.gitignore b/hyperdata/.gitignore new file mode 100644 index 00000000..d3ea3e88 --- /dev/null +++ b/hyperdata/.gitignore @@ -0,0 +1,10 @@ +node_modules +coverage +dist +types +.vscode +.DS_Store +.eslintcache +*.log* +*.conf* +*.env* diff --git a/hyperdata/.prettierrc b/hyperdata/.prettierrc new file mode 100644 index 00000000..e6f8ee39 --- /dev/null +++ b/hyperdata/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "es5", + "semi": false +} diff --git a/hyperdata/LICENSE b/hyperdata/LICENSE new file mode 100644 index 00000000..244229eb --- /dev/null +++ b/hyperdata/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/hyperdata/README.md b/hyperdata/README.md new file mode 100644 index 00000000..7aab831d --- /dev/null +++ b/hyperdata/README.md @@ -0,0 +1,73 @@ +# @kodadot1/content + +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![Github Actions][github-actions-src]][github-actions-href] +[![Codecov][codecov-src]][codecov-href] + +> Magic static void main + +## Usage + +Install package: + +```sh +# npm +npm install @kodadot1/content + +# yarn +yarn add @kodadot1/content + +# pnpm +pnpm install @kodadot1/content +``` + +Import: + +```js +// ESM +import * as static from "@kodadot1/content"; + +// CommonJS +const static = require("@kodadot1/content"); +``` + +## Available files + +### 🔧 normalize + +Unifying data structure into one format + +- attributeFrom - unify attribute to one format +- contentFrom - unify metadata to one format +- normalize - sanitize content fields to one format + +### 🔧 utils + +Misc utils for manipulating with data + +### 🔧 types + +Misc types for metadata namely: + +- OpenSea +- FxHash +- Tezos (TZIP-16) +- ERC-5773 + +## License + +Made with 💛 + +Published under [MIT License](./LICENSE). + + + +[npm-version-src]: https://img.shields.io/npm/v/@kodadot1/content?style=flat-square +[npm-version-href]: https://npmjs.com/package/@kodadot1/content +[npm-downloads-src]: https://img.shields.io/npm/dm/@kodadot1/content?style=flat-square +[npm-downloads-href]: https://npmjs.com/package/@kodadot1/content +[github-actions-src]: https://img.shields.io/github/actions/workflow/status/@kodadot1/content/ci.yml?branch=main&style=flat-square +[github-actions-href]: https://github.com/@kodadot1/content/actions?query=workflow%3Aci +[codecov-src]: https://img.shields.io/codecov/c/gh/@kodadot1/content/main?style=flat-square +[codecov-href]: https://codecov.io/gh/@kodadot1/content diff --git a/hyperdata/justfile b/hyperdata/justfile new file mode 100644 index 00000000..171aa63a --- /dev/null +++ b/hyperdata/justfile @@ -0,0 +1,11 @@ +build: + pnpm build + +test: + pnpm test + +publish: + pnpm publish --access public + +c VERSION: + git commit -am ":bookmark: static@{{VERSION}}" diff --git a/hyperdata/package.json b/hyperdata/package.json new file mode 100644 index 00000000..fa91bc0f --- /dev/null +++ b/hyperdata/package.json @@ -0,0 +1,42 @@ +{ + "name": "@kodadot1/hyperdata", + "version": "0.0.1-rc.1", + "description": "Get unified NFT content", + "repository": "kodadot/nft-gallery", + "license": "MIT", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "dev": "vitest dev", + "lint": "eslint --cache --ext .ts,.js,.mjs,.cjs . && prettier -c src test", + "lint:fix": "eslint --cache --ext .ts,.js,.mjs,.cjs . --fix && prettier -c src test -w", + "prepack": "pnpm run build", + "release": "pnpm test && changelogen --release && npm publish && git push --follow-tags", + "test": "vitest run" + }, + "devDependencies": { + "@kodadot1/minipfs": "0.4.0-rc.0", + "@vitest/coverage-c8": "^0.33.0", + "changelogen": "^0.5.4", + "eslint": "^8.47.0", + "eslint-config-unjs": "^0.2.1", + "prettier": "^2.8.8", + "typescript": "^4.9.5", + "unbuild": "^1.2.1", + "vitest": "^0.34.1" + } +} diff --git a/hyperdata/src/constatnts.ts b/hyperdata/src/constatnts.ts new file mode 100644 index 00000000..8f4fbe36 --- /dev/null +++ b/hyperdata/src/constatnts.ts @@ -0,0 +1,4 @@ +export const LEWD = 'NSFW' +// based on https://www.regextester.com/110183 +// list ipfs://bafkreiequ3mnfu2ytaixzdzolcpfwlc3vrj4544pzqg4tjvyth2zi5mhgq +export const MIME_TYPE = /\w{4,12}\/[-+.\w]{2,62}/ diff --git a/hyperdata/src/index.ts b/hyperdata/src/index.ts new file mode 100644 index 00000000..2cb58be6 --- /dev/null +++ b/hyperdata/src/index.ts @@ -0,0 +1,3 @@ +export * from './normalize' +export * from './utils' +export * from './types' diff --git a/hyperdata/src/normalize.ts b/hyperdata/src/normalize.ts new file mode 100644 index 00000000..e1c65306 --- /dev/null +++ b/hyperdata/src/normalize.ts @@ -0,0 +1,128 @@ +import { MIME_TYPE } from './constatnts' +import { + Attribute, + Content, + FXHashMetadata, + GenArt, + OpenSeaAttribute, + OpenSeaMetadata, + PluralAssetMetadata, + PluralAttribute, + SanitizerFunc, + TezosAttribute, + TezosMetadata, +} from './types' + +export function attributeFrom(attr: OpenSeaAttribute): Attribute +export function attributeFrom(attr: TezosAttribute): Attribute +export function attributeFrom(attr: PluralAttribute): Attribute +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function attributeFrom(attr: any): Attribute { + const display: string = attr.display_type || attr.type || '' + const trait: string | undefined = attr.trait_type || attr.name || attr.label + const value: string = attr.value?.toString() + + return { + display, + trait: trait?.toString() ?? '', + value, + } +} + +export function contentFrom(meta: OpenSeaMetadata, eager?: boolean): Content +export function contentFrom(meta: FXHashMetadata, eager?: boolean): Content +export function contentFrom(meta: TezosMetadata, eager?: boolean): Content +export function contentFrom(meta: PluralAssetMetadata, eager?: boolean): Content +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function contentFrom(meta: any, eager?: boolean): Content { + const description = meta.description || '' + const thumbnail = meta.thumbnailUri || meta.thumbnail + const image = meta.image || meta.displayUri || thumbnail || meta.mediaUri + const animationUrl = meta.animation_url || meta.mediaUri || meta.artifactUri + const attributes = meta.attributes?.map(attributeFrom) || [] + const name = meta.name + const type = meta.type + const externalUrl = meta.external_url || meta.youtube_url || meta.externalUri + const tags = Array.isArray(meta.tags) ? meta.tags : [] + let generative: GenArt | undefined + + if (eager) { + generative = generativeFrom(meta) + } + + return { + description, + image, + animationUrl, // rename to media? + attributes, + name, + type: MIME_TYPE.test(type) ? type : '', + externalUrl, + tags, + thumbnail, + generative, + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function generativeFrom(meta: any): GenArt | undefined { + const uri = meta.generativeUri || meta.generatorUri + + if (!uri) { + return undefined + } + + const previewHash = meta.previewHash + const previewParam = meta.previewParam || 'fxhash' + const capture = meta.capture + const settings = meta.settings + + return { + uri, + previewHash, + previewParam, + capture, + settings, + } +} + +export function normalize(content: Content, sanitizer: SanitizerFunc): Content { + return { + ...content, + image: sanitizer(content.image), + animationUrl: sanitizer(content.animationUrl), + thumbnail: sanitizer(content.thumbnail), + } +} + +export function mergeAttributes( + attrs: OpenSeaAttribute[], + overrides: Attribute[] +): Attribute[] +export function mergeAttributes( + attrs: TezosAttribute[], + overrides: Attribute[] +): Attribute[] +export function mergeAttributes( + attrs: PluralAttribute[], + overrides: Attribute[] +): Attribute[] +// export function mergeAttributes(attrs: Attribute[], overrides: Attribute[]): Attribute[] +export function mergeAttributes( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attrs: any[], + overrides: Attribute[] +): Attribute[] { + const initial = new Map( + attrs.map(attributeFrom).map((a) => [a.trait, a.value]) + ) + + for (const override of overrides) { + initial.set(override.trait, override.value) + } + + return Array.from(initial.entries()).map(([trait, value]) => ({ + trait, + value, + })) +} diff --git a/hyperdata/src/types.ts b/hyperdata/src/types.ts new file mode 100644 index 00000000..8292ed8a --- /dev/null +++ b/hyperdata/src/types.ts @@ -0,0 +1,155 @@ +export type OpenSeaAttribute = { + display_type?: string + trait_type: string + value: number | string +} + +export type TokenMetadata = { + name?: string + description: string + external_url?: string + image: string + animation_url?: string + attributes?: OpenSeaAttribute[] + mediaUri?: string + type?: string + thumbnailUri?: string +} + +export type BaseMetadata = { + name: string + description: string +} + +export type BaseOpenMetadata = { + image: string + animation_url?: string +} + +export type Tags = string[] + +export type OpenSeaMetadata = BaseMetadata & + BaseOpenMetadata & { + attributes?: OpenSeaAttribute[] + external_url?: string + background_color?: string + youtube_url?: string + } + +export type TezosAttribute = { + name: string + value: string + type?: string +} + +export type BaseTezosMetadata = { + thumbnailUri?: string + externalUri?: string +} + +export type WithTags = { + tags?: Tags +} + +// https://tzip.tezosagora.org/proposal/tzip-21/ +export type TezosMetadata = BaseMetadata & + BaseTezosMetadata & { + displayUri?: string + artifactUri?: string + attributes?: TezosAttribute[] + tags?: Tags + type?: string + formats?: TezosFormat[] + } + +// https://www.fxhash.xyz/doc/fxhash/integration-guide +export type GenerativeMetadata = { + generativeUri?: string + generatorUri?: string + previewHash: string +} + +type GenerativeFxHash = { + previewHash?: string + capture?: Record + settings?: Record +} + +export type FXHashMetadata = TezosMetadata & + GenerativeMetadata & + GenerativeFxHash + +export type TezosFormat = { + uri: string + hash: string + mimeType: string + dimensions: { + value: `${number}x${number}` + unit: string + } +} + +export type PluralAttribute = { + label: string + type?: string + value: string + // For backward compatibility + trait_type?: string + display_type?: string +} + +// https://eips.ethereum.org/EIPS/eip-5773 +export type PluralAssetMetadata = BaseMetadata & + BaseTezosMetadata & + BaseOpenMetadata & { + mediaUri?: string + attributes?: PluralAttribute[] + } + +export type LensMetadata = { + nsfw: boolean +} + +export type PossibleMetadata = + | OpenSeaMetadata + | TezosMetadata + | PluralAssetMetadata + | FXHashMetadata +export type PossibleAttribute = + | OpenSeaAttribute + | TezosAttribute + | PluralAttribute + +export type AllMetadata = OpenSeaMetadata & + TezosMetadata & + PluralAssetMetadata & + FXHashMetadata + +// TARGETS + +export type Content = { + animationUrl: string + attributes: Attribute[] + description: string + // id: string + image: string + name: string + type?: string + tags?: Tags + externalUrl?: string + thumbnail?: string + generative?: GenArt +} + +export type Attribute = { + display?: string + trait: string + value: string +} + +export type GenArt = GenerativeFxHash & { + uri: string + previewParam?: string +} + +export type SanitizerFunc = (url: T | undefined) => T diff --git a/hyperdata/src/utils.ts b/hyperdata/src/utils.ts new file mode 100644 index 00000000..847ab799 --- /dev/null +++ b/hyperdata/src/utils.ts @@ -0,0 +1,7 @@ +import { LEWD } from './constatnts' +import { Attribute } from './types' + +export function isLewdAttribute(attribute: Attribute | string): boolean { + const trait = typeof attribute === 'string' ? attribute : attribute.trait + return trait.toUpperCase() === LEWD +} diff --git a/hyperdata/tests/content.test.ts b/hyperdata/tests/content.test.ts new file mode 100644 index 00000000..86af5ca4 --- /dev/null +++ b/hyperdata/tests/content.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { TezosMetadata, contentFrom } from '../src' +import fxhash from './examples/fxhash.json' + +describe('contentFrom', () => { + it(`should parse FXhash metadata to Content correctly`, () => { + const metadata = fxhash as TezosMetadata + expect(contentFrom(metadata)).toStrictEqual({ + name: metadata.name, + description: metadata.description, + image: metadata.displayUri, + animationUrl: metadata.artifactUri, + attributes: [], + externalUrl: metadata.externalUri, + tags: [], + thumbnail: metadata.thumbnailUri, + type: '', + }) + }) +}) diff --git a/hyperdata/tests/examples/fxhash.json b/hyperdata/tests/examples/fxhash.json new file mode 100644 index 00000000..1b249479 --- /dev/null +++ b/hyperdata/tests/examples/fxhash.json @@ -0,0 +1,27 @@ +{ + "name": "A Bugged Forest", + "description": "A forest has stories to tell.\nAbout times that were, about times that will be.\n-\nThis project was minted in much unusual conditions. \nWe gathered here, by 38 degrees heat. We belong to a crowd who loves stories, of civilisations at stake and deities, of afterlife and worship. We drank and hugged and chanted pagan hymns. We minted the Bugged Forest from our place on earth that we call Hell just for the fun of it. We are passionate, and loving, and angry and desperate. We, people of the soil, under a bugged sun.\n-\nThis project is the long-form version of an algorithm that contains a bug. “The Bugged Tree”’s code was salvaged and stored and served as the core motivation and engine for an entire forest. Its concept is about releasing the control. I am a byproduct of evolution, an iteration of that specie who wanted utter control, and which at some point, lost control over its own creation. As a generative artist, I chose to welcome the event of chance into my creative process. The symbolism in chance made me feel humble and free. Unwilling to make any change to a story that the Tree needed to tell me, as a listener, rather than a writer of my own idealised fantasies.\n-\nThe iterations aren’t under control. They may be odd and off. We have to accept it. They are also plottable, press [s] in live mode to get a SVG. Attempt the plot at your own risk, as there’s a possibility for it to come near to impossibilities or cause paper or motor damage, and most certainly, a bugged drawing.\n-\nFrom Earth or from Hell, with love, yours truthfully, NFTBiker and zancan\n", + "childrenDescription": "A forest has stories to tell.\nAbout times that were, about times that will be.\n-\nThis project was minted in much unusual conditions. \nWe gathered here, by 38 degrees heat. We belong to a crowd who loves stories, of civilisations at stake and deities, of afterlife and worship. We drank and hugged and chanted pagan hymns. We minted the Bugged Forest from our place on earth that we call Hell just for the fun of it. We are passionate, and loving, and angry and desperate. We, people of the soil, under a bugged sun.\n-\nThis project is the long-form version of an algorithm that contains a bug. “The Bugged Tree”’s code was salvaged and stored and served as the core motivation and engine for an entire forest. Its concept is about releasing the control. I am a byproduct of evolution, an iteration of that specie who wanted utter control, and which at some point, lost control over its own creation. As a generative artist, I chose to welcome the event of chance into my creative process. The symbolism in chance made me feel humble and free. Unwilling to make any change to a story that the Tree needed to tell me, as a listener, rather than a writer of my own idealised fantasies.\n-\nThe iterations aren’t under control. They may be odd and off. We have to accept it. They are also plottable, press [s] in live mode to get a SVG. Attempt the plot at your own risk, as there’s a possibility for it to come near to impossibilities or cause paper or motor damage, and most certainly, a bugged drawing.\n-\nFrom Earth or from Hell, with love, yours truthfully, NFTBiker and zancan\n", + "tags": [], + "artifactUri": "ipfs://QmebKrkUNabQq1Yhp1QrK2EUPbUctrphu9xEmiz23bQDgM?fxhash=oouRoBFwwjEccFn6NzGso2mYXG2Pe4xaaKAkgs6ZhecKupC9HvR", + "displayUri": "ipfs://QmT5zXKLfiB3CuDuzM6ABU4iPP94c8Pg5ApZL6J78ueACk", + "thumbnailUri": "ipfs://QmRrU67A9hPVxM9qfZ451BrAWycKsNafoqhP63QwyPuPpg", + "generativeUri": "ipfs://QmebKrkUNabQq1Yhp1QrK2EUPbUctrphu9xEmiz23bQDgM", + "authenticityHash": "19ccae08cc094e384587ef2cdee34e01fae6b9396e3328e32632ebe38e6a8e49", + "previewHash": "oouRoBFwwjEccFn6NzGso2mYXG2Pe4xaaKAkgs6ZhecKupC9HvR", + "capture": { + "mode": "CANVAS", + "triggerMode": "FN_TRIGGER", + "gpu": false, + "canvasSelector": "canvas" + }, + "settings": { + "exploration": { + "preMint": { "enabled": true, "hashConstraints": null }, + "postMint": { "enabled": false, "hashConstraints": null } + } + }, + "symbol": "FXGEN", + "decimals": 0, + "version": "0.2" +} diff --git a/hyperdata/tests/generative.test.ts b/hyperdata/tests/generative.test.ts new file mode 100644 index 00000000..25cbfce6 --- /dev/null +++ b/hyperdata/tests/generative.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { FXHashMetadata, GenArt, generativeFrom } from '../src' +import fxhash from './examples/fxhash.json' + +describe('generativeFrom', () => { + it(`should parse FXhash metadata to Content correctly`, () => { + const metadata = fxhash as FXHashMetadata + const res: GenArt = { + uri: metadata.generativeUri || metadata.generatorUri || '', + previewHash: metadata.previewHash, + previewParam: 'fxhash', + capture: metadata.capture || {}, + settings: metadata.settings || {}, + } + + expect(generativeFrom(metadata)).toStrictEqual(res) + }) +}) diff --git a/hyperdata/tests/index.test.ts b/hyperdata/tests/index.test.ts new file mode 100644 index 00000000..d1f09aa2 --- /dev/null +++ b/hyperdata/tests/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { OpenSeaAttribute, attributeFrom } from '../src' + +describe('attributeFrom', () => { + it(`should parse OpenSea Attribute correctly`, () => { + const attribute: OpenSeaAttribute = { + trait_type: 'NSFW', + value: 1, + } + expect(attributeFrom(attribute)).toStrictEqual({ + display: '', + trait: 'NSFW', + value: '1', + }) + }) +}) diff --git a/hyperdata/tsconfig.json b/hyperdata/tsconfig.json new file mode 100644 index 00000000..e9588d9f --- /dev/null +++ b/hyperdata/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "esModuleInterop": true, + "strict": true + }, + "include": ["src"] +}