nappup 2.3.4 → 2.3.5
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/README.md +17 -0
- package/package.json +1 -1
- package/src/errors.js +78 -0
- package/src/helpers/app.js +1 -1
- package/src/index.js +168 -80
package/README.md
CHANGED
|
@@ -138,3 +138,20 @@ await publishApp(fileList, signer, {
|
|
|
138
138
|
- **`fileList`** — a `FileList` or array of `File` objects (each needs `webkitRelativePath`).
|
|
139
139
|
- **`signer`** — a [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)-compatible signer. In the browser, `window.nostr` is used automatically if omitted.
|
|
140
140
|
- **`onEvent`** — optional callback that receives progress events with a `type` (`'init'`, `'file-uploaded'`, `'complete'`, `'error'`, …) and `progress` (0–100).
|
|
141
|
+
|
|
142
|
+
Rejected uploads use `NappupError`, with a stable code from
|
|
143
|
+
`NAPPUP_ERROR_CODES`. The original error is retained as `cause`, and some
|
|
144
|
+
errors include structured `details`, so applications can show their own
|
|
145
|
+
recovery instructions without matching CLI-oriented message text:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
import publishApp, { NAPPUP_ERROR_CODES } from 'nappup'
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await publishApp(fileList, signer)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error.code === NAPPUP_ERROR_CODES.GENERIC_FOLDER_NAME) {
|
|
154
|
+
// Ask the user to choose a unique app folder name.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
package/package.json
CHANGED
package/src/errors.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const ERROR_CODE = /^[A-Z][A-Z0-9_]*$/
|
|
2
|
+
|
|
3
|
+
export const NAPPUP_ERROR_CODES = Object.freeze({
|
|
4
|
+
UPLOAD_CANCELLED: 'NAPPUP_UPLOAD_CANCELLED',
|
|
5
|
+
NO_SIGNER: 'NAPPUP_NO_SIGNER',
|
|
6
|
+
EMPTY_FILE_LIST: 'NAPPUP_EMPTY_FILE_LIST',
|
|
7
|
+
RELAY_LOOKUP_FAILED: 'NAPPUP_RELAY_LOOKUP_FAILED',
|
|
8
|
+
NO_OUTBOX_RELAYS: 'NAPPUP_NO_OUTBOX_RELAYS',
|
|
9
|
+
INVALID_D_TAG: 'NAPPUP_INVALID_D_TAG',
|
|
10
|
+
GENERIC_FOLDER_NAME: 'NAPPUP_GENERIC_FOLDER_NAME',
|
|
11
|
+
INVALID_FOLDER_NAME: 'NAPPUP_INVALID_FOLDER_NAME',
|
|
12
|
+
BLOSSOM_UPLOAD_FAILED: 'NAPPUP_BLOSSOM_UPLOAD_FAILED',
|
|
13
|
+
IRFS_UPLOAD_FAILED: 'NAPPUP_IRFS_UPLOAD_FAILED',
|
|
14
|
+
MANIFEST_UPLOAD_FAILED: 'NAPPUP_MANIFEST_UPLOAD_FAILED',
|
|
15
|
+
UPLOAD_FAILED: 'NAPPUP_UPLOAD_FAILED'
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Carries a stable machine-readable code while retaining technical context.
|
|
19
|
+
export class NappupError extends Error {
|
|
20
|
+
constructor (code, messageOrOptions = code, causeOrOptions) {
|
|
21
|
+
if (typeof code !== 'string' || !ERROR_CODE.test(code)) {
|
|
22
|
+
throw new TypeError('Nappup error code should be uppercase snake case')
|
|
23
|
+
}
|
|
24
|
+
const objectOptions = messageOrOptions && typeof messageOrOptions === 'object'
|
|
25
|
+
? messageOrOptions
|
|
26
|
+
: null
|
|
27
|
+
const trailingOptions = !objectOptions && causeOrOptions && typeof causeOrOptions === 'object' &&
|
|
28
|
+
(Object.hasOwn(causeOrOptions, 'cause') || Object.hasOwn(causeOrOptions, 'details'))
|
|
29
|
+
? causeOrOptions
|
|
30
|
+
: null
|
|
31
|
+
const options = objectOptions || trailingOptions
|
|
32
|
+
const message = objectOptions
|
|
33
|
+
? (objectOptions.message ?? code)
|
|
34
|
+
: (messageOrOptions ?? code)
|
|
35
|
+
const cause = objectOptions
|
|
36
|
+
? objectOptions.cause
|
|
37
|
+
: trailingOptions
|
|
38
|
+
? trailingOptions.cause
|
|
39
|
+
: causeOrOptions
|
|
40
|
+
super(message, cause === undefined ? undefined : { cause })
|
|
41
|
+
Object.defineProperty(this, 'name', {
|
|
42
|
+
configurable: true,
|
|
43
|
+
value: 'NappupError',
|
|
44
|
+
writable: true
|
|
45
|
+
})
|
|
46
|
+
Object.defineProperty(this, 'code', {
|
|
47
|
+
configurable: false,
|
|
48
|
+
enumerable: true,
|
|
49
|
+
value: code,
|
|
50
|
+
writable: false
|
|
51
|
+
})
|
|
52
|
+
if (options?.details !== undefined) {
|
|
53
|
+
Object.defineProperty(this, 'details', {
|
|
54
|
+
configurable: false,
|
|
55
|
+
enumerable: true,
|
|
56
|
+
value: options.details,
|
|
57
|
+
writable: false
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Ensures every error crossing nappup's public API has a documented code.
|
|
64
|
+
export function normalizeNappupError (error) {
|
|
65
|
+
if (typeof error?.code === 'string' && error.code.startsWith('NAPPUP_')) return error
|
|
66
|
+
if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
|
|
67
|
+
return new NappupError(
|
|
68
|
+
NAPPUP_ERROR_CODES.UPLOAD_CANCELLED,
|
|
69
|
+
error?.message || 'Upload cancelled',
|
|
70
|
+
{ cause: error }
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return new NappupError(
|
|
74
|
+
NAPPUP_ERROR_CODES.UPLOAD_FAILED,
|
|
75
|
+
error?.message || 'Upload failed',
|
|
76
|
+
{ cause: error }
|
|
77
|
+
)
|
|
78
|
+
}
|
package/src/helpers/app.js
CHANGED
|
@@ -5,5 +5,5 @@ export const GENERIC_BUILD_FOLDER_NAMES = new Set([
|
|
|
5
5
|
])
|
|
6
6
|
|
|
7
7
|
export function isNostrAppDTagSafe (string) {
|
|
8
|
-
return typeof string === 'string' && string.length <= NOSTR_APP_D_TAG_MAX_LENGTH
|
|
8
|
+
return typeof string === 'string' && string.length > 0 && string.length <= NOSTR_APP_D_TAG_MAX_LENGTH
|
|
9
9
|
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,9 @@ import { extractHtmlMetadata, findAppIcon, findIndexFile } from '#helpers/app-me
|
|
|
8
8
|
import { getBlossomServers, healthCheckServers, uploadFilesToBlossom } from '#services/blossom-upload.js'
|
|
9
9
|
import { uploadBinaryDataChunks } from '#services/irfs-upload.js'
|
|
10
10
|
import { uploadSiteManifest } from '#services/site-manifest.js'
|
|
11
|
+
import { NappupError, NAPPUP_ERROR_CODES, normalizeNappupError } from '#errors.js'
|
|
12
|
+
|
|
13
|
+
export { NappupError, NAPPUP_ERROR_CODES } from '#errors.js'
|
|
11
14
|
|
|
12
15
|
// TL;DR
|
|
13
16
|
// import publishApp from 'nappup'
|
|
@@ -26,21 +29,8 @@ import { uploadSiteManifest } from '#services/site-manifest.js'
|
|
|
26
29
|
// }
|
|
27
30
|
//
|
|
28
31
|
export default async function (fileList, nostrSigner, opts = {}) {
|
|
29
|
-
const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : null
|
|
30
|
-
let lastProgress = 0
|
|
31
32
|
try {
|
|
32
|
-
return await toApp(fileList, nostrSigner,
|
|
33
|
-
? {
|
|
34
|
-
...opts,
|
|
35
|
-
onEvent (event) {
|
|
36
|
-
lastProgress = event.progress ?? lastProgress
|
|
37
|
-
onEvent(event)
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
: opts)
|
|
41
|
-
} catch (err) {
|
|
42
|
-
if (onEvent) try { onEvent({ type: 'error', error: err, progress: lastProgress }) } catch (_) {}
|
|
43
|
-
throw err
|
|
33
|
+
return await toApp(fileList, nostrSigner, opts)
|
|
44
34
|
} finally {
|
|
45
35
|
await nostrRelays.disconnectAll()
|
|
46
36
|
}
|
|
@@ -59,8 +49,34 @@ export default async function (fileList, nostrSigner, opts = {}) {
|
|
|
59
49
|
* 'manifest-published' — unified site manifest and app metadata published
|
|
60
50
|
* 'complete' — { napp } (terminal, progress === 100)
|
|
61
51
|
* 'error' — { error } (terminal, error is rethrown)
|
|
52
|
+
*
|
|
53
|
+
* Every rejected error has a stable `NAPPUP_*` code and may retain its cause.
|
|
62
54
|
*/
|
|
63
|
-
export async function toApp (fileList, nostrSigner, {
|
|
55
|
+
export async function toApp (fileList, nostrSigner, opts = {}) {
|
|
56
|
+
const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : null
|
|
57
|
+
let lastProgress = 0
|
|
58
|
+
const publishOptions = onEvent
|
|
59
|
+
? {
|
|
60
|
+
...opts,
|
|
61
|
+
onEvent (event) {
|
|
62
|
+
lastProgress = event.progress ?? lastProgress
|
|
63
|
+
onEvent(event)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
: opts
|
|
67
|
+
try {
|
|
68
|
+
return await publishApp(fileList, nostrSigner, publishOptions)
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const normalized = normalizeNappupError(error)
|
|
71
|
+
if (onEvent) {
|
|
72
|
+
try { onEvent({ type: 'error', error: normalized, progress: lastProgress }) } catch (_) {}
|
|
73
|
+
}
|
|
74
|
+
throw normalized
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Implements publishing after the public boundary has installed error normalization.
|
|
79
|
+
async function publishApp (fileList, nostrSigner, {
|
|
64
80
|
log = () => {}, onEvent = () => {}, dTag, channel = 'main', shouldReupload = false
|
|
65
81
|
} = {}) {
|
|
66
82
|
let steps = 0
|
|
@@ -74,27 +90,66 @@ export async function toApp (fileList, nostrSigner, {
|
|
|
74
90
|
} catch (_) {}
|
|
75
91
|
}
|
|
76
92
|
if (!nostrSigner && typeof window !== 'undefined') nostrSigner = window.nostr
|
|
77
|
-
if (!nostrSigner)
|
|
93
|
+
if (!nostrSigner) {
|
|
94
|
+
throw new NappupError(NAPPUP_ERROR_CODES.NO_SIGNER, 'No Nostr signer found')
|
|
95
|
+
}
|
|
78
96
|
if (typeof window !== 'undefined' && nostrSigner === window.nostr) nostrSigner.getRelays = getRelays
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
98
|
+
fileList = Array.from(fileList || [])
|
|
99
|
+
if (!fileList.length) {
|
|
100
|
+
throw new NappupError(NAPPUP_ERROR_CODES.EMPTY_FILE_LIST, 'No app files were provided')
|
|
101
|
+
}
|
|
84
102
|
|
|
85
|
-
if (
|
|
86
|
-
if (!isNostrAppDTagSafe(dTag))
|
|
103
|
+
if (dTag !== undefined && dTag !== null) {
|
|
104
|
+
if (!isNostrAppDTagSafe(dTag)) {
|
|
105
|
+
throw new NappupError(
|
|
106
|
+
NAPPUP_ERROR_CODES.INVALID_D_TAG,
|
|
107
|
+
'dTag must be a non-empty string with at most 260 characters',
|
|
108
|
+
{ details: { dTag } }
|
|
109
|
+
)
|
|
110
|
+
}
|
|
87
111
|
} else {
|
|
88
|
-
const
|
|
112
|
+
const relativePath = typeof fileList[0]?.webkitRelativePath === 'string'
|
|
113
|
+
? fileList[0].webkitRelativePath
|
|
114
|
+
: ''
|
|
115
|
+
const folderName = relativePath.split('/')[0].trim()
|
|
89
116
|
if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
|
|
90
|
-
throw new
|
|
117
|
+
throw new NappupError(
|
|
118
|
+
NAPPUP_ERROR_CODES.GENERIC_FOLDER_NAME,
|
|
119
|
+
`Folder name "${folderName}" is a generic build folder. Please provide a d tag with the -d flag.`,
|
|
120
|
+
{ details: { folderName } }
|
|
121
|
+
)
|
|
91
122
|
}
|
|
92
123
|
dTag = folderName
|
|
93
124
|
if (!isNostrAppDTagSafe(dTag)) {
|
|
94
|
-
throw new
|
|
125
|
+
throw new NappupError(
|
|
126
|
+
NAPPUP_ERROR_CODES.INVALID_FOLDER_NAME,
|
|
127
|
+
'Could not derive a valid d tag from the folder name. Please provide one with the -d flag.',
|
|
128
|
+
{ details: { folderName } }
|
|
129
|
+
)
|
|
95
130
|
}
|
|
96
131
|
}
|
|
97
132
|
|
|
133
|
+
let signerRelays
|
|
134
|
+
try {
|
|
135
|
+
signerRelays = await nostrSigner.getRelays()
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new NappupError(
|
|
138
|
+
NAPPUP_ERROR_CODES.RELAY_LOOKUP_FAILED,
|
|
139
|
+
'Could not read the signer outbox relays',
|
|
140
|
+
{ cause: error }
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
const writeRelays = [...new Set((Array.isArray(signerRelays?.write) ? signerRelays.write : [])
|
|
144
|
+
.flatMap(relay => typeof relay === 'string' && relay.trim()
|
|
145
|
+
? [relay.trim().replace(/\/$/, '')]
|
|
146
|
+
: []
|
|
147
|
+
))]
|
|
148
|
+
log(`Found ${writeRelays.length} outbox relays:\n${writeRelays.join(', ')}`)
|
|
149
|
+
if (!writeRelays.length) {
|
|
150
|
+
throw new NappupError(NAPPUP_ERROR_CODES.NO_OUTBOX_RELAYS, 'No outbox relays found')
|
|
151
|
+
}
|
|
152
|
+
|
|
98
153
|
const fileMetadata = []
|
|
99
154
|
const nappJsonFile = fileList.find(file => file.webkitRelativePath.split('/').slice(1).join('/') === '.well-known/napp.json')
|
|
100
155
|
let nappJson = {}
|
|
@@ -128,7 +183,12 @@ export async function toApp (fileList, nostrSigner, {
|
|
|
128
183
|
let pause = 1000
|
|
129
184
|
|
|
130
185
|
log('Checking for blossom servers...')
|
|
131
|
-
|
|
186
|
+
let blossomServerUrls = []
|
|
187
|
+
try {
|
|
188
|
+
blossomServerUrls = await getBlossomServers(nostrSigner, writeRelays)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
log('Could not read Blossom server preferences; using relay-based upload instead', error)
|
|
191
|
+
}
|
|
132
192
|
let healthyBlossomServers = []
|
|
133
193
|
if (blossomServerUrls.length) {
|
|
134
194
|
log(`Found ${blossomServerUrls.length} blossom servers: ${blossomServerUrls.join(', ')}`)
|
|
@@ -238,7 +298,18 @@ export async function toApp (fileList, nostrSigner, {
|
|
|
238
298
|
shouldReupload,
|
|
239
299
|
log
|
|
240
300
|
})
|
|
241
|
-
if (failedFiles.length)
|
|
301
|
+
if (failedFiles.length) {
|
|
302
|
+
throw new NappupError(
|
|
303
|
+
NAPPUP_ERROR_CODES.BLOSSOM_UPLOAD_FAILED,
|
|
304
|
+
`${failedFiles.length} file(s) failed to upload to Blossom`,
|
|
305
|
+
{
|
|
306
|
+
details: {
|
|
307
|
+
failedFileCount: failedFiles.length,
|
|
308
|
+
filenames: failedFiles.map(failed => failed.filename).filter(Boolean)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
}
|
|
242
313
|
|
|
243
314
|
for (const uploaded of uploadedFiles) {
|
|
244
315
|
const metadata = {
|
|
@@ -254,65 +325,82 @@ export async function toApp (fileList, nostrSigner, {
|
|
|
254
325
|
}
|
|
255
326
|
} else {
|
|
256
327
|
for (const file of fileList) {
|
|
257
|
-
const nmmr = new NMMR()
|
|
258
|
-
let chunkLength = 0
|
|
259
|
-
for await (const chunk of streamToChunks(file.stream(), 51000)) {
|
|
260
|
-
chunkLength++
|
|
261
|
-
await nmmr.append(chunk)
|
|
262
|
-
}
|
|
263
|
-
// Empty IRFS blobs deliberately have no chunks and no manifest reference.
|
|
264
|
-
if (!chunkLength) {
|
|
265
|
-
steps++
|
|
266
|
-
emit({ type: 'file-uploaded', filename: file.webkitRelativePath.split('/').slice(1).join('/'), service: 'irfs' })
|
|
267
|
-
continue
|
|
268
|
-
}
|
|
269
|
-
|
|
270
328
|
const filename = file.webkitRelativePath.split('/').slice(1).join('/')
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
329
|
+
try {
|
|
330
|
+
const nmmr = new NMMR()
|
|
331
|
+
let chunkLength = 0
|
|
332
|
+
for await (const chunk of streamToChunks(file.stream(), 51000)) {
|
|
333
|
+
chunkLength++
|
|
334
|
+
await nmmr.append(chunk)
|
|
335
|
+
}
|
|
336
|
+
// Empty IRFS blobs deliberately have no chunks and no manifest reference.
|
|
337
|
+
if (!chunkLength) {
|
|
338
|
+
steps++
|
|
339
|
+
emit({ type: 'file-uploaded', filename, service: 'irfs' })
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
log(`Uploading ${chunkLength} file parts of ${filename}`)
|
|
344
|
+
;({ pause } = await uploadBinaryDataChunks({
|
|
345
|
+
nmmr, signer: nostrSigner, filename, chunkLength, log, pause, shouldReupload
|
|
346
|
+
}))
|
|
347
|
+
const metadata = {
|
|
348
|
+
rootHash: nmmr.getRoot(),
|
|
349
|
+
filename,
|
|
350
|
+
mimeType: file.type || 'application/octet-stream',
|
|
351
|
+
size: file.size
|
|
352
|
+
}
|
|
353
|
+
fileMetadata.push(metadata)
|
|
354
|
+
if (!iconMetadata && faviconFile && file === faviconFile) iconMetadata = { ...metadata }
|
|
355
|
+
steps++
|
|
356
|
+
emit({ type: 'file-uploaded', filename, service: 'irfs' })
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw new NappupError(
|
|
359
|
+
NAPPUP_ERROR_CODES.IRFS_UPLOAD_FAILED,
|
|
360
|
+
`Failed to upload "${filename}" to Nostr relays`,
|
|
361
|
+
{ cause: error, details: { filename } }
|
|
362
|
+
)
|
|
280
363
|
}
|
|
281
|
-
fileMetadata.push(metadata)
|
|
282
|
-
if (!iconMetadata && faviconFile && file === faviconFile) iconMetadata = { ...metadata }
|
|
283
|
-
steps++
|
|
284
|
-
emit({ type: 'file-uploaded', filename, service: 'irfs' })
|
|
285
364
|
}
|
|
286
365
|
}
|
|
287
366
|
|
|
288
367
|
log(`Uploading unified site manifest ${dTag}`)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
368
|
+
let manifest
|
|
369
|
+
try {
|
|
370
|
+
manifest = await uploadSiteManifest({
|
|
371
|
+
dTag,
|
|
372
|
+
channel,
|
|
373
|
+
fileMetadata,
|
|
374
|
+
name: manifestName,
|
|
375
|
+
nameLang: nappJson.name?.[0]?.[1],
|
|
376
|
+
isNameAuto: !nappJson.name?.[0]?.[0],
|
|
377
|
+
summary: manifestSummary,
|
|
378
|
+
summaryLang: nappJson.summary?.[0]?.[1],
|
|
379
|
+
isSummaryAuto: !nappJson.summary?.[0]?.[0],
|
|
380
|
+
icon: iconMetadata,
|
|
381
|
+
isIconAuto: !isExplicitIcon,
|
|
382
|
+
descriptions: nappJson.description,
|
|
383
|
+
keyArt: keyArtMetadata,
|
|
384
|
+
screenshots: screenshotMetadata,
|
|
385
|
+
uploadService,
|
|
386
|
+
sourceRelays: writeRelays,
|
|
387
|
+
blossomServers: healthyBlossomServers,
|
|
388
|
+
signer: nostrSigner,
|
|
389
|
+
log,
|
|
390
|
+
pause,
|
|
391
|
+
shouldReupload,
|
|
392
|
+
self: nappJson.self?.[0]?.[0],
|
|
393
|
+
countries: nappJson.country,
|
|
394
|
+
categories: nappJson.category,
|
|
395
|
+
hashtags: nappJson.hashtag
|
|
396
|
+
})
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throw new NappupError(
|
|
399
|
+
NAPPUP_ERROR_CODES.MANIFEST_UPLOAD_FAILED,
|
|
400
|
+
'Failed to publish the app manifest to Nostr relays',
|
|
401
|
+
{ cause: error }
|
|
402
|
+
)
|
|
403
|
+
}
|
|
316
404
|
|
|
317
405
|
const appEntity = appEncode({
|
|
318
406
|
dTag: manifest.tags.find(tag => tag[0] === 'd')[1],
|