create-dig-app 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/bin/create-dig-app.js +17 -0
- package/lib/cli.js +438 -0
- package/lib/index.js +235 -0
- package/lib/nft-cli.js +265 -0
- package/lib/nft-metadata.js +515 -0
- package/lib/substitute.js +47 -0
- package/lib/templates.js +185 -0
- package/package.json +51 -0
- package/templates/dapp-window-chia/README.md +63 -0
- package/templates/dapp-window-chia/_env.example +7 -0
- package/templates/dapp-window-chia/_gitignore +10 -0
- package/templates/dapp-window-chia/dig.toml +17 -0
- package/templates/dapp-window-chia/index.html +12 -0
- package/templates/dapp-window-chia/package.json +24 -0
- package/templates/dapp-window-chia/src/App.jsx +91 -0
- package/templates/dapp-window-chia/src/main.jsx +10 -0
- package/templates/dapp-window-chia/src/styles.css +71 -0
- package/templates/dapp-window-chia/src/wallet.js +47 -0
- package/templates/dapp-window-chia/vite.config.js +14 -0
- package/templates/next-static/README.md +41 -0
- package/templates/next-static/_gitignore +6 -0
- package/templates/next-static/app/globals.css +49 -0
- package/templates/next-static/app/layout.jsx +14 -0
- package/templates/next-static/app/page.jsx +18 -0
- package/templates/next-static/dig.toml +17 -0
- package/templates/next-static/next.config.mjs +11 -0
- package/templates/next-static/package.json +16 -0
- package/templates/nft-collection/README.md +119 -0
- package/templates/nft-collection/_env.example +5 -0
- package/templates/nft-collection/_gitignore +9 -0
- package/templates/nft-collection/assets/banner.svg +11 -0
- package/templates/nft-collection/assets/icon.svg +10 -0
- package/templates/nft-collection/collection.json +14 -0
- package/templates/nft-collection/dig.toml +23 -0
- package/templates/nft-collection/images/sample-1.svg +5 -0
- package/templates/nft-collection/images/sample-2.svg +5 -0
- package/templates/nft-collection/licenses/.gitkeep +1 -0
- package/templates/nft-collection/metadata/.gitkeep +2 -0
- package/templates/nft-collection/package.json +18 -0
- package/templates/nft-collection/scripts/dig-nft.mjs +479 -0
- package/templates/nft-collection/src/mint.js +72 -0
- package/templates/nft-collection/src/styles.css +64 -0
- package/templates/nft-collection/traits.csv +3 -0
- package/templates/nft-drop/README.md +71 -0
- package/templates/nft-drop/_env.example +7 -0
- package/templates/nft-drop/_gitignore +10 -0
- package/templates/nft-drop/dig.toml +18 -0
- package/templates/nft-drop/index.html +12 -0
- package/templates/nft-drop/package.json +24 -0
- package/templates/nft-drop/src/App.jsx +91 -0
- package/templates/nft-drop/src/main.jsx +10 -0
- package/templates/nft-drop/src/mint.js +72 -0
- package/templates/nft-drop/src/styles.css +70 -0
- package/templates/nft-drop/vite.config.js +13 -0
- package/templates/static-site/README.md +41 -0
- package/templates/static-site/_gitignore +5 -0
- package/templates/static-site/dig.toml +19 -0
- package/templates/static-site/package.json +10 -0
- package/templates/static-site/scripts/build.mjs +14 -0
- package/templates/static-site/src/index.html +20 -0
- package/templates/static-site/src/styles.css +49 -0
- package/templates/vite-react/README.md +42 -0
- package/templates/vite-react/_gitignore +5 -0
- package/templates/vite-react/dig.toml +17 -0
- package/templates/vite-react/index.html +12 -0
- package/templates/vite-react/package.json +20 -0
- package/templates/vite-react/src/App.jsx +18 -0
- package/templates/vite-react/src/main.jsx +10 -0
- package/templates/vite-react/src/styles.css +49 -0
- package/templates/vite-react/vite.config.js +9 -0
- package/templates-ts/dapp-window-chia/README.md +67 -0
- package/templates-ts/dapp-window-chia/_env.example +7 -0
- package/templates-ts/dapp-window-chia/_gitignore +10 -0
- package/templates-ts/dapp-window-chia/dig.toml +17 -0
- package/templates-ts/dapp-window-chia/index.html +12 -0
- package/templates-ts/dapp-window-chia/package.json +28 -0
- package/templates-ts/dapp-window-chia/src/App.tsx +93 -0
- package/templates-ts/dapp-window-chia/src/main.tsx +13 -0
- package/templates-ts/dapp-window-chia/src/styles.css +71 -0
- package/templates-ts/dapp-window-chia/src/vite-env.d.ts +11 -0
- package/templates-ts/dapp-window-chia/src/wallet.ts +62 -0
- package/templates-ts/dapp-window-chia/tsconfig.json +20 -0
- package/templates-ts/dapp-window-chia/vite.config.ts +14 -0
- package/templates-ts/next-static/README.md +43 -0
- package/templates-ts/next-static/_gitignore +7 -0
- package/templates-ts/next-static/app/globals.css +49 -0
- package/templates-ts/next-static/app/layout.tsx +16 -0
- package/templates-ts/next-static/app/page.tsx +18 -0
- package/templates-ts/next-static/dig.toml +17 -0
- package/templates-ts/next-static/next-env.d.ts +5 -0
- package/templates-ts/next-static/next.config.mjs +11 -0
- package/templates-ts/next-static/package.json +23 -0
- package/templates-ts/next-static/tsconfig.json +21 -0
- package/templates-ts/nft-drop/README.md +74 -0
- package/templates-ts/nft-drop/_env.example +7 -0
- package/templates-ts/nft-drop/_gitignore +10 -0
- package/templates-ts/nft-drop/dig.toml +18 -0
- package/templates-ts/nft-drop/index.html +12 -0
- package/templates-ts/nft-drop/package.json +28 -0
- package/templates-ts/nft-drop/src/App.tsx +93 -0
- package/templates-ts/nft-drop/src/main.tsx +13 -0
- package/templates-ts/nft-drop/src/mint.ts +86 -0
- package/templates-ts/nft-drop/src/styles.css +70 -0
- package/templates-ts/nft-drop/src/vite-env.d.ts +11 -0
- package/templates-ts/nft-drop/tsconfig.json +20 -0
- package/templates-ts/nft-drop/vite.config.ts +13 -0
- package/templates-ts/vite-react/README.md +44 -0
- package/templates-ts/vite-react/_gitignore +5 -0
- package/templates-ts/vite-react/dig.toml +17 -0
- package/templates-ts/vite-react/index.html +12 -0
- package/templates-ts/vite-react/package.json +24 -0
- package/templates-ts/vite-react/src/App.tsx +18 -0
- package/templates-ts/vite-react/src/main.tsx +13 -0
- package/templates-ts/vite-react/src/styles.css +49 -0
- package/templates-ts/vite-react/src/vite-env.d.ts +1 -0
- package/templates-ts/vite-react/tsconfig.json +20 -0
- package/templates-ts/vite-react/vite.config.ts +9 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
// CHIP-0007 NFT metadata + license generation + validation — the canonical core.
|
|
2
|
+
//
|
|
3
|
+
// This module is the SINGLE SOURCE OF TRUTH for the create-dig-app NFT tooling. It is unit-tested
|
|
4
|
+
// directly (test/nft-metadata.test.js) and VENDORED verbatim into the `nft-collection` template's
|
|
5
|
+
// scripts/dig-nft.mjs so a scaffolded project runs the exact same, dependency-free logic. (The
|
|
6
|
+
// template's copy stays byte-identical to this file — see scripts/dig-nft.mjs's header.)
|
|
7
|
+
//
|
|
8
|
+
// ─── Why hand-rolled JSON instead of the wasm? ────────────────────────────────────────────────────
|
|
9
|
+
// create-dig-app has ZERO runtime dependencies (npm ci installs nothing; CI runs on the stdlib). The
|
|
10
|
+
// off-chain CHIP-0007 JSON is a fixed, byte-pinned shape, so we reproduce it directly rather than
|
|
11
|
+
// pull in @dignetwork/chip35-dl-coin-wasm. The canonical form is a MUTUAL BYTE-MIRROR between
|
|
12
|
+
// chip35_dl_coin (core/src/metadata.rs, collection.rs) and digstore (digstore-chain/src/metadata.rs,
|
|
13
|
+
// collection.rs), both pinned by byte-string tests. We mirror it exactly:
|
|
14
|
+
// - field order: format, name, description, sensitive_content, collection, attributes,
|
|
15
|
+
// series_number, series_total, minting_tool
|
|
16
|
+
// - omit: description/collection/series_*/minting_tool when null; sensitive_content when
|
|
17
|
+
// false; attributes (and collection.attributes) when empty
|
|
18
|
+
// - render: compact JSON (serde_json::to_string ≡ JSON.stringify with no spaces), no key sort
|
|
19
|
+
// - hash: metadata_hash = sha256(canonical_json_bytes); data/license_hash = sha256(bytes)
|
|
20
|
+
// (SYSTEM.md → Shared contracts → "CHIP-0007 NFT metadata".) Drift here diverges the on-chain
|
|
21
|
+
// metadata_hash and every verifying client rejects the NFT — keep it in lock-step with the test
|
|
22
|
+
// vectors copied from digstore's pinned tests.
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
|
|
26
|
+
/** The CHIP-0007 format tag stamped on every off-chain metadata document. */
|
|
27
|
+
export const CHIP0007_FORMAT = "CHIP-0007";
|
|
28
|
+
|
|
29
|
+
/** The tool tag the DIG ecosystem stamps on minted metadata (byte-mirror of digstore/chip35). */
|
|
30
|
+
export const MINTING_TOOL = "DIG";
|
|
31
|
+
|
|
32
|
+
// ── Canonical JSON + hashing ────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Lowercase-hex SHA-256 of raw bytes — the ecosystem-wide hash primitive (`chia_sha2::Sha256` is a
|
|
36
|
+
* standard NIST SHA-256, so node's crypto produces byte-identical digests).
|
|
37
|
+
* @param {Uint8Array|Buffer} bytes
|
|
38
|
+
* @returns {string} 64-char lowercase hex
|
|
39
|
+
*/
|
|
40
|
+
export function sha256Hex(bytes) {
|
|
41
|
+
const buf = bytes instanceof Uint8Array ? Buffer.from(bytes) : Buffer.from(bytes ?? []);
|
|
42
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Coerce a value to the canonical attribute string. CHIP-0007 stores every trait value as a string
|
|
47
|
+
* for byte-stable hashing (numbers/booleans are stringified), matching the Rust `Attribute.value:
|
|
48
|
+
* String`.
|
|
49
|
+
* @param {unknown} v
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function attrValue(v) {
|
|
53
|
+
return v == null ? "" : String(v);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a list of `{trait_type,value}` (accepting `traitType` too) into the canonical attribute
|
|
58
|
+
* array: trimmed string `trait_type` + stringified `value`, dropping any entry with an empty
|
|
59
|
+
* trait_type. Order is preserved (CHIP-0007 does not sort attributes).
|
|
60
|
+
* @param {Array<{trait_type?:string,traitType?:string,value:unknown}>} attrs
|
|
61
|
+
* @returns {Array<{trait_type:string,value:string}>}
|
|
62
|
+
*/
|
|
63
|
+
function normalizeAttributes(attrs) {
|
|
64
|
+
return (attrs ?? [])
|
|
65
|
+
.map((a) => ({ trait_type: String(a?.trait_type ?? a?.traitType ?? "").trim(), value: attrValue(a?.value) }))
|
|
66
|
+
.filter((a) => a.trait_type !== "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a canonical CHIP-0007 metadata object from loose inputs. The returned object's keys are in
|
|
71
|
+
* the pinned field order and omit empty/false/None fields, so {@link canonicalJson} of it is
|
|
72
|
+
* byte-identical to chip35/digstore's `to_canonical_json()`.
|
|
73
|
+
*
|
|
74
|
+
* @param {Object} input
|
|
75
|
+
* @param {string} input.name Required item name.
|
|
76
|
+
* @param {string} [input.description] Optional; omitted when empty/undefined.
|
|
77
|
+
* @param {boolean} [input.sensitive_content] Omitted when falsey.
|
|
78
|
+
* @param {{id:string,name:string,attributes?:Array}} [input.collection] Collection ref; its
|
|
79
|
+
* `attributes` are omitted when empty.
|
|
80
|
+
* @param {Array<{trait_type?:string,traitType?:string,value:unknown}>} [input.attributes] Per-item traits.
|
|
81
|
+
* @param {number} [input.series_number] 1-based position; omitted when undefined.
|
|
82
|
+
* @param {number} [input.series_total] Series size; omitted when undefined.
|
|
83
|
+
* @param {string} [input.minting_tool] Tool tag; omitted when undefined.
|
|
84
|
+
* @returns {object} A plain object with keys in canonical order (only the present fields).
|
|
85
|
+
*/
|
|
86
|
+
export function buildChip0007Metadata(input) {
|
|
87
|
+
const md = { format: CHIP0007_FORMAT, name: String(input?.name ?? "") };
|
|
88
|
+
|
|
89
|
+
const description = input?.description;
|
|
90
|
+
if (description != null && String(description) !== "") md.description = String(description);
|
|
91
|
+
|
|
92
|
+
if (input?.sensitive_content === true) md.sensitive_content = true;
|
|
93
|
+
|
|
94
|
+
if (input?.collection) {
|
|
95
|
+
const c = input.collection;
|
|
96
|
+
const ref = { id: String(c.id ?? ""), name: String(c.name ?? "") };
|
|
97
|
+
const cattrs = normalizeAttributes(c.attributes);
|
|
98
|
+
if (cattrs.length > 0) ref.attributes = cattrs;
|
|
99
|
+
md.collection = ref;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const attributes = normalizeAttributes(input?.attributes);
|
|
103
|
+
if (attributes.length > 0) md.attributes = attributes;
|
|
104
|
+
|
|
105
|
+
if (input?.series_number != null) md.series_number = Number(input.series_number);
|
|
106
|
+
if (input?.series_total != null) md.series_total = Number(input.series_total);
|
|
107
|
+
if (input?.minting_tool != null && String(input.minting_tool) !== "") {
|
|
108
|
+
md.minting_tool = String(input.minting_tool);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return md;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Render a CHIP-0007 metadata object to its canonical JSON string. Because
|
|
116
|
+
* {@link buildChip0007Metadata} already orders + prunes the keys, this is just a compact
|
|
117
|
+
* `JSON.stringify` (no spaces, no key sort) — byte-identical to `serde_json::to_string`.
|
|
118
|
+
* @param {object} md A metadata object produced by {@link buildChip0007Metadata} (or {@link mergeItem}).
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function canonicalJson(md) {
|
|
122
|
+
return JSON.stringify(md);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Compute the on-chain `metadata_hash` for a metadata object: sha256 of its canonical JSON bytes.
|
|
127
|
+
* @param {object} md
|
|
128
|
+
* @returns {string} lowercase hex
|
|
129
|
+
*/
|
|
130
|
+
export function metadataHashHex(md) {
|
|
131
|
+
return sha256Hex(Buffer.from(canonicalJson(md), "utf8"));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Collection id slug ────────────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Derive the stable collection id from a name — lowercase, non-alphanumerics → single dash, trimmed.
|
|
138
|
+
* Byte-identical to digstore's `slug()` and the hub's `collectionId()` so the `collection.id` in the
|
|
139
|
+
* metadata matches across the ecosystem.
|
|
140
|
+
* @param {string} name
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
export function collectionId(name) {
|
|
144
|
+
return String(name ?? "")
|
|
145
|
+
.toLowerCase()
|
|
146
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
147
|
+
.replace(/^-+|-+$/g, "");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Common-merge: stamp the collection block + series + minting_tool onto each item ────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build one item's CHIP-0007 metadata by merging the COLLECTION-level common fields (the collection
|
|
154
|
+
* ref + shared attributes) with the item's own traits, and filling 1-based series numbering +
|
|
155
|
+
* `minting_tool = "DIG"`. Mirror of digstore/chip35 `generate_item_metadata` per-item logic.
|
|
156
|
+
*
|
|
157
|
+
* @param {{id:string,name:string,attributes?:Array}} collection The collection definition.
|
|
158
|
+
* @param {{name:string,description?:string,sensitive_content?:boolean,attributes?:Array}} item
|
|
159
|
+
* @param {number} index 0-based item index (series_number = index + 1).
|
|
160
|
+
* @param {number} total Total item count (series_total).
|
|
161
|
+
* @returns {object} canonical-order metadata object.
|
|
162
|
+
*/
|
|
163
|
+
export function mergeItem(collection, item, index, total) {
|
|
164
|
+
return buildChip0007Metadata({
|
|
165
|
+
name: item?.name,
|
|
166
|
+
description: item?.description,
|
|
167
|
+
sensitive_content: item?.sensitive_content,
|
|
168
|
+
collection: { id: collection?.id, name: collection?.name, attributes: collection?.attributes },
|
|
169
|
+
attributes: item?.attributes,
|
|
170
|
+
series_number: index + 1,
|
|
171
|
+
series_total: total,
|
|
172
|
+
minting_tool: MINTING_TOOL,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generate the per-item CHIP-0007 metadata for a whole collection (one document per item, in order).
|
|
178
|
+
* @param {{id:string,name:string,attributes?:Array}} collection
|
|
179
|
+
* @param {Array<object>} items
|
|
180
|
+
* @returns {object[]}
|
|
181
|
+
*/
|
|
182
|
+
export function generateItemMetadata(collection, items) {
|
|
183
|
+
const total = items.length;
|
|
184
|
+
return items.map((item, i) => mergeItem(collection, item, i, total));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Traits manifests — CSV + JSON (mirrors the hub's traits-manifest aliases) ──────────────────────
|
|
188
|
+
|
|
189
|
+
/** Column header aliases (case-insensitive) that map to the item's name / media file / description. */
|
|
190
|
+
const NAME_KEYS = new Set(["name", "title"]);
|
|
191
|
+
const FILE_KEYS = new Set(["file", "filename", "image", "media", "asset"]);
|
|
192
|
+
const DESC_KEYS = new Set(["description", "desc"]);
|
|
193
|
+
|
|
194
|
+
/** Classify a manifest column header into its role; anything else is a trait column. */
|
|
195
|
+
function classifyKey(key) {
|
|
196
|
+
const k = String(key ?? "").trim().toLowerCase();
|
|
197
|
+
if (NAME_KEYS.has(k)) return "name";
|
|
198
|
+
if (FILE_KEYS.has(k)) return "file";
|
|
199
|
+
if (DESC_KEYS.has(k)) return "description";
|
|
200
|
+
return "trait";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Split one CSV line, honoring double-quoted cells (RFC-4180-ish; commas inside quotes are kept). */
|
|
204
|
+
function splitCsvLine(line) {
|
|
205
|
+
const out = [];
|
|
206
|
+
let cur = "";
|
|
207
|
+
let inQuotes = false;
|
|
208
|
+
for (let i = 0; i < line.length; i++) {
|
|
209
|
+
const ch = line[i];
|
|
210
|
+
if (inQuotes) {
|
|
211
|
+
if (ch === '"') {
|
|
212
|
+
if (line[i + 1] === '"') {
|
|
213
|
+
cur += '"';
|
|
214
|
+
i++;
|
|
215
|
+
} else inQuotes = false;
|
|
216
|
+
} else cur += ch;
|
|
217
|
+
} else if (ch === '"') inQuotes = true;
|
|
218
|
+
else if (ch === ",") {
|
|
219
|
+
out.push(cur);
|
|
220
|
+
cur = "";
|
|
221
|
+
} else cur += ch;
|
|
222
|
+
}
|
|
223
|
+
out.push(cur);
|
|
224
|
+
return out.map((c) => c.trim());
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Turn a header→cell record into a normalized item ({name, file?, description?, attributes[]}). */
|
|
228
|
+
function recordToItem(rec) {
|
|
229
|
+
let name = "";
|
|
230
|
+
let description;
|
|
231
|
+
let file;
|
|
232
|
+
const attributes = [];
|
|
233
|
+
for (const [col, raw] of Object.entries(rec)) {
|
|
234
|
+
const role = classifyKey(col);
|
|
235
|
+
const val = raw == null ? "" : String(raw).trim();
|
|
236
|
+
if (role === "name") {
|
|
237
|
+
if (!name) name = val;
|
|
238
|
+
} else if (role === "file") {
|
|
239
|
+
if (!file && val) file = val;
|
|
240
|
+
} else if (role === "description") {
|
|
241
|
+
if (!description && val) description = val;
|
|
242
|
+
} else if (val !== "") {
|
|
243
|
+
attributes.push({ trait_type: col, value: val }); // keep the original column name as trait_type
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (!name) throw new Error("Every item needs a name (a `name` or `title` column).");
|
|
247
|
+
return { name, description: description || undefined, file: file || undefined, attributes };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Parse a traits CSV into normalized items. The header row names the columns; `name`/`title`,
|
|
252
|
+
* `file`/`filename`/`image`/`media`/`asset`, and `description`/`desc` are special, every other column
|
|
253
|
+
* becomes a trait whose `trait_type` is the column header. Empty cells are not emitted as traits.
|
|
254
|
+
* @param {string} text
|
|
255
|
+
* @returns {Array<{name:string,file?:string,description?:string,attributes:Array}>}
|
|
256
|
+
*/
|
|
257
|
+
export function parseTraitsCsv(text) {
|
|
258
|
+
const lines = String(text ?? "")
|
|
259
|
+
.split(/\r?\n/)
|
|
260
|
+
.filter((l) => l.trim() !== "");
|
|
261
|
+
if (lines.length < 2) return [];
|
|
262
|
+
const header = splitCsvLine(lines[0]);
|
|
263
|
+
const items = [];
|
|
264
|
+
for (let i = 1; i < lines.length; i++) {
|
|
265
|
+
const cells = splitCsvLine(lines[i]);
|
|
266
|
+
const rec = {};
|
|
267
|
+
header.forEach((h, j) => {
|
|
268
|
+
rec[h] = cells[j] ?? "";
|
|
269
|
+
});
|
|
270
|
+
items.push(recordToItem(rec));
|
|
271
|
+
}
|
|
272
|
+
return items;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Convert one loose JSON object into a normalized item (accepts a nested `attributes` array too). */
|
|
276
|
+
function jsonObjToItem(obj) {
|
|
277
|
+
let name = "";
|
|
278
|
+
let description;
|
|
279
|
+
let file;
|
|
280
|
+
let attributes = [];
|
|
281
|
+
for (const [key, raw] of Object.entries(obj ?? {})) {
|
|
282
|
+
const k = String(key).toLowerCase();
|
|
283
|
+
if (k === "attributes" && Array.isArray(raw)) {
|
|
284
|
+
attributes = normalizeAttributes(raw);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const role = classifyKey(key);
|
|
288
|
+
const val = raw == null ? "" : String(raw).trim();
|
|
289
|
+
if (role === "name") {
|
|
290
|
+
if (!name) name = val;
|
|
291
|
+
} else if (role === "file") {
|
|
292
|
+
if (!file && val) file = val;
|
|
293
|
+
} else if (role === "description") {
|
|
294
|
+
if (!description && val) description = val;
|
|
295
|
+
} else if (val !== "") {
|
|
296
|
+
attributes.push({ trait_type: key, value: val });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!name) throw new Error("Every item needs a name (a `name` or `title` field).");
|
|
300
|
+
return { name, description: description || undefined, file: file || undefined, attributes };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Parse a traits manifest JSON into normalized items. Accepts an array of items, an
|
|
305
|
+
* `{ items: [...] }` envelope, or a single item object. JSON attributes may use `trait_type` or
|
|
306
|
+
* `traitType`; both normalize to `trait_type`.
|
|
307
|
+
* @param {string|object} text
|
|
308
|
+
* @returns {Array<object>}
|
|
309
|
+
*/
|
|
310
|
+
export function parseTraitsJson(text) {
|
|
311
|
+
const data = typeof text === "string" ? JSON.parse(text) : text;
|
|
312
|
+
let arr;
|
|
313
|
+
if (Array.isArray(data)) arr = data;
|
|
314
|
+
else if (Array.isArray(data?.items)) arr = data.items;
|
|
315
|
+
else if (data && typeof data === "object") arr = [data];
|
|
316
|
+
else throw new Error("The traits manifest JSON must be an array, an object, or { items: [...] }.");
|
|
317
|
+
return arr.map(jsonObjToItem);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Build items directly from image filenames (no manifest): one item per image, the name humanized
|
|
322
|
+
* from the filename stem (dashes/underscores → spaces, title-cased), no traits.
|
|
323
|
+
* @param {string[]} fileNames image file names (e.g. ["frog-1.png"])
|
|
324
|
+
* @returns {Array<{name:string,file:string,attributes:Array}>}
|
|
325
|
+
*/
|
|
326
|
+
export function itemsFromImages(fileNames) {
|
|
327
|
+
return (fileNames ?? []).map((file) => {
|
|
328
|
+
const stem = String(file).replace(/\.[^.]+$/, "");
|
|
329
|
+
const name = stem
|
|
330
|
+
.replace(/[_-]+/g, " ")
|
|
331
|
+
.replace(/\s+/g, " ")
|
|
332
|
+
.trim()
|
|
333
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
334
|
+
return { name: name || stem, file, attributes: [] };
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Capsule resource URIs (dig:// URN first, https gateway fallback) ───────────────────────────────
|
|
339
|
+
|
|
340
|
+
/** The HTTPS gateway host for decrypted capsule resources (mirrors the hub's CAPSULE_HTTPS_GATEWAY). */
|
|
341
|
+
export const CAPSULE_HTTPS_GATEWAY = "usercontent.dig.net";
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Build the CHIP-0007 media URIs for a resource inside a capsule: the canonical `dig://` URN first,
|
|
345
|
+
* then the https gateway fallback (the order the hub + digstore use).
|
|
346
|
+
* @param {{storeId:string,root?:string,resource:string}} args
|
|
347
|
+
* @returns {{urn:string,https:string,uris:string[]}}
|
|
348
|
+
*/
|
|
349
|
+
export function capsuleResourceUris({ storeId, root, resource }) {
|
|
350
|
+
const sid = String(storeId ?? "").trim();
|
|
351
|
+
const r = String(root ?? "").trim();
|
|
352
|
+
const res = String(resource ?? "").trim().replace(/^\/+/, "");
|
|
353
|
+
const urn = `dig://urn:dig:chia:${sid}${r ? `:${r}` : ""}/${res}`;
|
|
354
|
+
const https = `https://${sid}.${CAPSULE_HTTPS_GATEWAY}/${res}`;
|
|
355
|
+
return { urn, https, uris: [urn, https] };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── License templates ───────────────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The common license choices the generator offers. Each entry has a human `title`, the on-disk
|
|
362
|
+
* `file` name, and a `text(holder, year)` renderer. The actual legal text of CC0/CC-BY is long; we
|
|
363
|
+
* ship a concise, accurate human-readable summary + the canonical SPDX id + the upstream deed URL
|
|
364
|
+
* (the standard pattern for NFT license docs), and bake the chosen license's URI + hash into the
|
|
365
|
+
* manifest. For the full legal code, the deed URL is authoritative.
|
|
366
|
+
* @type {Record<string,{title:string,spdx:string,deed?:string,text:(opts:{holder?:string,year?:number})=>string}>}
|
|
367
|
+
*/
|
|
368
|
+
export const LICENSES = {
|
|
369
|
+
cc0: {
|
|
370
|
+
title: "CC0 1.0 — public domain dedication",
|
|
371
|
+
spdx: "CC0-1.0",
|
|
372
|
+
deed: "https://creativecommons.org/publicdomain/zero/1.0/",
|
|
373
|
+
text: ({ holder = "the creator", year = new Date().getFullYear() } = {}) =>
|
|
374
|
+
[
|
|
375
|
+
"CC0 1.0 Universal (CC0 1.0) Public Domain Dedication",
|
|
376
|
+
"",
|
|
377
|
+
`The work associated with this NFT is dedicated to the public domain by ${holder} (${year}).`,
|
|
378
|
+
"To the extent possible under law, the creator has waived all copyright and related or",
|
|
379
|
+
"neighboring rights to this work. You can copy, modify, distribute and perform the work, even",
|
|
380
|
+
"for commercial purposes, all without asking permission.",
|
|
381
|
+
"",
|
|
382
|
+
"SPDX-License-Identifier: CC0-1.0",
|
|
383
|
+
"Full text: https://creativecommons.org/publicdomain/zero/1.0/legalcode",
|
|
384
|
+
].join("\n") + "\n",
|
|
385
|
+
},
|
|
386
|
+
"cc-by-4.0": {
|
|
387
|
+
title: "CC BY 4.0 — attribution required",
|
|
388
|
+
spdx: "CC-BY-4.0",
|
|
389
|
+
deed: "https://creativecommons.org/licenses/by/4.0/",
|
|
390
|
+
text: ({ holder = "the creator", year = new Date().getFullYear() } = {}) =>
|
|
391
|
+
[
|
|
392
|
+
"Creative Commons Attribution 4.0 International (CC BY 4.0)",
|
|
393
|
+
"",
|
|
394
|
+
`Copyright (c) ${year} ${holder}.`,
|
|
395
|
+
"",
|
|
396
|
+
"You are free to share and adapt this work for any purpose, even commercially, provided you give",
|
|
397
|
+
"appropriate credit, provide a link to the license, and indicate if changes were made.",
|
|
398
|
+
"",
|
|
399
|
+
"SPDX-License-Identifier: CC-BY-4.0",
|
|
400
|
+
"Full text: https://creativecommons.org/licenses/by/4.0/legalcode",
|
|
401
|
+
].join("\n") + "\n",
|
|
402
|
+
},
|
|
403
|
+
"all-rights-reserved": {
|
|
404
|
+
title: "All rights reserved — no reuse without permission",
|
|
405
|
+
spdx: "LicenseRef-All-Rights-Reserved",
|
|
406
|
+
text: ({ holder = "the creator", year = new Date().getFullYear() } = {}) =>
|
|
407
|
+
[
|
|
408
|
+
"All Rights Reserved",
|
|
409
|
+
"",
|
|
410
|
+
`Copyright (c) ${year} ${holder}. All rights reserved.`,
|
|
411
|
+
"",
|
|
412
|
+
"No part of the work associated with this NFT may be reproduced, distributed, or transmitted in",
|
|
413
|
+
"any form or by any means without the prior written permission of the copyright holder, except",
|
|
414
|
+
"for the holder's own personal display of the NFT they own.",
|
|
415
|
+
].join("\n") + "\n",
|
|
416
|
+
},
|
|
417
|
+
commercial: {
|
|
418
|
+
title: "Limited commercial license — holder may use commercially",
|
|
419
|
+
spdx: "LicenseRef-DIG-Commercial-1.0",
|
|
420
|
+
text: ({ holder = "the creator", year = new Date().getFullYear() } = {}) =>
|
|
421
|
+
[
|
|
422
|
+
"Limited Commercial License (DIG Commercial 1.0)",
|
|
423
|
+
"",
|
|
424
|
+
`Copyright (c) ${year} ${holder}.`,
|
|
425
|
+
"",
|
|
426
|
+
"The verified owner of this NFT is granted a worldwide, non-exclusive license to use, reproduce,",
|
|
427
|
+
"and display the associated artwork for commercial purposes, up to gross revenues of US$100,000",
|
|
428
|
+
"per year attributable to the artwork. This license transfers with ownership of the NFT and",
|
|
429
|
+
"terminates on transfer. All other rights are reserved by the copyright holder.",
|
|
430
|
+
"",
|
|
431
|
+
"SPDX-License-Identifier: LicenseRef-DIG-Commercial-1.0",
|
|
432
|
+
].join("\n") + "\n",
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
/** The on-disk filename for a license id (e.g. "cc0" → "LICENSE-cc0.txt"). */
|
|
437
|
+
export function licenseFileName(id) {
|
|
438
|
+
return `LICENSE-${id}.txt`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Render a license document's text for a chosen id.
|
|
443
|
+
* @param {string} id One of {@link LICENSES} keys.
|
|
444
|
+
* @param {{holder?:string,year?:number}} [opts]
|
|
445
|
+
* @returns {string}
|
|
446
|
+
* @throws if `id` is not a known license.
|
|
447
|
+
*/
|
|
448
|
+
export function licenseText(id, opts = {}) {
|
|
449
|
+
const lic = LICENSES[id];
|
|
450
|
+
if (!lic) throw new Error(`Unknown license "${id}". Available: ${Object.keys(LICENSES).join(", ")}.`);
|
|
451
|
+
return lic.text(opts);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Validation ────────────────────────────────────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
/** Raised when CHIP-0007 metadata (or its URI/hash agreement) fails validation. */
|
|
457
|
+
export class ValidationError extends Error {
|
|
458
|
+
constructor(message) {
|
|
459
|
+
super(message);
|
|
460
|
+
this.name = "ValidationError";
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Validate a CHIP-0007 off-chain metadata document against the schema, and (optionally) verify
|
|
466
|
+
* URI↔hash agreement for the on-chain media hashes the way `digstore`'s `validate_uri_hash` does:
|
|
467
|
+
* the on-chain `*_hash` MUST equal `sha256(bytes)` of what the URI actually serves.
|
|
468
|
+
*
|
|
469
|
+
* @param {object} md The off-chain CHIP-0007 metadata object.
|
|
470
|
+
* @param {Object} [checks] Optional hash-agreement checks.
|
|
471
|
+
* @param {string} [checks.metadata_hash] Expected hash; must equal sha256(canonicalJson(md)).
|
|
472
|
+
* @param {{data_hash?:string,data_bytes?:Uint8Array}} [checks.media] data_hash vs sha256(data_bytes).
|
|
473
|
+
* @param {{license_hash?:string,license_bytes?:Uint8Array}} [checks.license]
|
|
474
|
+
* @throws {ValidationError} on any schema or hash-agreement failure.
|
|
475
|
+
*/
|
|
476
|
+
export function validateMetadata(md, checks = {}) {
|
|
477
|
+
if (!md || typeof md !== "object") throw new ValidationError("metadata must be an object");
|
|
478
|
+
if (md.format !== CHIP0007_FORMAT) {
|
|
479
|
+
throw new ValidationError(`format must be "${CHIP0007_FORMAT}" (got ${JSON.stringify(md.format)})`);
|
|
480
|
+
}
|
|
481
|
+
if (typeof md.name !== "string" || md.name === "") throw new ValidationError("name is required");
|
|
482
|
+
if ("attributes" in md) {
|
|
483
|
+
if (!Array.isArray(md.attributes)) throw new ValidationError("attributes must be an array");
|
|
484
|
+
for (const a of md.attributes) {
|
|
485
|
+
if (typeof a?.trait_type !== "string" || typeof a?.value !== "string") {
|
|
486
|
+
throw new ValidationError("each attribute must be { trait_type: string, value: string }");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if ("collection" in md) {
|
|
491
|
+
const c = md.collection;
|
|
492
|
+
if (typeof c?.id !== "string" || typeof c?.name !== "string") {
|
|
493
|
+
throw new ValidationError("collection must have a string id and name");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (checks.metadata_hash != null) {
|
|
498
|
+
const got = metadataHashHex(md);
|
|
499
|
+
if (got !== String(checks.metadata_hash).toLowerCase()) {
|
|
500
|
+
throw new ValidationError(`metadata_hash mismatch: computed ${got}, expected ${checks.metadata_hash}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (checks.media?.data_hash != null && checks.media?.data_bytes != null) {
|
|
504
|
+
const got = sha256Hex(checks.media.data_bytes);
|
|
505
|
+
if (got !== String(checks.media.data_hash).toLowerCase()) {
|
|
506
|
+
throw new ValidationError(`data_hash mismatch: image bytes hash to ${got}, expected ${checks.media.data_hash}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (checks.license?.license_hash != null && checks.license?.license_bytes != null) {
|
|
510
|
+
const got = sha256Hex(checks.license.license_bytes);
|
|
511
|
+
if (got !== String(checks.license.license_hash).toLowerCase()) {
|
|
512
|
+
throw new ValidationError(`license_hash mismatch: license bytes hash to ${got}, expected ${checks.license.license_hash}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Placeholder substitution for template files.
|
|
2
|
+
//
|
|
3
|
+
// Template files use `__TOKEN__` placeholders (uppercase + underscores, wrapped in double
|
|
4
|
+
// underscores) so they are: (a) obvious in source, (b) never valid identifiers/JSON that would run
|
|
5
|
+
// or parse by accident, and (c) trivially greppable in tests ("no leftover __PLACEHOLDER__"). We
|
|
6
|
+
// substitute every known token; an UNKNOWN `__TOKEN__` left in a template is a template bug, so the
|
|
7
|
+
// test suite asserts none remain after scaffolding.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The substitution map for one scaffold. Keys are the token names WITHOUT the surrounding
|
|
11
|
+
* underscores (e.g. "APP_NAME" → replaces "__APP_NAME__").
|
|
12
|
+
* @typedef {Record<string, string>} Substitutions
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Replace every `__TOKEN__` in `text` whose TOKEN is present in `subs`. Tokens absent from `subs`
|
|
17
|
+
* are left untouched (so a typo surfaces as a leftover placeholder in tests rather than silently
|
|
18
|
+
* blanking).
|
|
19
|
+
* @param {string} text
|
|
20
|
+
* @param {Substitutions} subs
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function applySubstitutions(text, subs) {
|
|
24
|
+
return text.replace(/__([A-Z][A-Z0-9_]*)__/g, (whole, token) =>
|
|
25
|
+
Object.prototype.hasOwnProperty.call(subs, token) ? subs[token] : whole,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the standard substitution map for a scaffold.
|
|
31
|
+
* @param {Object} args
|
|
32
|
+
* @param {string} args.appName The normalized (npm-safe) app/package name.
|
|
33
|
+
* @param {string} args.displayName The original user-supplied name (for prose/titles).
|
|
34
|
+
* @param {string} args.sdkVersion The @dignetwork/dig-sdk version range to pin.
|
|
35
|
+
* @param {string} args.outputDir The template's built-output dir.
|
|
36
|
+
* @param {string} args.buildCommand The template's build command.
|
|
37
|
+
* @returns {Substitutions}
|
|
38
|
+
*/
|
|
39
|
+
export function buildSubstitutions({ appName, displayName, sdkVersion, outputDir, buildCommand }) {
|
|
40
|
+
return {
|
|
41
|
+
APP_NAME: appName,
|
|
42
|
+
DISPLAY_NAME: displayName,
|
|
43
|
+
SDK_VERSION: sdkVersion,
|
|
44
|
+
OUTPUT_DIR: outputDir,
|
|
45
|
+
BUILD_COMMAND: buildCommand,
|
|
46
|
+
};
|
|
47
|
+
}
|