@damian-buho/support-ukraine 1.0.0

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.
@@ -0,0 +1,656 @@
1
+ // tests/index.test.ts
2
+ import { describe, it, before, beforeEach } from "node:test";
3
+ import assert from "node:assert/strict";
4
+
5
+ // src/types.ts
6
+ import { z } from "zod";
7
+ var charityTagSchema = z.enum(["military", "humanitarian", "animals"]);
8
+ var charitySchema = z.object({
9
+ id: z.string().min(1),
10
+ name: z.string().min(1),
11
+ tagline: z.string().min(1),
12
+ url: z.string().url(),
13
+ tags: z.array(charityTagSchema).min(1)
14
+ });
15
+ var charitiesSchema = z.array(charitySchema);
16
+
17
+ // src/charities.yaml
18
+ var charities_default = [{ "id": "united24", "name": "United24", "tagline": "Official fundraising platform of Ukraine", "url": "https://u24.gov.ua", "tags": ["humanitarian"] }, { "id": "come-back-alive", "name": "Come Back Alive", "tagline": "Supporting the Ukrainian Armed Forces since 2014", "url": "https://www.comebackalive.in.ua", "tags": ["military"] }, { "id": "razom-for-ukraine", "name": "Razom for Ukraine", "tagline": "Emergency response and humanitarian aid", "url": "https://www.razomforukraine.org", "tags": ["humanitarian"] }, { "id": "ukrainian-red-cross", "name": "Ukrainian Red Cross", "tagline": "Humanitarian assistance in conflict zones", "url": "https://redcross.org.ua/en/", "tags": ["humanitarian"] }, { "id": "nova-ukraine", "name": "Nova Ukraine", "tagline": "Humanitarian aid and advocacy", "url": "https://novaukraine.org", "tags": ["humanitarian"] }, { "id": "voices-of-children", "name": "Voices of Children", "tagline": "Psychological support for children affected by war", "url": "https://voices.org.ua/en/", "tags": ["humanitarian"] }, { "id": "prytula-foundation", "name": "Prytula Foundation", "tagline": "Defence technology and equipment for Ukraine", "url": "https://prytulafoundation.org/en", "tags": ["military"] }, { "id": "army-of-drones", "name": "Army of Drones", "tagline": "Reconnaissance drones for the frontline", "url": "https://armyofdrones.com", "tags": ["military"] }, { "id": "world-central-kitchen", "name": "World Central Kitchen", "tagline": "Fresh meals for people in conflict zones", "url": "https://wck.org/relief/activation-chefs-for-ukraine/", "tags": ["humanitarian"] }, { "id": "cats-of-kyiv", "name": "Cats of Kyiv", "tagline": "Cat shelter rescuing and rehoming cats in Kyiv", "url": "https://cats.kiev.ua", "tags": ["animals"] }, { "id": "uanimals", "name": "UAnimals", "tagline": "Animal rescue and advocacy organization in Ukraine", "url": "https://uanimals.org", "tags": ["animals"] }, { "id": "hospitallers", "name": "Hospitallers Medical Battalion", "tagline": "Ukrainian voluntary paramedics providing first aid and evacuation", "url": "https://www.hospitallers.org.uk", "tags": ["humanitarian"] }, { "id": "sternenko-fund", "name": "Sternenko Fund", "tagline": "FPV drone supply for Ukrainian defence forces", "url": "https://www.sternenkofund.org", "tags": ["military"] }, { "id": "life-robots-ukraine", "name": "Life Robots Ukraine", "tagline": "Robots for demining Ukraine", "url": "https://liferobots.com.ua", "tags": ["humanitarian"] }, { "id": "sirius-shelter", "name": "Sirius", "tagline": "Ukraine's largest animal shelter for dogs and cats", "url": "https://dogcat.com.ua", "tags": ["animals"] }];
19
+
20
+ // src/styles.scss
21
+ var styles_default = ".support-ukraine-block{width:100%;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,sans-serif;text-decoration:none;box-sizing:border-box;line-height:1.4;z-index:2147483647;background-color:#0057b7}@media(prefers-color-scheme: dark){.support-ukraine-block{background-color:gold}}.support-ukraine-block--shift{position:relative}.support-ukraine-block--overlap{position:fixed;top:0;left:0}.support-ukraine-block__link{display:inline-flex;align-items:center;flex-wrap:wrap;gap:0 .25em;text-decoration:none;flex:1;height:100%;justify-content:flex-start;text-align:left;padding:1rem 2rem;box-sizing:border-box;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.4)}@media(prefers-color-scheme: dark){.support-ukraine-block__link{color:#000;text-shadow:0 1px 2px hsla(0,0%,100%,.4)}}.support-ukraine-block__link:hover{opacity:.9}.support-ukraine-block__prefix{white-space:nowrap}.support-ukraine-block__info{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.support-ukraine-block__all{display:inline-flex;align-items:center;text-decoration:none;padding:1rem 2rem;box-sizing:border-box;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.4);white-space:nowrap;opacity:.8}@media(prefers-color-scheme: dark){.support-ukraine-block__all{color:#000;text-shadow:0 1px 2px hsla(0,0%,100%,.4)}}.support-ukraine-block__all:hover{opacity:1}.support-ukraine-block__all-ellipsis{display:none}@media(max-width: 640px){.support-ukraine-block{flex-wrap:wrap}.support-ukraine-block__link{flex:1 1 100%;padding:.75rem 1.25rem}.support-ukraine-block__info{flex:1 1 100%;white-space:normal;overflow:visible;text-overflow:unset;padding-left:1.5em}.support-ukraine-block__all{display:none}}[dir=rtl] .support-ukraine-block__link{text-align:right;justify-content:flex-end}[dir=rtl] .support-ukraine-block__all{text-align:right}@media(max-width: 640px){[dir=rtl] .support-ukraine-block__info{padding-left:0;padding-right:1.5em}}";
22
+
23
+ // src/locales/index.ts
24
+ var localeLoaders = {
25
+ en: () => import("./en-PKQHFIBK.js"),
26
+ es: () => import("./es-O2475R7Z.js"),
27
+ fr: () => import("./fr-X6V3WLWM.js"),
28
+ de: () => import("./de-TH7AAXFP.js"),
29
+ pt: () => import("./pt-FYGLQOH5.js"),
30
+ it: () => import("./it-2WUTSGIT.js"),
31
+ pl: () => import("./pl-6W3JBZBK.js"),
32
+ nl: () => import("./nl-XSVZSNMF.js"),
33
+ sv: () => import("./sv-LSP66UWZ.js"),
34
+ uk: () => import("./uk-TI5W6EQE.js"),
35
+ ar: () => import("./ar-KPYMLJQQ.js"),
36
+ zh: () => import("./zh-KPKJOFBN.js"),
37
+ hi: () => import("./hi-AGHL2F3M.js"),
38
+ th: () => import("./th-PBC35HRN.js"),
39
+ ja: () => import("./ja-PRYDFFQ7.js"),
40
+ ko: () => import("./ko-2G672DOI.js")
41
+ };
42
+ var RTL_LOCALES = ["ar"];
43
+ function resolveLocale(tag) {
44
+ const base = (tag.split("-", 1)[0] ?? "").toLowerCase();
45
+ if (Object.hasOwn(localeLoaders, base)) {
46
+ return base;
47
+ }
48
+ return "en";
49
+ }
50
+
51
+ // src/i18n.ts
52
+ var cache = /* @__PURE__ */ new Map();
53
+ function detectLocale(preferred) {
54
+ if (preferred) {
55
+ return resolveLocale(preferred);
56
+ }
57
+ if (typeof navigator !== "undefined" && navigator.languages) {
58
+ for (const tag of navigator.languages) {
59
+ const resolved = resolveLocale(tag);
60
+ if (resolved !== "en") {
61
+ return resolved;
62
+ }
63
+ }
64
+ }
65
+ if (typeof navigator !== "undefined" && navigator.language) {
66
+ const resolved = resolveLocale(navigator.language);
67
+ if (resolved !== "en") {
68
+ return resolved;
69
+ }
70
+ }
71
+ return "en";
72
+ }
73
+ async function loadLocale(lang) {
74
+ const code = resolveLocale(lang);
75
+ const cached = cache.get(code);
76
+ if (cached) {
77
+ return cached;
78
+ }
79
+ try {
80
+ const loaded = await localeLoaders[code]();
81
+ cache.set(code, loaded.default);
82
+ return loaded.default;
83
+ } catch {
84
+ if (code !== "en") {
85
+ return loadLocale("en");
86
+ }
87
+ throw new Error(`[support-ukraine] Failed to load locale "${code}"`);
88
+ }
89
+ }
90
+ function mergeCharities(base, locale) {
91
+ return base.map((charity) => {
92
+ const translated = locale.charities[charity.id];
93
+ if (translated?.tagline) {
94
+ return { ...charity, tagline: translated.tagline };
95
+ }
96
+ return charity;
97
+ });
98
+ }
99
+ function isRTL(lang) {
100
+ const code = resolveLocale(lang);
101
+ return RTL_LOCALES.includes(code);
102
+ }
103
+ function formatBannerText(charity, messages) {
104
+ return `\u{1F1FA}\u{1F1E6} ${messages.supportUkraine}: ${charity.name}: ${charity.tagline}`;
105
+ }
106
+
107
+ // src/index.ts
108
+ var DEFAULT_CHARITIES = charitiesSchema.parse(charities_default);
109
+ var CSS_PREFIX = "support-ukraine-block";
110
+ var STORAGE_KEY = "support-ukraine-seen";
111
+ function readSeen() {
112
+ try {
113
+ const raw = localStorage.getItem(STORAGE_KEY);
114
+ return raw ? new Set(JSON.parse(raw)) : /* @__PURE__ */ new Set();
115
+ } catch {
116
+ return /* @__PURE__ */ new Set();
117
+ }
118
+ }
119
+ function writeSeen(seen) {
120
+ try {
121
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...seen]));
122
+ } catch {
123
+ }
124
+ }
125
+ function updateSeen(mutate) {
126
+ const seen = readSeen();
127
+ mutate(seen);
128
+ writeSeen(seen);
129
+ }
130
+ function randomItem(items) {
131
+ if (items.length === 0) {
132
+ throw new Error("randomItem: cannot pick from an empty array");
133
+ }
134
+ return items[Math.floor(Math.random() * items.length)];
135
+ }
136
+ function injectStyles() {
137
+ const id = `${CSS_PREFIX}-styles`;
138
+ if (document.querySelector(`#${id}`)) {
139
+ return;
140
+ }
141
+ const style = document.createElement("style");
142
+ style.id = id;
143
+ style.textContent = styles_default;
144
+ document.head.append(style);
145
+ }
146
+ async function supportUkraineBlock(options = {}) {
147
+ const {
148
+ mode = "shift",
149
+ fontSize = "0.875rem",
150
+ tags,
151
+ dontRepeat = true,
152
+ isInConsole = true,
153
+ locale: requestedLocale
154
+ } = options;
155
+ injectStyles();
156
+ const lang = detectLocale(requestedLocale);
157
+ const messages = await loadLocale(lang);
158
+ const localizedCharities = mergeCharities(DEFAULT_CHARITIES, messages);
159
+ let candidates = tags && tags.length > 0 ? localizedCharities.filter((charity2) => charity2.tags.some((t) => tags.includes(t))) : localizedCharities;
160
+ if (candidates.length === 0) {
161
+ candidates = localizedCharities;
162
+ }
163
+ if (dontRepeat) {
164
+ updateSeen((seen) => {
165
+ const unseen = candidates.filter((c) => !seen.has(c.url));
166
+ if (unseen.length > 0) {
167
+ candidates = unseen;
168
+ } else {
169
+ seen.clear();
170
+ }
171
+ });
172
+ }
173
+ const charity = randomItem(candidates);
174
+ if (dontRepeat) {
175
+ updateSeen((seen) => {
176
+ seen.add(charity.url);
177
+ });
178
+ }
179
+ const banner = document.createElement("header");
180
+ banner.className = `${CSS_PREFIX} ${CSS_PREFIX}--${mode}`;
181
+ banner.lang = lang;
182
+ if (isRTL(lang)) {
183
+ banner.setAttribute("dir", "rtl");
184
+ }
185
+ const link = document.createElement("a");
186
+ link.className = `${CSS_PREFIX}__link`;
187
+ link.href = charity.url;
188
+ link.target = "_blank";
189
+ link.rel = "noopener noreferrer";
190
+ link.style.fontSize = fontSize;
191
+ const prefix = document.createElement("span");
192
+ prefix.className = `${CSS_PREFIX}__prefix`;
193
+ prefix.textContent = `\u{1F1FA}\u{1F1E6} ${messages.supportUkraine}:`;
194
+ const info = document.createElement("span");
195
+ info.className = `${CSS_PREFIX}__info`;
196
+ info.textContent = `${charity.name}: ${charity.tagline}`;
197
+ link.append(prefix, info);
198
+ banner.append(link);
199
+ const allLink = document.createElement("a");
200
+ allLink.className = `${CSS_PREFIX}__all`;
201
+ allLink.href = "https://damian-buho.github.io/support-ukraine/";
202
+ allLink.target = "_blank";
203
+ allLink.rel = "noopener noreferrer";
204
+ allLink.style.fontSize = fontSize;
205
+ const allText = document.createElement("span");
206
+ allText.className = `${CSS_PREFIX}__all-text`;
207
+ allText.textContent = messages.allCharities;
208
+ const allEllipsis = document.createElement("span");
209
+ allEllipsis.className = `${CSS_PREFIX}__all-ellipsis`;
210
+ allEllipsis.textContent = "\u2026";
211
+ allLink.append(allText, allEllipsis);
212
+ banner.append(allLink);
213
+ const mount = options.element ?? document.body;
214
+ mount.prepend(banner);
215
+ if (isInConsole) {
216
+ console.info("[support-ukraine] banner", `${charity.name}: ${charity.tagline}`, charity.url);
217
+ }
218
+ return banner;
219
+ }
220
+
221
+ // tests/index.test.ts
222
+ describe("DEFAULT_CHARITIES", () => {
223
+ it("is a non-empty array", () => {
224
+ assert.ok(Array.isArray(DEFAULT_CHARITIES));
225
+ assert.ok(DEFAULT_CHARITIES.length > 0);
226
+ });
227
+ it("every charity has id, name, tagline, url, and tags", () => {
228
+ const validTags = /* @__PURE__ */ new Set(["military", "humanitarian", "animals"]);
229
+ for (const charity of DEFAULT_CHARITIES) {
230
+ assert.equal(typeof charity.id, "string", "id must be a string");
231
+ assert.ok(charity.id.length > 0, "id must not be empty");
232
+ assert.equal(typeof charity.name, "string", "name must be a string");
233
+ assert.ok(charity.name.length > 0, "name must not be empty");
234
+ assert.equal(typeof charity.tagline, "string", "tagline must be a string");
235
+ assert.ok(charity.tagline.length > 0, "tagline must not be empty");
236
+ assert.equal(typeof charity.url, "string", "url must be a string");
237
+ assert.ok(charity.url.startsWith("http"), "url must start with http");
238
+ assert.ok(Array.isArray(charity.tags), "tags must be an array");
239
+ assert.ok(charity.tags.length > 0, "tags must not be empty");
240
+ for (const tag of charity.tags) {
241
+ assert.ok(validTags.has(tag), `tag "${tag}" must be a valid CharityTag`);
242
+ }
243
+ }
244
+ });
245
+ it("has at least 5 charities", () => {
246
+ assert.ok(DEFAULT_CHARITIES.length >= 5);
247
+ });
248
+ it("has unique charity ids", () => {
249
+ const ids = DEFAULT_CHARITIES.map((c) => c.id);
250
+ const unique = new Set(ids);
251
+ assert.equal(ids.length, unique.size, "charity ids must be unique");
252
+ });
253
+ it("has unique charity names", () => {
254
+ const names = DEFAULT_CHARITIES.map((c) => c.name);
255
+ const unique = new Set(names);
256
+ assert.equal(names.length, unique.size, "charity names must be unique");
257
+ });
258
+ it("has charities for each tag", () => {
259
+ const tags = /* @__PURE__ */ new Set();
260
+ for (const charity of DEFAULT_CHARITIES) {
261
+ for (const tag of charity.tags) {
262
+ tags.add(tag);
263
+ }
264
+ }
265
+ assert.ok(tags.has("military"), "must have at least one military charity");
266
+ assert.ok(tags.has("humanitarian"), "must have at least one humanitarian charity");
267
+ assert.ok(tags.has("animals"), "must have at least one animals charity");
268
+ });
269
+ });
270
+ describe("charitiesSchema", () => {
271
+ it("accepts valid charity objects", () => {
272
+ const valid = [
273
+ {
274
+ id: "test",
275
+ name: "Test",
276
+ tagline: "Helping",
277
+ url: "https://example.com",
278
+ tags: ["military"]
279
+ }
280
+ ];
281
+ assert.doesNotThrow(() => charitiesSchema.parse(valid));
282
+ });
283
+ it("rejects missing required fields", () => {
284
+ const incomplete = [{ id: "test" }];
285
+ assert.throws(() => charitiesSchema.parse(incomplete));
286
+ });
287
+ it("rejects invalid url", () => {
288
+ const bad = [{ id: "t", name: "T", tagline: "T", url: "not-a-url", tags: ["military"] }];
289
+ assert.throws(() => charitiesSchema.parse(bad));
290
+ });
291
+ it("rejects invalid tag", () => {
292
+ const bad = [{ id: "t", name: "T", tagline: "T", url: "https://x.com", tags: ["invalid"] }];
293
+ assert.throws(() => charitiesSchema.parse(bad));
294
+ });
295
+ it("rejects empty tags array", () => {
296
+ const bad = [{ id: "t", name: "T", tagline: "T", url: "https://x.com", tags: [] }];
297
+ assert.throws(() => charitiesSchema.parse(bad));
298
+ });
299
+ });
300
+ describe("randomItem", () => {
301
+ it("is a function", () => {
302
+ assert.equal(typeof randomItem, "function");
303
+ });
304
+ it("returns an item from the array", () => {
305
+ const items = ["a", "b", "c"];
306
+ for (let index = 0; index < 20; index++) {
307
+ const result = randomItem(items);
308
+ assert.ok(items.includes(result));
309
+ }
310
+ });
311
+ it("returns the single item when array has one element", () => {
312
+ assert.equal(randomItem(["only"]), "only");
313
+ });
314
+ it("throws on empty array", () => {
315
+ assert.throws(() => randomItem([]), /empty array/);
316
+ });
317
+ });
318
+ describe("resolveLocale", () => {
319
+ it('returns "en" for English tags', () => {
320
+ assert.equal(resolveLocale("en"), "en");
321
+ assert.equal(resolveLocale("en-US"), "en");
322
+ assert.equal(resolveLocale("en-GB"), "en");
323
+ });
324
+ it("resolves supported locales by base tag", () => {
325
+ assert.equal(resolveLocale("es"), "es");
326
+ assert.equal(resolveLocale("es-MX"), "es");
327
+ assert.equal(resolveLocale("fr"), "fr");
328
+ assert.equal(resolveLocale("fr-CA"), "fr");
329
+ assert.equal(resolveLocale("ar"), "ar");
330
+ assert.equal(resolveLocale("zh-CN"), "zh");
331
+ assert.equal(resolveLocale("ja"), "ja");
332
+ assert.equal(resolveLocale("ko"), "ko");
333
+ });
334
+ it('falls back to "en" for unsupported locales', () => {
335
+ assert.equal(resolveLocale("xx"), "en");
336
+ assert.equal(resolveLocale("unknown"), "en");
337
+ });
338
+ });
339
+ describe("detectLocale", () => {
340
+ it("returns the preferred locale when provided", () => {
341
+ assert.equal(detectLocale("es"), "es");
342
+ assert.equal(detectLocale("fr-CA"), "fr");
343
+ });
344
+ it('falls back to "en" when navigator is undefined (Node)', () => {
345
+ const savedNavigator = navigator;
346
+ delete globalThis.navigator;
347
+ assert.equal(detectLocale(), "en");
348
+ globalThis.navigator = savedNavigator;
349
+ });
350
+ it("prefers navigator.languages over navigator.language", () => {
351
+ const savedNavigator = navigator;
352
+ globalThis.navigator = {
353
+ language: "en-US",
354
+ languages: ["es-MX", "en-US"]
355
+ };
356
+ assert.equal(detectLocale(), "es");
357
+ globalThis.navigator = savedNavigator;
358
+ });
359
+ it("falls back to navigator.language when navigator.languages has no match", () => {
360
+ const savedNavigator = navigator;
361
+ globalThis.navigator = {
362
+ language: "fr-CA",
363
+ languages: ["en-US"]
364
+ };
365
+ assert.equal(detectLocale(), "fr");
366
+ globalThis.navigator = savedNavigator;
367
+ });
368
+ });
369
+ describe("loadLocale", () => {
370
+ it("loads the English locale", async () => {
371
+ const messages = await loadLocale("en");
372
+ assert.equal(typeof messages.supportUkraine, "string");
373
+ assert.ok(messages.supportUkraine.length > 0);
374
+ assert.equal(typeof messages.allCharities, "string");
375
+ assert.ok(messages.allCharities.length > 0);
376
+ assert.equal(typeof messages.charities, "object");
377
+ });
378
+ it("loads a non-English locale", async () => {
379
+ const messages = await loadLocale("es");
380
+ assert.equal(typeof messages.supportUkraine, "string");
381
+ assert.ok(messages.supportUkraine.length > 0);
382
+ assert.equal(typeof messages.charities, "object");
383
+ });
384
+ it("falls back to English for unknown locale", async () => {
385
+ const messages = await loadLocale("zz");
386
+ assert.equal(messages.supportUkraine, "Support Ukraine");
387
+ });
388
+ });
389
+ describe("mergeCharities", () => {
390
+ it("merges locale taglines over base charities", async () => {
391
+ const messages = await loadLocale("es");
392
+ const merged = mergeCharities(DEFAULT_CHARITIES, messages);
393
+ assert.equal(merged.length, DEFAULT_CHARITIES.length);
394
+ const united24 = merged.find((c) => c.id === "united24");
395
+ assert.ok(united24);
396
+ assert.notEqual(united24.tagline, DEFAULT_CHARITIES[0].tagline);
397
+ });
398
+ it("keeps English tagline when charity id is missing from locale", async () => {
399
+ const messages = {
400
+ supportUkraine: "Test",
401
+ allCharities: "Test",
402
+ donate: "Test",
403
+ charities: {}
404
+ };
405
+ const merged = mergeCharities(DEFAULT_CHARITIES, messages);
406
+ for (const [index, item] of merged.entries()) {
407
+ assert.equal(item.tagline, DEFAULT_CHARITIES[index].tagline);
408
+ }
409
+ });
410
+ });
411
+ describe("isRTL", () => {
412
+ it("returns true for Arabic", () => {
413
+ assert.equal(isRTL("ar"), true);
414
+ assert.equal(isRTL("ar-SA"), true);
415
+ });
416
+ it("returns false for LTR locales", () => {
417
+ assert.equal(isRTL("en"), false);
418
+ assert.equal(isRTL("es"), false);
419
+ assert.equal(isRTL("fr"), false);
420
+ assert.equal(isRTL("zh"), false);
421
+ assert.equal(isRTL("ja"), false);
422
+ });
423
+ });
424
+ describe("formatBannerText", () => {
425
+ it("formats the banner text with charity and messages", async () => {
426
+ const messages = await loadLocale("en");
427
+ const charity = DEFAULT_CHARITIES[0];
428
+ const result = formatBannerText(charity, messages);
429
+ assert.ok(result.includes(charity.name));
430
+ assert.ok(result.includes(charity.tagline));
431
+ assert.ok(result.includes(messages.supportUkraine));
432
+ });
433
+ });
434
+ describe("supportUkraineBlock", () => {
435
+ it("is a function", () => {
436
+ assert.equal(typeof supportUkraineBlock, "function");
437
+ });
438
+ });
439
+ var MockElement = class {
440
+ _attrs = /* @__PURE__ */ new Map();
441
+ _children = [];
442
+ className = "";
443
+ href = "";
444
+ textContent = "";
445
+ style = { fontSize: "" };
446
+ append(...items) {
447
+ this._children.push(...items);
448
+ }
449
+ prepend(_item) {
450
+ }
451
+ setAttribute(name, value) {
452
+ this._attrs.set(name, value);
453
+ }
454
+ getAttribute(name) {
455
+ return this._attrs.get(name) ?? "";
456
+ }
457
+ get firstChild() {
458
+ return this._children[0];
459
+ }
460
+ get lastChild() {
461
+ return this._children.at(-1);
462
+ }
463
+ };
464
+ function setupDom() {
465
+ if (!globalThis.document) {
466
+ Object.defineProperty(globalThis, "document", {
467
+ value: {
468
+ head: { append() {
469
+ } },
470
+ body: { prepend() {
471
+ } },
472
+ createElement() {
473
+ return new MockElement();
474
+ },
475
+ querySelector() {
476
+ }
477
+ },
478
+ writable: true,
479
+ configurable: true
480
+ });
481
+ }
482
+ }
483
+ var STORAGE_KEY2 = "support-ukraine-seen";
484
+ var storage = { store: /* @__PURE__ */ new Map() };
485
+ function setupStorage() {
486
+ storage.store = /* @__PURE__ */ new Map();
487
+ if (!globalThis.localStorage) {
488
+ Object.defineProperty(globalThis, "localStorage", {
489
+ value: {
490
+ getItem(key) {
491
+ return storage.store.get(key);
492
+ },
493
+ setItem(key, value) {
494
+ storage.store.set(key, value);
495
+ },
496
+ clear() {
497
+ storage.store.clear();
498
+ }
499
+ },
500
+ writable: true,
501
+ configurable: true
502
+ });
503
+ }
504
+ }
505
+ describe("dontRepeat", () => {
506
+ before(() => {
507
+ setupDom();
508
+ setupStorage();
509
+ });
510
+ beforeEach(() => {
511
+ storage.store.clear();
512
+ });
513
+ it("does not touch localStorage when dontRepeat is false", async () => {
514
+ await supportUkraineBlock({ dontRepeat: false });
515
+ assert.equal(storage.store.size, 0);
516
+ });
517
+ it("writes seen URL to localStorage after mounting", async () => {
518
+ const banner = await supportUkraineBlock({
519
+ dontRepeat: true,
520
+ tags: ["animals"]
521
+ });
522
+ const seen = JSON.parse(storage.store.get(STORAGE_KEY2) ?? "[]");
523
+ assert.equal(seen.length, 1);
524
+ const link = banner.firstChild;
525
+ assert.equal(seen[0], link.href);
526
+ });
527
+ it("avoids repeating charities across calls", async () => {
528
+ const seenUrls = /* @__PURE__ */ new Set();
529
+ for (let index = 0; index < 3; index++) {
530
+ const banner = await supportUkraineBlock({ tags: ["animals"], dontRepeat: true });
531
+ const link = banner.firstChild;
532
+ seenUrls.add(link.href);
533
+ }
534
+ assert.equal(seenUrls.size, 3, "all 3 animal charities should appear exactly once");
535
+ });
536
+ it("resets after all charities have been seen", async () => {
537
+ await supportUkraineBlock({ tags: ["animals"], dontRepeat: true });
538
+ await supportUkraineBlock({ tags: ["animals"], dontRepeat: true });
539
+ const afterFirstCycle = JSON.parse(storage.store.get(STORAGE_KEY2) ?? "[]");
540
+ assert.equal(afterFirstCycle.length, 2);
541
+ const banner = await supportUkraineBlock({ tags: ["animals"], dontRepeat: true });
542
+ const link = banner.firstChild;
543
+ const animals = DEFAULT_CHARITIES.filter((c) => c.tags.includes("animals"));
544
+ assert.ok(animals.some((c) => c.url === link.href));
545
+ });
546
+ it("works with tags filter", async () => {
547
+ const military = DEFAULT_CHARITIES.filter((c) => c.tags.includes("military"));
548
+ const seenUrls = /* @__PURE__ */ new Set();
549
+ for (let index = 0; index < military.length; index++) {
550
+ const banner = await supportUkraineBlock({
551
+ tags: ["military"],
552
+ dontRepeat: true
553
+ });
554
+ const link = banner.firstChild;
555
+ seenUrls.add(link.href);
556
+ }
557
+ assert.equal(seenUrls.size, military.length);
558
+ for (const url of seenUrls) {
559
+ assert.ok(
560
+ military.some((c) => c.url === url),
561
+ "only military charities shown"
562
+ );
563
+ }
564
+ });
565
+ it("treats empty tags array as no filter", async () => {
566
+ const banner = await supportUkraineBlock({
567
+ tags: [],
568
+ dontRepeat: false
569
+ });
570
+ const link = banner.firstChild;
571
+ assert.ok(link.href.length > 0, "should mount a banner with a valid charity");
572
+ });
573
+ it("falls back to all charities when tags match nothing", async () => {
574
+ const banner = await supportUkraineBlock({
575
+ tags: ["nonexistent"],
576
+ dontRepeat: false
577
+ });
578
+ const link = banner.firstChild;
579
+ assert.ok(link.href.length > 0, "should mount a banner despite no tag match");
580
+ });
581
+ });
582
+ describe("i18n integration", () => {
583
+ before(() => {
584
+ setupDom();
585
+ setupStorage();
586
+ });
587
+ beforeEach(() => {
588
+ storage.store.clear();
589
+ });
590
+ it("banner uses translated text when locale is specified", async () => {
591
+ const banner = await supportUkraineBlock({
592
+ locale: "es",
593
+ tags: ["animals"],
594
+ dontRepeat: false
595
+ });
596
+ const link = banner.firstChild;
597
+ const info = link.lastChild;
598
+ const text = info.textContent;
599
+ assert.ok(text.length > 0, "banner text should not be empty");
600
+ const animals = DEFAULT_CHARITIES.filter((c) => c.tags.includes("animals"));
601
+ assert.ok(
602
+ animals.some((c) => text.includes(c.name)),
603
+ "should include a charity name"
604
+ );
605
+ });
606
+ it("banner uses English by default", async () => {
607
+ const savedNavigator = navigator;
608
+ delete globalThis.navigator;
609
+ try {
610
+ const banner = await supportUkraineBlock({
611
+ tags: ["animals"],
612
+ dontRepeat: false
613
+ });
614
+ const link = banner.firstChild;
615
+ const prefix = link.firstChild;
616
+ const text = prefix.textContent;
617
+ assert.ok(text.includes("Support Ukraine"), "should contain English text");
618
+ } finally {
619
+ globalThis.navigator = savedNavigator;
620
+ }
621
+ });
622
+ it('banner has dir="rtl" for Arabic locale', async () => {
623
+ const banner = await supportUkraineBlock({
624
+ locale: "ar",
625
+ tags: ["animals"],
626
+ dontRepeat: false
627
+ });
628
+ const direction = banner.getAttribute(
629
+ "dir"
630
+ );
631
+ assert.equal(direction, "rtl");
632
+ });
633
+ it('banner does not have dir="rtl" for LTR locales', async () => {
634
+ const banner = await supportUkraineBlock({
635
+ locale: "en",
636
+ tags: ["animals"],
637
+ dontRepeat: false
638
+ });
639
+ const direction = banner.getAttribute(
640
+ "dir"
641
+ );
642
+ assert.notEqual(direction, "rtl");
643
+ });
644
+ it("allCharities link uses translated text", async () => {
645
+ const banner = await supportUkraineBlock({
646
+ locale: "es",
647
+ tags: ["animals"],
648
+ dontRepeat: false
649
+ });
650
+ const allLink = banner.lastChild;
651
+ const allText = allLink.firstChild;
652
+ const text = allText.textContent;
653
+ assert.ok(text.length > 0, "allCharities text should not be empty");
654
+ assert.notEqual(text, "All charities", "should be translated");
655
+ });
656
+ });
@@ -0,0 +1,5 @@
1
+ // src/locales/it.yaml
2
+ var it_default = { "supportUkraine": "Sostieni l'Ucraina", "allCharities": "Tutte le associazioni", "donate": "Dona", "charities": { "united24": { "tagline": "Piattaforma ufficiale di raccolta fondi dell'Ucraina" }, "come-back-alive": { "tagline": "A sostegno delle forze armate ucraine dal 2014" }, "razom-for-ukraine": { "tagline": "Interventi di emergenza e aiuti umanitari" }, "ukrainian-red-cross": { "tagline": "Assistenza umanitaria nelle zone di conflitto" }, "nova-ukraine": { "tagline": "Aiuti umanitari e tutela dei diritti" }, "voices-of-children": { "tagline": "Supporto psicologico per i bambini colpiti dalla guerra" }, "prytula-foundation": { "tagline": "Tecnologie di difesa e attrezzature per l'Ucraina" }, "medical-hilfe": { "tagline": "Forniture mediche per gli ospedali ucraini" }, "army-of-drones": { "tagline": "Droni da ricognizione per il fronte" }, "world-central-kitchen": { "tagline": "Pasti freschi per le persone nelle zone di conflitto" }, "cats-of-kyiv": { "tagline": "Rifugio che salva e adotta gatti a Kyiv" }, "uanimals": { "tagline": "Organizzazione di salvataggio animali e tutela dei diritti in Ucraina" }, "hospitallers": { "tagline": "Paramedici volontari ucraini che prestano primo soccorso e evacuazione" }, "sternenko-fund": { "tagline": "Fornitura di droni FPV per le forze di difesa ucraine" }, "life-robots-ukraine": { "tagline": "Robot per lo sminamento dell'Ucraina" }, "sirius-shelter": { "tagline": "Il pi\xF9 grande rifugio animale dell'Ucraina per cani e gatti" } } };
3
+ export {
4
+ it_default as default
5
+ };
@@ -0,0 +1,5 @@
1
+ // src/locales/ja.yaml
2
+ var ja_default = { "supportUkraine": "\u30A6\u30AF\u30E9\u30A4\u30CA\u3092\u652F\u63F4\u3059\u308B", "allCharities": "\u3059\u3079\u3066\u306E\u6148\u5584\u56E3\u4F53", "donate": "\u5BC4\u4ED8\u3059\u308B", "charities": { "united24": { "tagline": "\u30A6\u30AF\u30E9\u30A4\u30CA\u306E\u516C\u5F0F fundraising \u30D7\u30E9\u30C3\u30C8\u30D5\u30A9\u30FC\u30E0" }, "come-back-alive": { "tagline": "2014\u5E74\u304B\u3089\u30A6\u30AF\u30E9\u30A4\u30CA\u8ECD\u3092\u652F\u63F4" }, "razom-for-ukraine": { "tagline": "\u7DCA\u6025\u5BFE\u5FDC\u3068\u4EBA\u9053\u652F\u63F4" }, "ukrainian-red-cross": { "tagline": "\u7D1B\u4E89\u5730\u57DF\u3067\u306E\u4EBA\u9053\u652F\u63F4" }, "nova-ukraine": { "tagline": "\u4EBA\u9053\u652F\u63F4\u3068\u5553\u767A\u6D3B\u52D5" }, "voices-of-children": { "tagline": "\u6226\u4E89\u306E\u5F71\u97FF\u3092\u53D7\u3051\u305F\u5150\u7AE5\u3078\u306E\u5FC3\u7406\u652F\u63F4" }, "prytula-foundation": { "tagline": "\u30A6\u30AF\u30E9\u30A4\u30CA\u306E\u9632\u885B\u6280\u8853\u3068\u88C5\u5099" }, "medical-hilfe": { "tagline": "\u30A6\u30AF\u30E9\u30A4\u30CA\u75C5\u9662\u3078\u306E\u533B\u7642\u7269\u8CC7" }, "army-of-drones": { "tagline": "\u524D\u7DDA\u5411\u3051\u5075\u5BDF\u30C9\u30ED\u30FC\u30F3" }, "world-central-kitchen": { "tagline": "\u7D1B\u4E89\u5730\u57DF\u306E\u4EBA\u3005\u3078\u306E\u65B0\u9BAE\u306A\u98DF\u4E8B" }, "cats-of-kyiv": { "tagline": "\u30AD\u30FC\u30A6\u306E\u732B\u3092\u4FDD\u8B77\u30FB\u91CC\u89AA\u63A2\u3057\u3092\u3059\u308B\u30B7\u30A7\u30EB\u30BF\u30FC" }, "uanimals": { "tagline": "\u30A6\u30AF\u30E9\u30A4\u30CA\u306E\u52D5\u7269\u4FDD\u8B77\u3068\u5553\u767A\u56E3\u4F53" }, "hospitallers": { "tagline": "\u5FDC\u6025\u51E6\u7F6E\u3068\u907F\u96E3\u3092\u63D0\u4F9B\u3059\u308B\u30A6\u30AF\u30E9\u30A4\u30CA\u306E\u30DC\u30E9\u30F3\u30C6\u30A3\u30A2\u6551\u6025\u968A" }, "sternenko-fund": { "tagline": "\u30A6\u30AF\u30E9\u30A4\u30CA\u9632\u885B\u90E8\u968A\u3078\u306EFPV\u30C9\u30ED\u30FC\u30F3\u4F9B\u7D66" }, "life-robots-ukraine": { "tagline": "\u30A6\u30AF\u30E9\u30A4\u30CA\u306E\u6A5F\u96F7\u9664\u53BB\u3092\u652F\u3048\u308B\u30ED\u30DC\u30C3\u30C8" }, "sirius-shelter": { "tagline": "\u72AC\u3068\u732B\u306E\u305F\u3081\u306E\u30A6\u30AF\u30E9\u30A4\u30CA\u6700\u5927\u306E\u52D5\u7269\u4FDD\u8B77\u65BD\u8A2D" } } };
3
+ export {
4
+ ja_default as default
5
+ };