trainfyi-embed 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,1265 @@
1
+ /* trainfyi-embed v1.0.0 | MIT | https://widget.trainfyi.com */
2
+
3
+ // src/styles/modern.ts
4
+ function getModernCSS() {
5
+ return `
6
+ /* Modern: gradient accent header */
7
+ .transportfyi-header {
8
+ background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 70%, #000));
9
+ border-radius: 12px 12px 0 0;
10
+ padding: 16px 20px;
11
+ display: flex;
12
+ align-items: flex-start;
13
+ gap: 14px;
14
+ }
15
+
16
+ .transportfyi-header-title {
17
+ font-size: 15px; font-weight: 700; color: #fff; margin: 0 0 4px 0; line-height: 1.3;
18
+ }
19
+
20
+ .transportfyi-header-subtitle {
21
+ font-size: 12px; color: rgba(255, 255, 255, 0.8); margin: 0;
22
+ }
23
+
24
+ .transportfyi-img {
25
+ width: 56px; height: 56px; border-radius: 8px; object-fit: cover;
26
+ background: rgba(255, 255, 255, 0.15); flex-shrink: 0;
27
+ display: flex; align-items: center; justify-content: center; overflow: hidden;
28
+ font-size: 24px; color: #fff; font-weight: 700;
29
+ }
30
+
31
+ .transportfyi-img img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; }
32
+
33
+ .transportfyi-body { padding: 16px 20px; }
34
+
35
+ /* Key-value rows */
36
+ .transportfyi-row {
37
+ display: flex; justify-content: space-between; align-items: flex-start; gap: 12px;
38
+ padding: 8px 0; border-bottom: 1px solid var(--border);
39
+ }
40
+ .transportfyi-row:last-child { border-bottom: none; }
41
+
42
+ .transportfyi-label { font-size: 12px; color: var(--muted); flex-shrink: 0; }
43
+ .transportfyi-value { font-size: 13px; font-weight: 600; color: var(--text); text-align: right; word-break: break-word; }
44
+
45
+ .transportfyi-kv-rows { padding: 4px 20px 8px; }
46
+ .transportfyi-kv-row {
47
+ display: flex; justify-content: space-between; padding: 6px 0;
48
+ border-bottom: 1px solid var(--border); font-size: 13px;
49
+ }
50
+ .transportfyi-kv-row:last-child { border-bottom: none; }
51
+ .transportfyi-kv-label { color: var(--muted); font-size: 12px; }
52
+ .transportfyi-kv-value { font-weight: 600; color: var(--text); text-align: right; }
53
+
54
+ /* Description text */
55
+ .transportfyi-desc {
56
+ padding: 0 20px 12px; font-size: 13px; color: var(--muted); line-height: 1.5;
57
+ }
58
+
59
+ /* View link */
60
+ .transportfyi-view-link { padding: 8px 20px; border-top: 1px solid var(--border); text-align: center; }
61
+ .transportfyi-view-link a {
62
+ color: var(--link); text-decoration: none; font-size: 13px; font-weight: 500;
63
+ display: inline-flex; align-items: center; gap: 4px;
64
+ }
65
+ .transportfyi-view-link a:hover { text-decoration: underline; }
66
+ .transportfyi-view-link svg { width: 12px; height: 12px; }
67
+
68
+ /* Pills */
69
+ .transportfyi-pills { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 20px; }
70
+ .transportfyi-pill {
71
+ display: inline-block; font-size: 11px; padding: 3px 10px; border-radius: 12px;
72
+ background: var(--surface); color: var(--text); border: 1px solid var(--border);
73
+ }
74
+
75
+ /* Code badges (IATA/ICAO) */
76
+ .transportfyi-code {
77
+ font-family: ui-monospace, 'SF Mono', monospace; font-weight: 700; font-size: 18px;
78
+ color: #fff; letter-spacing: 0.05em;
79
+ }
80
+
81
+ /* Stat grid (for airport/station stats) */
82
+ .transportfyi-stat-grid {
83
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
84
+ background: var(--border); margin: 0;
85
+ }
86
+ .transportfyi-stat-cell {
87
+ background: var(--bg); padding: 10px 12px; text-align: center;
88
+ }
89
+ .transportfyi-stat-num { font-size: 16px; font-weight: 700; color: var(--text); }
90
+ .transportfyi-stat-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2px; }
91
+ `;
92
+ }
93
+
94
+ // src/styles/clean.ts
95
+ function getCleanCSS() {
96
+ return `
97
+ /* Clean: flat header with accent left bar */
98
+ .transportfyi-header {
99
+ border-left: 4px solid var(--accent);
100
+ padding: 14px 16px;
101
+ display: flex;
102
+ align-items: flex-start;
103
+ gap: 12px;
104
+ background: var(--surface);
105
+ }
106
+
107
+ .transportfyi-header-title {
108
+ font-size: 14px; font-weight: 700; color: var(--text); margin: 0 0 3px 0; line-height: 1.3;
109
+ }
110
+
111
+ .transportfyi-header-subtitle {
112
+ font-size: 12px; color: var(--muted); margin: 0;
113
+ }
114
+
115
+ .transportfyi-img {
116
+ width: 44px; height: 44px; border-radius: 6px; object-fit: cover;
117
+ background: var(--badge-bg); flex-shrink: 0;
118
+ display: flex; align-items: center; justify-content: center; overflow: hidden;
119
+ font-size: 20px; color: var(--accent); font-weight: 700;
120
+ }
121
+
122
+ .transportfyi-img img { width: 100%; height: 100%; object-fit: cover; border-radius: 6px; }
123
+
124
+ .transportfyi-body { padding: 12px 16px; }
125
+
126
+ .transportfyi-row {
127
+ display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;
128
+ padding: 6px 0; border-bottom: 1px solid var(--border);
129
+ }
130
+ .transportfyi-row:last-child { border-bottom: none; }
131
+ .transportfyi-label { font-size: 12px; color: var(--muted); flex-shrink: 0; }
132
+ .transportfyi-value { font-size: 12px; font-weight: 600; color: var(--text); text-align: right; }
133
+
134
+ .transportfyi-kv-rows { padding: 4px 16px 6px; }
135
+ .transportfyi-kv-row {
136
+ display: flex; justify-content: space-between; padding: 5px 0;
137
+ border-bottom: 1px solid var(--border); font-size: 12px;
138
+ }
139
+ .transportfyi-kv-row:last-child { border-bottom: none; }
140
+ .transportfyi-kv-label { color: var(--muted); }
141
+ .transportfyi-kv-value { font-weight: 600; color: var(--text); text-align: right; }
142
+
143
+ .transportfyi-desc { padding: 0 16px 10px; font-size: 12px; color: var(--muted); line-height: 1.5; }
144
+
145
+ .transportfyi-view-link { padding: 6px 16px; border-top: 1px solid var(--border); text-align: center; }
146
+ .transportfyi-view-link a {
147
+ color: var(--link); text-decoration: none; font-size: 12px; font-weight: 500;
148
+ display: inline-flex; align-items: center; gap: 4px;
149
+ }
150
+ .transportfyi-view-link a:hover { text-decoration: underline; }
151
+ .transportfyi-view-link svg { width: 11px; height: 11px; }
152
+
153
+ .transportfyi-pills { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 16px; }
154
+ .transportfyi-pill {
155
+ display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 3px;
156
+ background: var(--surface); color: var(--text); border: 1px solid var(--border);
157
+ }
158
+
159
+ .transportfyi-code {
160
+ font-family: ui-monospace, 'SF Mono', monospace; font-weight: 700; font-size: 16px;
161
+ color: var(--accent); letter-spacing: 0.05em;
162
+ }
163
+
164
+ .transportfyi-stat-grid {
165
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
166
+ background: var(--border); margin: 0;
167
+ }
168
+ .transportfyi-stat-cell { background: var(--bg); padding: 8px 10px; text-align: center; }
169
+ .transportfyi-stat-num { font-size: 14px; font-weight: 700; color: var(--text); }
170
+ .transportfyi-stat-label { font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2px; }
171
+ `;
172
+ }
173
+
174
+ // src/themes.ts
175
+ function getStyleCSS(style) {
176
+ switch (style) {
177
+ case "clean":
178
+ return getCleanCSS();
179
+ case "modern":
180
+ default:
181
+ return getModernCSS();
182
+ }
183
+ }
184
+ function getThemeCSS(accent, style = "modern") {
185
+ return `
186
+ :host {
187
+ display: block;
188
+ --site-accent: ${accent};
189
+ }
190
+
191
+ .transportfyi-widget {
192
+ box-sizing: border-box;
193
+ min-width: 240px;
194
+ max-width: 420px;
195
+ border-radius: 8px;
196
+ overflow: hidden;
197
+ border: 1px solid var(--border);
198
+ background: var(--bg);
199
+ color: var(--text);
200
+ font-size: 14px;
201
+ line-height: 1.6;
202
+ transition: border-color 0.2s;
203
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
204
+ }
205
+
206
+ .transportfyi-widget:hover {
207
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
208
+ }
209
+
210
+ .transportfyi-widget[data-size="compact"] { max-width: 280px; font-size: 13px; }
211
+ .transportfyi-widget[data-size="default"] { max-width: 420px; }
212
+ .transportfyi-widget[data-size="large"] { max-width: 720px; }
213
+
214
+ /* Light theme */
215
+ .transportfyi-widget[data-theme="light"] {
216
+ --bg: #fff; --text: #1e293b; --border: #e2e8f0; --accent: var(--site-accent);
217
+ --muted: #64748b; --surface: #f8fafc; --badge-bg: #f1f5f9; --badge-text: #374151;
218
+ --link: var(--site-accent); --copy-bg: #f3f4f6; --copy-hover: #e5e7eb;
219
+ --input-bg: #ffffff; --input-border: #d1d5db; --input-focus: var(--site-accent);
220
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
221
+ }
222
+
223
+ /* Dark theme */
224
+ .transportfyi-widget[data-theme="dark"] {
225
+ --bg: #1a1a1a; --text: #f3f4f6; --border: #374151; --accent: var(--site-accent);
226
+ --muted: #9ca3af; --surface: #111827; --badge-bg: #374151; --badge-text: #d1d5db;
227
+ --link: color-mix(in srgb, var(--site-accent) 80%, #fff); --copy-bg: #374151; --copy-hover: #4b5563;
228
+ --input-bg: #111111; --input-border: #4b5563; --input-focus: var(--site-accent);
229
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
230
+ }
231
+
232
+ /* Sepia theme */
233
+ .transportfyi-widget[data-theme="sepia"] {
234
+ --bg: #f5f0e8; --text: #3d3529; --border: #d4c5a9; --accent: var(--site-accent);
235
+ --muted: #8b7d6b; --surface: #ede8df; --badge-bg: #e8e0d0; --badge-text: #5c4f3d;
236
+ --link: color-mix(in srgb, var(--site-accent) 70%, #3d3529); --copy-bg: #e8e0d0; --copy-hover: #ddd4c0;
237
+ --input-bg: #f5f0e8; --input-border: #c4b49a; --input-focus: var(--site-accent);
238
+ --shadow: 0 1px 3px rgba(61, 53, 41, 0.12);
239
+ }
240
+
241
+ .transportfyi-widget *, .transportfyi-widget *::before, .transportfyi-widget *::after { box-sizing: border-box; }
242
+
243
+ /* Loading */
244
+ .transportfyi-loading {
245
+ padding: 20px 16px; text-align: center; color: var(--muted); font-size: 13px;
246
+ display: flex; align-items: center; justify-content: center; gap: 8px;
247
+ }
248
+ .transportfyi-spinner {
249
+ width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent);
250
+ border-radius: 50%; animation: transportfyi-spin 0.7s linear infinite; display: inline-block; flex-shrink: 0;
251
+ }
252
+ @keyframes transportfyi-spin { to { transform: rotate(360deg); } }
253
+
254
+ /* Error */
255
+ .transportfyi-error { padding: 16px; color: var(--muted); font-size: 13px; text-align: center; }
256
+ .transportfyi-error p { margin: 0 0 8px 0; }
257
+ .transportfyi-error a { color: var(--link); text-decoration: none; }
258
+ .transportfyi-error a:hover { text-decoration: underline; }
259
+
260
+ /* Badge */
261
+ .transportfyi-badge {
262
+ display: inline-block; font-size: 10px; font-weight: 600; padding: 2px 7px;
263
+ border-radius: 4px; background: var(--badge-bg); color: var(--badge-text);
264
+ text-transform: uppercase; letter-spacing: 0.04em;
265
+ }
266
+
267
+ /* Search */
268
+ .transportfyi-search-wrap { padding: 12px 16px; }
269
+ .transportfyi-search-form { display: flex; gap: 8px; }
270
+ .transportfyi-search-input {
271
+ flex: 1; padding: 8px 12px; border: 1px solid var(--input-border); border-radius: 6px;
272
+ background: var(--input-bg); color: var(--text); font-size: 13px; font-family: inherit;
273
+ outline: none; transition: border-color 0.15s;
274
+ }
275
+ .transportfyi-search-input:focus { border-color: var(--input-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--input-focus) 20%, transparent); }
276
+ .transportfyi-search-input::placeholder { color: var(--muted); }
277
+ .transportfyi-search-btn {
278
+ background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 8px 14px;
279
+ font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit;
280
+ transition: opacity 0.15s; white-space: nowrap;
281
+ }
282
+ .transportfyi-search-btn:hover { opacity: 0.9; }
283
+ .transportfyi-search-results { padding: 0 16px 12px; }
284
+ .transportfyi-result-item { padding: 8px 0; border-bottom: 1px solid var(--border); }
285
+ .transportfyi-result-item:last-child { border-bottom: none; }
286
+ .transportfyi-result-title { font-size: 13px; font-weight: 600; color: var(--text); margin: 0 0 3px 0; }
287
+ .transportfyi-result-meta { font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
288
+
289
+ /* Powered by footer */
290
+ .transportfyi-powered {
291
+ display: block; text-align: center; padding: 8px 16px; font-size: 11px; color: var(--muted);
292
+ border-top: 1px solid var(--border);
293
+ }
294
+ .transportfyi-powered a { color: var(--link); text-decoration: none; font-weight: 500; }
295
+ .transportfyi-powered a:hover { text-decoration: underline; }
296
+
297
+ /* Copy button */
298
+ .transportfyi-copy-btn {
299
+ background: var(--copy-bg); color: var(--text); border: none; border-radius: 5px;
300
+ padding: 4px 9px; font-size: 11px; cursor: pointer; display: inline-flex;
301
+ align-items: center; gap: 4px; transition: background 0.15s; font-family: inherit;
302
+ }
303
+ .transportfyi-copy-btn:hover { background: var(--copy-hover); }
304
+ .transportfyi-copy-btn svg { width: 11px; height: 11px; }
305
+
306
+ /* Mono */
307
+ .transportfyi-mono { font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-size: 13px; }
308
+
309
+ /* Spec bar (for aircraft specs visualization) */
310
+ .transportfyi-spec-bar-wrap { padding: 0 18px 12px; }
311
+ .transportfyi-spec-bar { display: flex; flex-direction: column; gap: 8px; }
312
+ .transportfyi-spec-bar-item { display: flex; align-items: center; gap: 8px; }
313
+ .transportfyi-spec-bar-label { font-size: 11px; color: var(--muted); min-width: 60px; text-align: right; }
314
+ .transportfyi-spec-bar-track { flex: 1; height: 8px; background: var(--surface); border-radius: 4px; overflow: hidden; }
315
+ .transportfyi-spec-bar-fill { height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.3s ease; }
316
+ .transportfyi-spec-bar-value { font-size: 11px; color: var(--text); min-width: 50px; font-weight: 600; }
317
+
318
+ /* Amenity icons */
319
+ .transportfyi-amenities { display: flex; gap: 6px; flex-wrap: wrap; padding: 4px 0; }
320
+ .transportfyi-amenity {
321
+ display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 3px 8px;
322
+ border-radius: 12px; background: var(--surface); color: var(--muted); border: 1px solid var(--border);
323
+ }
324
+ .transportfyi-amenity svg { width: 12px; height: 12px; }
325
+ .transportfyi-amenity--active { background: color-mix(in srgb, var(--accent) 10%, var(--bg)); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
326
+
327
+ ${getStyleCSS(style)}
328
+ `;
329
+ }
330
+
331
+ // src/shadow.ts
332
+ function createShadow(el, config) {
333
+ const widgetStyle = el.dataset.styleVariant || "modern";
334
+ const shadow = el.attachShadow({ mode: "open" });
335
+ const style = document.createElement("style");
336
+ style.textContent = getThemeCSS(config.accent, widgetStyle);
337
+ shadow.appendChild(style);
338
+ return shadow;
339
+ }
340
+ function resolveTheme(el) {
341
+ const raw = el.dataset.theme || "light";
342
+ if (raw === "auto") {
343
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
344
+ }
345
+ return raw;
346
+ }
347
+ function createWidgetRoot(shadow, el, extraClass) {
348
+ const theme = resolveTheme(el);
349
+ const size = el.dataset.size || "default";
350
+ const div = document.createElement("div");
351
+ div.className = ["transportfyi-widget", extraClass].filter(Boolean).join(" ");
352
+ div.setAttribute("data-theme", theme);
353
+ div.setAttribute("data-size", size);
354
+ shadow.appendChild(div);
355
+ if (el.dataset.theme === "auto") {
356
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
357
+ div.setAttribute("data-theme", e.matches ? "dark" : "light");
358
+ });
359
+ }
360
+ return div;
361
+ }
362
+ function renderLoading(container) {
363
+ container.innerHTML = `
364
+ <div class="transportfyi-loading">
365
+ <span class="transportfyi-spinner"></span>
366
+ Loading\u2026
367
+ </div>
368
+ `;
369
+ }
370
+ function renderError(container, message, config) {
371
+ container.innerHTML = `
372
+ <div class="transportfyi-error">
373
+ <p>${message}</p>
374
+ <a href="https://${config.domain}" target="_blank" rel="noopener">
375
+ Visit ${config.name}
376
+ </a>
377
+ </div>
378
+ `;
379
+ }
380
+ var externalLinkIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
381
+ function poweredByHTML(config) {
382
+ return `<span class="transportfyi-powered">Powered by <a href="https://${config.domain}" target="_blank" rel="noopener">${config.name}</a></span>`;
383
+ }
384
+
385
+ // src/api.ts
386
+ var CACHE_TTL_MS = 5 * 60 * 1e3;
387
+ function cacheKey(url) {
388
+ return `transportfyi_embed_${url}`;
389
+ }
390
+ function getFromCache(url) {
391
+ try {
392
+ const raw = sessionStorage.getItem(cacheKey(url));
393
+ if (!raw) return null;
394
+ const entry = JSON.parse(raw);
395
+ if (Date.now() - entry.ts > CACHE_TTL_MS) {
396
+ sessionStorage.removeItem(cacheKey(url));
397
+ return null;
398
+ }
399
+ return entry.data;
400
+ } catch (e) {
401
+ return null;
402
+ }
403
+ }
404
+ function setInCache(url, data) {
405
+ try {
406
+ const entry = { data, ts: Date.now() };
407
+ sessionStorage.setItem(cacheKey(url), JSON.stringify(entry));
408
+ } catch (e) {
409
+ }
410
+ }
411
+ async function fetchAPI(baseUrl, path, params) {
412
+ const base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
413
+ const relativePath = path.startsWith("/") ? path.slice(1) : path;
414
+ const url = new URL(relativePath, base);
415
+ if (params) {
416
+ Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
417
+ }
418
+ const urlStr = url.toString();
419
+ const cached = getFromCache(urlStr);
420
+ if (cached !== null) return cached;
421
+ const response = await fetch(urlStr, {
422
+ headers: { Accept: "application/json" }
423
+ });
424
+ if (!response.ok) {
425
+ throw new Error(`API error ${response.status}: ${urlStr}`);
426
+ }
427
+ const data = await response.json();
428
+ setInCache(urlStr, data);
429
+ return data;
430
+ }
431
+
432
+ // src/cards/shared.ts
433
+ function esc(s) {
434
+ if (!s) return "";
435
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
436
+ }
437
+ function kvRow(label, value) {
438
+ if (value === null || value === void 0 || value === "") return "";
439
+ return `<div class="transportfyi-kv-row"><span class="transportfyi-kv-label">${esc(label)}</span><span class="transportfyi-kv-value">${esc(String(value))}</span></div>`;
440
+ }
441
+ function fmtNum(n) {
442
+ if (n === null || n === void 0) return "";
443
+ return n.toLocaleString("en-US");
444
+ }
445
+ function statCell(value, label) {
446
+ return `<div class="transportfyi-stat-cell"><div class="transportfyi-stat-num">${esc(String(value))}</div><div class="transportfyi-stat-label">${esc(label)}</div></div>`;
447
+ }
448
+ function airportTypeLabel(type) {
449
+ const map = {
450
+ large_airport: "Large Airport",
451
+ medium_airport: "Medium Airport",
452
+ small_airport: "Small Airport",
453
+ heliport: "Heliport",
454
+ seaplane_base: "Seaplane Base",
455
+ closed: "Closed",
456
+ balloonport: "Balloonport"
457
+ };
458
+ return map[type] || type;
459
+ }
460
+ function stationTypeLabel(type) {
461
+ const map = {
462
+ major: "Major Station",
463
+ regional: "Regional Station",
464
+ suburban: "Suburban Station",
465
+ local: "Local Station",
466
+ metro: "Metro Station",
467
+ tram: "Tram Stop",
468
+ heritage: "Heritage Station"
469
+ };
470
+ return map[type] || type;
471
+ }
472
+ function renderFAQCard(faqs, config) {
473
+ if (!faqs || !faqs.length) return `<div class="transportfyi-body">No FAQs available.</div>${poweredByHTML(config)}`;
474
+ const items = faqs.map((faq) => `
475
+ <details class="transportfyi-faq-item" style="border-bottom:1px solid var(--border);padding:10px 18px;">
476
+ <summary style="cursor:pointer;font-size:0.9rem;font-weight:600;color:var(--text);list-style:none;display:flex;justify-content:space-between;align-items:center;">
477
+ ${esc(faq.question)}
478
+ <span style="flex-shrink:0;margin-left:8px;font-size:0.75rem;color:var(--muted);">+</span>
479
+ </summary>
480
+ <div style="margin-top:8px;font-size:0.85rem;color:var(--muted);line-height:1.5;">
481
+ ${esc(faq.answer)}
482
+ </div>
483
+ </details>
484
+ `).join("");
485
+ return `
486
+ <div class="transportfyi-header">
487
+ <div>
488
+ <div class="transportfyi-header-title">Frequently Asked Questions</div>
489
+ <div class="transportfyi-header-subtitle">${faqs.length} questions</div>
490
+ </div>
491
+ </div>
492
+ ${items}
493
+ ${poweredByHTML(config)}
494
+ `;
495
+ }
496
+
497
+ // src/cards/airport.ts
498
+ function renderAirportCard(data, config) {
499
+ var _a, _b, _c, _d, _e, _f, _g, _h;
500
+ const name = String((_a = data.name) != null ? _a : "");
501
+ const iata = String((_b = data.iata_code) != null ? _b : "");
502
+ const icao = String((_d = (_c = data.ident) != null ? _c : data.icao_code) != null ? _d : "");
503
+ const type = String((_e = data.type) != null ? _e : "");
504
+ const countryName = String((_f = data.country_name) != null ? _f : "");
505
+ const regionName = String((_g = data.region_name) != null ? _g : "");
506
+ const municipality = String((_h = data.municipality) != null ? _h : "");
507
+ const elevationFt = data.elevation_ft != null ? Number(data.elevation_ft) : null;
508
+ const runwayCount = data.runway_count != null ? Number(data.runway_count) : 0;
509
+ const airlineCount = data.airline_count != null ? Number(data.airline_count) : 0;
510
+ const destCount = data.destination_count != null ? Number(data.destination_count) : 0;
511
+ const detailUrl = iata ? `https://${config.domain}/${esc(iata)}/` : `https://${config.domain}/`;
512
+ const typeLabel = airportTypeLabel(type);
513
+ return `
514
+ <div class="transportfyi-header">
515
+ <div class="transportfyi-img">
516
+ <span class="transportfyi-code">${esc(iata || "?")}</span>
517
+ </div>
518
+ <div>
519
+ <div class="transportfyi-header-title">${esc(name)}</div>
520
+ <div class="transportfyi-header-subtitle">
521
+ ${esc(municipality || countryName)}${icao ? ` \xB7 ICAO: ${esc(icao)}` : ""}
522
+ </div>
523
+ </div>
524
+ </div>
525
+ ${type ? `<div style="padding:8px 20px 0;"><span class="transportfyi-badge" style="background:${config.accent};color:#fff">${esc(typeLabel)}</span></div>` : ""}
526
+ <div class="transportfyi-stat-grid">
527
+ ${statCell(fmtNum(runwayCount) || "0", "Runways")}
528
+ ${statCell(fmtNum(airlineCount) || "0", "Airlines")}
529
+ ${statCell(fmtNum(destCount) || "0", "Destinations")}
530
+ </div>
531
+ <div class="transportfyi-kv-rows">
532
+ ${kvRow("Country", countryName)}
533
+ ${kvRow("Region", regionName)}
534
+ ${elevationFt != null ? kvRow("Elevation", `${fmtNum(elevationFt)} ft`) : ""}
535
+ </div>
536
+ <div class="transportfyi-view-link"><a href="${detailUrl}" target="_blank" rel="noopener">View on ${esc(config.name)} ${externalLinkIcon}</a></div>
537
+ ${poweredByHTML(config)}
538
+ `;
539
+ }
540
+
541
+ // src/cards/airline.ts
542
+ function renderAirlineCard(data, config) {
543
+ var _a, _b, _c, _d, _e, _f;
544
+ const name = String((_a = data.name) != null ? _a : "");
545
+ const slug = String((_b = data.slug) != null ? _b : "");
546
+ const iata = String((_c = data.iata_code) != null ? _c : "");
547
+ const icao = String((_d = data.icao_code) != null ? _d : "");
548
+ const callsign = String((_e = data.callsign) != null ? _e : "");
549
+ const countryName = String((_f = data.country_name) != null ? _f : "");
550
+ const isActive = Boolean(data.is_active);
551
+ const routeCount = data.route_count != null ? Number(data.route_count) : 0;
552
+ const destCount = data.destination_count != null ? Number(data.destination_count) : 0;
553
+ const countryCount = data.country_count != null ? Number(data.country_count) : 0;
554
+ const detailUrl = `https://${config.domain}/${esc(slug)}/`;
555
+ return `
556
+ <div class="transportfyi-header">
557
+ <div class="transportfyi-img">
558
+ <span class="transportfyi-code">${esc(iata || "?")}</span>
559
+ </div>
560
+ <div>
561
+ <div class="transportfyi-header-title">${esc(name)}</div>
562
+ <div class="transportfyi-header-subtitle">${esc(countryName)}${icao ? ` \xB7 ICAO: ${esc(icao)}` : ""}</div>
563
+ </div>
564
+ </div>
565
+ <div style="padding:8px 20px 0;display:flex;gap:6px;flex-wrap:wrap;">
566
+ ${isActive ? '<span class="transportfyi-badge" style="background:#16a34a;color:#fff">Active</span>' : '<span class="transportfyi-badge" style="background:#dc2626;color:#fff">Inactive</span>'}
567
+ ${callsign ? `<span class="transportfyi-badge">${esc(callsign)}</span>` : ""}
568
+ </div>
569
+ <div class="transportfyi-stat-grid">
570
+ ${statCell(fmtNum(routeCount) || "0", "Routes")}
571
+ ${statCell(fmtNum(destCount) || "0", "Destinations")}
572
+ ${statCell(fmtNum(countryCount) || "0", "Countries")}
573
+ </div>
574
+ <div class="transportfyi-view-link"><a href="${detailUrl}" target="_blank" rel="noopener">View on ${esc(config.name)} ${externalLinkIcon}</a></div>
575
+ ${poweredByHTML(config)}
576
+ `;
577
+ }
578
+
579
+ // src/cards/aircraft.ts
580
+ function specBar(label, value, max, unit) {
581
+ const pct = Math.min(100, Math.round(value / max * 100));
582
+ return `
583
+ <div class="transportfyi-spec-bar-item">
584
+ <span class="transportfyi-spec-bar-label">${esc(label)}</span>
585
+ <div class="transportfyi-spec-bar-track">
586
+ <div class="transportfyi-spec-bar-fill" style="width:${pct}%"></div>
587
+ </div>
588
+ <span class="transportfyi-spec-bar-value">${fmtNum(value)} ${esc(unit)}</span>
589
+ </div>
590
+ `;
591
+ }
592
+ function statusBadge(status) {
593
+ const colors = {
594
+ in_production: "#16a34a",
595
+ in_service: "#3b82f6",
596
+ retired: "#9ca3af",
597
+ development: "#d97706",
598
+ limited: "#8b5cf6"
599
+ };
600
+ const labels = {
601
+ in_production: "In Production",
602
+ in_service: "In Service",
603
+ retired: "Retired",
604
+ development: "In Development",
605
+ limited: "Limited"
606
+ };
607
+ const bg = colors[status] || "#6b7280";
608
+ const label = labels[status] || status;
609
+ return `<span class="transportfyi-badge" style="background:${bg};color:#fff">${esc(label)}</span>`;
610
+ }
611
+ function renderAircraftCard(data, config) {
612
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
613
+ const name = String((_a = data.name) != null ? _a : "");
614
+ const slug = String((_b = data.slug) != null ? _b : "");
615
+ const iata = String((_c = data.iata_code) != null ? _c : "");
616
+ const icao = String((_d = data.icao_code) != null ? _d : "");
617
+ const manufacturer = String((_e = data.manufacturer_name) != null ? _e : "");
618
+ const familyName = String((_f = data.family_name) != null ? _f : "");
619
+ const status = String((_g = data.status) != null ? _g : "");
620
+ const rangeKm = data.range_km != null ? Number(data.range_km) : 0;
621
+ const maxSpeed = data.max_speed_kmh != null ? Number(data.max_speed_kmh) : 0;
622
+ const cruiseSpeed = data.cruise_speed_kmh != null ? Number(data.cruise_speed_kmh) : 0;
623
+ const typicalSeats = data.typical_seats != null ? Number(data.typical_seats) : 0;
624
+ const maxSeats = data.max_seats != null ? Number(data.max_seats) : 0;
625
+ const lengthM = data.length_m != null ? Number(data.length_m) : null;
626
+ const wingspanM = data.wingspan_m != null ? Number(data.wingspan_m) : null;
627
+ const heightM = data.height_m != null ? Number(data.height_m) : null;
628
+ const engineCount = data.engine_count != null ? Number(data.engine_count) : null;
629
+ const engineType = String((_h = data.engine_type) != null ? _h : "");
630
+ const engineModel = String((_i = data.engine_model) != null ? _i : "");
631
+ const firstFlight = data.first_flight_date ? String(data.first_flight_date).slice(0, 4) : "";
632
+ const fleetCount = data.fleet_count != null ? Number(data.fleet_count) : 0;
633
+ const detailUrl = `https://${config.domain}/${esc(slug)}/`;
634
+ return `
635
+ <div class="transportfyi-header">
636
+ <div class="transportfyi-img">
637
+ <span class="transportfyi-code">${esc(iata || icao || "\u2708")}</span>
638
+ </div>
639
+ <div>
640
+ <div class="transportfyi-header-title">${esc(name)}</div>
641
+ <div class="transportfyi-header-subtitle">${esc(manufacturer)}${familyName ? ` \xB7 ${esc(familyName)}` : ""}</div>
642
+ </div>
643
+ </div>
644
+ <div style="padding:8px 20px 0;display:flex;gap:6px;flex-wrap:wrap;">
645
+ ${status ? statusBadge(status) : ""}
646
+ ${fleetCount > 0 ? `<span class="transportfyi-badge">${fmtNum(fleetCount)} in fleet</span>` : ""}
647
+ </div>
648
+ <div class="transportfyi-spec-bar-wrap">
649
+ <div class="transportfyi-spec-bar">
650
+ ${rangeKm > 0 ? specBar("Range", rangeKm, 18e3, "km") : ""}
651
+ ${maxSpeed > 0 ? specBar("Max Speed", maxSpeed, 1e3, "km/h") : ""}
652
+ ${typicalSeats > 0 ? specBar("Seats", typicalSeats, 600, "") : ""}
653
+ </div>
654
+ </div>
655
+ <div class="transportfyi-kv-rows">
656
+ ${cruiseSpeed > 0 ? kvRow("Cruise Speed", `${fmtNum(cruiseSpeed)} km/h`) : ""}
657
+ ${maxSeats > 0 ? kvRow("Max Seats", fmtNum(maxSeats)) : ""}
658
+ ${lengthM != null ? kvRow("Length", `${lengthM} m`) : ""}
659
+ ${wingspanM != null ? kvRow("Wingspan", `${wingspanM} m`) : ""}
660
+ ${heightM != null ? kvRow("Height", `${heightM} m`) : ""}
661
+ ${engineCount != null ? kvRow("Engines", `${engineCount}\xD7 ${esc(engineType)}`) : ""}
662
+ ${engineModel ? kvRow("Engine Model", engineModel) : ""}
663
+ ${firstFlight ? kvRow("First Flight", firstFlight) : ""}
664
+ </div>
665
+ <div class="transportfyi-view-link"><a href="${detailUrl}" target="_blank" rel="noopener">View on ${esc(config.name)} ${externalLinkIcon}</a></div>
666
+ ${poweredByHTML(config)}
667
+ `;
668
+ }
669
+
670
+ // src/cards/station.ts
671
+ var wifiIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>`;
672
+ var loungeIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v3"/><path d="M2 11v5a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v2H6v-2a2 2 0 0 0-4 0z"/><path d="M4 18v2"/><path d="M20 18v2"/></svg>`;
673
+ var luggageIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="7" width="12" height="14" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/><path d="M8 21v1"/><path d="M16 21v1"/></svg>`;
674
+ function amenityPill(icon, label, active) {
675
+ const cls = active ? "transportfyi-amenity transportfyi-amenity--active" : "transportfyi-amenity";
676
+ return `<span class="${cls}">${icon} ${esc(label)}</span>`;
677
+ }
678
+ function renderStationCard(data, config) {
679
+ var _a, _b, _c, _d, _e;
680
+ const name = String((_a = data.name) != null ? _a : "");
681
+ const slug = String((_b = data.slug) != null ? _b : "");
682
+ const type = String((_c = data.type) != null ? _c : "");
683
+ const countryName = String((_d = data.country_name) != null ? _d : "");
684
+ const timezone = String((_e = data.timezone) != null ? _e : "");
685
+ const isMainStation = Boolean(data.is_main_station);
686
+ const platforms = data.platforms != null ? Number(data.platforms) : null;
687
+ const hasWifi = Boolean(data.has_wifi);
688
+ const hasLounge = Boolean(data.has_lounge);
689
+ const hasLuggage = Boolean(data.has_luggage_storage);
690
+ const yearOpened = data.year_opened != null ? Number(data.year_opened) : null;
691
+ const operatorCount = data.operator_count != null ? Number(data.operator_count) : 0;
692
+ const routeCount = data.route_count != null ? Number(data.route_count) : 0;
693
+ const popularityScore = data.popularity_score != null ? Number(data.popularity_score) : 0;
694
+ const detailUrl = `https://${config.domain}/station/${esc(slug)}/`;
695
+ const typeLabel = stationTypeLabel(type);
696
+ const showAmenities = hasWifi || hasLounge || hasLuggage;
697
+ return `
698
+ <div class="transportfyi-header">
699
+ <div class="transportfyi-img">\u{1F689}</div>
700
+ <div>
701
+ <div class="transportfyi-header-title">${esc(name)}</div>
702
+ <div class="transportfyi-header-subtitle">${esc(countryName)}</div>
703
+ </div>
704
+ </div>
705
+ <div style="padding:8px 20px 0;display:flex;gap:6px;flex-wrap:wrap;">
706
+ ${type ? `<span class="transportfyi-badge" style="background:${config.accent};color:#fff">${esc(typeLabel)}</span>` : ""}
707
+ ${isMainStation ? '<span class="transportfyi-badge" style="background:#16a34a;color:#fff">Main Station</span>' : ""}
708
+ </div>
709
+ <div class="transportfyi-stat-grid">
710
+ ${statCell(fmtNum(operatorCount) || "0", "Operators")}
711
+ ${statCell(fmtNum(routeCount) || "0", "Routes")}
712
+ ${statCell(platforms != null ? String(platforms) : "-", "Platforms")}
713
+ </div>
714
+ ${showAmenities ? `
715
+ <div style="padding:8px 20px;">
716
+ <div class="transportfyi-amenities">
717
+ ${amenityPill(wifiIcon, "WiFi", hasWifi)}
718
+ ${amenityPill(loungeIcon, "Lounge", hasLounge)}
719
+ ${amenityPill(luggageIcon, "Luggage", hasLuggage)}
720
+ </div>
721
+ </div>
722
+ ` : ""}
723
+ <div class="transportfyi-kv-rows">
724
+ ${kvRow("Timezone", timezone)}
725
+ ${yearOpened != null ? kvRow("Opened", String(yearOpened)) : ""}
726
+ ${popularityScore > 0 ? kvRow("Popularity", String(popularityScore)) : ""}
727
+ </div>
728
+ <div class="transportfyi-view-link"><a href="${detailUrl}" target="_blank" rel="noopener">View on ${esc(config.name)} ${externalLinkIcon}</a></div>
729
+ ${poweredByHTML(config)}
730
+ `;
731
+ }
732
+
733
+ // src/widgets/entity.ts
734
+ function getEntityPath(config, slug) {
735
+ switch (config.site) {
736
+ case "airportfyi":
737
+ return `airports/${slug}/`;
738
+ case "airlinefyi":
739
+ return `airlines/${slug}/`;
740
+ case "planefyi":
741
+ return `aircraft-types/${slug}/`;
742
+ case "trainfyi":
743
+ return `stations/${slug}/`;
744
+ default:
745
+ return `${config.entitySlug}/${slug}/`;
746
+ }
747
+ }
748
+ function initEntityWidget(el, config) {
749
+ var _a;
750
+ const dataset = el.dataset;
751
+ const slug = (_a = dataset.slug) != null ? _a : "";
752
+ if (!slug) {
753
+ const shadow2 = createShadow(el, config);
754
+ const container2 = createWidgetRoot(shadow2, el, "transportfyi-entity-widget");
755
+ renderError(container2, "Missing data-slug attribute.", config);
756
+ return;
757
+ }
758
+ const shadow = createShadow(el, config);
759
+ const container = createWidgetRoot(shadow, el, "transportfyi-entity-widget");
760
+ renderLoading(container);
761
+ const path = getEntityPath(config, slug);
762
+ fetchAPI(config.apiBase, path).then((data) => {
763
+ var _a2;
764
+ let html;
765
+ switch (config.site) {
766
+ case "airportfyi":
767
+ html = renderAirportCard(data, config);
768
+ break;
769
+ case "airlinefyi":
770
+ html = renderAirlineCard(data, config);
771
+ break;
772
+ case "planefyi":
773
+ html = renderAircraftCard(data, config);
774
+ break;
775
+ case "trainfyi":
776
+ html = renderStationCard(data, config);
777
+ break;
778
+ default: {
779
+ const name = String((_a2 = data.name) != null ? _a2 : slug);
780
+ html = `
781
+ <div style="padding:16px;">
782
+ <div style="font-size:1rem;font-weight:600;margin-bottom:8px;">${esc(name)}</div>
783
+ <a href="https://${config.domain}" target="_blank" rel="noopener"
784
+ style="color:${config.accent};text-decoration:none;font-size:0.85rem;">
785
+ View on ${esc(config.name)} ${externalLinkIcon}
786
+ </a>
787
+ </div>
788
+ `;
789
+ break;
790
+ }
791
+ }
792
+ container.innerHTML = html;
793
+ }).catch(() => {
794
+ renderError(container, `Unable to load "${esc(slug)}". Please try again later.`, config);
795
+ });
796
+ }
797
+
798
+ // src/widgets/compare.ts
799
+ function getEntityPath2(config, slug) {
800
+ switch (config.site) {
801
+ case "airportfyi":
802
+ return `airports/${slug}/`;
803
+ case "airlinefyi":
804
+ return `airlines/${slug}/`;
805
+ case "planefyi":
806
+ return `aircraft-types/${slug}/`;
807
+ case "trainfyi":
808
+ return `stations/${slug}/`;
809
+ default:
810
+ return `${config.entitySlug}/${slug}/`;
811
+ }
812
+ }
813
+ function getDetailUrl(config, data) {
814
+ var _a, _b;
815
+ const slug = String((_a = data.slug) != null ? _a : "");
816
+ switch (config.site) {
817
+ case "airportfyi":
818
+ return `https://${config.domain}/${String((_b = data.iata_code) != null ? _b : slug)}/`;
819
+ case "trainfyi":
820
+ return `https://${config.domain}/station/${slug}/`;
821
+ default:
822
+ return `https://${config.domain}/${slug}/`;
823
+ }
824
+ }
825
+ function getCompareFields(config) {
826
+ const num = (v) => fmtNum(Number(v) || 0);
827
+ switch (config.site) {
828
+ case "airportfyi":
829
+ return [
830
+ { key: "country_name", label: "Country" },
831
+ { key: "type", label: "Type" },
832
+ { key: "runway_count", label: "Runways", fmt: num },
833
+ { key: "airline_count", label: "Airlines", fmt: num },
834
+ { key: "destination_count", label: "Destinations", fmt: num },
835
+ { key: "elevation_ft", label: "Elevation (ft)", fmt: num }
836
+ ];
837
+ case "airlinefyi":
838
+ return [
839
+ { key: "country_name", label: "Country" },
840
+ { key: "iata_code", label: "IATA" },
841
+ { key: "route_count", label: "Routes", fmt: num },
842
+ { key: "destination_count", label: "Destinations", fmt: num },
843
+ { key: "country_count", label: "Countries", fmt: num }
844
+ ];
845
+ case "planefyi":
846
+ return [
847
+ { key: "manufacturer_name", label: "Manufacturer" },
848
+ { key: "typical_seats", label: "Seats", fmt: num },
849
+ { key: "range_km", label: "Range (km)", fmt: num },
850
+ { key: "max_speed_kmh", label: "Max Speed (km/h)", fmt: num },
851
+ { key: "wingspan_m", label: "Wingspan (m)" },
852
+ { key: "engine_count", label: "Engines", fmt: num }
853
+ ];
854
+ case "trainfyi":
855
+ return [
856
+ { key: "country_name", label: "Country" },
857
+ { key: "type", label: "Type" },
858
+ { key: "operator_count", label: "Operators", fmt: num },
859
+ { key: "route_count", label: "Routes", fmt: num },
860
+ { key: "platforms", label: "Platforms" }
861
+ ];
862
+ default:
863
+ return [];
864
+ }
865
+ }
866
+ function initCompareWidget(el, config) {
867
+ var _a;
868
+ const dataset = el.dataset;
869
+ const slugs = ((_a = dataset.slugs) != null ? _a : "").split(",").map((s) => s.trim()).filter(Boolean);
870
+ const shadow = createShadow(el, config);
871
+ const container = createWidgetRoot(shadow, el, "transportfyi-compare-widget");
872
+ if (slugs.length < 2) {
873
+ renderError(container, "Missing data-slugs attribute (comma-separated pair).", config);
874
+ return;
875
+ }
876
+ renderLoading(container);
877
+ const [slugA, slugB] = slugs;
878
+ Promise.all([
879
+ fetchAPI(config.apiBase, getEntityPath2(config, slugA)),
880
+ fetchAPI(config.apiBase, getEntityPath2(config, slugB))
881
+ ]).then(([a, b]) => {
882
+ var _a2, _b;
883
+ const nameA = String((_a2 = a.name) != null ? _a2 : slugA);
884
+ const nameB = String((_b = b.name) != null ? _b : slugB);
885
+ const urlA = getDetailUrl(config, a);
886
+ const urlB = getDetailUrl(config, b);
887
+ const fields = getCompareFields(config);
888
+ const rows = fields.map((f) => {
889
+ var _a3, _b2;
890
+ const valA = f.fmt ? f.fmt(a[f.key]) : String((_a3 = a[f.key]) != null ? _a3 : "-");
891
+ const valB = f.fmt ? f.fmt(b[f.key]) : String((_b2 = b[f.key]) != null ? _b2 : "-");
892
+ return `<tr><td style="color:var(--muted);font-size:12px;padding:6px 8px;">${esc(f.label)}</td><td style="font-weight:600;padding:6px 8px;text-align:center;">${esc(valA)}</td><td style="font-weight:600;padding:6px 8px;text-align:center;">${esc(valB)}</td></tr>`;
893
+ }).join("");
894
+ container.innerHTML = `
895
+ <div class="transportfyi-header">
896
+ <div>
897
+ <div class="transportfyi-header-title">${esc(nameA)} vs ${esc(nameB)}</div>
898
+ <div class="transportfyi-header-subtitle">Side-by-side comparison</div>
899
+ </div>
900
+ </div>
901
+ <div style="padding:0 16px 8px;overflow-x:auto;">
902
+ <table style="width:100%;border-collapse:collapse;font-size:13px;">
903
+ <thead><tr>
904
+ <th style="text-align:left;padding:8px;font-size:11px;color:var(--muted);"></th>
905
+ <th style="text-align:center;padding:8px;font-weight:700;">${esc(nameA)}</th>
906
+ <th style="text-align:center;padding:8px;font-weight:700;">${esc(nameB)}</th>
907
+ </tr></thead>
908
+ <tbody>${rows}</tbody>
909
+ </table>
910
+ </div>
911
+ <div style="display:flex;gap:8px;padding:8px 16px;">
912
+ <a href="${esc(urlA)}" target="_blank" rel="noopener" style="flex:1;text-align:center;color:var(--link);font-size:12px;text-decoration:none;">${esc(nameA)} ${externalLinkIcon}</a>
913
+ <a href="${esc(urlB)}" target="_blank" rel="noopener" style="flex:1;text-align:center;color:var(--link);font-size:12px;text-decoration:none;">${esc(nameB)} ${externalLinkIcon}</a>
914
+ </div>
915
+ ${poweredByHTML(config)}
916
+ `;
917
+ }).catch(() => {
918
+ renderError(container, "Unable to load comparison data.", config);
919
+ });
920
+ }
921
+
922
+ // src/widgets/search.ts
923
+ function getSearchPath(config) {
924
+ switch (config.site) {
925
+ case "airportfyi":
926
+ return "airports/";
927
+ case "airlinefyi":
928
+ return "airlines/";
929
+ case "planefyi":
930
+ return "aircraft-types/";
931
+ case "trainfyi":
932
+ return "stations/";
933
+ default:
934
+ return `${config.entitySlug}/`;
935
+ }
936
+ }
937
+ function getDetailUrl2(config, item) {
938
+ var _a, _b;
939
+ const slug = String((_a = item.slug) != null ? _a : "");
940
+ switch (config.site) {
941
+ case "airportfyi": {
942
+ const iata = String((_b = item.iata_code) != null ? _b : slug);
943
+ return `https://${config.domain}/${iata}/`;
944
+ }
945
+ case "airlinefyi":
946
+ return `https://${config.domain}/${slug}/`;
947
+ case "planefyi":
948
+ return `https://${config.domain}/${slug}/`;
949
+ case "trainfyi":
950
+ return `https://${config.domain}/station/${slug}/`;
951
+ default:
952
+ return `https://${config.domain}/${slug}/`;
953
+ }
954
+ }
955
+ function getSubtitle(config, item) {
956
+ var _a, _b, _c, _d, _e, _f, _g;
957
+ switch (config.site) {
958
+ case "airportfyi": {
959
+ const iata = String((_a = item.iata_code) != null ? _a : "");
960
+ const country = String((_b = item.country_name) != null ? _b : "");
961
+ return [iata, country].filter(Boolean).join(" \xB7 ");
962
+ }
963
+ case "airlinefyi": {
964
+ const iata = String((_c = item.iata_code) != null ? _c : "");
965
+ const country = String((_d = item.country_name) != null ? _d : "");
966
+ return [iata, country].filter(Boolean).join(" \xB7 ");
967
+ }
968
+ case "planefyi": {
969
+ const mfg = String((_e = item.manufacturer_name) != null ? _e : "");
970
+ return mfg;
971
+ }
972
+ case "trainfyi": {
973
+ const country = String((_f = item.country_name) != null ? _f : "");
974
+ const type = String((_g = item.type) != null ? _g : "");
975
+ return [type, country].filter(Boolean).join(" \xB7 ");
976
+ }
977
+ default:
978
+ return "";
979
+ }
980
+ }
981
+ function initSearchWidget(el, config) {
982
+ const dataset = el.dataset;
983
+ const placeholder = dataset.placeholder || `Search ${config.entityName}...`;
984
+ const shadow = createShadow(el, config);
985
+ const container = createWidgetRoot(shadow, el, "transportfyi-search-widget");
986
+ container.innerHTML = `
987
+ <div class="transportfyi-search-wrap">
988
+ <form class="transportfyi-search-form">
989
+ <input type="text" class="transportfyi-search-input" placeholder="${esc(placeholder)}" autocomplete="off" />
990
+ <button type="submit" class="transportfyi-search-btn">Search</button>
991
+ </form>
992
+ </div>
993
+ <div class="transportfyi-search-results" style="display:none;"></div>
994
+ ${poweredByHTML(config)}
995
+ `;
996
+ const form = container.querySelector("form");
997
+ const input = container.querySelector("input");
998
+ const results = container.querySelector(".transportfyi-search-results");
999
+ form.addEventListener("submit", (e) => {
1000
+ e.preventDefault();
1001
+ const q = input.value.trim();
1002
+ if (!q) return;
1003
+ results.style.display = "block";
1004
+ results.innerHTML = '<div style="padding:8px 0;color:var(--muted);font-size:12px;">Searching\u2026</div>';
1005
+ fetchAPI(
1006
+ config.apiBase,
1007
+ getSearchPath(config),
1008
+ { search: q, limit: "5" }
1009
+ ).then((resp) => {
1010
+ const items = resp.results || [];
1011
+ if (!items.length) {
1012
+ results.innerHTML = `<div style="padding:8px 0;color:var(--muted);font-size:12px;">No results. <a href="https://${config.domain}${config.searchPath}?q=${encodeURIComponent(q)}" target="_blank" rel="noopener" style="color:var(--link);">Search on ${esc(config.name)}</a></div>`;
1013
+ return;
1014
+ }
1015
+ results.innerHTML = items.map((item) => {
1016
+ var _a, _b;
1017
+ const name = String((_b = (_a = item.name) != null ? _a : item.slug) != null ? _b : "");
1018
+ const url = getDetailUrl2(config, item);
1019
+ const sub = getSubtitle(config, item);
1020
+ return `
1021
+ <div class="transportfyi-result-item">
1022
+ <a href="${esc(url)}" target="_blank" rel="noopener" class="transportfyi-result-title" style="color:var(--link);text-decoration:none;">${esc(name)}</a>
1023
+ ${sub ? `<div class="transportfyi-result-meta">${esc(sub)}</div>` : ""}
1024
+ </div>
1025
+ `;
1026
+ }).join("");
1027
+ }).catch(() => {
1028
+ results.innerHTML = `<div style="padding:8px 0;color:var(--muted);font-size:12px;">Search failed. <a href="https://${config.domain}${config.searchPath}?q=${encodeURIComponent(q)}" target="_blank" rel="noopener" style="color:var(--link);">Try on ${esc(config.name)}</a></div>`;
1029
+ });
1030
+ });
1031
+ }
1032
+
1033
+ // src/rich-snippets.ts
1034
+ function injectFAQPage(faqs, domain, _siteName) {
1035
+ if (document.querySelector('script[data-transportfyi-snippet="faq"]')) return;
1036
+ const jsonLd = {
1037
+ "@context": "https://schema.org",
1038
+ "@type": "FAQPage",
1039
+ mainEntity: faqs.map((faq) => ({
1040
+ "@type": "Question",
1041
+ name: faq.question,
1042
+ acceptedAnswer: {
1043
+ "@type": "Answer",
1044
+ text: faq.answer
1045
+ }
1046
+ })),
1047
+ url: `https://${domain}/`
1048
+ };
1049
+ const script = document.createElement("script");
1050
+ script.type = "application/ld+json";
1051
+ script.setAttribute("data-transportfyi-snippet", "faq");
1052
+ script.textContent = JSON.stringify(jsonLd);
1053
+ document.head.appendChild(script);
1054
+ }
1055
+
1056
+ // src/widgets/faq.ts
1057
+ function initFAQWidget(el, config) {
1058
+ const dataset = el.dataset;
1059
+ const shadow = createShadow(el, config);
1060
+ const container = createWidgetRoot(shadow, el, "transportfyi-faq-widget");
1061
+ renderLoading(container);
1062
+ fetchAPI(
1063
+ config.apiBase,
1064
+ "faqs/",
1065
+ { limit: "10" }
1066
+ ).then((resp) => {
1067
+ const faqs = resp.results || [];
1068
+ container.innerHTML = renderFAQCard(faqs, config);
1069
+ if (dataset.noSnippet !== "true" && faqs.length > 0) {
1070
+ injectFAQPage(faqs, config.domain, config.name);
1071
+ }
1072
+ }).catch(() => {
1073
+ renderError(container, "Unable to load FAQs.", config);
1074
+ });
1075
+ }
1076
+
1077
+ // src/inline/type-badge.ts
1078
+ function getPath(config, slug) {
1079
+ switch (config.site) {
1080
+ case "airportfyi":
1081
+ return `airports/${slug}/`;
1082
+ case "trainfyi":
1083
+ return `stations/${slug}/`;
1084
+ default:
1085
+ return "";
1086
+ }
1087
+ }
1088
+ function initTypeBadgeWidget(el, config) {
1089
+ var _a;
1090
+ const slug = (_a = el.dataset.slug) != null ? _a : "";
1091
+ if (!slug) return;
1092
+ const path = getPath(config, slug);
1093
+ if (!path) return;
1094
+ const shadow = createShadow(el, config);
1095
+ const container = createWidgetRoot(shadow, el);
1096
+ container.style.display = "inline-block";
1097
+ container.style.maxWidth = "none";
1098
+ container.style.border = "none";
1099
+ container.style.borderRadius = "4px";
1100
+ fetchAPI(config.apiBase, path).then((data) => {
1101
+ var _a2;
1102
+ const type = String((_a2 = data.type) != null ? _a2 : "");
1103
+ const label = config.site === "airportfyi" ? airportTypeLabel(type) : stationTypeLabel(type);
1104
+ container.innerHTML = `<span class="transportfyi-badge" style="background:${config.accent};color:#fff">${esc(label)}</span>`;
1105
+ }).catch(() => {
1106
+ container.innerHTML = "";
1107
+ });
1108
+ }
1109
+
1110
+ // src/inline/amenities.ts
1111
+ var icons = {
1112
+ wifi: "\u{1F4F6}",
1113
+ lounge: "\u{1F6CB}\uFE0F",
1114
+ luggage: "\u{1F9F3}"
1115
+ };
1116
+ function initAmenitiesWidget(el, config) {
1117
+ var _a;
1118
+ const slug = (_a = el.dataset.slug) != null ? _a : "";
1119
+ if (!slug) return;
1120
+ const shadow = createShadow(el, config);
1121
+ const container = createWidgetRoot(shadow, el);
1122
+ container.style.display = "inline-flex";
1123
+ container.style.maxWidth = "none";
1124
+ container.style.border = "none";
1125
+ container.style.gap = "4px";
1126
+ fetchAPI(config.apiBase, `stations/${slug}/`).then((data) => {
1127
+ const amenities = [];
1128
+ if (data.has_wifi) amenities.push(`${icons.wifi} WiFi`);
1129
+ if (data.has_lounge) amenities.push(`${icons.lounge} Lounge`);
1130
+ if (data.has_luggage_storage) amenities.push(`${icons.luggage} Luggage`);
1131
+ if (!amenities.length) {
1132
+ container.innerHTML = "";
1133
+ return;
1134
+ }
1135
+ container.innerHTML = amenities.map((a) => `<span class="transportfyi-badge" style="font-size:10px;">${a}</span>`).join("");
1136
+ }).catch(() => {
1137
+ container.innerHTML = "";
1138
+ });
1139
+ }
1140
+
1141
+ // src/_entry_trainfyi.ts
1142
+ function initWidget(el, type, config) {
1143
+ const widgetStyle = el.dataset.style || "modern";
1144
+ void widgetStyle;
1145
+ switch (type) {
1146
+ case "entity":
1147
+ initEntityWidget(el, config);
1148
+ break;
1149
+ case "compare":
1150
+ initCompareWidget(el, config);
1151
+ break;
1152
+ case "search":
1153
+ initSearchWidget(el, config);
1154
+ break;
1155
+ case "faq":
1156
+ initFAQWidget(el, config);
1157
+ break;
1158
+ case "type-badge":
1159
+ initTypeBadgeWidget(el, config);
1160
+ break;
1161
+ case "amenities":
1162
+ initAmenitiesWidget(el, config);
1163
+ break;
1164
+ default:
1165
+ break;
1166
+ }
1167
+ }
1168
+ function lazyInit(el, callback) {
1169
+ if ("IntersectionObserver" in window) {
1170
+ const observer = new IntersectionObserver((entries) => {
1171
+ entries.forEach((entry) => {
1172
+ if (entry.isIntersecting) {
1173
+ observer.unobserve(el);
1174
+ callback();
1175
+ }
1176
+ });
1177
+ }, { rootMargin: "200px" });
1178
+ observer.observe(el);
1179
+ } else {
1180
+ callback();
1181
+ }
1182
+ }
1183
+ function processElement(el, config) {
1184
+ if (el.shadowRoot) return;
1185
+ const dataKey = config.attribute.replace("data-", "");
1186
+ const camelKey = dataKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1187
+ const widgetType = el.dataset[camelKey];
1188
+ if (!widgetType) return;
1189
+ lazyInit(el, () => {
1190
+ if (!el.shadowRoot) initWidget(el, widgetType, config);
1191
+ });
1192
+ }
1193
+ function initAll(config) {
1194
+ document.querySelectorAll(`[${config.attribute}]`).forEach((el) => processElement(el, config));
1195
+ }
1196
+ (function bootstrap() {
1197
+ const config = '{"site":"trainfyi","name":"TrainFYI","domain":"trainfyi.com","accent":"#EA580C","attribute":"data-trainfyi","apiBase":"https://trainfyi.com/api/v1/","searchPath":"/search/","entityName":"Stations","entitySlug":"stations"}';
1198
+ if (document.readyState === "loading") {
1199
+ document.addEventListener("DOMContentLoaded", () => initAll(config));
1200
+ } else {
1201
+ initAll(config);
1202
+ }
1203
+ const observer = new MutationObserver((mutations) => {
1204
+ mutations.forEach((mutation) => {
1205
+ mutation.addedNodes.forEach((node) => {
1206
+ var _a;
1207
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
1208
+ const el = node;
1209
+ if (el.hasAttribute(config.attribute)) processElement(el, config);
1210
+ (_a = el.querySelectorAll) == null ? void 0 : _a.call(el, `[${config.attribute}]`).forEach((child) => processElement(child, config));
1211
+ });
1212
+ });
1213
+ });
1214
+ observer.observe(document.body || document.documentElement, { childList: true, subtree: true });
1215
+ })();
1216
+ function makeWidgetElement(widgetType, initFn, domainAttrs) {
1217
+ const observed = [...domainAttrs, "theme", "style-variant", "size"];
1218
+ return class extends HTMLElement {
1219
+ static get observedAttributes() {
1220
+ return observed;
1221
+ }
1222
+ connectedCallback() {
1223
+ if (this.shadowRoot) return;
1224
+ this._syncDataAttrs();
1225
+ initFn(this, '{"site":"trainfyi","name":"TrainFYI","domain":"trainfyi.com","accent":"#EA580C","attribute":"data-trainfyi","apiBase":"https://trainfyi.com/api/v1/","searchPath":"/search/","entityName":"Stations","entitySlug":"stations"}');
1226
+ }
1227
+ attributeChangedCallback(_name, oldVal, newVal) {
1228
+ if (oldVal === newVal || !this.shadowRoot) return;
1229
+ const shadow = this.shadowRoot;
1230
+ while (shadow.firstChild) shadow.firstChild.remove();
1231
+ this._syncDataAttrs();
1232
+ initFn(this, '{"site":"trainfyi","name":"TrainFYI","domain":"trainfyi.com","accent":"#EA580C","attribute":"data-trainfyi","apiBase":"https://trainfyi.com/api/v1/","searchPath":"/search/","entityName":"Stations","entitySlug":"stations"}');
1233
+ }
1234
+ _syncDataAttrs() {
1235
+ const attrKey = '{"site":"trainfyi","name":"TrainFYI","domain":"trainfyi.com","accent":"#EA580C","attribute":"data-trainfyi","apiBase":"https://trainfyi.com/api/v1/","searchPath":"/search/","entityName":"Stations","entitySlug":"stations"}'.attribute.replace("data-", "");
1236
+ this.dataset[attrKey] = widgetType;
1237
+ for (const a of domainAttrs) {
1238
+ const val = this.getAttribute(a);
1239
+ if (val !== null) this.dataset[a] = val;
1240
+ }
1241
+ const theme = this.getAttribute("theme");
1242
+ if (theme !== null) this.dataset.theme = theme;
1243
+ const styleVariant = this.getAttribute("style-variant");
1244
+ if (styleVariant !== null) this.dataset.style = styleVariant;
1245
+ const size = this.getAttribute("size");
1246
+ if (size !== null) this.dataset.size = size;
1247
+ }
1248
+ };
1249
+ }
1250
+ (function registerElements() {
1251
+ if (typeof customElements === "undefined") return;
1252
+ const site = '{"site":"trainfyi","name":"TrainFYI","domain":"trainfyi.com","accent":"#EA580C","attribute":"data-trainfyi","apiBase":"https://trainfyi.com/api/v1/","searchPath":"/search/","entityName":"Stations","entitySlug":"stations"}'.site;
1253
+ const defs = [
1254
+ [`${site}-entity`, initEntityWidget, ["slug"]],
1255
+ [`${site}-compare`, initCompareWidget, ["slugs"]],
1256
+ [`${site}-search`, initSearchWidget, ["placeholder", "query"]],
1257
+ [`${site}-faq`, initFAQWidget, ["slug", "category"]]
1258
+ ];
1259
+ for (const [tagName, initFn, attrs] of defs) {
1260
+ if (!customElements.get(tagName)) {
1261
+ const widgetType = tagName.slice(site.length + 1);
1262
+ customElements.define(tagName, makeWidgetElement(widgetType, initFn, attrs));
1263
+ }
1264
+ }
1265
+ })();