dsh-featherhunter-plugin-base 0.0.1 → 0.0.2-drill
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/data/img/card-wechat-2026-11-06.png +0 -0
- package/data/img/qr-wechat-2026-11-06.png +0 -0
- package/data/manifest.json +6 -6
- package/dist/client/content.d.ts +116 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +507 -16
- package/dist/client/modal.d.ts +14 -0
- package/dist/client/types.d.ts +5 -0
- package/dist/core/index.js +6 -6
- package/dist/node/cli.js +737 -7
- package/dist/node/index.d.ts +3 -26
- package/dist/node/index.js +382 -21
- package/dist/node/readme-sync.d.ts +143 -0
- package/dist/node/renderers.d.ts +48 -0
- package/dist/node/types.d.ts +20 -1
- package/package.json +4 -1
package/dist/client/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
var BASELINE = {
|
|
3
3
|
"manifest": {
|
|
4
4
|
"schema": 1,
|
|
5
|
-
"version": "0.0.
|
|
6
|
-
"revision":
|
|
7
|
-
"updatedAt": "2026-10-
|
|
5
|
+
"version": "0.0.2-drill",
|
|
6
|
+
"revision": 2,
|
|
7
|
+
"updatedAt": "2026-10-31T00:00:00+08:00",
|
|
8
8
|
"fallbackUrl": "https://github.com/FeatherHunter/featherhunter-plugin-manage",
|
|
9
9
|
"groups": [
|
|
10
10
|
{
|
|
@@ -79,10 +79,10 @@ var BASELINE = {
|
|
|
79
79
|
"zh": "DSH \u63D2\u4EF6\u4EA4\u6D41\u7FA4\u5FAE\u4FE1\u7FA4\u4E8C\u7EF4\u7801",
|
|
80
80
|
"en": "DSH plugin community group WeChat QR code"
|
|
81
81
|
},
|
|
82
|
-
"card": "card-wechat-2026-
|
|
82
|
+
"card": "card-wechat-2026-11-06.png",
|
|
83
83
|
"qr": {
|
|
84
84
|
"kind": "remote",
|
|
85
|
-
"path": "qr-wechat-2026-
|
|
85
|
+
"path": "qr-wechat-2026-11-06.png"
|
|
86
86
|
},
|
|
87
87
|
"title": {
|
|
88
88
|
"zh": "DSH \u63D2\u4EF6\u4EA4\u6D41\u7FA4",
|
|
@@ -92,7 +92,7 @@ var BASELINE = {
|
|
|
92
92
|
"zh": "\u7FA4\u804A\uFF1ADSH \u63D2\u4EF6\u4EA4\u6D41\u7FA4",
|
|
93
93
|
"en": "Group chat: DSH Plugin Community"
|
|
94
94
|
},
|
|
95
|
-
"validUntil": "2026-
|
|
95
|
+
"validUntil": "2026-11-06"
|
|
96
96
|
}
|
|
97
97
|
],
|
|
98
98
|
"plugins": [
|
|
@@ -227,6 +227,411 @@ function formatValidity(validUntil, options) {
|
|
|
227
227
|
}
|
|
228
228
|
return `\u8BE5\u4E8C\u7EF4\u7801 ${days} \u5929\u5185\uFF08${month} \u6708 ${day} \u65E5\u524D\uFF09\u6709\u6548\uFF0C\u91CD\u65B0\u8FDB\u5165\u5C06\u66F4\u65B0`;
|
|
229
229
|
}
|
|
230
|
+
function isRecord(value) {
|
|
231
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
232
|
+
}
|
|
233
|
+
function requireString(record, key, where) {
|
|
234
|
+
const value = record[key];
|
|
235
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
236
|
+
throw new Error(`manifest: ${where}.${key} must be a non-empty string`);
|
|
237
|
+
}
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
function requireI18n(record, key, where) {
|
|
241
|
+
const value = record[key];
|
|
242
|
+
if (!isRecord(value)) throw new Error(`manifest: ${where}.${key} must be an object`);
|
|
243
|
+
const zh = value.zh;
|
|
244
|
+
const en = value.en;
|
|
245
|
+
if (typeof zh !== "string" || zh.length === 0) {
|
|
246
|
+
throw new Error(`manifest: ${where}.${key}.zh must be a non-empty string`);
|
|
247
|
+
}
|
|
248
|
+
if (typeof en !== "string" || en.length === 0) {
|
|
249
|
+
throw new Error(`manifest: ${where}.${key}.en must be a non-empty string`);
|
|
250
|
+
}
|
|
251
|
+
return { zh, en };
|
|
252
|
+
}
|
|
253
|
+
function requireOrder(record, where) {
|
|
254
|
+
const value = record["order"];
|
|
255
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
256
|
+
throw new Error(`manifest: ${where}.order must be a number`);
|
|
257
|
+
}
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
function validateGroup(value, index) {
|
|
261
|
+
const where = `groups[${index}]`;
|
|
262
|
+
if (!isRecord(value)) throw new Error(`manifest: ${where} must be an object`);
|
|
263
|
+
const id = requireString(value, "id", where);
|
|
264
|
+
const platform = requireString(value, "platform", where);
|
|
265
|
+
const order = requireOrder(value, where);
|
|
266
|
+
const readmeTitle = requireI18n(value, "readmeTitle", where);
|
|
267
|
+
const alt = requireI18n(value, "alt", where);
|
|
268
|
+
const card = requireString(value, "card", where);
|
|
269
|
+
const title = requireI18n(value, "title", where);
|
|
270
|
+
const qrRaw = value["qr"];
|
|
271
|
+
if (!isRecord(qrRaw)) throw new Error(`manifest: ${where}.qr must be an object`);
|
|
272
|
+
const kind = qrRaw["kind"];
|
|
273
|
+
let qr;
|
|
274
|
+
if (kind === "bundled") {
|
|
275
|
+
qr = { kind: "bundled", key: requireString(qrRaw, "key", `${where}.qr`) };
|
|
276
|
+
} else if (kind === "remote") {
|
|
277
|
+
qr = { kind: "remote", path: requireString(qrRaw, "path", `${where}.qr`) };
|
|
278
|
+
} else {
|
|
279
|
+
throw new Error(`manifest: ${where}.qr.kind must be "bundled" or "remote"`);
|
|
280
|
+
}
|
|
281
|
+
const group = { id, platform, order, readmeTitle, alt, card, qr, title };
|
|
282
|
+
const subtitle = value["subtitle"];
|
|
283
|
+
if (subtitle !== void 0) {
|
|
284
|
+
if (!isRecord(subtitle)) throw new Error(`manifest: ${where}.subtitle must be an object`);
|
|
285
|
+
group.subtitle = requireI18n(value, "subtitle", where);
|
|
286
|
+
}
|
|
287
|
+
const note = value["note"];
|
|
288
|
+
if (note !== void 0) {
|
|
289
|
+
if (!isRecord(note)) throw new Error(`manifest: ${where}.note must be an object`);
|
|
290
|
+
group.note = requireI18n(value, "note", where);
|
|
291
|
+
}
|
|
292
|
+
const validUntil = value["validUntil"];
|
|
293
|
+
if (validUntil !== void 0) {
|
|
294
|
+
if (typeof validUntil !== "string" || parseDateOnly(validUntil) === null) {
|
|
295
|
+
throw new Error(`manifest: ${where}.validUntil must be a valid YYYY-MM-DD date`);
|
|
296
|
+
}
|
|
297
|
+
group.validUntil = validUntil;
|
|
298
|
+
}
|
|
299
|
+
return group;
|
|
300
|
+
}
|
|
301
|
+
function validatePlugin(value, index) {
|
|
302
|
+
const where = `plugins[${index}]`;
|
|
303
|
+
if (!isRecord(value)) throw new Error(`manifest: ${where} must be an object`);
|
|
304
|
+
const id = requireString(value, "id", where);
|
|
305
|
+
const order = requireOrder(value, where);
|
|
306
|
+
const verifiedAt = requireString(value, "verifiedAt", where);
|
|
307
|
+
if (parseDateOnly(verifiedAt) === null) {
|
|
308
|
+
throw new Error(`manifest: ${where}.verifiedAt must be a valid YYYY-MM-DD date`);
|
|
309
|
+
}
|
|
310
|
+
const name = requireString(value, "name", where);
|
|
311
|
+
const repo = requireString(value, "repo", where);
|
|
312
|
+
const desc = requireI18n(value, "desc", where);
|
|
313
|
+
const entry = { id, order, verifiedAt, name, repo, desc };
|
|
314
|
+
const url = value["url"];
|
|
315
|
+
if (url !== void 0) entry.url = requireString(value, "url", where);
|
|
316
|
+
return entry;
|
|
317
|
+
}
|
|
318
|
+
function parseManifest(raw) {
|
|
319
|
+
if (!isRecord(raw)) throw new Error("manifest: root must be an object");
|
|
320
|
+
if (raw["schema"] !== 1) throw new Error("manifest: schema must be 1");
|
|
321
|
+
const version = requireString(raw, "version", "manifest");
|
|
322
|
+
const revision = raw["revision"];
|
|
323
|
+
if (typeof revision !== "number" || !Number.isInteger(revision) || revision < 0) {
|
|
324
|
+
throw new Error("manifest: revision must be a non-negative integer");
|
|
325
|
+
}
|
|
326
|
+
const updatedAt = requireString(raw, "updatedAt", "manifest");
|
|
327
|
+
if (Number.isNaN(Date.parse(updatedAt))) {
|
|
328
|
+
throw new Error("manifest: updatedAt must be a parseable timestamp");
|
|
329
|
+
}
|
|
330
|
+
const fallbackUrl = requireString(raw, "fallbackUrl", "manifest");
|
|
331
|
+
if (!/^https?:\/\//.test(fallbackUrl)) {
|
|
332
|
+
throw new Error("manifest: fallbackUrl must be an absolute http(s) URL");
|
|
333
|
+
}
|
|
334
|
+
const groupsRaw = raw["groups"];
|
|
335
|
+
if (!Array.isArray(groupsRaw)) throw new Error("manifest: groups must be an array");
|
|
336
|
+
if (groupsRaw.length === 0) throw new Error("manifest: groups must not be empty");
|
|
337
|
+
const pluginsRaw = raw["plugins"];
|
|
338
|
+
if (!Array.isArray(pluginsRaw)) throw new Error("manifest: plugins must be an array");
|
|
339
|
+
if (pluginsRaw.length === 0) throw new Error("manifest: plugins must not be empty");
|
|
340
|
+
const groups = groupsRaw.map(validateGroup);
|
|
341
|
+
const plugins = pluginsRaw.map(validatePlugin);
|
|
342
|
+
const ids = /* @__PURE__ */ new Set();
|
|
343
|
+
for (const group of groups) {
|
|
344
|
+
if (ids.has(group.id)) throw new Error(`manifest: duplicate group id "${group.id}"`);
|
|
345
|
+
ids.add(group.id);
|
|
346
|
+
}
|
|
347
|
+
return { schema: 1, version, revision, updatedAt, fallbackUrl, groups, plugins };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/client/content.ts
|
|
351
|
+
var PACKAGE_NAME = "dsh-featherhunter-plugin-base";
|
|
352
|
+
var MANIFEST_TIMEOUT_MS = 1500;
|
|
353
|
+
var MANIFEST_CACHE_TTL_MS = 30 * 60 * 1e3;
|
|
354
|
+
var MAX_IMAGE_BYTES = 512 * 1024;
|
|
355
|
+
var ALLOWED_CDN_HOSTS = [
|
|
356
|
+
"unpkg.com",
|
|
357
|
+
"registry.npmmirror.com",
|
|
358
|
+
"cdn.jsdelivr.net"
|
|
359
|
+
];
|
|
360
|
+
var SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
361
|
+
var PACKAGE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
362
|
+
var IMAGE_PATH_PATTERN = /^[a-z0-9][a-z0-9._-]*\.(?:png|jpe?g)$/i;
|
|
363
|
+
var MAX_ID_LENGTH = 64;
|
|
364
|
+
var MAX_TEXT_LENGTH = 200;
|
|
365
|
+
var MAX_NOTE_LENGTH = 300;
|
|
366
|
+
var MAX_PATH_LENGTH = 128;
|
|
367
|
+
function fail(message) {
|
|
368
|
+
throw new Error("manifest: " + message);
|
|
369
|
+
}
|
|
370
|
+
function isRecord2(value) {
|
|
371
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
372
|
+
}
|
|
373
|
+
function requireString2(value, field, maxLength) {
|
|
374
|
+
if (typeof value !== "string" || value.length === 0) fail(field + " must be a non-empty string");
|
|
375
|
+
if (value.length > maxLength) fail(field + " is longer than " + maxLength + " characters");
|
|
376
|
+
return value;
|
|
377
|
+
}
|
|
378
|
+
function requireI18nText(value, field) {
|
|
379
|
+
if (!isRecord2(value)) fail(field + " must be an object with zh/en");
|
|
380
|
+
requireString2(value["zh"], field + ".zh", MAX_TEXT_LENGTH);
|
|
381
|
+
requireString2(value["en"], field + ".en", MAX_TEXT_LENGTH);
|
|
382
|
+
}
|
|
383
|
+
function parseUrl(value) {
|
|
384
|
+
try {
|
|
385
|
+
return new URL(value);
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function isHttpsUrl(value) {
|
|
391
|
+
const url = parseUrl(value);
|
|
392
|
+
return url !== null && url.protocol === "https:";
|
|
393
|
+
}
|
|
394
|
+
function isAllowedCdnUrl(value) {
|
|
395
|
+
const url = parseUrl(value);
|
|
396
|
+
if (!url || url.protocol !== "https:") return false;
|
|
397
|
+
return ALLOWED_CDN_HOSTS.includes(url.hostname);
|
|
398
|
+
}
|
|
399
|
+
function imageUrls(version, path, packageName = PACKAGE_NAME) {
|
|
400
|
+
if (!SEMVER_PATTERN.test(version)) return [];
|
|
401
|
+
if (!IMAGE_PATH_PATTERN.test(path) || path.length > MAX_PATH_LENGTH) return [];
|
|
402
|
+
return [
|
|
403
|
+
"https://cdn.jsdelivr.net/npm/" + packageName + "@" + version + "/data/img/" + path,
|
|
404
|
+
"https://registry.npmmirror.com/" + packageName + "/" + version + "/files/data/img/" + path
|
|
405
|
+
];
|
|
406
|
+
}
|
|
407
|
+
function defaultChannels(packageName = PACKAGE_NAME) {
|
|
408
|
+
const unpkgUrl = "https://unpkg.com/" + packageName + "@latest/data/manifest.json";
|
|
409
|
+
const npmmirrorLatest = "https://registry.npmmirror.com/" + packageName + "/latest";
|
|
410
|
+
const jsdelivrUrl = "https://cdn.jsdelivr.net/npm/" + packageName + "@latest/data/manifest.json";
|
|
411
|
+
return [
|
|
412
|
+
{
|
|
413
|
+
name: "unpkg",
|
|
414
|
+
load: async (context) => ({ raw: JSON.parse(await context.fetchText(unpkgUrl)), url: unpkgUrl })
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
// Two steps on purpose: the dist-tag document names the exact version,
|
|
418
|
+
// and only the versioned file listing is immutable enough to trust.
|
|
419
|
+
name: "npmmirror",
|
|
420
|
+
load: async (context) => {
|
|
421
|
+
const latest = await context.fetchJson(npmmirrorLatest);
|
|
422
|
+
const version = isRecord2(latest) ? latest["version"] : void 0;
|
|
423
|
+
if (typeof version !== "string" || !SEMVER_PATTERN.test(version)) {
|
|
424
|
+
fail("npmmirror latest is missing a usable version");
|
|
425
|
+
}
|
|
426
|
+
const url = "https://registry.npmmirror.com/" + packageName + "/" + version + "/files/data/manifest.json";
|
|
427
|
+
return { raw: JSON.parse(await context.fetchText(url)), url };
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: "jsdelivr",
|
|
432
|
+
load: async (context) => ({
|
|
433
|
+
raw: JSON.parse(await context.fetchText(jsdelivrUrl)),
|
|
434
|
+
url: jsdelivrUrl
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
function validateRemoteManifest(raw) {
|
|
440
|
+
const manifest = parseManifest(raw);
|
|
441
|
+
if (!SEMVER_PATTERN.test(manifest.version)) fail("version must be a semantic version");
|
|
442
|
+
if (!isHttpsUrl(manifest.fallbackUrl)) fail("fallbackUrl must be https");
|
|
443
|
+
const seen = /* @__PURE__ */ new Set();
|
|
444
|
+
for (const group of manifest.groups) {
|
|
445
|
+
if (group.id.length > MAX_ID_LENGTH) fail("group id is too long");
|
|
446
|
+
if (seen.has(group.id)) fail("duplicate group id " + group.id);
|
|
447
|
+
seen.add(group.id);
|
|
448
|
+
requireString2(group.platform, "group.platform", MAX_ID_LENGTH);
|
|
449
|
+
requireI18nText(group.readmeTitle, "group.readmeTitle");
|
|
450
|
+
requireI18nText(group.alt, "group.alt");
|
|
451
|
+
requireI18nText(group.title, "group.title");
|
|
452
|
+
if (group.subtitle) requireI18nText(group.subtitle, "group.subtitle");
|
|
453
|
+
if (group.note) {
|
|
454
|
+
if (!isRecord2(group.note)) fail("group.note must be an object with zh/en");
|
|
455
|
+
requireString2(group.note.zh, "group.note.zh", MAX_NOTE_LENGTH);
|
|
456
|
+
requireString2(group.note.en, "group.note.en", MAX_NOTE_LENGTH);
|
|
457
|
+
}
|
|
458
|
+
requireString2(group.card, "group.card", MAX_PATH_LENGTH);
|
|
459
|
+
if (!IMAGE_PATH_PATTERN.test(group.card)) fail("group.card must be a png/jpg file name");
|
|
460
|
+
if (group.qr.kind === "remote") {
|
|
461
|
+
if (!IMAGE_PATH_PATTERN.test(group.qr.path)) fail("group.qr.path must be a png/jpg file name");
|
|
462
|
+
if (group.qr.path.length > MAX_PATH_LENGTH) fail("group.qr.path is too long");
|
|
463
|
+
} else {
|
|
464
|
+
requireString2(group.qr.key, "group.qr.key", MAX_ID_LENGTH);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return manifest;
|
|
468
|
+
}
|
|
469
|
+
function toDataUrl(bytes, contentType) {
|
|
470
|
+
let binary = "";
|
|
471
|
+
const chunk = 32768;
|
|
472
|
+
for (let index = 0; index < bytes.length; index += chunk) {
|
|
473
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
|
|
474
|
+
}
|
|
475
|
+
const type = contentType && contentType.startsWith("image/") ? contentType : "image/png";
|
|
476
|
+
return "data:" + type + ";base64," + btoa(binary);
|
|
477
|
+
}
|
|
478
|
+
var sessionCache = null;
|
|
479
|
+
var inFlight = null;
|
|
480
|
+
function resetContentCache() {
|
|
481
|
+
sessionCache = null;
|
|
482
|
+
inFlight = null;
|
|
483
|
+
}
|
|
484
|
+
var ContentLoader = class {
|
|
485
|
+
packageName;
|
|
486
|
+
timeoutMs;
|
|
487
|
+
maxImageBytes;
|
|
488
|
+
fetchImpl;
|
|
489
|
+
now;
|
|
490
|
+
channels;
|
|
491
|
+
constructor(options = {}) {
|
|
492
|
+
this.packageName = options.packageName ?? PACKAGE_NAME;
|
|
493
|
+
if (!PACKAGE_NAME_PATTERN.test(this.packageName)) {
|
|
494
|
+
throw new Error("invalid package name: " + this.packageName);
|
|
495
|
+
}
|
|
496
|
+
this.timeoutMs = options.timeoutMs ?? MANIFEST_TIMEOUT_MS;
|
|
497
|
+
this.maxImageBytes = options.maxImageBytes ?? MAX_IMAGE_BYTES;
|
|
498
|
+
this.now = options.now ?? (() => Date.now());
|
|
499
|
+
const injected = options.fetchImpl ?? globalThis.fetch;
|
|
500
|
+
if (typeof injected !== "function") throw new Error("fetch is not available in this environment");
|
|
501
|
+
this.fetchImpl = injected;
|
|
502
|
+
if (options.manifestUrl !== void 0) {
|
|
503
|
+
if (!isHttpsUrl(options.manifestUrl)) throw new Error("manifestUrl must be https");
|
|
504
|
+
const url = options.manifestUrl;
|
|
505
|
+
this.channels = [
|
|
506
|
+
{ name: "custom", load: async (context) => ({ raw: JSON.parse(await context.fetchText(url)), url }) }
|
|
507
|
+
];
|
|
508
|
+
} else if (options.channels) {
|
|
509
|
+
this.channels = options.channels.slice();
|
|
510
|
+
} else {
|
|
511
|
+
this.channels = defaultChannels(this.packageName);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** Cached content when it is still inside the 30 minute window. */
|
|
515
|
+
getCached() {
|
|
516
|
+
if (!sessionCache) return null;
|
|
517
|
+
if (this.now() - sessionCache.at >= MANIFEST_CACHE_TTL_MS) return null;
|
|
518
|
+
return sessionCache.content;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Fetch and validate the manifest once per 30 minutes; concurrent callers
|
|
522
|
+
* share one request. Resolves to null when every channel failed.
|
|
523
|
+
*/
|
|
524
|
+
load() {
|
|
525
|
+
const cached = this.getCached();
|
|
526
|
+
if (cached !== null || sessionCache !== null && this.now() - sessionCache.at < MANIFEST_CACHE_TTL_MS) {
|
|
527
|
+
return Promise.resolve(cached);
|
|
528
|
+
}
|
|
529
|
+
if (inFlight) return inFlight;
|
|
530
|
+
const startedAt = this.now();
|
|
531
|
+
const promise = this.fetchContent(startedAt).then((content) => {
|
|
532
|
+
sessionCache = { at: this.now(), content };
|
|
533
|
+
return content;
|
|
534
|
+
}).finally(() => {
|
|
535
|
+
inFlight = null;
|
|
536
|
+
});
|
|
537
|
+
inFlight = promise;
|
|
538
|
+
return promise;
|
|
539
|
+
}
|
|
540
|
+
/** Prefetch every remote QR image of a manifest (best effort). */
|
|
541
|
+
async ensureImages(content) {
|
|
542
|
+
const pending = [];
|
|
543
|
+
for (const group of content.manifest.groups) {
|
|
544
|
+
if (group.qr.kind !== "remote") continue;
|
|
545
|
+
const key = content.manifest.version + "/" + group.qr.path;
|
|
546
|
+
if (content.images.has(key)) continue;
|
|
547
|
+
pending.push(this.loadImage(content, group.qr.path, key));
|
|
548
|
+
}
|
|
549
|
+
await Promise.all(pending);
|
|
550
|
+
}
|
|
551
|
+
/** Data URI for a remote QR path, or null when it is not prefetched. */
|
|
552
|
+
imageFor(content, path) {
|
|
553
|
+
if (!content) return null;
|
|
554
|
+
return content.images.get(content.manifest.version + "/" + path) ?? null;
|
|
555
|
+
}
|
|
556
|
+
/** Warm the cache: manifest plus images. Never rejects. */
|
|
557
|
+
async preload() {
|
|
558
|
+
try {
|
|
559
|
+
const content = await this.load();
|
|
560
|
+
if (content) await this.ensureImages(content);
|
|
561
|
+
} catch {
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async loadImage(content, path, key) {
|
|
565
|
+
for (const url of imageUrls(content.manifest.version, path, this.packageName)) {
|
|
566
|
+
if (!isAllowedCdnUrl(url)) continue;
|
|
567
|
+
try {
|
|
568
|
+
const data = await this.fetchImage(url);
|
|
569
|
+
if (data) {
|
|
570
|
+
content.images.set(key, data);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async fetchImage(url) {
|
|
578
|
+
const response = await this.request(url);
|
|
579
|
+
if (!response.ok) return null;
|
|
580
|
+
const declared = Number(response.headers.get("content-length"));
|
|
581
|
+
if (Number.isFinite(declared) && declared > this.maxImageBytes) return null;
|
|
582
|
+
const buffer = await response.arrayBuffer();
|
|
583
|
+
if (buffer.byteLength > this.maxImageBytes) return null;
|
|
584
|
+
return toDataUrl(new Uint8Array(buffer), response.headers.get("content-type"));
|
|
585
|
+
}
|
|
586
|
+
async fetchContent(fetchedAt) {
|
|
587
|
+
const context = {
|
|
588
|
+
packageName: this.packageName,
|
|
589
|
+
fetchText: (url) => this.fetchText(url),
|
|
590
|
+
fetchJson: async (url) => JSON.parse(await this.fetchText(url))
|
|
591
|
+
};
|
|
592
|
+
for (const channel of this.channels) {
|
|
593
|
+
try {
|
|
594
|
+
const result = await channel.load(context);
|
|
595
|
+
const manifest = validateRemoteManifest(result.raw);
|
|
596
|
+
return { manifest, source: channel.name, fetchedAt, images: /* @__PURE__ */ new Map() };
|
|
597
|
+
} catch {
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
async fetchText(url) {
|
|
603
|
+
const response = await this.request(url);
|
|
604
|
+
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
605
|
+
return response.text();
|
|
606
|
+
}
|
|
607
|
+
/** One request with the 1.5s abort window; the timer is always cleared. */
|
|
608
|
+
async request(url) {
|
|
609
|
+
if (!isAllowedCdnUrl(url)) throw new Error("blocked url: " + url);
|
|
610
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
611
|
+
let timer;
|
|
612
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
613
|
+
timer = setTimeout(() => {
|
|
614
|
+
if (controller) controller.abort();
|
|
615
|
+
reject(new Error("timeout after " + this.timeoutMs + "ms"));
|
|
616
|
+
}, this.timeoutMs);
|
|
617
|
+
});
|
|
618
|
+
try {
|
|
619
|
+
const request = this.fetchImpl(url, {
|
|
620
|
+
signal: controller ? controller.signal : void 0,
|
|
621
|
+
cache: "no-store",
|
|
622
|
+
credentials: "omit"
|
|
623
|
+
});
|
|
624
|
+
request.catch(() => {
|
|
625
|
+
});
|
|
626
|
+
return await Promise.race([request, timeout]);
|
|
627
|
+
} finally {
|
|
628
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
function createContentLoader(options = {}) {
|
|
633
|
+
return new ContentLoader(options);
|
|
634
|
+
}
|
|
230
635
|
|
|
231
636
|
// src/client/styles.ts
|
|
232
637
|
var BASE_STYLE_ID = "dsh-base-style";
|
|
@@ -351,6 +756,7 @@ var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled])
|
|
|
351
756
|
function mountContactModal(config) {
|
|
352
757
|
const { doc, lang } = config;
|
|
353
758
|
const win = doc.defaultView;
|
|
759
|
+
let current = config;
|
|
354
760
|
const t = (key) => resolveText(key, lang, config.texts);
|
|
355
761
|
ensureBaseStyles(doc);
|
|
356
762
|
const dialog = doc.createElement("dialog");
|
|
@@ -388,8 +794,11 @@ function mountContactModal(config) {
|
|
|
388
794
|
body.className = "dsh-base-modal__body";
|
|
389
795
|
const grid = doc.createElement("div");
|
|
390
796
|
grid.className = "dsh-base-modal__grid";
|
|
391
|
-
|
|
392
|
-
|
|
797
|
+
function renderGroups(groups, columns) {
|
|
798
|
+
grid.style.setProperty("--dsh-base-columns", String(columns));
|
|
799
|
+
grid.replaceChildren(...sortByOrder(groups).map((group) => buildCard(group)));
|
|
800
|
+
}
|
|
801
|
+
renderGroups(current.groups, current.columns);
|
|
393
802
|
body.append(grid);
|
|
394
803
|
const footer = doc.createElement("div");
|
|
395
804
|
footer.className = "dsh-base-modal__footer";
|
|
@@ -474,9 +883,9 @@ function mountContactModal(config) {
|
|
|
474
883
|
const card = doc.createElement("div");
|
|
475
884
|
card.className = "dsh-base-modal__card";
|
|
476
885
|
card.dataset.groupId = group.id;
|
|
477
|
-
const expired = isExpired(group.validUntil,
|
|
886
|
+
const expired = isExpired(group.validUntil, current.now);
|
|
478
887
|
if (expired) card.classList.add("dsh-base-modal__card--expired");
|
|
479
|
-
const qrSrc = expired ? null :
|
|
888
|
+
const qrSrc = expired ? null : current.resolveQr(group);
|
|
480
889
|
const cardTitle = doc.createElement("h3");
|
|
481
890
|
cardTitle.className = "dsh-base-modal__card-title";
|
|
482
891
|
cardTitle.textContent = pickText(group.title, lang);
|
|
@@ -505,7 +914,7 @@ function mountContactModal(config) {
|
|
|
505
914
|
note.textContent = t("expired");
|
|
506
915
|
card.append(note);
|
|
507
916
|
} else {
|
|
508
|
-
const validity = formatValidity(group.validUntil, { lang, now:
|
|
917
|
+
const validity = formatValidity(group.validUntil, { lang, now: current.now });
|
|
509
918
|
const noteText = validity ?? (group.note ? pickText(group.note, lang) : null);
|
|
510
919
|
if (noteText) {
|
|
511
920
|
const note = doc.createElement("span");
|
|
@@ -517,7 +926,7 @@ function mountContactModal(config) {
|
|
|
517
926
|
if (!qrSrc) {
|
|
518
927
|
const link = doc.createElement("a");
|
|
519
928
|
link.className = "dsh-base-modal__card-link";
|
|
520
|
-
link.href =
|
|
929
|
+
link.href = current.fallbackUrl;
|
|
521
930
|
link.target = "_blank";
|
|
522
931
|
link.rel = "noopener noreferrer";
|
|
523
932
|
link.textContent = t("fallback");
|
|
@@ -545,9 +954,15 @@ function mountContactModal(config) {
|
|
|
545
954
|
selectText();
|
|
546
955
|
}
|
|
547
956
|
}
|
|
957
|
+
function update(next) {
|
|
958
|
+
if (closed) return;
|
|
959
|
+
current = { ...current, ...next };
|
|
960
|
+
renderGroups(current.groups, current.columns);
|
|
961
|
+
}
|
|
548
962
|
return {
|
|
549
963
|
dialog,
|
|
550
|
-
close: (restoreFocus = true) => destroy(restoreFocus)
|
|
964
|
+
close: (restoreFocus = true) => destroy(restoreFocus),
|
|
965
|
+
update
|
|
551
966
|
};
|
|
552
967
|
}
|
|
553
968
|
|
|
@@ -683,6 +1098,11 @@ function resolveBaselineQr(group) {
|
|
|
683
1098
|
if (group.qr.kind !== "bundled") return null;
|
|
684
1099
|
return BASELINE.qr[group.qr.key] ?? null;
|
|
685
1100
|
}
|
|
1101
|
+
function resolveGroupQr(group, content, loader) {
|
|
1102
|
+
if (group.qr.kind === "bundled") return resolveBaselineQr(group);
|
|
1103
|
+
if (!content || !loader) return null;
|
|
1104
|
+
return loader.imageFor(content, group.qr.path);
|
|
1105
|
+
}
|
|
686
1106
|
function mergeTexts(overrides, extra) {
|
|
687
1107
|
if (!overrides && !extra) return void 0;
|
|
688
1108
|
return { ...overrides, ...extra };
|
|
@@ -692,6 +1112,19 @@ function classes(base, extra) {
|
|
|
692
1112
|
}
|
|
693
1113
|
function createBaseUI(React, options = {}) {
|
|
694
1114
|
let active = null;
|
|
1115
|
+
let loader = options.contentLoader ?? null;
|
|
1116
|
+
function getLoader() {
|
|
1117
|
+
if (loader) return loader;
|
|
1118
|
+
try {
|
|
1119
|
+
loader = createContentLoader({
|
|
1120
|
+
manifestUrl: options.manifestUrl,
|
|
1121
|
+
fetchImpl: options.fetchImpl
|
|
1122
|
+
});
|
|
1123
|
+
} catch {
|
|
1124
|
+
loader = null;
|
|
1125
|
+
}
|
|
1126
|
+
return loader;
|
|
1127
|
+
}
|
|
695
1128
|
function widgetText(key) {
|
|
696
1129
|
return resolveText(key, detectLang(options.lang), options.texts);
|
|
697
1130
|
}
|
|
@@ -705,6 +1138,41 @@ function createBaseUI(React, options = {}) {
|
|
|
705
1138
|
function closeContactModal() {
|
|
706
1139
|
closeActive(true);
|
|
707
1140
|
}
|
|
1141
|
+
function buildResolver(callOptions, content) {
|
|
1142
|
+
const custom = callOptions.resolveQr ?? options.resolveQr;
|
|
1143
|
+
if (custom) return custom;
|
|
1144
|
+
return (group) => resolveGroupQr(group, content, loader);
|
|
1145
|
+
}
|
|
1146
|
+
function applyContent(handle, content) {
|
|
1147
|
+
if (active !== handle) return;
|
|
1148
|
+
const groups = content.manifest.groups.length > 0 ? content.manifest.groups : BASELINE.manifest.groups;
|
|
1149
|
+
handle.update({
|
|
1150
|
+
groups,
|
|
1151
|
+
columns: clampColumns(void 0, groups.length),
|
|
1152
|
+
fallbackUrl: content.manifest.fallbackUrl,
|
|
1153
|
+
resolveQr: (group) => resolveGroupQr(group, content, loader) ?? null
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
async function refreshRemote(handle, callOptions) {
|
|
1157
|
+
if (callOptions.groups && callOptions.groups.length > 0) return;
|
|
1158
|
+
const currentLoader = getLoader();
|
|
1159
|
+
if (!currentLoader) return;
|
|
1160
|
+
const content = await currentLoader.load();
|
|
1161
|
+
if (!content || active !== handle) return;
|
|
1162
|
+
await currentLoader.ensureImages(content);
|
|
1163
|
+
if (active !== handle) return;
|
|
1164
|
+
const custom = callOptions.resolveQr ?? options.resolveQr;
|
|
1165
|
+
if (custom) {
|
|
1166
|
+
handle.update({
|
|
1167
|
+
groups: content.manifest.groups,
|
|
1168
|
+
columns: clampColumns(callOptions.columns ?? options.columns, content.manifest.groups.length),
|
|
1169
|
+
fallbackUrl: content.manifest.fallbackUrl,
|
|
1170
|
+
resolveQr: (group) => custom(group) ?? null
|
|
1171
|
+
});
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
applyContent(handle, content);
|
|
1175
|
+
}
|
|
708
1176
|
function openContactModal(callOptions = {}) {
|
|
709
1177
|
if (typeof document === "undefined") return () => {
|
|
710
1178
|
};
|
|
@@ -716,14 +1184,15 @@ function createBaseUI(React, options = {}) {
|
|
|
716
1184
|
const lang = detectLang(callOptions.lang ?? options.lang);
|
|
717
1185
|
const candidate = callOptions.trigger ?? document.activeElement;
|
|
718
1186
|
const trigger = candidate && typeof candidate.focus === "function" ? candidate : null;
|
|
719
|
-
const
|
|
1187
|
+
const cached = getLoader()?.getCached() ?? null;
|
|
1188
|
+
const resolver = buildResolver(callOptions, cached);
|
|
720
1189
|
const handle = mountContactModal({
|
|
721
1190
|
doc: document,
|
|
722
1191
|
lang,
|
|
723
1192
|
groups,
|
|
724
1193
|
columns: clampColumns(callOptions.columns ?? options.columns, groups.length),
|
|
725
1194
|
trigger,
|
|
726
|
-
fallbackUrl: callOptions.fallbackUrl ?? options.fallbackUrl ?? BASELINE.manifest.fallbackUrl,
|
|
1195
|
+
fallbackUrl: callOptions.fallbackUrl ?? options.fallbackUrl ?? cached?.manifest.fallbackUrl ?? BASELINE.manifest.fallbackUrl,
|
|
727
1196
|
texts: mergeTexts(options.texts, callOptions.texts),
|
|
728
1197
|
resolveQr: (group) => resolver(group) ?? null,
|
|
729
1198
|
now: callOptions.now
|
|
@@ -734,6 +1203,7 @@ function createBaseUI(React, options = {}) {
|
|
|
734
1203
|
if (active === handle) active = null;
|
|
735
1204
|
if (sharedModal === handle) sharedModal = null;
|
|
736
1205
|
});
|
|
1206
|
+
void refreshRemote(handle, callOptions);
|
|
737
1207
|
return () => {
|
|
738
1208
|
if (active === handle) active = null;
|
|
739
1209
|
if (sharedModal === handle) sharedModal = null;
|
|
@@ -806,6 +1276,15 @@ function createBaseUI(React, options = {}) {
|
|
|
806
1276
|
});
|
|
807
1277
|
}
|
|
808
1278
|
async function preloadContent() {
|
|
1279
|
+
const currentLoader = getLoader();
|
|
1280
|
+
if (!currentLoader) return;
|
|
1281
|
+
await currentLoader.preload();
|
|
1282
|
+
if (options.resolveQr) return;
|
|
1283
|
+
const content = currentLoader.getCached();
|
|
1284
|
+
const handle = active;
|
|
1285
|
+
if (!content || !handle) return;
|
|
1286
|
+
if (active !== handle) return;
|
|
1287
|
+
applyContent(handle, content);
|
|
809
1288
|
}
|
|
810
1289
|
return {
|
|
811
1290
|
ContactButton,
|
|
@@ -817,10 +1296,22 @@ function createBaseUI(React, options = {}) {
|
|
|
817
1296
|
};
|
|
818
1297
|
}
|
|
819
1298
|
export {
|
|
1299
|
+
ALLOWED_CDN_HOSTS,
|
|
820
1300
|
BASE_CSS,
|
|
821
1301
|
BASE_STYLE_ID,
|
|
1302
|
+
ContentLoader,
|
|
1303
|
+
MANIFEST_CACHE_TTL_MS,
|
|
1304
|
+
MANIFEST_TIMEOUT_MS,
|
|
1305
|
+
MAX_IMAGE_BYTES,
|
|
822
1306
|
MESSAGES,
|
|
823
1307
|
createBaseUI,
|
|
1308
|
+
createContentLoader,
|
|
1309
|
+
defaultChannels,
|
|
824
1310
|
ensureBaseStyles,
|
|
825
|
-
|
|
1311
|
+
imageUrls,
|
|
1312
|
+
isAllowedCdnUrl,
|
|
1313
|
+
isHttpsUrl,
|
|
1314
|
+
resetContentCache,
|
|
1315
|
+
resolveText,
|
|
1316
|
+
validateRemoteManifest
|
|
826
1317
|
};
|
package/dist/client/modal.d.ts
CHANGED
|
@@ -15,10 +15,24 @@ export interface ContactModalConfig {
|
|
|
15
15
|
/** Clock override for date-dependent validity text. */
|
|
16
16
|
now?: Date;
|
|
17
17
|
}
|
|
18
|
+
export interface ContactModalUpdate {
|
|
19
|
+
groups?: readonly Group[];
|
|
20
|
+
/** Already clamped to 1..4 columns. */
|
|
21
|
+
columns?: number;
|
|
22
|
+
/** Returns null when no image is available (card degrades to a link). */
|
|
23
|
+
resolveQr?: (group: Group) => string | null;
|
|
24
|
+
fallbackUrl?: string;
|
|
25
|
+
now?: Date;
|
|
26
|
+
}
|
|
18
27
|
export interface ContactModalHandle {
|
|
19
28
|
dialog: HTMLDialogElement;
|
|
20
29
|
/** Destroy the dialog; restore focus by default. */
|
|
21
30
|
close: (restoreFocus?: boolean) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Re-render the group cards in place. Used when the remote manifest arrives
|
|
33
|
+
* after the baseline was painted, so the dialog is never rebuilt.
|
|
34
|
+
*/
|
|
35
|
+
update: (next: ContactModalUpdate) => void;
|
|
22
36
|
}
|
|
23
37
|
/**
|
|
24
38
|
* Build the contact dialog, enter it into the top layer with showModal() and
|
package/dist/client/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
2
|
import type { Group, Lang, PluginEntry } from "../core/types";
|
|
3
|
+
import type { ContentLoader, FetchLike } from "./content";
|
|
3
4
|
import type { TextOverrides } from "./texts";
|
|
4
5
|
/**
|
|
5
6
|
* Resolve the image source for a group's QR code.
|
|
@@ -18,6 +19,10 @@ export interface BaseUIOptions {
|
|
|
18
19
|
selfId?: string;
|
|
19
20
|
/** Manifest URL override (defaults to the published CDN manifest). */
|
|
20
21
|
manifestUrl?: string;
|
|
22
|
+
/** Fetch implementation override (testing seam). */
|
|
23
|
+
fetchImpl?: FetchLike;
|
|
24
|
+
/** Pre-built content loader override (testing seam). */
|
|
25
|
+
contentLoader?: ContentLoader;
|
|
21
26
|
/** Text overrides keyed by message id. */
|
|
22
27
|
texts?: TextOverrides;
|
|
23
28
|
/** Max columns of the contact grid (hard ceiling: 4). */
|