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.
@@ -2,9 +2,9 @@
2
2
  var BASELINE = {
3
3
  "manifest": {
4
4
  "schema": 1,
5
- "version": "0.0.1",
6
- "revision": 1,
7
- "updatedAt": "2026-10-23T00:00:00+08:00",
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-10-23.png",
82
+ "card": "card-wechat-2026-11-06.png",
83
83
  "qr": {
84
84
  "kind": "remote",
85
- "path": "qr-wechat-2026-10-23.png"
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-10-30"
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
- grid.style.setProperty("--dsh-base-columns", String(config.columns));
392
- for (const group of sortByOrder(config.groups)) grid.append(buildCard(group));
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, config.now);
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 : config.resolveQr(group);
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: config.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 = config.fallbackUrl;
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 resolver = callOptions.resolveQr ?? options.resolveQr ?? resolveBaselineQr;
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
- resolveText
1311
+ imageUrls,
1312
+ isAllowedCdnUrl,
1313
+ isHttpsUrl,
1314
+ resetContentCache,
1315
+ resolveText,
1316
+ validateRemoteManifest
826
1317
  };
@@ -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
@@ -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). */