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,479 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dig-nft — the dependency-free CHIP-0007 metadata / license / validate tool for this collection.
|
|
3
|
+
//
|
|
4
|
+
// Run via the package.json scripts (no install needed — pure Node stdlib):
|
|
5
|
+
// npm run generate:metadata # images/ [+ traits.csv|traits.json] + collection.json → metadata/*.json + items.json
|
|
6
|
+
// npm run generate:license # collection.json `license` → licenses/LICENSE-<id>.txt (+ wires its hash)
|
|
7
|
+
// npm run validate # re-verify schema + URI/hash agreement against the real bytes
|
|
8
|
+
// node scripts/dig-nft.mjs <metadata|license|validate>
|
|
9
|
+
//
|
|
10
|
+
// WHY hand-rolled JSON: the off-chain CHIP-0007 JSON is a fixed, byte-pinned shape that is a MUTUAL
|
|
11
|
+
// BYTE-MIRROR between chip35_dl_coin (core/src/metadata.rs) and digstore (digstore-chain/src/
|
|
12
|
+
// metadata.rs), both pinned by byte-string tests. We reproduce it EXACTLY so the on-chain
|
|
13
|
+
// `metadata_hash` matches and the collection mints cleanly via `digstore collection mint` and the
|
|
14
|
+
// hub NFT studio. This file is vendored verbatim from create-dig-app's lib/nft-metadata.js +
|
|
15
|
+
// lib/nft-cli.js — do not diverge the canonical shape (field order / skip rules / hashing) from the
|
|
16
|
+
// ecosystem, or every verifying client will reject the NFT.
|
|
17
|
+
//
|
|
18
|
+
// field order: format, name, description, sensitive_content, collection, attributes,
|
|
19
|
+
// series_number, series_total, minting_tool
|
|
20
|
+
// omit: description/collection/series_*/minting_tool when null; sensitive_content when
|
|
21
|
+
// false; attributes (and collection.attributes) when empty
|
|
22
|
+
// render: compact JSON (no spaces, no key sort) ≡ serde_json::to_string
|
|
23
|
+
// hash: metadata_hash = sha256(canonical_json_bytes); data/license_hash = sha256(bytes)
|
|
24
|
+
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import {
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
readdirSync,
|
|
31
|
+
statSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
|
|
37
|
+
// ── Canonical CHIP-0007 core (mirror of lib/nft-metadata.js) ──────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const CHIP0007_FORMAT = "CHIP-0007";
|
|
40
|
+
const MINTING_TOOL = "DIG";
|
|
41
|
+
const CAPSULE_HTTPS_GATEWAY = "usercontent.dig.net";
|
|
42
|
+
const PLACEHOLDER_STORE_ID = "STORE_ID_AFTER_PUBLISH";
|
|
43
|
+
|
|
44
|
+
function sha256Hex(bytes) {
|
|
45
|
+
const buf = bytes instanceof Uint8Array ? Buffer.from(bytes) : Buffer.from(bytes ?? []);
|
|
46
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function attrValue(v) {
|
|
50
|
+
return v == null ? "" : String(v);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeAttributes(attrs) {
|
|
54
|
+
return (attrs ?? [])
|
|
55
|
+
.map((a) => ({ trait_type: String(a?.trait_type ?? a?.traitType ?? "").trim(), value: attrValue(a?.value) }))
|
|
56
|
+
.filter((a) => a.trait_type !== "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildChip0007Metadata(input) {
|
|
60
|
+
const md = { format: CHIP0007_FORMAT, name: String(input?.name ?? "") };
|
|
61
|
+
const description = input?.description;
|
|
62
|
+
if (description != null && String(description) !== "") md.description = String(description);
|
|
63
|
+
if (input?.sensitive_content === true) md.sensitive_content = true;
|
|
64
|
+
if (input?.collection) {
|
|
65
|
+
const c = input.collection;
|
|
66
|
+
const ref = { id: String(c.id ?? ""), name: String(c.name ?? "") };
|
|
67
|
+
const cattrs = normalizeAttributes(c.attributes);
|
|
68
|
+
if (cattrs.length > 0) ref.attributes = cattrs;
|
|
69
|
+
md.collection = ref;
|
|
70
|
+
}
|
|
71
|
+
const attributes = normalizeAttributes(input?.attributes);
|
|
72
|
+
if (attributes.length > 0) md.attributes = attributes;
|
|
73
|
+
if (input?.series_number != null) md.series_number = Number(input.series_number);
|
|
74
|
+
if (input?.series_total != null) md.series_total = Number(input.series_total);
|
|
75
|
+
if (input?.minting_tool != null && String(input.minting_tool) !== "") md.minting_tool = String(input.minting_tool);
|
|
76
|
+
return md;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const canonicalJson = (md) => JSON.stringify(md);
|
|
80
|
+
const metadataHashHex = (md) => sha256Hex(Buffer.from(canonicalJson(md), "utf8"));
|
|
81
|
+
|
|
82
|
+
function collectionId(name) {
|
|
83
|
+
return String(name ?? "")
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
86
|
+
.replace(/^-+|-+$/g, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mergeItem(collection, item, index, total) {
|
|
90
|
+
return buildChip0007Metadata({
|
|
91
|
+
name: item?.name,
|
|
92
|
+
description: item?.description,
|
|
93
|
+
sensitive_content: item?.sensitive_content,
|
|
94
|
+
collection: { id: collection?.id, name: collection?.name, attributes: collection?.attributes },
|
|
95
|
+
attributes: item?.attributes,
|
|
96
|
+
series_number: index + 1,
|
|
97
|
+
series_total: total,
|
|
98
|
+
minting_tool: MINTING_TOOL,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const generateItemMetadata = (collection, items) =>
|
|
103
|
+
items.map((item, i) => mergeItem(collection, item, i, items.length));
|
|
104
|
+
|
|
105
|
+
const NAME_KEYS = new Set(["name", "title"]);
|
|
106
|
+
const FILE_KEYS = new Set(["file", "filename", "image", "media", "asset"]);
|
|
107
|
+
const DESC_KEYS = new Set(["description", "desc"]);
|
|
108
|
+
|
|
109
|
+
function classifyKey(key) {
|
|
110
|
+
const k = String(key ?? "").trim().toLowerCase();
|
|
111
|
+
if (NAME_KEYS.has(k)) return "name";
|
|
112
|
+
if (FILE_KEYS.has(k)) return "file";
|
|
113
|
+
if (DESC_KEYS.has(k)) return "description";
|
|
114
|
+
return "trait";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function splitCsvLine(line) {
|
|
118
|
+
const out = [];
|
|
119
|
+
let cur = "";
|
|
120
|
+
let inQuotes = false;
|
|
121
|
+
for (let i = 0; i < line.length; i++) {
|
|
122
|
+
const ch = line[i];
|
|
123
|
+
if (inQuotes) {
|
|
124
|
+
if (ch === '"') {
|
|
125
|
+
if (line[i + 1] === '"') { cur += '"'; i++; }
|
|
126
|
+
else inQuotes = false;
|
|
127
|
+
} else cur += ch;
|
|
128
|
+
} else if (ch === '"') inQuotes = true;
|
|
129
|
+
else if (ch === ",") { out.push(cur); cur = ""; }
|
|
130
|
+
else cur += ch;
|
|
131
|
+
}
|
|
132
|
+
out.push(cur);
|
|
133
|
+
return out.map((c) => c.trim());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function recordToItem(rec) {
|
|
137
|
+
let name = "", description, file;
|
|
138
|
+
const attributes = [];
|
|
139
|
+
for (const [col, raw] of Object.entries(rec)) {
|
|
140
|
+
const role = classifyKey(col);
|
|
141
|
+
const val = raw == null ? "" : String(raw).trim();
|
|
142
|
+
if (role === "name") { if (!name) name = val; }
|
|
143
|
+
else if (role === "file") { if (!file && val) file = val; }
|
|
144
|
+
else if (role === "description") { if (!description && val) description = val; }
|
|
145
|
+
else if (val !== "") attributes.push({ trait_type: col, value: val });
|
|
146
|
+
}
|
|
147
|
+
if (!name) throw new Error("Every item needs a name (a `name` or `title` column).");
|
|
148
|
+
return { name, description: description || undefined, file: file || undefined, attributes };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseTraitsCsv(text) {
|
|
152
|
+
const lines = String(text ?? "").split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
153
|
+
if (lines.length < 2) return [];
|
|
154
|
+
const header = splitCsvLine(lines[0]);
|
|
155
|
+
const items = [];
|
|
156
|
+
for (let i = 1; i < lines.length; i++) {
|
|
157
|
+
const cells = splitCsvLine(lines[i]);
|
|
158
|
+
const rec = {};
|
|
159
|
+
header.forEach((h, j) => { rec[h] = cells[j] ?? ""; });
|
|
160
|
+
items.push(recordToItem(rec));
|
|
161
|
+
}
|
|
162
|
+
return items;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function jsonObjToItem(obj) {
|
|
166
|
+
let name = "", description, file;
|
|
167
|
+
let attributes = [];
|
|
168
|
+
for (const [key, raw] of Object.entries(obj ?? {})) {
|
|
169
|
+
const k = String(key).toLowerCase();
|
|
170
|
+
if (k === "attributes" && Array.isArray(raw)) { attributes = normalizeAttributes(raw); continue; }
|
|
171
|
+
const role = classifyKey(key);
|
|
172
|
+
const val = raw == null ? "" : String(raw).trim();
|
|
173
|
+
if (role === "name") { if (!name) name = val; }
|
|
174
|
+
else if (role === "file") { if (!file && val) file = val; }
|
|
175
|
+
else if (role === "description") { if (!description && val) description = val; }
|
|
176
|
+
else if (val !== "") attributes.push({ trait_type: key, value: val });
|
|
177
|
+
}
|
|
178
|
+
if (!name) throw new Error("Every item needs a name (a `name` or `title` field).");
|
|
179
|
+
return { name, description: description || undefined, file: file || undefined, attributes };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseTraitsJson(text) {
|
|
183
|
+
const data = typeof text === "string" ? JSON.parse(text) : text;
|
|
184
|
+
let arr;
|
|
185
|
+
if (Array.isArray(data)) arr = data;
|
|
186
|
+
else if (Array.isArray(data?.items)) arr = data.items;
|
|
187
|
+
else if (data && typeof data === "object") arr = [data];
|
|
188
|
+
else throw new Error("The traits manifest JSON must be an array, an object, or { items: [...] }.");
|
|
189
|
+
return arr.map(jsonObjToItem);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function itemsFromImages(fileNames) {
|
|
193
|
+
return (fileNames ?? []).map((file) => {
|
|
194
|
+
const stem = String(file).replace(/\.[^.]+$/, "");
|
|
195
|
+
const name = stem
|
|
196
|
+
.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim()
|
|
197
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
198
|
+
return { name: name || stem, file, attributes: [] };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function capsuleResourceUris({ storeId, root, resource }) {
|
|
203
|
+
const sid = String(storeId ?? "").trim();
|
|
204
|
+
const r = String(root ?? "").trim();
|
|
205
|
+
const res = String(resource ?? "").trim().replace(/^\/+/, "");
|
|
206
|
+
const urn = `dig://urn:dig:chia:${sid}${r ? `:${r}` : ""}/${res}`;
|
|
207
|
+
const https = `https://${sid}.${CAPSULE_HTTPS_GATEWAY}/${res}`;
|
|
208
|
+
return { urn, https, uris: [urn, https] };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const LICENSES = {
|
|
212
|
+
cc0: {
|
|
213
|
+
title: "CC0 1.0 — public domain dedication",
|
|
214
|
+
text: ({ holder = "the creator", year = new Date().getFullYear() } = {}) =>
|
|
215
|
+
[
|
|
216
|
+
"CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", "",
|
|
217
|
+
`The work associated with this NFT is dedicated to the public domain by ${holder} (${year}).`,
|
|
218
|
+
"To the extent possible under law, the creator has waived all copyright and related or",
|
|
219
|
+
"neighboring rights to this work. You can copy, modify, distribute and perform the work, even",
|
|
220
|
+
"for commercial purposes, all without asking permission.", "",
|
|
221
|
+
"SPDX-License-Identifier: CC0-1.0",
|
|
222
|
+
"Full text: https://creativecommons.org/publicdomain/zero/1.0/legalcode",
|
|
223
|
+
].join("\n") + "\n",
|
|
224
|
+
},
|
|
225
|
+
"cc-by-4.0": {
|
|
226
|
+
title: "CC BY 4.0 — attribution required",
|
|
227
|
+
text: ({ holder = "the creator", year = new Date().getFullYear() } = {}) =>
|
|
228
|
+
[
|
|
229
|
+
"Creative Commons Attribution 4.0 International (CC BY 4.0)", "",
|
|
230
|
+
`Copyright (c) ${year} ${holder}.`, "",
|
|
231
|
+
"You are free to share and adapt this work for any purpose, even commercially, provided you give",
|
|
232
|
+
"appropriate credit, provide a link to the license, and indicate if changes were made.", "",
|
|
233
|
+
"SPDX-License-Identifier: CC-BY-4.0",
|
|
234
|
+
"Full text: https://creativecommons.org/licenses/by/4.0/legalcode",
|
|
235
|
+
].join("\n") + "\n",
|
|
236
|
+
},
|
|
237
|
+
"all-rights-reserved": {
|
|
238
|
+
title: "All rights reserved — no reuse without permission",
|
|
239
|
+
text: ({ holder = "the creator", year = new Date().getFullYear() } = {}) =>
|
|
240
|
+
[
|
|
241
|
+
"All Rights Reserved", "",
|
|
242
|
+
`Copyright (c) ${year} ${holder}. All rights reserved.`, "",
|
|
243
|
+
"No part of the work associated with this NFT may be reproduced, distributed, or transmitted in",
|
|
244
|
+
"any form or by any means without the prior written permission of the copyright holder, except",
|
|
245
|
+
"for the holder's own personal display of the NFT they own.",
|
|
246
|
+
].join("\n") + "\n",
|
|
247
|
+
},
|
|
248
|
+
commercial: {
|
|
249
|
+
title: "Limited commercial license — holder may use commercially",
|
|
250
|
+
text: ({ holder = "the creator", year = new Date().getFullYear() } = {}) =>
|
|
251
|
+
[
|
|
252
|
+
"Limited Commercial License (DIG Commercial 1.0)", "",
|
|
253
|
+
`Copyright (c) ${year} ${holder}.`, "",
|
|
254
|
+
"The verified owner of this NFT is granted a worldwide, non-exclusive license to use, reproduce,",
|
|
255
|
+
"and display the associated artwork for commercial purposes, up to gross revenues of US$100,000",
|
|
256
|
+
"per year attributable to the artwork. This license transfers with ownership of the NFT and",
|
|
257
|
+
"terminates on transfer. All other rights are reserved by the copyright holder.", "",
|
|
258
|
+
"SPDX-License-Identifier: LicenseRef-DIG-Commercial-1.0",
|
|
259
|
+
].join("\n") + "\n",
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const licenseFileName = (id) => `LICENSE-${id}.txt`;
|
|
264
|
+
function licenseText(id, opts = {}) {
|
|
265
|
+
const lic = LICENSES[id];
|
|
266
|
+
if (!lic) throw new Error(`Unknown license "${id}". Available: ${Object.keys(LICENSES).join(", ")}.`);
|
|
267
|
+
return lic.text(opts);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
class ValidationError extends Error {
|
|
271
|
+
constructor(message) { super(message); this.name = "ValidationError"; }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function validateMetadata(md, checks = {}) {
|
|
275
|
+
if (!md || typeof md !== "object") throw new ValidationError("metadata must be an object");
|
|
276
|
+
if (md.format !== CHIP0007_FORMAT) throw new ValidationError(`format must be "${CHIP0007_FORMAT}" (got ${JSON.stringify(md.format)})`);
|
|
277
|
+
if (typeof md.name !== "string" || md.name === "") throw new ValidationError("name is required");
|
|
278
|
+
if ("attributes" in md) {
|
|
279
|
+
if (!Array.isArray(md.attributes)) throw new ValidationError("attributes must be an array");
|
|
280
|
+
for (const a of md.attributes) {
|
|
281
|
+
if (typeof a?.trait_type !== "string" || typeof a?.value !== "string") {
|
|
282
|
+
throw new ValidationError("each attribute must be { trait_type: string, value: string }");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if ("collection" in md) {
|
|
287
|
+
const c = md.collection;
|
|
288
|
+
if (typeof c?.id !== "string" || typeof c?.name !== "string") throw new ValidationError("collection must have a string id and name");
|
|
289
|
+
}
|
|
290
|
+
if (checks.metadata_hash != null) {
|
|
291
|
+
const got = metadataHashHex(md);
|
|
292
|
+
if (got !== String(checks.metadata_hash).toLowerCase()) throw new ValidationError(`metadata_hash mismatch: computed ${got}, expected ${checks.metadata_hash}`);
|
|
293
|
+
}
|
|
294
|
+
if (checks.media?.data_hash != null && checks.media?.data_bytes != null) {
|
|
295
|
+
const got = sha256Hex(checks.media.data_bytes);
|
|
296
|
+
if (got !== String(checks.media.data_hash).toLowerCase()) throw new ValidationError(`data_hash mismatch: image bytes hash to ${got}, expected ${checks.media.data_hash}`);
|
|
297
|
+
}
|
|
298
|
+
if (checks.license?.license_hash != null && checks.license?.license_bytes != null) {
|
|
299
|
+
const got = sha256Hex(checks.license.license_bytes);
|
|
300
|
+
if (got !== String(checks.license.license_hash).toLowerCase()) throw new ValidationError(`license_hash mismatch: license bytes hash to ${got}, expected ${checks.license.license_hash}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Project-directory orchestration (mirror of lib/nft-cli.js) ────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif"]);
|
|
307
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
308
|
+
|
|
309
|
+
function loadCollection(root) {
|
|
310
|
+
const path = join(root, "collection.json");
|
|
311
|
+
if (!existsSync(path)) throw new Error(`collection.json not found at ${path}`);
|
|
312
|
+
const col = JSON.parse(readFileSync(path, "utf8"));
|
|
313
|
+
if (!col?.name) throw new Error("collection.json must have a `name`.");
|
|
314
|
+
return { ...col, id: col.id || collectionId(col.name) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function listImages(root) {
|
|
318
|
+
const dir = join(root, "images");
|
|
319
|
+
if (!existsSync(dir)) throw new Error(`images/ directory not found at ${dir}`);
|
|
320
|
+
const files = readdirSync(dir)
|
|
321
|
+
.filter((f) => statSync(join(dir, f)).isFile() && IMAGE_EXT.has(extname(f).toLowerCase()))
|
|
322
|
+
.sort();
|
|
323
|
+
if (files.length === 0) throw new Error("no images found in images/ — add your item art (png/jpg/gif/webp/svg) first.");
|
|
324
|
+
return files;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function resolveItems(root, imageFiles) {
|
|
328
|
+
const csv = join(root, "traits.csv");
|
|
329
|
+
const jsonTraits = join(root, "traits.json");
|
|
330
|
+
let items;
|
|
331
|
+
if (existsSync(csv)) items = parseTraitsCsv(readFileSync(csv, "utf8"));
|
|
332
|
+
else if (existsSync(jsonTraits)) items = parseTraitsJson(readFileSync(jsonTraits, "utf8"));
|
|
333
|
+
else return itemsFromImages(imageFiles);
|
|
334
|
+
return items.map((item, i) => ({ ...item, file: item.file || imageFiles[i] }));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function readGeneratedLicense(root, collection) {
|
|
338
|
+
const id = collection?.license;
|
|
339
|
+
if (!id || !LICENSES[id]) return null;
|
|
340
|
+
const file = licenseFileName(id);
|
|
341
|
+
const path = join(root, "licenses", file);
|
|
342
|
+
if (!existsSync(path)) return null;
|
|
343
|
+
const bytes = readFileSync(path);
|
|
344
|
+
const { uris } = capsuleResourceUris({ storeId: PLACEHOLDER_STORE_ID, resource: `licenses/${file}` });
|
|
345
|
+
return { id, file, hash: sha256Hex(bytes), uris };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function generateMetadata(root) {
|
|
349
|
+
const collection = loadCollection(root);
|
|
350
|
+
const imageFiles = listImages(root);
|
|
351
|
+
const items = resolveItems(root, imageFiles);
|
|
352
|
+
const metadataDir = join(root, "metadata");
|
|
353
|
+
mkdirSync(metadataDir, { recursive: true });
|
|
354
|
+
const license = readGeneratedLicense(root, collection);
|
|
355
|
+
const mds = generateItemMetadata(collection, items);
|
|
356
|
+
const manifest = [];
|
|
357
|
+
for (let i = 0; i < items.length; i++) {
|
|
358
|
+
const item = items[i];
|
|
359
|
+
const md = mds[i];
|
|
360
|
+
const file = item.file || imageFiles[i];
|
|
361
|
+
const imgPath = join(root, "images", file);
|
|
362
|
+
if (!existsSync(imgPath)) throw new Error(`item "${item.name}" references missing image: images/${file}`);
|
|
363
|
+
const imgBytes = readFileSync(imgPath);
|
|
364
|
+
const dataHash = sha256Hex(imgBytes);
|
|
365
|
+
const stem = basename(file, extname(file));
|
|
366
|
+
const metaName = `${stem}.json`;
|
|
367
|
+
writeFileSync(join(metadataDir, metaName), canonicalJson(md) + "\n");
|
|
368
|
+
const metadataHash = metadataHashHex(md);
|
|
369
|
+
const data = capsuleResourceUris({ storeId: PLACEHOLDER_STORE_ID, resource: `images/${file}` });
|
|
370
|
+
const meta = capsuleResourceUris({ storeId: PLACEHOLDER_STORE_ID, resource: `metadata/${metaName}` });
|
|
371
|
+
manifest.push({
|
|
372
|
+
name: md.name,
|
|
373
|
+
description: item.description || undefined,
|
|
374
|
+
attributes: md.attributes || [],
|
|
375
|
+
media: {
|
|
376
|
+
data_uris: data.uris, data_hash: dataHash,
|
|
377
|
+
metadata_uris: meta.uris, metadata_hash: metadataHash,
|
|
378
|
+
license_uris: license ? license.uris : [], license_hash: license ? license.hash : null,
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const manifestPath = join(root, "items.json");
|
|
383
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
384
|
+
return { count: items.length, manifestPath, metadataDir };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function generateLicense(root) {
|
|
388
|
+
const collection = loadCollection(root);
|
|
389
|
+
const id = collection?.license;
|
|
390
|
+
if (!id) throw new Error('collection.json has no `license` field — set one (e.g. "cc0").');
|
|
391
|
+
if (!LICENSES[id]) throw new Error(`Unknown license "${id}". Available: ${Object.keys(LICENSES).join(", ")}.`);
|
|
392
|
+
const holder = collection.creator || collection.name || "the creator";
|
|
393
|
+
const text = licenseText(id, { holder, year: new Date().getFullYear() });
|
|
394
|
+
const licensesDir = join(root, "licenses");
|
|
395
|
+
mkdirSync(licensesDir, { recursive: true });
|
|
396
|
+
const file = licenseFileName(id);
|
|
397
|
+
const path = join(licensesDir, file);
|
|
398
|
+
writeFileSync(path, text);
|
|
399
|
+
return { id, file, hash: sha256Hex(Buffer.from(text, "utf8")), path };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function resourceTail(uri) {
|
|
403
|
+
if (!uri || typeof uri !== "string") return null;
|
|
404
|
+
const i = uri.indexOf("/", uri.indexOf("://") + 3);
|
|
405
|
+
return i >= 0 ? uri.slice(i + 1) : null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function validateProject(root) {
|
|
409
|
+
const manifestPath = join(root, "items.json");
|
|
410
|
+
if (!existsSync(manifestPath)) throw new Error("items.json not found — run the metadata generator first.");
|
|
411
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
412
|
+
if (!Array.isArray(manifest) || manifest.length === 0) throw new Error("items.json is empty — nothing to validate.");
|
|
413
|
+
const metadataDir = join(root, "metadata");
|
|
414
|
+
const metaFiles = existsSync(metadataDir) ? readdirSync(metadataDir).filter((f) => f.endsWith(".json")).sort() : [];
|
|
415
|
+
for (const f of metaFiles) {
|
|
416
|
+
const raw = readFileSync(join(metadataDir, f), "utf8");
|
|
417
|
+
const md = JSON.parse(raw);
|
|
418
|
+
validateMetadata(md);
|
|
419
|
+
if (raw.trim() !== canonicalJson(md)) throw new Error(`metadata/${f} is not in canonical form (re-run the metadata generator).`);
|
|
420
|
+
}
|
|
421
|
+
let checked = 0;
|
|
422
|
+
for (const item of manifest) {
|
|
423
|
+
const media = item?.media ?? {};
|
|
424
|
+
const md = buildChip0007Metadata({ name: item.name, attributes: item.attributes });
|
|
425
|
+
if (media.metadata_hash != null) {
|
|
426
|
+
const matches = metaFiles.some(
|
|
427
|
+
(f) => metadataHashHex(JSON.parse(readFileSync(join(metadataDir, f), "utf8"))) === media.metadata_hash,
|
|
428
|
+
);
|
|
429
|
+
if (!matches && metadataHashHex(md) !== media.metadata_hash) {
|
|
430
|
+
throw new Error(`item "${item.name}": metadata_hash does not match any metadata/*.json`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const imgFile = resourceTail(media.data_uris?.[0]);
|
|
434
|
+
if (imgFile && media.data_hash != null) {
|
|
435
|
+
const imgPath = join(root, imgFile);
|
|
436
|
+
if (!existsSync(imgPath)) throw new Error(`item "${item.name}": image not found at ${imgFile}`);
|
|
437
|
+
validateMetadata(md, { media: { data_hash: media.data_hash, data_bytes: readFileSync(imgPath) } });
|
|
438
|
+
}
|
|
439
|
+
const licFile = resourceTail(media.license_uris?.[0]);
|
|
440
|
+
if (licFile && media.license_hash != null) {
|
|
441
|
+
const licPath = join(root, licFile);
|
|
442
|
+
if (!existsSync(licPath)) throw new Error(`item "${item.name}": license not found at ${licFile}`);
|
|
443
|
+
validateMetadata(md, { license: { license_hash: media.license_hash, license_bytes: readFileSync(licPath) } });
|
|
444
|
+
}
|
|
445
|
+
checked++;
|
|
446
|
+
}
|
|
447
|
+
return { ok: true, checked };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── CLI dispatch ──────────────────────────────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
function main() {
|
|
453
|
+
const cmd = process.argv[2];
|
|
454
|
+
try {
|
|
455
|
+
if (cmd === "metadata") {
|
|
456
|
+
const r = generateMetadata(ROOT);
|
|
457
|
+
console.log(`Generated ${r.count} CHIP-0007 metadata file(s) → metadata/ and the items.json manifest.`);
|
|
458
|
+
console.log("Next: `npm run validate`, then mint with `digstore collection mint --collection collection.json --manifest items.json`.");
|
|
459
|
+
} else if (cmd === "license") {
|
|
460
|
+
const r = generateLicense(ROOT);
|
|
461
|
+
console.log(`Wrote licenses/${r.file} (sha256 ${r.hash}).`);
|
|
462
|
+
console.log("Re-run `npm run generate:metadata` to wire the license URI + hash into items.json.");
|
|
463
|
+
} else if (cmd === "validate") {
|
|
464
|
+
const r = validateProject(ROOT);
|
|
465
|
+
console.log(`OK — ${r.checked} item(s) valid: CHIP-0007 schema + data/metadata/license hashes agree with the real bytes.`);
|
|
466
|
+
} else {
|
|
467
|
+
console.error("Usage: node scripts/dig-nft.mjs <metadata|license|validate>");
|
|
468
|
+
console.error(" metadata generate CHIP-0007 metadata/*.json + items.json from images/ + collection.json");
|
|
469
|
+
console.error(" license write the license chosen in collection.json into licenses/");
|
|
470
|
+
console.error(" validate re-verify schema + URI/hash agreement before minting");
|
|
471
|
+
process.exit(2);
|
|
472
|
+
}
|
|
473
|
+
} catch (e) {
|
|
474
|
+
console.error(`Error: ${e?.message ?? e}`);
|
|
475
|
+
process.exit(1);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
main();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Optional in-app mint wiring via @dignetwork/dig-sdk.
|
|
2
|
+
//
|
|
3
|
+
// The PRIMARY way to mint this collection is the `digstore` CLI — it reads collection.json + the
|
|
4
|
+
// generated items.json and builds + signs + pushes the spends for you:
|
|
5
|
+
//
|
|
6
|
+
// digstore collection mint --collection collection.json --manifest items.json --did <your-did>
|
|
7
|
+
//
|
|
8
|
+
// This file is here for the other path: minting from your OWN web app/page with a connected wallet.
|
|
9
|
+
// It connects a wallet with `ChiaProvider` and shows the shape of a mint — build the coin spends
|
|
10
|
+
// with `@dignetwork/dig-sdk/spend` (the canonical CHIP-0035 builder), then `signCoinSpends`. Spends
|
|
11
|
+
// are NEVER hand-rolled. Nothing here mints or spends until you wire your collection and call it.
|
|
12
|
+
|
|
13
|
+
import { ChiaProvider } from "@dignetwork/dig-sdk";
|
|
14
|
+
|
|
15
|
+
// WalletConnect Cloud / Reown project id (see .env.example). When set, enables the WalletConnect →
|
|
16
|
+
// Sage fallback so you can connect Sage in a normal browser. Read from the process env here (this is
|
|
17
|
+
// a plain Node/ESM module, not a Vite app); get a free id at https://cloud.reown.com.
|
|
18
|
+
const WALLETCONNECT_PROJECT_ID =
|
|
19
|
+
(typeof process !== "undefined" && process.env && process.env.WALLETCONNECT_PROJECT_ID) || "";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Connect a wallet to mint. `mode: "auto"` prefers the injected DIG Browser wallet (window.chia) and
|
|
23
|
+
* falls back to WalletConnect → Sage when a project id is configured (the SDK drives its
|
|
24
|
+
* WalletConnectTransport — we never hand-roll WC).
|
|
25
|
+
*
|
|
26
|
+
* @param {{ onUri?: (uri: string) => void }} [hooks] `onUri` receives the WalletConnect pairing URI
|
|
27
|
+
* so a UI can show a QR / deep link to Sage during the fallback.
|
|
28
|
+
* @returns {Promise<import("@dignetwork/dig-sdk").ChiaProvider>}
|
|
29
|
+
*/
|
|
30
|
+
export async function connectWallet(hooks = {}) {
|
|
31
|
+
return ChiaProvider.connect({
|
|
32
|
+
mode: "auto", // prefer injected window.chia; fall back to WalletConnect → Sage if configured
|
|
33
|
+
chain: "chia:mainnet",
|
|
34
|
+
walletConnect: WALLETCONNECT_PROJECT_ID
|
|
35
|
+
? {
|
|
36
|
+
projectId: WALLETCONNECT_PROJECT_ID,
|
|
37
|
+
metadata: {
|
|
38
|
+
name: "__DISPLAY_NAME__",
|
|
39
|
+
description: "An NFT collection built with create-dig-app",
|
|
40
|
+
url: "https://example.on.dig.net",
|
|
41
|
+
icons: [],
|
|
42
|
+
},
|
|
43
|
+
onUri: hooks.onUri,
|
|
44
|
+
}
|
|
45
|
+
: undefined,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build + sign a collection mint with the canonical CHIP-0035 builder. Filling in the concrete
|
|
51
|
+
* builder calls from your items.json is left to you — see the spend builder + the tutorial:
|
|
52
|
+
* https://github.com/DIG-Network/dig-sdk
|
|
53
|
+
* https://docs.dig.net/docs/audiences/nft-developers
|
|
54
|
+
*
|
|
55
|
+
* Most collections mint with the `digstore collection mint` CLI instead (see the file header) — that
|
|
56
|
+
* path needs no in-app wiring at all.
|
|
57
|
+
*
|
|
58
|
+
* @param {import("@dignetwork/dig-sdk").ChiaProvider} provider A connected wallet.
|
|
59
|
+
*/
|
|
60
|
+
export async function mint(provider) {
|
|
61
|
+
const spend = await import("@dignetwork/dig-sdk/spend");
|
|
62
|
+
spend.init();
|
|
63
|
+
// TODO: build your mint coin spends from items.json with the wasm builder, then sign them.
|
|
64
|
+
const coinSpends = [];
|
|
65
|
+
if (coinSpends.length === 0) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"No mint spend wired yet. Mint via `digstore collection mint`, or build coin spends with " +
|
|
68
|
+
"@dignetwork/dig-sdk/spend and sign them. See https://docs.dig.net/docs/audiences/nft-developers",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return provider.signCoinSpends(coinSpends);
|
|
72
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
--fg: #0f172a;
|
|
4
|
+
--muted: #64748b;
|
|
5
|
+
--bg: #f8fafc;
|
|
6
|
+
--error: #dc2626;
|
|
7
|
+
|
|
8
|
+
/* DIG brand accent — violet -> magenta (SYSTEM.md visual theme; matches dig.net/hub/status). */
|
|
9
|
+
--dig-violet: #5800D6;
|
|
10
|
+
--dig-magenta: #FF00DE;
|
|
11
|
+
--accent: var(--dig-violet);
|
|
12
|
+
--grad-brand: linear-gradient(115deg, #5800D6 0%, #FF00DE 100%);
|
|
13
|
+
|
|
14
|
+
/* DIG brand fonts — named first, system stack as the offline/no-fetch fallback so a published
|
|
15
|
+
capsule never depends on an external font host (keeps the collection page self-contained). */
|
|
16
|
+
--dig-font: "Space Grotesk", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
|
17
|
+
--dig-mono: "Space Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
18
|
+
}
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:root { --fg: #e2e8f0; --muted: #94a3b8; --bg: #0b1120; }
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; }
|
|
23
|
+
body {
|
|
24
|
+
margin: 0;
|
|
25
|
+
min-height: 100vh;
|
|
26
|
+
font: 16px/1.6 var(--dig-font);
|
|
27
|
+
color: var(--fg);
|
|
28
|
+
background: var(--bg);
|
|
29
|
+
}
|
|
30
|
+
.banner { width: 100%; max-height: 320px; object-fit: cover; display: block; }
|
|
31
|
+
.wrap { max-width: 64rem; margin: 0 auto; padding: 2rem; }
|
|
32
|
+
h1 {
|
|
33
|
+
font-size: 2.25rem;
|
|
34
|
+
margin: 0 0 0.5rem;
|
|
35
|
+
font-weight: 700;
|
|
36
|
+
background: var(--grad-brand);
|
|
37
|
+
-webkit-background-clip: text;
|
|
38
|
+
background-clip: text;
|
|
39
|
+
color: transparent;
|
|
40
|
+
}
|
|
41
|
+
.muted { color: var(--muted); }
|
|
42
|
+
.error { color: var(--error); }
|
|
43
|
+
button {
|
|
44
|
+
font: inherit;
|
|
45
|
+
padding: 0.6rem 1.1rem;
|
|
46
|
+
border: 0;
|
|
47
|
+
border-radius: 0.5rem;
|
|
48
|
+
background: var(--grad-brand);
|
|
49
|
+
color: white;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
}
|
|
52
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-top: 1.5rem; }
|
|
53
|
+
.item { border-radius: 0.75rem; overflow: hidden; background: rgba(127, 127, 127, 0.12); }
|
|
54
|
+
.item img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
|
|
55
|
+
.item .label { padding: 0.5rem 0.75rem; font-weight: 600; }
|
|
56
|
+
code { font-family: var(--dig-mono); word-break: break-all; }
|
|
57
|
+
pre {
|
|
58
|
+
font-family: var(--dig-mono);
|
|
59
|
+
margin-top: 1.5rem;
|
|
60
|
+
background: rgba(127, 127, 127, 0.12);
|
|
61
|
+
padding: 1rem;
|
|
62
|
+
border-radius: 0.5rem;
|
|
63
|
+
overflow-x: auto;
|
|
64
|
+
}
|