@skhema/embed 0.1.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.
package/dist/index.cjs ADDED
@@ -0,0 +1,2061 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const types = require("@skhema/types");
4
+ const COMPONENT_COLORS = {
5
+ diagnosis: {
6
+ bg: "oklch(0.65 0.06 25 / 0.15)",
7
+ text: "oklch(0.65 0.06 25)",
8
+ border: "oklch(0.65 0.06 25 / 0.3)"
9
+ },
10
+ method: {
11
+ bg: "oklch(0.6 0.06 250 / 0.15)",
12
+ text: "oklch(0.6 0.06 250)",
13
+ border: "oklch(0.6 0.06 250 / 0.3)"
14
+ },
15
+ initiatives: {
16
+ bg: "oklch(0.6 0.06 155 / 0.15)",
17
+ text: "oklch(0.6 0.06 155)",
18
+ border: "oklch(0.6 0.06 155 / 0.3)"
19
+ },
20
+ measures: {
21
+ bg: "oklch(0.6 0.06 300 / 0.15)",
22
+ text: "oklch(0.6 0.06 300)",
23
+ border: "oklch(0.6 0.06 300 / 0.3)"
24
+ },
25
+ support: {
26
+ bg: "oklch(0.65 0.06 65 / 0.15)",
27
+ text: "oklch(0.65 0.06 65)",
28
+ border: "oklch(0.65 0.06 65 / 0.3)"
29
+ }
30
+ };
31
+ const SHARED_CARD_STYLES = `
32
+ /* Monospace acronym badge */
33
+ .skhema-acronym-badge {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
37
+ font-size: 10px;
38
+ font-weight: 600;
39
+ padding: 2px 6px;
40
+ border-radius: 2px;
41
+ letter-spacing: 0.02em;
42
+ flex-shrink: 0;
43
+ }
44
+
45
+ /* Footer attribution tagline */
46
+ .skhema-footer-attribution {
47
+ font-size: 11px;
48
+ color: var(--skhema-text-muted);
49
+ line-height: 1.4;
50
+ }
51
+
52
+ .skhema-footer-attribution a {
53
+ color: var(--skhema-text-muted);
54
+ text-decoration: underline;
55
+ text-decoration-color: var(--skhema-border);
56
+ text-underline-offset: 2px;
57
+ }
58
+
59
+ .skhema-footer-attribution a:hover {
60
+ color: var(--skhema-text);
61
+ }
62
+
63
+ /* Contributor line in footer */
64
+ .skhema-contributor-line {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 6px;
68
+ font-size: 12px;
69
+ color: var(--skhema-text-muted);
70
+ }
71
+
72
+ .skhema-contributor-line svg {
73
+ width: 14px;
74
+ height: 14px;
75
+ flex-shrink: 0;
76
+ }
77
+
78
+ /* Save button — primary brand color */
79
+ .skhema-save-btn {
80
+ display: inline-flex;
81
+ align-items: center;
82
+ gap: 6px;
83
+ background: var(--skhema-primary);
84
+ color: white;
85
+ border: none;
86
+ padding: 6px 14px;
87
+ border-radius: calc(var(--skhema-radius) + 2px);
88
+ font-size: 12px;
89
+ font-weight: 500;
90
+ text-decoration: none;
91
+ cursor: pointer;
92
+ transition: background 0.15s ease;
93
+ white-space: nowrap;
94
+ }
95
+
96
+ .skhema-save-btn:hover {
97
+ background: var(--skhema-primary-hover);
98
+ }
99
+
100
+ .skhema-save-btn:active {
101
+ background: var(--skhema-primary-pressed);
102
+ }
103
+
104
+ .skhema-save-btn:focus {
105
+ outline: 2px solid var(--skhema-primary);
106
+ outline-offset: 2px;
107
+ }
108
+
109
+ .skhema-save-btn::after {
110
+ content: '\\2192';
111
+ font-size: 13px;
112
+ }
113
+ `;
114
+ const USER_ICON_SVG = `<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="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
115
+ const ANALYTICS_ENDPOINT = "https://analytics.skhema.com/functions/v1/embed-manage";
116
+ const TRACKING_COOKIE_NAME = "_sk";
117
+ const TRACKING_EXPIRY_HOURS = 24;
118
+ const MAX_TRACKED_ITEMS = 50;
119
+ function getTrackedEmbeds() {
120
+ try {
121
+ const cookie = document.cookie.split("; ").find((row) => row.startsWith(`${TRACKING_COOKIE_NAME}=`));
122
+ if (!cookie) return [];
123
+ const data = JSON.parse(decodeURIComponent(cookie.split("=")[1]));
124
+ const now = Date.now();
125
+ const cutoff = now - TRACKING_EXPIRY_HOURS * 60 * 60 * 1e3;
126
+ return data.filter((item) => item.timestamp > cutoff);
127
+ } catch {
128
+ return [];
129
+ }
130
+ }
131
+ function setTrackedEmbeds(tracked) {
132
+ try {
133
+ const limited = tracked.slice(-MAX_TRACKED_ITEMS);
134
+ const expires = new Date(
135
+ Date.now() + TRACKING_EXPIRY_HOURS * 60 * 60 * 1e3
136
+ );
137
+ document.cookie = `${TRACKING_COOKIE_NAME}=${encodeURIComponent(
138
+ JSON.stringify(limited)
139
+ )}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
140
+ } catch {
141
+ }
142
+ }
143
+ function hasBeenTracked(contentHash) {
144
+ const tracked = getTrackedEmbeds();
145
+ return tracked.some((item) => item.contentHash === contentHash);
146
+ }
147
+ function markAsTracked(contentHash) {
148
+ const tracked = getTrackedEmbeds();
149
+ tracked.push({
150
+ contentHash,
151
+ timestamp: Date.now()
152
+ });
153
+ setTrackedEmbeds(tracked);
154
+ }
155
+ class AnalyticsBatcher {
156
+ constructor() {
157
+ this.batch = {
158
+ embeds: [],
159
+ clicks: [],
160
+ componentEmbeds: [],
161
+ componentClicks: []
162
+ };
163
+ this.batchTimeout = null;
164
+ this.BATCH_DELAY = 2e3;
165
+ this.MAX_BATCH_SIZE = 10;
166
+ }
167
+ addEmbedLoad(analytics) {
168
+ this.batch.embeds.push(analytics);
169
+ this.scheduleBatchSend();
170
+ }
171
+ addClick(contentData) {
172
+ this.batch.clicks.push(contentData);
173
+ this.scheduleBatchSend();
174
+ }
175
+ addComponentEmbedLoad(analytics) {
176
+ this.batch.componentEmbeds.push(analytics);
177
+ this.scheduleBatchSend();
178
+ }
179
+ addComponentClick(contentData) {
180
+ this.batch.componentClicks.push(contentData);
181
+ this.scheduleBatchSend();
182
+ }
183
+ scheduleBatchSend() {
184
+ if (this.batchTimeout) {
185
+ clearTimeout(this.batchTimeout);
186
+ }
187
+ const totalItems = this.batch.embeds.length + this.batch.clicks.length + this.batch.componentEmbeds.length + this.batch.componentClicks.length;
188
+ if (totalItems >= this.MAX_BATCH_SIZE) {
189
+ this.sendBatch();
190
+ return;
191
+ }
192
+ this.batchTimeout = window.setTimeout(() => {
193
+ this.sendBatch();
194
+ }, this.BATCH_DELAY);
195
+ }
196
+ async sendBatch() {
197
+ if (this.batchTimeout) {
198
+ clearTimeout(this.batchTimeout);
199
+ this.batchTimeout = null;
200
+ }
201
+ const currentBatch = { ...this.batch };
202
+ this.batch = {
203
+ embeds: [],
204
+ clicks: [],
205
+ componentEmbeds: [],
206
+ componentClicks: []
207
+ };
208
+ const hasItems = currentBatch.embeds.length > 0 || currentBatch.clicks.length > 0 || currentBatch.componentEmbeds.length > 0 || currentBatch.componentClicks.length > 0;
209
+ if (!hasItems) return;
210
+ if (currentBatch.embeds.length > 0) {
211
+ await this.sendEmbeds(currentBatch.embeds);
212
+ }
213
+ if (currentBatch.clicks.length > 0) {
214
+ await this.sendClicks(currentBatch.clicks);
215
+ }
216
+ if (currentBatch.componentEmbeds.length > 0) {
217
+ await this.sendComponentEmbeds(currentBatch.componentEmbeds);
218
+ }
219
+ if (currentBatch.componentClicks.length > 0) {
220
+ await this.sendComponentClicks(currentBatch.componentClicks);
221
+ }
222
+ }
223
+ async sendEmbeds(embeds) {
224
+ if (embeds.length === 0) return;
225
+ try {
226
+ const payload = {
227
+ action: "embed",
228
+ events: embeds.map((embed) => ({
229
+ contributor_id: embed.contributorId,
230
+ element_type: embed.elementType,
231
+ content_hash: embed.contentHash,
232
+ content: embed.content,
233
+ page_url: embed.pageUrl,
234
+ page_title: embed.pageTitle || "",
235
+ user_agent: embed.userAgent || ""
236
+ }))
237
+ };
238
+ await sendWithRetry(ANALYTICS_ENDPOINT, payload, "json");
239
+ } catch (error) {
240
+ console.debug("Embed tracking failed:", error);
241
+ }
242
+ }
243
+ async sendClicks(clicks) {
244
+ for (const click of clicks) {
245
+ try {
246
+ const data = {
247
+ action: "click",
248
+ contributor_id: click.contributor_id,
249
+ content_hash: click.content_hash,
250
+ source_url: click.source_url,
251
+ element_type: click.element_type
252
+ };
253
+ fetch(ANALYTICS_ENDPOINT, {
254
+ method: "POST",
255
+ headers: { "Content-Type": "application/json" },
256
+ body: JSON.stringify(data),
257
+ credentials: "omit",
258
+ keepalive: true
259
+ }).catch(() => {
260
+ });
261
+ } catch (error) {
262
+ console.debug("Click tracking failed:", error);
263
+ }
264
+ }
265
+ }
266
+ async sendComponentEmbeds(embeds) {
267
+ if (embeds.length === 0) return;
268
+ try {
269
+ const payload = {
270
+ action: "component_embed",
271
+ events: embeds.map((embed) => ({
272
+ contributor_id: embed.contributorId,
273
+ component_type: embed.componentType,
274
+ component_hash: embed.componentHash,
275
+ title: embed.title,
276
+ elements: embed.elements.map((el) => ({
277
+ element_type: el.elementType,
278
+ content: el.content,
279
+ content_hash: el.contentHash
280
+ })),
281
+ page_url: embed.pageUrl,
282
+ page_title: embed.pageTitle || "",
283
+ user_agent: embed.userAgent || ""
284
+ }))
285
+ };
286
+ await sendWithRetry(ANALYTICS_ENDPOINT, payload, "json");
287
+ } catch (error) {
288
+ console.debug("Component embed tracking failed:", error);
289
+ }
290
+ }
291
+ async sendComponentClicks(clicks) {
292
+ for (const click of clicks) {
293
+ try {
294
+ const data = {
295
+ action: "component_click",
296
+ contributor_id: click.contributor_id,
297
+ component_type: click.component_type,
298
+ component_hash: click.component_hash,
299
+ title: click.title,
300
+ source_url: click.source_url
301
+ };
302
+ fetch(ANALYTICS_ENDPOINT, {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ body: JSON.stringify(data),
306
+ credentials: "omit",
307
+ keepalive: true
308
+ }).catch(() => {
309
+ });
310
+ } catch (error) {
311
+ console.debug("Component click tracking failed:", error);
312
+ }
313
+ }
314
+ }
315
+ // Ensure batch is sent when page unloads
316
+ flush() {
317
+ const hasItems = this.batch.embeds.length > 0 || this.batch.clicks.length > 0 || this.batch.componentEmbeds.length > 0 || this.batch.componentClicks.length > 0;
318
+ if (hasItems) {
319
+ this.sendBatch();
320
+ }
321
+ }
322
+ }
323
+ const analyticsBatcher = new AnalyticsBatcher();
324
+ if (typeof window !== "undefined") {
325
+ window.addEventListener("beforeunload", () => {
326
+ analyticsBatcher.flush();
327
+ });
328
+ document.addEventListener("visibilitychange", () => {
329
+ if (document.visibilityState === "hidden") {
330
+ analyticsBatcher.flush();
331
+ }
332
+ });
333
+ }
334
+ async function sendWithRetry(url, data, contentType = "json", maxRetries = 3) {
335
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
336
+ try {
337
+ const options = {
338
+ method: "POST",
339
+ credentials: "omit",
340
+ keepalive: true
341
+ };
342
+ if (contentType === "json") {
343
+ options.headers = { "Content-Type": "application/json" };
344
+ options.body = JSON.stringify(data);
345
+ } else {
346
+ options.body = data;
347
+ }
348
+ const response = await fetch(url, options);
349
+ if (response.ok) return;
350
+ if (response.status >= 400 && response.status < 500) {
351
+ break;
352
+ }
353
+ } catch (error) {
354
+ if (attempt === maxRetries - 1) {
355
+ console.debug("Analytics failed after retries:", error);
356
+ return;
357
+ }
358
+ }
359
+ await new Promise(
360
+ (resolve) => setTimeout(resolve, Math.pow(2, attempt) * 1e3)
361
+ );
362
+ }
363
+ }
364
+ async function trackEmbedLoad(analytics) {
365
+ try {
366
+ if (hasBeenTracked(analytics.contentHash)) {
367
+ console.debug("Embed already tracked, skipping:", analytics.contentHash);
368
+ return;
369
+ }
370
+ markAsTracked(analytics.contentHash);
371
+ analyticsBatcher.addEmbedLoad(analytics);
372
+ } catch (error) {
373
+ console.debug("Analytics tracking failed:", error);
374
+ }
375
+ }
376
+ async function trackClick(contentData) {
377
+ try {
378
+ analyticsBatcher.addClick(contentData);
379
+ } catch (error) {
380
+ console.debug("Click tracking failed:", error);
381
+ }
382
+ }
383
+ async function trackComponentEmbedLoad(analytics) {
384
+ try {
385
+ if (hasBeenTracked(analytics.componentHash)) {
386
+ console.debug(
387
+ "Component embed already tracked, skipping:",
388
+ analytics.componentHash
389
+ );
390
+ return;
391
+ }
392
+ markAsTracked(analytics.componentHash);
393
+ analyticsBatcher.addComponentEmbedLoad(analytics);
394
+ } catch (error) {
395
+ console.debug("Component analytics tracking failed:", error);
396
+ }
397
+ }
398
+ async function trackComponentClick(contentData) {
399
+ try {
400
+ analyticsBatcher.addComponentClick(contentData);
401
+ } catch (error) {
402
+ console.debug("Component click tracking failed:", error);
403
+ }
404
+ }
405
+ function shouldTrackAnalytics(element) {
406
+ const trackAnalytics = element.getAttribute("track-analytics");
407
+ return trackAnalytics !== "false";
408
+ }
409
+ function isValidComponentType(componentType) {
410
+ const validTypes = Object.values(types.COMPONENT_TYPES).map((t) => t.value);
411
+ return validTypes.includes(componentType);
412
+ }
413
+ function validateElementBelongsToComponent(elementType, componentType) {
414
+ if (!isValidComponentType(componentType)) return false;
415
+ const validElements = types.SKHEMA_MAPPING.elementFlow[componentType];
416
+ if (!validElements) return false;
417
+ return validElements.some((e) => e.value === elementType);
418
+ }
419
+ function getComponentTypeLabel(componentType) {
420
+ const type = Object.values(types.COMPONENT_TYPES).find(
421
+ (t) => t.value === componentType
422
+ );
423
+ return type?.label || componentType;
424
+ }
425
+ function getComponentTypeAcronym(componentType) {
426
+ const type = Object.values(types.COMPONENT_TYPES).find(
427
+ (t) => t.value === componentType
428
+ );
429
+ return type?.acronym || componentType.substring(0, 2).toUpperCase();
430
+ }
431
+ function resolveComponentType(elementType) {
432
+ for (const [componentValue, elements] of Object.entries(
433
+ types.SKHEMA_MAPPING.elementFlow
434
+ )) {
435
+ if (elements.some((e) => e.value === elementType)) {
436
+ return componentValue;
437
+ }
438
+ }
439
+ return "diagnosis";
440
+ }
441
+ function getElementTypesForComponent(componentType) {
442
+ if (!isValidComponentType(componentType)) return [];
443
+ const elements = types.SKHEMA_MAPPING.elementFlow[componentType];
444
+ return elements?.map((e) => e.value) || [];
445
+ }
446
+ function generateContentHash(content) {
447
+ let hash = 0;
448
+ const cleanContent = content.trim().substring(0, 200);
449
+ for (let i = 0; i < cleanContent.length; i++) {
450
+ const char = cleanContent.charCodeAt(i);
451
+ hash = (hash << 5) - hash + char;
452
+ hash = hash & hash;
453
+ }
454
+ return Math.abs(hash).toString(36).substring(0, 12);
455
+ }
456
+ function generateComponentHash(elements) {
457
+ const sorted = [...elements].sort((a, b) => {
458
+ const typeCompare = a.elementType.localeCompare(b.elementType);
459
+ if (typeCompare !== 0) return typeCompare;
460
+ return a.content.localeCompare(b.content);
461
+ });
462
+ const combined = sorted.map((e) => `${e.elementType}:${e.content}`).join("|");
463
+ return generateContentHash(combined);
464
+ }
465
+ function sanitizeContent(content) {
466
+ const htmlEncode = (str) => {
467
+ const div = document.createElement("div");
468
+ div.textContent = str;
469
+ return div.innerHTML;
470
+ };
471
+ let sanitized = stripUrls(content);
472
+ sanitized = htmlEncode(sanitized);
473
+ sanitized = sanitized.replace(/\n/g, "<br>");
474
+ sanitized = applyTextWrapping(sanitized);
475
+ return sanitized;
476
+ }
477
+ function stripUrls(text) {
478
+ const patterns = [
479
+ // Standard URLs with protocols
480
+ /https?:\/\/[^\s<>"{}|\\^`[\]]+/gi,
481
+ // FTP URLs
482
+ /ftp:\/\/[^\s<>"{}|\\^`[\]]+/gi,
483
+ // URLs without protocol but with www
484
+ /www\.[^\s<>"{}|\\^`[\]]+/gi,
485
+ // Email-like patterns
486
+ /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/gi,
487
+ // Common domain patterns (anything.com, anything.org, etc.)
488
+ /(?:^|\s)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:\/[^\s]*)?/gi
489
+ ];
490
+ let stripped = text;
491
+ patterns.forEach((pattern) => {
492
+ stripped = stripped.replace(pattern, "");
493
+ });
494
+ stripped = stripped.replace(/\s+/g, " ").trim();
495
+ return stripped;
496
+ }
497
+ function applyTextWrapping(text) {
498
+ const words = text.split(/(\s+)/);
499
+ return words.map((word) => {
500
+ if (/^\s+$/.test(word) || word.includes("<")) {
501
+ return word;
502
+ }
503
+ if (word.length > 30) {
504
+ return word.replace(/(.{10})/g, "$1​");
505
+ }
506
+ return word;
507
+ }).join("");
508
+ }
509
+ function validateContentSecurity(content) {
510
+ const issues = [];
511
+ if (/<script[\s>]/i.test(content)) {
512
+ issues.push("Script tags detected");
513
+ }
514
+ if (/on\w+\s*=/i.test(content)) {
515
+ issues.push("Event handlers detected");
516
+ }
517
+ if (/javascript:/i.test(content)) {
518
+ issues.push("JavaScript protocol detected");
519
+ }
520
+ if (/data:[^,]*script/i.test(content)) {
521
+ issues.push("Data URL with script detected");
522
+ }
523
+ if (/<iframe[\s>]/i.test(content)) {
524
+ issues.push("Iframe tags detected");
525
+ }
526
+ if (/https?:\/\//i.test(content) || /www\./i.test(content)) {
527
+ issues.push("URLs detected in content");
528
+ }
529
+ return {
530
+ isSecure: issues.length === 0,
531
+ issues
532
+ };
533
+ }
534
+ function isValidElementType(elementType) {
535
+ const validTypes = Object.values(types.ELEMENT_TYPES).map((type) => type.value);
536
+ return validTypes.includes(elementType);
537
+ }
538
+ function validateAttributes(element) {
539
+ const errors = [];
540
+ const elementType = element.getAttribute("element-type");
541
+ const contributorId = element.getAttribute("contributor-id");
542
+ if (!elementType) {
543
+ errors.push("Missing required attribute: element-type");
544
+ } else if (!isValidElementType(elementType)) {
545
+ const validTypes = Object.values(types.ELEMENT_TYPES).map((t) => t.value).join(", ");
546
+ errors.push(
547
+ `Invalid element-type "${elementType}". Valid types: ${validTypes}`
548
+ );
549
+ }
550
+ if (!contributorId) {
551
+ errors.push("Missing required attribute: contributor-id");
552
+ } else if (contributorId.trim().length === 0) {
553
+ errors.push("contributor-id cannot be empty");
554
+ }
555
+ return {
556
+ isValid: errors.length === 0,
557
+ errors,
558
+ elementType: isValidElementType(elementType || "") ? elementType : void 0,
559
+ contributorId: contributorId || void 0
560
+ };
561
+ }
562
+ function getElementTypeLabel(elementType) {
563
+ const type = Object.values(types.ELEMENT_TYPES).find((t) => t.value === elementType);
564
+ return type?.label || elementType;
565
+ }
566
+ function getElementTypeAcronym(elementType) {
567
+ const type = Object.values(types.ELEMENT_TYPES).find((t) => t.value === elementType);
568
+ return type?.acronym || elementType.substring(0, 2).toUpperCase();
569
+ }
570
+ function generateStructuredData(content, elementType, contributorId, sourceUrl) {
571
+ return {
572
+ "@context": "https://schema.org",
573
+ "@type": "AnalysisContent",
574
+ text: content,
575
+ analysisType: elementType,
576
+ category: getElementTypeLabel(elementType),
577
+ contributor: contributorId,
578
+ url: generateRedirectUrl(content, elementType, contributorId),
579
+ provider: {
580
+ "@type": "Organization",
581
+ name: "Skhema",
582
+ url: "https://skhema.com"
583
+ },
584
+ isPartOf: {
585
+ "@type": "WebPage",
586
+ url: sourceUrl
587
+ },
588
+ dateCreated: (/* @__PURE__ */ new Date()).toISOString(),
589
+ platform: "Skhema"
590
+ };
591
+ }
592
+ function generateRedirectUrl(content, elementType, contributorId, options = {}) {
593
+ const baseUrl = options.baseUrl || "https://app.skhema.com/save";
594
+ const contentHash = generateContentHash(content);
595
+ const sourceUrl = encodeURIComponent(window.location.href);
596
+ const timestamp = Date.now();
597
+ const params = new URLSearchParams({
598
+ source: sourceUrl,
599
+ t: timestamp.toString(),
600
+ utm_source: options.utmSource || "web_component",
601
+ utm_medium: options.utmMedium || "embedded",
602
+ utm_campaign: options.utmCampaign || elementType,
603
+ utm_content: contributorId
604
+ });
605
+ return `${baseUrl}?type=contributor&contributor_id=${contributorId}&element_type=${elementType}&content_hash=${contentHash}&${params.toString()}`;
606
+ }
607
+ function createMetaTags(content, elementType, contributorId) {
608
+ const label = getElementTypeLabel(elementType);
609
+ return `
610
+ <div itemscope itemtype="https://schema.org/AnalysisContent" style="display:none;">
611
+ <meta itemprop="analysisType" content="${elementType}">
612
+ <meta itemprop="text" content="${content}">
613
+ <meta itemprop="contributor" content="${contributorId}">
614
+ <meta itemprop="category" content="${label}">
615
+ <meta itemprop="platform" content="Skhema">
616
+ </div>
617
+ `;
618
+ }
619
+ function generateComponentStructuredData(title, componentType, contributorId, elements, sourceUrl) {
620
+ const componentLabel = getComponentTypeLabel(componentType);
621
+ return {
622
+ "@context": "https://schema.org",
623
+ "@type": "CreativeWork",
624
+ name: title,
625
+ description: `${componentLabel} strategy component`,
626
+ category: componentLabel,
627
+ contributor: contributorId,
628
+ url: generateComponentRedirectUrl(
629
+ generateContentHash(elements.map((e) => e.content).join("|")),
630
+ componentType,
631
+ contributorId
632
+ ),
633
+ hasPart: elements.map((el) => ({
634
+ "@type": "AnalysisContent",
635
+ text: el.content,
636
+ analysisType: el.elementType,
637
+ category: getElementTypeLabel(el.elementType)
638
+ })),
639
+ provider: {
640
+ "@type": "Organization",
641
+ name: "Skhema",
642
+ url: "https://skhema.com"
643
+ },
644
+ isPartOf: {
645
+ "@type": "WebPage",
646
+ url: sourceUrl
647
+ },
648
+ dateCreated: (/* @__PURE__ */ new Date()).toISOString(),
649
+ platform: "Skhema"
650
+ };
651
+ }
652
+ function generateComponentRedirectUrl(componentHash, componentType, contributorId, options = {}) {
653
+ const baseUrl = options.baseUrl || "https://app.skhema.com/save";
654
+ const sourceUrl = encodeURIComponent(window.location.href);
655
+ const timestamp = Date.now();
656
+ const params = new URLSearchParams({
657
+ source: sourceUrl,
658
+ t: timestamp.toString(),
659
+ utm_source: options.utmSource || "web_component",
660
+ utm_medium: options.utmMedium || "embedded",
661
+ utm_campaign: options.utmCampaign || componentType,
662
+ utm_content: contributorId
663
+ });
664
+ return `${baseUrl}?type=component&component_type=${componentType}&component_hash=${componentHash}&contributor_id=${contributorId}&${params.toString()}`;
665
+ }
666
+ function createAriaAttributes(elementType) {
667
+ const label = getElementTypeLabel(elementType);
668
+ return {
669
+ role: "article",
670
+ "aria-label": `${label} - Strategic element`,
671
+ "aria-describedby": "skhema-description"
672
+ };
673
+ }
674
+ const styles$1 = `
675
+ :host {
676
+ --skhema-primary: hsl(344 57% 54%);
677
+ --skhema-primary-hover: hsl(344 50% 47%);
678
+ --skhema-secondary: hsl(345 100% 75%);
679
+
680
+ /* Light mode colors */
681
+ --skhema-bg: hsl(0 0% 100%);
682
+ --skhema-card: hsl(0 0% 100%);
683
+ --skhema-border: hsl(214.3 31.8% 91.4%);
684
+ --skhema-text: hsl(222.2 84% 4.9%);
685
+ --skhema-text-muted: hsl(215.4 16.3% 46.9%);
686
+ --skhema-accent: hsl(210 40% 96%);
687
+ --skhema-surface-subtle: hsl(210 40% 97%);
688
+
689
+ /* Shadows */
690
+ --skhema-shadow: 0 1px 3px 0 hsl(0 0 0 / 0.1), 0 1px 2px -1px hsl(0 0 0 / 0.1);
691
+ --skhema-shadow-md: 0 4px 6px -1px hsl(0 0 0 / 0.1), 0 2px 4px -2px hsl(0 0 0 / 0.1);
692
+ --skhema-shadow-lg: 0 10px 15px -3px hsl(0 0 0 / 0.1), 0 4px 6px -4px hsl(0 0 0 / 0.1);
693
+ --skhema-radius: 0.1rem;
694
+
695
+ display: block;
696
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Inter', sans-serif;
697
+ line-height: 1.5;
698
+ color: var(--skhema-text);
699
+ }
700
+
701
+ /* Dark mode */
702
+ .skhema-component-card[data-theme="dark"],
703
+ .skhema-skeleton[data-theme="dark"] {
704
+ --skhema-bg: hsl(222.2 84% 4.9%);
705
+ --skhema-card: hsl(222.2 84% 4.9%);
706
+ --skhema-border: hsl(217.2 32.6% 17.5%);
707
+ --skhema-text: hsl(210 40% 98%);
708
+ --skhema-text-muted: hsl(215 20.2% 65.1%);
709
+ --skhema-accent: hsl(217.2 32.6% 17.5%);
710
+ --skhema-surface-subtle: hsl(217.2 32.6% 12%);
711
+ }
712
+
713
+ /* Main card */
714
+ .skhema-component-card {
715
+ position: relative;
716
+ background: var(--skhema-card);
717
+ border: 1px solid var(--skhema-border);
718
+ border-radius: calc(var(--skhema-radius) + 2px);
719
+ padding: 0;
720
+ box-shadow: var(--skhema-shadow);
721
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
722
+ max-width: 600px;
723
+ margin: 8px 0;
724
+ overflow: hidden;
725
+ }
726
+
727
+ .skhema-component-card:hover {
728
+ box-shadow: var(--skhema-shadow-lg);
729
+ transform: translateY(-1px);
730
+ }
731
+
732
+ /* Colored top border */
733
+ .skhema-top-border {
734
+ height: 2px;
735
+ width: 100%;
736
+ }
737
+
738
+ /* Header */
739
+ .skhema-header {
740
+ display: flex;
741
+ align-items: center;
742
+ gap: 8px;
743
+ padding: 12px 16px;
744
+ border-bottom: 1px solid var(--skhema-border);
745
+ }
746
+
747
+ .skhema-type-label {
748
+ font-size: 13px;
749
+ font-weight: 500;
750
+ color: var(--skhema-text-muted);
751
+ }
752
+
753
+ .skhema-title-separator {
754
+ color: var(--skhema-text-muted);
755
+ font-size: 13px;
756
+ }
757
+
758
+ .skhema-title {
759
+ font-size: 13px;
760
+ font-weight: 600;
761
+ color: var(--skhema-text);
762
+ }
763
+
764
+ /* Elements section */
765
+ .skhema-elements {
766
+ padding: 16px;
767
+ display: flex;
768
+ flex-direction: column;
769
+ gap: 16px;
770
+ }
771
+
772
+ /* Element group */
773
+ .skhema-element-group {
774
+ display: flex;
775
+ flex-direction: column;
776
+ gap: 4px;
777
+ }
778
+
779
+ .skhema-element-label {
780
+ text-transform: uppercase;
781
+ letter-spacing: 0.05em;
782
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
783
+ font-size: 10px;
784
+ font-weight: 600;
785
+ color: var(--skhema-text-muted);
786
+ }
787
+
788
+ .skhema-element-content {
789
+ font-size: 14px;
790
+ line-height: 1.6;
791
+ color: var(--skhema-text);
792
+ padding-left: 8px;
793
+ border-left: 2px solid var(--skhema-border);
794
+ word-wrap: break-word;
795
+ overflow-wrap: break-word;
796
+ }
797
+
798
+ /* Footer */
799
+ .skhema-footer {
800
+ padding: 12px 16px;
801
+ border-top: 1px solid var(--skhema-border);
802
+ }
803
+
804
+ .skhema-footer-row {
805
+ display: flex;
806
+ align-items: center;
807
+ justify-content: space-between;
808
+ gap: 12px;
809
+ }
810
+
811
+ ${SHARED_CARD_STYLES}
812
+
813
+ /* Error state */
814
+ .skhema-error {
815
+ background: hsl(0 93% 94%);
816
+ border: 1px solid hsl(0 84% 60%);
817
+ color: hsl(0 74% 42%);
818
+ padding: 12px;
819
+ border-radius: calc(var(--skhema-radius) + 2px);
820
+ font-size: 13px;
821
+ }
822
+
823
+ .skhema-error-title {
824
+ font-weight: 600;
825
+ margin-bottom: 8px;
826
+ }
827
+
828
+ .skhema-error-list {
829
+ margin: 0;
830
+ padding-left: 16px;
831
+ }
832
+
833
+ /* Skeleton loading state */
834
+ .skhema-skeleton {
835
+ background: var(--skhema-card);
836
+ border: 1px solid var(--skhema-border);
837
+ border-radius: calc(var(--skhema-radius) + 2px);
838
+ padding: 16px;
839
+ box-shadow: var(--skhema-shadow);
840
+ max-width: 600px;
841
+ margin: 8px 0;
842
+ animation: skeletonPulse 1.5s ease-in-out infinite;
843
+ }
844
+
845
+ .skhema-skeleton-header {
846
+ display: flex;
847
+ align-items: center;
848
+ gap: 12px;
849
+ margin-bottom: 12px;
850
+ }
851
+
852
+ .skhema-skeleton-badge {
853
+ width: 32px;
854
+ height: 20px;
855
+ border-radius: 2px;
856
+ background: linear-gradient(90deg,
857
+ var(--skhema-border) 25%,
858
+ var(--skhema-accent) 50%,
859
+ var(--skhema-border) 75%);
860
+ background-size: 200% 100%;
861
+ animation: shimmer 1.5s infinite;
862
+ }
863
+
864
+ .skhema-skeleton-text {
865
+ flex: 1;
866
+ }
867
+
868
+ .skhema-skeleton-line {
869
+ height: 12px;
870
+ background: linear-gradient(90deg,
871
+ var(--skhema-border) 25%,
872
+ var(--skhema-accent) 50%,
873
+ var(--skhema-border) 75%);
874
+ background-size: 200% 100%;
875
+ animation: shimmer 1.5s infinite;
876
+ border-radius: calc(var(--skhema-radius) + 2px);
877
+ margin: 6px 0;
878
+ }
879
+
880
+ .skhema-skeleton-line.short { width: 40%; }
881
+ .skhema-skeleton-line.medium { width: 70%; }
882
+
883
+ .skhema-skeleton-content {
884
+ margin: 16px 0;
885
+ }
886
+
887
+ @keyframes skeletonPulse {
888
+ 0%, 100% { opacity: 1; }
889
+ 50% { opacity: 0.8; }
890
+ }
891
+
892
+ @keyframes shimmer {
893
+ 0% { background-position: -200% 0; }
894
+ 100% { background-position: 200% 0; }
895
+ }
896
+
897
+ /* Responsive */
898
+ @media (max-width: 640px) {
899
+ .skhema-component-card {
900
+ margin: 4px 0;
901
+ }
902
+
903
+ .skhema-header {
904
+ padding: 10px 12px;
905
+ flex-wrap: wrap;
906
+ }
907
+
908
+ .skhema-elements {
909
+ padding: 12px;
910
+ }
911
+
912
+ .skhema-footer {
913
+ padding: 10px 12px;
914
+ }
915
+
916
+ .skhema-footer-row {
917
+ flex-direction: column;
918
+ align-items: stretch;
919
+ gap: 8px;
920
+ }
921
+
922
+ .skhema-save-btn {
923
+ justify-content: center;
924
+ }
925
+ }
926
+
927
+ /* Accessibility */
928
+ @media (prefers-reduced-motion: reduce) {
929
+ .skhema-component-card,
930
+ .skhema-save-btn {
931
+ transition: none;
932
+ }
933
+ }
934
+
935
+ .skhema-structured-data {
936
+ display: none !important;
937
+ }
938
+ `;
939
+ class SkhemaComponent extends HTMLElement {
940
+ constructor() {
941
+ super();
942
+ this.contentData = null;
943
+ this.componentConnected = false;
944
+ this.hasTrackedLoad = false;
945
+ this.pendingRender = null;
946
+ this.themeObserver = null;
947
+ this.mediaQueryListener = null;
948
+ this.shadow = this.attachShadow({ mode: "closed" });
949
+ this.renderSkeleton();
950
+ }
951
+ static get observedAttributes() {
952
+ return [
953
+ "component-type",
954
+ "contributor-id",
955
+ "title",
956
+ "theme",
957
+ "track-analytics",
958
+ "source-url"
959
+ ];
960
+ }
961
+ connectedCallback() {
962
+ if (this.componentConnected) return;
963
+ this.componentConnected = true;
964
+ try {
965
+ this.addPreconnectHints();
966
+ this.addEventListener("skhema:element-ready", () => {
967
+ this.scheduleRender();
968
+ });
969
+ this.scheduleRender();
970
+ this.setupThemeListeners();
971
+ } catch (error) {
972
+ this.renderError("Failed to initialize component", error);
973
+ }
974
+ }
975
+ disconnectedCallback() {
976
+ this.cleanupThemeListeners();
977
+ if (this.pendingRender !== null) {
978
+ cancelAnimationFrame(this.pendingRender);
979
+ this.pendingRender = null;
980
+ }
981
+ }
982
+ attributeChangedCallback(_name, oldValue, newValue) {
983
+ if (oldValue !== newValue && this.componentConnected) {
984
+ this.scheduleRender();
985
+ }
986
+ }
987
+ scheduleRender() {
988
+ if (this.pendingRender !== null) {
989
+ cancelAnimationFrame(this.pendingRender);
990
+ }
991
+ this.pendingRender = requestAnimationFrame(() => {
992
+ this.pendingRender = null;
993
+ this.render();
994
+ });
995
+ }
996
+ render() {
997
+ const validation = this.validateComponentAttributes();
998
+ if (!validation.isValid) {
999
+ this.renderError("Invalid component attributes", validation.errors);
1000
+ return;
1001
+ }
1002
+ const componentType = validation.componentType;
1003
+ const contributorId = validation.contributorId;
1004
+ const title = this.getAttribute("title") || "";
1005
+ const childElements = this.gatherChildElements(componentType);
1006
+ if (childElements.length === 0) {
1007
+ this.renderError("Component requires child elements", [
1008
+ "Add <skhema-element> children inside this component"
1009
+ ]);
1010
+ return;
1011
+ }
1012
+ const componentHash = generateComponentHash(
1013
+ childElements.map((el) => ({
1014
+ elementType: el.elementType,
1015
+ content: el.content
1016
+ }))
1017
+ );
1018
+ this.contentData = {
1019
+ contributor_id: contributorId,
1020
+ component_type: componentType,
1021
+ component_hash: componentHash,
1022
+ title,
1023
+ elements: childElements.map((el) => ({
1024
+ element_type: el.elementType,
1025
+ content: el.content,
1026
+ content_hash: el.contentHash
1027
+ })),
1028
+ source_url: this.getAttribute("source-url") || window.location.href,
1029
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1030
+ page_title: document.title
1031
+ };
1032
+ this.renderContent(childElements);
1033
+ this.addStructuredData();
1034
+ this.trackLoad();
1035
+ }
1036
+ validateComponentAttributes() {
1037
+ const errors = [];
1038
+ const componentType = this.getAttribute("component-type");
1039
+ const contributorId = this.getAttribute("contributor-id");
1040
+ if (!componentType) {
1041
+ errors.push("Missing required attribute: component-type");
1042
+ } else if (!isValidComponentType(componentType)) {
1043
+ errors.push(`Invalid component-type "${componentType}"`);
1044
+ }
1045
+ if (!contributorId) {
1046
+ errors.push("Missing required attribute: contributor-id");
1047
+ } else if (contributorId.trim().length === 0) {
1048
+ errors.push("contributor-id cannot be empty");
1049
+ }
1050
+ return {
1051
+ isValid: errors.length === 0,
1052
+ errors,
1053
+ componentType: isValidComponentType(componentType || "") ? componentType : void 0,
1054
+ contributorId: contributorId || void 0
1055
+ };
1056
+ }
1057
+ gatherChildElements(componentType) {
1058
+ const children = Array.from(
1059
+ this.querySelectorAll("skhema-element")
1060
+ );
1061
+ const elements = [];
1062
+ for (const child of children) {
1063
+ const data = child.getElementData?.();
1064
+ if (!data) continue;
1065
+ if (!validateElementBelongsToComponent(data.elementType, componentType)) {
1066
+ console.warn(
1067
+ `skhema-component: element type "${data.elementType}" does not belong to component type "${componentType}"`
1068
+ );
1069
+ }
1070
+ elements.push(data);
1071
+ }
1072
+ const orderedTypes = getElementTypesForComponent(componentType);
1073
+ elements.sort((a, b) => {
1074
+ const aIdx = orderedTypes.indexOf(a.elementType);
1075
+ const bIdx = orderedTypes.indexOf(b.elementType);
1076
+ const aOrder = aIdx === -1 ? orderedTypes.length : aIdx;
1077
+ const bOrder = bIdx === -1 ? orderedTypes.length : bIdx;
1078
+ return aOrder - bOrder;
1079
+ });
1080
+ return elements;
1081
+ }
1082
+ renderContent(elements) {
1083
+ if (!this.contentData) return;
1084
+ const { component_type, contributor_id, title } = this.contentData;
1085
+ const componentLabel = getComponentTypeLabel(component_type);
1086
+ const acronym = getComponentTypeAcronym(component_type);
1087
+ const colors = COMPONENT_COLORS[component_type];
1088
+ const displayName = this.formatContributorName(contributor_id);
1089
+ const themeAttribute = this.getAttribute("theme") || "auto";
1090
+ const actualTheme = this.getActualTheme(themeAttribute);
1091
+ const redirectUrl = generateComponentRedirectUrl(
1092
+ this.contentData.component_hash,
1093
+ component_type,
1094
+ contributor_id
1095
+ );
1096
+ const badgeStyle = colors ? `background: ${colors.bg}; color: ${colors.text}; border: 1px solid ${colors.border};` : "";
1097
+ const topBorderColor = colors ? colors.text : "var(--skhema-primary)";
1098
+ const groupedElements = this.groupElementsByType(elements);
1099
+ const elementSectionsHtml = groupedElements.map(
1100
+ (group) => `
1101
+ <div class="skhema-element-group">
1102
+ <div class="skhema-element-label">${getElementTypeLabel(group.elementType)}</div>
1103
+ ${group.items.map(
1104
+ (item) => `<div class="skhema-element-content">${sanitizeContent(item.content)}</div>`
1105
+ ).join("")}
1106
+ </div>
1107
+ `
1108
+ ).join("");
1109
+ this.setAttribute("role", "article");
1110
+ this.setAttribute(
1111
+ "aria-label",
1112
+ `${componentLabel} component${title ? ` — ${title}` : ""}`
1113
+ );
1114
+ const titleHtml = title ? `<span class="skhema-title-separator">&mdash;</span><span class="skhema-title">${title}</span>` : "";
1115
+ this.shadow.innerHTML = `
1116
+ <style>${styles$1}</style>
1117
+
1118
+ <div class="skhema-component-card" data-theme="${actualTheme}">
1119
+ <div class="skhema-top-border" style="background: ${topBorderColor};"></div>
1120
+
1121
+ <div class="skhema-header">
1122
+ <span class="skhema-acronym-badge" style="${badgeStyle}" title="${componentLabel}">
1123
+ ${acronym}
1124
+ </span>
1125
+ <span class="skhema-type-label">${componentLabel}</span>
1126
+ ${titleHtml}
1127
+ </div>
1128
+
1129
+ <div class="skhema-elements">
1130
+ ${elementSectionsHtml}
1131
+ </div>
1132
+
1133
+ <div class="skhema-footer">
1134
+ <div class="skhema-footer-row">
1135
+ <span class="skhema-contributor-line">
1136
+ ${USER_ICON_SVG}
1137
+ ${displayName}
1138
+ </span>
1139
+ <a href="${redirectUrl}"
1140
+ class="skhema-save-btn"
1141
+ target="_blank"
1142
+ rel="noopener noreferrer"
1143
+ title="Save this component to Skhema">
1144
+ Save to Skhema
1145
+ </a>
1146
+ </div>
1147
+ <div class="skhema-footer-attribution">
1148
+ Strategy powered by <a href="https://skhema.com" target="_blank" rel="noopener noreferrer">Skhema</a>
1149
+ </div>
1150
+ </div>
1151
+ </div>
1152
+
1153
+ <slot style="display:none;"></slot>
1154
+ `;
1155
+ const saveBtn = this.shadow.querySelector(
1156
+ ".skhema-save-btn"
1157
+ );
1158
+ if (saveBtn) {
1159
+ saveBtn.addEventListener("click", (event) => {
1160
+ this.handleSaveClick(event);
1161
+ });
1162
+ }
1163
+ }
1164
+ groupElementsByType(elements) {
1165
+ const groups = /* @__PURE__ */ new Map();
1166
+ for (const el of elements) {
1167
+ const existing = groups.get(el.elementType);
1168
+ if (existing) {
1169
+ existing.push(el);
1170
+ } else {
1171
+ groups.set(el.elementType, [el]);
1172
+ }
1173
+ }
1174
+ return Array.from(groups.entries()).map(([elementType, items]) => ({
1175
+ elementType,
1176
+ items
1177
+ }));
1178
+ }
1179
+ getActualTheme(themeAttribute) {
1180
+ if (themeAttribute === "light" || themeAttribute === "dark") {
1181
+ return themeAttribute;
1182
+ }
1183
+ const htmlElement = document.documentElement;
1184
+ const bodyElement = document.body;
1185
+ const htmlTheme = htmlElement.getAttribute("data-theme") || htmlElement.getAttribute("theme") || htmlElement.className.match(/theme-(\w+)/)?.[1];
1186
+ const bodyTheme = bodyElement.getAttribute("data-theme") || bodyElement.getAttribute("theme") || bodyElement.className.match(/theme-(\w+)/)?.[1];
1187
+ const hasDarkClass = htmlElement.classList.contains("dark") || bodyElement.classList.contains("dark") || htmlElement.classList.contains("dark-mode") || bodyElement.classList.contains("dark-mode");
1188
+ if (hasDarkClass || htmlTheme === "dark" || bodyTheme === "dark") {
1189
+ return "dark";
1190
+ }
1191
+ const computedStyles = window.getComputedStyle(htmlElement);
1192
+ const colorScheme = computedStyles.getPropertyValue("color-scheme");
1193
+ if (colorScheme && colorScheme.includes("dark")) {
1194
+ return "dark";
1195
+ }
1196
+ if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
1197
+ return "dark";
1198
+ }
1199
+ return "light";
1200
+ }
1201
+ formatContributorName(contributorId) {
1202
+ return contributorId.split(/[_-]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ");
1203
+ }
1204
+ addPreconnectHints() {
1205
+ if (document.querySelector(
1206
+ 'link[rel="preconnect"][href*="analytics.skhema.com"]'
1207
+ )) {
1208
+ return;
1209
+ }
1210
+ try {
1211
+ const preconnectApi = document.createElement("link");
1212
+ preconnectApi.rel = "preconnect";
1213
+ preconnectApi.href = "https://analytics.skhema.com";
1214
+ document.head.appendChild(preconnectApi);
1215
+ const dnsPrefetch = document.createElement("link");
1216
+ dnsPrefetch.rel = "dns-prefetch";
1217
+ dnsPrefetch.href = "https://skhema.com";
1218
+ document.head.appendChild(dnsPrefetch);
1219
+ } catch (error) {
1220
+ console.debug("Failed to add preconnect hints:", error);
1221
+ }
1222
+ }
1223
+ renderSkeleton() {
1224
+ const themeAttribute = this.getAttribute("theme") || "auto";
1225
+ const actualTheme = this.getActualTheme(themeAttribute);
1226
+ this.shadow.innerHTML = `
1227
+ <style>${styles$1}</style>
1228
+
1229
+ <div class="skhema-skeleton" data-theme="${actualTheme}">
1230
+ <div class="skhema-skeleton-header">
1231
+ <div class="skhema-skeleton-badge"></div>
1232
+ <div class="skhema-skeleton-text">
1233
+ <div class="skhema-skeleton-line medium"></div>
1234
+ </div>
1235
+ </div>
1236
+ <div class="skhema-skeleton-content">
1237
+ <div class="skhema-skeleton-line short"></div>
1238
+ <div class="skhema-skeleton-line"></div>
1239
+ <div class="skhema-skeleton-line short"></div>
1240
+ <div class="skhema-skeleton-line"></div>
1241
+ <div class="skhema-skeleton-line medium"></div>
1242
+ </div>
1243
+ </div>
1244
+
1245
+ <slot style="display:none;"></slot>
1246
+ `;
1247
+ }
1248
+ renderError(title, errors) {
1249
+ const errorList = Array.isArray(errors) ? errors : [String(errors)];
1250
+ this.shadow.innerHTML = `
1251
+ <style>${styles$1}</style>
1252
+
1253
+ <div class="skhema-component-card">
1254
+ <div class="skhema-error">
1255
+ <div class="skhema-error-title">Skhema Component Error: ${title}</div>
1256
+ <ul class="skhema-error-list">
1257
+ ${errorList.map((error) => `<li>${error}</li>`).join("")}
1258
+ </ul>
1259
+ </div>
1260
+ </div>
1261
+
1262
+ <slot style="display:none;"></slot>
1263
+ `;
1264
+ this.dispatchEvent(
1265
+ new CustomEvent("skhema:error", {
1266
+ detail: { error: title, details: errors },
1267
+ bubbles: true
1268
+ })
1269
+ );
1270
+ }
1271
+ addStructuredData() {
1272
+ if (!this.contentData) return;
1273
+ const { component_type, contributor_id, title, elements, source_url } = this.contentData;
1274
+ const structuredData = generateComponentStructuredData(
1275
+ title,
1276
+ component_type,
1277
+ contributor_id,
1278
+ elements.map((el) => ({
1279
+ elementType: el.element_type,
1280
+ content: el.content
1281
+ })),
1282
+ source_url
1283
+ );
1284
+ const script = document.createElement("script");
1285
+ script.type = "application/ld+json";
1286
+ script.textContent = JSON.stringify(structuredData);
1287
+ script.className = "skhema-structured-data";
1288
+ document.head.appendChild(script);
1289
+ }
1290
+ async trackLoad() {
1291
+ if (!shouldTrackAnalytics(this) || !this.contentData || this.hasTrackedLoad) {
1292
+ return;
1293
+ }
1294
+ this.hasTrackedLoad = true;
1295
+ const analytics = {
1296
+ contributorId: this.contentData.contributor_id,
1297
+ componentType: this.contentData.component_type,
1298
+ componentHash: this.contentData.component_hash,
1299
+ title: this.contentData.title,
1300
+ elements: this.contentData.elements.map((el) => ({
1301
+ elementType: el.element_type,
1302
+ content: el.content,
1303
+ contentHash: el.content_hash
1304
+ })),
1305
+ pageUrl: window.location.href,
1306
+ pageTitle: document.title,
1307
+ timestamp: Date.now(),
1308
+ userAgent: navigator.userAgent
1309
+ };
1310
+ await trackComponentEmbedLoad(analytics);
1311
+ this.dispatchEvent(
1312
+ new CustomEvent("skhema:component-load", {
1313
+ detail: analytics,
1314
+ bubbles: true
1315
+ })
1316
+ );
1317
+ }
1318
+ async handleSaveClick(_event) {
1319
+ if (!this.contentData) return;
1320
+ if (shouldTrackAnalytics(this)) {
1321
+ await trackComponentClick(this.contentData);
1322
+ }
1323
+ this.dispatchEvent(
1324
+ new CustomEvent("skhema:component-click", {
1325
+ detail: this.contentData,
1326
+ bubbles: true
1327
+ })
1328
+ );
1329
+ }
1330
+ // Public API
1331
+ getContentData() {
1332
+ return this.contentData;
1333
+ }
1334
+ refresh() {
1335
+ this.render();
1336
+ }
1337
+ setupThemeListeners() {
1338
+ const themeAttribute = this.getAttribute("theme");
1339
+ if (themeAttribute === "auto" || !themeAttribute) {
1340
+ if (window.matchMedia) {
1341
+ this.mediaQueryListener = window.matchMedia(
1342
+ "(prefers-color-scheme: dark)"
1343
+ );
1344
+ const handleThemeChange = () => this.updateTheme();
1345
+ this.mediaQueryListener.addEventListener("change", handleThemeChange);
1346
+ }
1347
+ this.themeObserver = new MutationObserver(() => this.updateTheme());
1348
+ this.themeObserver.observe(document.documentElement, {
1349
+ attributes: true,
1350
+ attributeFilter: ["class", "data-theme", "theme"]
1351
+ });
1352
+ this.themeObserver.observe(document.body, {
1353
+ attributes: true,
1354
+ attributeFilter: ["class", "data-theme", "theme"]
1355
+ });
1356
+ }
1357
+ }
1358
+ cleanupThemeListeners() {
1359
+ if (this.themeObserver) {
1360
+ this.themeObserver.disconnect();
1361
+ this.themeObserver = null;
1362
+ }
1363
+ if (this.mediaQueryListener) {
1364
+ this.mediaQueryListener = null;
1365
+ }
1366
+ }
1367
+ updateTheme() {
1368
+ const themeAttribute = this.getAttribute("theme") || "auto";
1369
+ if (themeAttribute === "auto") {
1370
+ const card = this.shadow.querySelector(".skhema-component-card");
1371
+ if (card) {
1372
+ const newTheme = this.getActualTheme("auto");
1373
+ card.setAttribute("data-theme", newTheme);
1374
+ }
1375
+ }
1376
+ }
1377
+ }
1378
+ const styles = `
1379
+ :host {
1380
+ /* Skhema Brand Colors - matching UI library */
1381
+ --skhema-primary: hsl(344 57% 54%); /* #cd476a */
1382
+ --skhema-primary-hover: hsl(344 50% 47%); /* #b53d5e */
1383
+ --skhema-primary-pressed: hsl(343 50% 41%); /* #9d3552 */
1384
+ --skhema-secondary: hsl(345 100% 75%); /* #ff82a2 */
1385
+
1386
+ /* Light mode colors */
1387
+ --skhema-bg: hsl(0 0% 100%);
1388
+ --skhema-card: hsl(0 0% 100%);
1389
+ --skhema-border: hsl(214.3 31.8% 91.4%);
1390
+ --skhema-text: hsl(222.2 84% 4.9%);
1391
+ --skhema-text-muted: hsl(215.4 16.3% 46.9%);
1392
+ --skhema-accent: hsl(210 40% 96%);
1393
+
1394
+ /* Shadows matching UI library */
1395
+ --skhema-shadow: 0 1px 3px 0 hsl(0 0 0 / 0.1), 0 1px 2px -1px hsl(0 0 0 / 0.1);
1396
+ --skhema-shadow-md: 0 4px 6px -1px hsl(0 0 0 / 0.1), 0 2px 4px -2px hsl(0 0 0 / 0.1);
1397
+ --skhema-shadow-lg: 0 10px 15px -3px hsl(0 0 0 / 0.1), 0 4px 6px -4px hsl(0 0 0 / 0.1);
1398
+ --skhema-radius: 0.1rem;
1399
+
1400
+ display: block;
1401
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Inter', sans-serif;
1402
+ line-height: 1.5;
1403
+ color: var(--skhema-text);
1404
+ }
1405
+
1406
+ /* Dark mode styles - applied via data-theme attribute */
1407
+ .skhema-element-card[data-theme="dark"],
1408
+ .skhema-skeleton[data-theme="dark"] {
1409
+ --skhema-bg: hsl(222.2 84% 4.9%);
1410
+ --skhema-card: hsl(222.2 84% 4.9%);
1411
+ --skhema-border: hsl(217.2 32.6% 17.5%);
1412
+ --skhema-text: hsl(210 40% 98%);
1413
+ --skhema-text-muted: hsl(215 20.2% 65.1%);
1414
+ --skhema-accent: hsl(217.2 32.6% 17.5%);
1415
+ }
1416
+
1417
+ /* Main component card */
1418
+ .skhema-element-card {
1419
+ position: relative;
1420
+ background: var(--skhema-card);
1421
+ border: 1px solid var(--skhema-border);
1422
+ border-radius: calc(var(--skhema-radius) + 2px);
1423
+ padding: 0;
1424
+ box-shadow: var(--skhema-shadow);
1425
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1426
+ max-width: 600px;
1427
+ margin: 8px 0;
1428
+ overflow: hidden;
1429
+ }
1430
+
1431
+ .skhema-element-card:hover {
1432
+ box-shadow: var(--skhema-shadow-lg);
1433
+ border-color: var(--skhema-primary);
1434
+ transform: translateY(-1px);
1435
+ }
1436
+
1437
+ /* Header section with acronym badge + element type label */
1438
+ .skhema-header {
1439
+ display: flex;
1440
+ align-items: center;
1441
+ gap: 8px;
1442
+ padding: 12px 16px;
1443
+ border-bottom: 1px solid var(--skhema-border);
1444
+ }
1445
+
1446
+ /* Content section */
1447
+ .skhema-content {
1448
+ padding: 16px;
1449
+ }
1450
+
1451
+ .skhema-content-text {
1452
+ font-size: 15px;
1453
+ line-height: 1.6;
1454
+ color: var(--skhema-text);
1455
+ margin: 0;
1456
+ word-wrap: break-word;
1457
+ overflow-wrap: break-word;
1458
+ hyphens: auto;
1459
+ max-width: 100%;
1460
+ }
1461
+
1462
+ /* Footer section */
1463
+ .skhema-footer {
1464
+ padding: 12px 16px;
1465
+ border-top: 1px solid var(--skhema-border);
1466
+ }
1467
+
1468
+ .skhema-footer-row {
1469
+ display: flex;
1470
+ align-items: center;
1471
+ justify-content: space-between;
1472
+ gap: 12px;
1473
+ }
1474
+
1475
+ ${SHARED_CARD_STYLES}
1476
+
1477
+ /* Element type label next to badge */
1478
+ .skhema-type-label {
1479
+ font-size: 13px;
1480
+ font-weight: 500;
1481
+ color: var(--skhema-text);
1482
+ }
1483
+
1484
+ /* Error state */
1485
+ .skhema-error {
1486
+ background: hsl(0 93% 94%);
1487
+ border: 1px solid hsl(0 84% 60%);
1488
+ color: hsl(0 74% 42%);
1489
+ padding: 12px;
1490
+ border-radius: calc(var(--skhema-radius) + 2px);
1491
+ font-size: 13px;
1492
+ }
1493
+
1494
+ .skhema-error-title {
1495
+ font-weight: 600;
1496
+ margin-bottom: 8px;
1497
+ }
1498
+
1499
+ .skhema-error-list {
1500
+ margin: 0;
1501
+ padding-left: 16px;
1502
+ }
1503
+
1504
+ /* Skeleton loading state */
1505
+ .skhema-skeleton {
1506
+ background: var(--skhema-card);
1507
+ border: 1px solid var(--skhema-border);
1508
+ border-radius: calc(var(--skhema-radius) + 2px);
1509
+ padding: 16px;
1510
+ box-shadow: var(--skhema-shadow);
1511
+ max-width: 600px;
1512
+ margin: 8px 0;
1513
+ animation: skeletonPulse 1.5s ease-in-out infinite;
1514
+ }
1515
+
1516
+ .skhema-skeleton-header {
1517
+ display: flex;
1518
+ align-items: center;
1519
+ gap: 12px;
1520
+ margin-bottom: 12px;
1521
+ }
1522
+
1523
+ .skhema-skeleton-badge {
1524
+ width: 32px;
1525
+ height: 20px;
1526
+ border-radius: 2px;
1527
+ background: linear-gradient(90deg,
1528
+ var(--skhema-border) 25%,
1529
+ var(--skhema-accent) 50%,
1530
+ var(--skhema-border) 75%);
1531
+ background-size: 200% 100%;
1532
+ animation: shimmer 1.5s infinite;
1533
+ }
1534
+
1535
+ .skhema-skeleton-text {
1536
+ flex: 1;
1537
+ }
1538
+
1539
+ .skhema-skeleton-line {
1540
+ height: 12px;
1541
+ background: linear-gradient(90deg,
1542
+ var(--skhema-border) 25%,
1543
+ var(--skhema-accent) 50%,
1544
+ var(--skhema-border) 75%);
1545
+ background-size: 200% 100%;
1546
+ animation: shimmer 1.5s infinite;
1547
+ border-radius: calc(var(--skhema-radius) + 2px);
1548
+ margin: 6px 0;
1549
+ }
1550
+
1551
+ .skhema-skeleton-line.short {
1552
+ width: 40%;
1553
+ }
1554
+
1555
+ .skhema-skeleton-line.medium {
1556
+ width: 70%;
1557
+ }
1558
+
1559
+ .skhema-skeleton-content {
1560
+ margin: 16px 0;
1561
+ }
1562
+
1563
+ @keyframes skeletonPulse {
1564
+ 0%, 100% {
1565
+ opacity: 1;
1566
+ }
1567
+ 50% {
1568
+ opacity: 0.8;
1569
+ }
1570
+ }
1571
+
1572
+ @keyframes shimmer {
1573
+ 0% {
1574
+ background-position: -200% 0;
1575
+ }
1576
+ 100% {
1577
+ background-position: 200% 0;
1578
+ }
1579
+ }
1580
+
1581
+ /* Responsive design */
1582
+ @media (max-width: 640px) {
1583
+ .skhema-element-card {
1584
+ margin: 4px 0;
1585
+ }
1586
+
1587
+ .skhema-header {
1588
+ padding: 10px 12px;
1589
+ }
1590
+
1591
+ .skhema-content {
1592
+ padding: 12px;
1593
+ }
1594
+
1595
+ .skhema-footer {
1596
+ padding: 10px 12px;
1597
+ }
1598
+
1599
+ .skhema-footer-row {
1600
+ flex-direction: column;
1601
+ align-items: stretch;
1602
+ gap: 8px;
1603
+ }
1604
+
1605
+ .skhema-save-btn {
1606
+ justify-content: center;
1607
+ }
1608
+ }
1609
+
1610
+ /* Accessibility */
1611
+ @media (prefers-reduced-motion: reduce) {
1612
+ .skhema-element-card,
1613
+ .skhema-save-btn {
1614
+ transition: none;
1615
+ }
1616
+
1617
+ .skhema-save-btn::after {
1618
+ transition: none;
1619
+ }
1620
+
1621
+ .skhema-save-btn:hover::after {
1622
+ transform: none;
1623
+ }
1624
+ }
1625
+
1626
+ .skhema-structured-data {
1627
+ display: none !important;
1628
+ }
1629
+ `;
1630
+ class SkhemaElement extends HTMLElement {
1631
+ constructor() {
1632
+ super();
1633
+ this.contentData = null;
1634
+ this.componentConnected = false;
1635
+ this.hasTrackedLoad = false;
1636
+ this.nestedMode = false;
1637
+ this.themeObserver = null;
1638
+ this.mediaQueryListener = null;
1639
+ this.shadow = this.attachShadow({ mode: "closed" });
1640
+ this.renderSkeleton();
1641
+ }
1642
+ static get observedAttributes() {
1643
+ return [
1644
+ "element-type",
1645
+ "contributor-id",
1646
+ "content",
1647
+ "source-url",
1648
+ "theme",
1649
+ "track-analytics"
1650
+ ];
1651
+ }
1652
+ connectedCallback() {
1653
+ if (this.componentConnected) return;
1654
+ this.componentConnected = true;
1655
+ if (this.closest("skhema-component")) {
1656
+ this.nestedMode = true;
1657
+ const validation = validateAttributes(this);
1658
+ if (!validation.isValid) {
1659
+ console.warn(
1660
+ "skhema-element: invalid attributes in nested mode",
1661
+ validation.errors
1662
+ );
1663
+ return;
1664
+ }
1665
+ const content = this.getContent();
1666
+ if (!content.trim()) {
1667
+ console.warn("skhema-element: empty content in nested mode");
1668
+ return;
1669
+ }
1670
+ this.contentData = {
1671
+ contributor_id: validation.contributorId,
1672
+ element_type: validation.elementType,
1673
+ content,
1674
+ content_hash: generateContentHash(content),
1675
+ source_url: this.getAttribute("source-url") || window.location.href,
1676
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1677
+ page_title: document.title
1678
+ };
1679
+ this.shadow.innerHTML = "";
1680
+ this.dispatchEvent(
1681
+ new CustomEvent("skhema:element-ready", {
1682
+ bubbles: true,
1683
+ composed: true,
1684
+ detail: this.getElementData()
1685
+ })
1686
+ );
1687
+ return;
1688
+ }
1689
+ try {
1690
+ this.addPreconnectHints();
1691
+ requestAnimationFrame(() => {
1692
+ this.render();
1693
+ this.trackLoad();
1694
+ this.setupThemeListeners();
1695
+ });
1696
+ } catch (error) {
1697
+ this.renderError("Failed to initialize component", error);
1698
+ }
1699
+ }
1700
+ disconnectedCallback() {
1701
+ this.cleanupThemeListeners();
1702
+ }
1703
+ attributeChangedCallback(_name, oldValue, newValue) {
1704
+ if (oldValue !== newValue && this.componentConnected && !this.nestedMode) {
1705
+ this.render();
1706
+ }
1707
+ }
1708
+ /**
1709
+ * Returns element data for parent <skhema-component> consumption.
1710
+ */
1711
+ getElementData() {
1712
+ if (!this.contentData) return null;
1713
+ return {
1714
+ elementType: this.contentData.element_type,
1715
+ content: this.contentData.content,
1716
+ contentHash: this.contentData.content_hash
1717
+ };
1718
+ }
1719
+ render() {
1720
+ const validation = validateAttributes(this);
1721
+ if (!validation.isValid) {
1722
+ this.renderError("Invalid component attributes", validation.errors);
1723
+ return;
1724
+ }
1725
+ const content = this.getContent();
1726
+ if (!content.trim()) {
1727
+ this.renderError("Component requires content", [
1728
+ "Add content between the opening and closing tags, or use the content attribute"
1729
+ ]);
1730
+ return;
1731
+ }
1732
+ const securityValidation = validateContentSecurity(content);
1733
+ if (!securityValidation.isSecure) {
1734
+ this.renderError(
1735
+ "Content security validation failed",
1736
+ securityValidation.issues
1737
+ );
1738
+ return;
1739
+ }
1740
+ this.contentData = {
1741
+ contributor_id: validation.contributorId,
1742
+ element_type: validation.elementType,
1743
+ content,
1744
+ content_hash: generateContentHash(content),
1745
+ source_url: this.getAttribute("source-url") || window.location.href,
1746
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1747
+ page_title: document.title
1748
+ };
1749
+ this.renderContent();
1750
+ this.addStructuredData();
1751
+ }
1752
+ getContent() {
1753
+ return this.getAttribute("content") || this.textContent || "";
1754
+ }
1755
+ renderContent() {
1756
+ if (!this.contentData) return;
1757
+ const { element_type, contributor_id, content } = this.contentData;
1758
+ const label = getElementTypeLabel(element_type);
1759
+ const redirectUrl = generateRedirectUrl(
1760
+ content,
1761
+ element_type,
1762
+ contributor_id
1763
+ );
1764
+ const themeAttribute = this.getAttribute("theme") || "auto";
1765
+ const actualTheme = this.getActualTheme(themeAttribute);
1766
+ const displayName = this.formatContributorName(contributor_id);
1767
+ const componentType = resolveComponentType(element_type);
1768
+ const acronym = getComponentTypeAcronym(componentType);
1769
+ const colors = COMPONENT_COLORS[componentType];
1770
+ const ariaAttrs = createAriaAttributes(element_type);
1771
+ Object.entries(ariaAttrs).forEach(([key, value]) => {
1772
+ this.setAttribute(key, value);
1773
+ });
1774
+ const badgeStyle = colors ? `background: ${colors.bg}; color: ${colors.text}; border: 1px solid ${colors.border};` : "";
1775
+ this.shadow.innerHTML = `
1776
+ <style>${styles}</style>
1777
+
1778
+ <div class="skhema-element-card" data-theme="${actualTheme}">
1779
+ <div class="skhema-header">
1780
+ <span class="skhema-acronym-badge" style="${badgeStyle}" title="${label}">
1781
+ ${acronym}
1782
+ </span>
1783
+ <span class="skhema-type-label">${label}</span>
1784
+ </div>
1785
+
1786
+ <div class="skhema-content">
1787
+ <div class="skhema-content-text">${sanitizeContent(content)}</div>
1788
+ </div>
1789
+
1790
+ <div class="skhema-footer">
1791
+ <div class="skhema-footer-row">
1792
+ <span class="skhema-contributor-line">
1793
+ ${USER_ICON_SVG}
1794
+ ${displayName}
1795
+ </span>
1796
+ <a href="${redirectUrl}"
1797
+ class="skhema-save-btn"
1798
+ target="_blank"
1799
+ rel="noopener noreferrer"
1800
+ title="Save this element to Skhema">
1801
+ Save to Skhema
1802
+ </a>
1803
+ </div>
1804
+ <div class="skhema-footer-attribution">
1805
+ Strategy powered by <a href="https://skhema.com" target="_blank" rel="noopener noreferrer">Skhema</a>
1806
+ </div>
1807
+ </div>
1808
+ </div>
1809
+ `;
1810
+ const saveBtn = this.shadow.querySelector(
1811
+ ".skhema-save-btn"
1812
+ );
1813
+ if (saveBtn) {
1814
+ saveBtn.addEventListener("click", (event) => {
1815
+ this.handleSaveClick(event);
1816
+ });
1817
+ }
1818
+ }
1819
+ getActualTheme(themeAttribute) {
1820
+ if (themeAttribute === "light" || themeAttribute === "dark") {
1821
+ return themeAttribute;
1822
+ }
1823
+ const htmlElement = document.documentElement;
1824
+ const bodyElement = document.body;
1825
+ const htmlTheme = htmlElement.getAttribute("data-theme") || htmlElement.getAttribute("theme") || htmlElement.className.match(/theme-(\w+)/)?.[1];
1826
+ const bodyTheme = bodyElement.getAttribute("data-theme") || bodyElement.getAttribute("theme") || bodyElement.className.match(/theme-(\w+)/)?.[1];
1827
+ const hasDarkClass = htmlElement.classList.contains("dark") || bodyElement.classList.contains("dark") || htmlElement.classList.contains("dark-mode") || bodyElement.classList.contains("dark-mode");
1828
+ if (hasDarkClass || htmlTheme === "dark" || bodyTheme === "dark") {
1829
+ return "dark";
1830
+ }
1831
+ const computedStyles = window.getComputedStyle(htmlElement);
1832
+ const colorScheme = computedStyles.getPropertyValue("color-scheme");
1833
+ if (colorScheme && colorScheme.includes("dark")) {
1834
+ return "dark";
1835
+ }
1836
+ if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
1837
+ return "dark";
1838
+ }
1839
+ return "light";
1840
+ }
1841
+ formatContributorName(contributorId) {
1842
+ return contributorId.split(/[_-]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ");
1843
+ }
1844
+ addPreconnectHints() {
1845
+ if (document.querySelector(
1846
+ 'link[rel="preconnect"][href*="analytics.skhema.com"]'
1847
+ )) {
1848
+ return;
1849
+ }
1850
+ try {
1851
+ const preconnectApi = document.createElement("link");
1852
+ preconnectApi.rel = "preconnect";
1853
+ preconnectApi.href = "https://analytics.skhema.com";
1854
+ document.head.appendChild(preconnectApi);
1855
+ const dnsPrefetch = document.createElement("link");
1856
+ dnsPrefetch.rel = "dns-prefetch";
1857
+ dnsPrefetch.href = "https://skhema.com";
1858
+ document.head.appendChild(dnsPrefetch);
1859
+ } catch (error) {
1860
+ console.debug("Failed to add preconnect hints:", error);
1861
+ }
1862
+ }
1863
+ renderSkeleton() {
1864
+ const themeAttribute = this.getAttribute("theme") || "auto";
1865
+ const actualTheme = this.getActualTheme(themeAttribute);
1866
+ this.shadow.innerHTML = `
1867
+ <style>${styles}</style>
1868
+
1869
+ <div class="skhema-skeleton" data-theme="${actualTheme}">
1870
+ <div class="skhema-skeleton-header">
1871
+ <div class="skhema-skeleton-badge"></div>
1872
+ <div class="skhema-skeleton-text">
1873
+ <div class="skhema-skeleton-line medium"></div>
1874
+ </div>
1875
+ </div>
1876
+ <div class="skhema-skeleton-content">
1877
+ <div class="skhema-skeleton-line"></div>
1878
+ <div class="skhema-skeleton-line"></div>
1879
+ <div class="skhema-skeleton-line medium"></div>
1880
+ </div>
1881
+ </div>
1882
+ `;
1883
+ }
1884
+ renderError(title, errors) {
1885
+ const errorList = Array.isArray(errors) ? errors : [String(errors)];
1886
+ this.shadow.innerHTML = `
1887
+ <style>${styles}</style>
1888
+
1889
+ <div class="skhema-element-card">
1890
+ <div class="skhema-error">
1891
+ <div class="skhema-error-title">Skhema Component Error: ${title}</div>
1892
+ <ul class="skhema-error-list">
1893
+ ${errorList.map((error) => `<li>${error}</li>`).join("")}
1894
+ </ul>
1895
+ </div>
1896
+ </div>
1897
+ `;
1898
+ this.dispatchEvent(
1899
+ new CustomEvent("skhema:error", {
1900
+ detail: { error: title, details: errors },
1901
+ bubbles: true
1902
+ })
1903
+ );
1904
+ }
1905
+ addStructuredData() {
1906
+ if (!this.contentData) return;
1907
+ const { content, element_type, contributor_id, source_url } = this.contentData;
1908
+ const structuredData = generateStructuredData(
1909
+ content,
1910
+ element_type,
1911
+ contributor_id,
1912
+ source_url
1913
+ );
1914
+ const script = document.createElement("script");
1915
+ script.type = "application/ld+json";
1916
+ script.textContent = JSON.stringify(structuredData);
1917
+ script.className = "skhema-structured-data";
1918
+ document.head.appendChild(script);
1919
+ const metaDiv = document.createElement("div");
1920
+ metaDiv.innerHTML = createMetaTags(content, element_type, contributor_id);
1921
+ metaDiv.className = "skhema-structured-data";
1922
+ document.body.appendChild(metaDiv);
1923
+ }
1924
+ async trackLoad() {
1925
+ if (!shouldTrackAnalytics(this) || !this.contentData || this.hasTrackedLoad) {
1926
+ return;
1927
+ }
1928
+ this.hasTrackedLoad = true;
1929
+ const analytics = {
1930
+ contributorId: this.contentData.contributor_id,
1931
+ elementType: this.contentData.element_type,
1932
+ contentHash: this.contentData.content_hash,
1933
+ content: this.contentData.content,
1934
+ pageUrl: window.location.href,
1935
+ pageTitle: document.title,
1936
+ timestamp: Date.now(),
1937
+ userAgent: navigator.userAgent
1938
+ };
1939
+ await trackEmbedLoad(analytics);
1940
+ this.dispatchEvent(
1941
+ new CustomEvent("skhema:load", {
1942
+ detail: analytics,
1943
+ bubbles: true
1944
+ })
1945
+ );
1946
+ }
1947
+ async handleSaveClick(_event) {
1948
+ if (!this.contentData) return;
1949
+ if (shouldTrackAnalytics(this)) {
1950
+ await trackClick(this.contentData);
1951
+ }
1952
+ this.dispatchEvent(
1953
+ new CustomEvent("skhema:click", {
1954
+ detail: this.contentData,
1955
+ bubbles: true
1956
+ })
1957
+ );
1958
+ }
1959
+ // Public API methods
1960
+ getContentData() {
1961
+ return this.contentData;
1962
+ }
1963
+ refresh() {
1964
+ if (!this.nestedMode) {
1965
+ this.render();
1966
+ }
1967
+ }
1968
+ setupThemeListeners() {
1969
+ const themeAttribute = this.getAttribute("theme");
1970
+ if (themeAttribute === "auto" || !themeAttribute) {
1971
+ if (window.matchMedia) {
1972
+ this.mediaQueryListener = window.matchMedia(
1973
+ "(prefers-color-scheme: dark)"
1974
+ );
1975
+ const handleThemeChange = () => this.updateTheme();
1976
+ this.mediaQueryListener.addEventListener("change", handleThemeChange);
1977
+ }
1978
+ this.themeObserver = new MutationObserver(() => this.updateTheme());
1979
+ this.themeObserver.observe(document.documentElement, {
1980
+ attributes: true,
1981
+ attributeFilter: ["class", "data-theme", "theme"]
1982
+ });
1983
+ this.themeObserver.observe(document.body, {
1984
+ attributes: true,
1985
+ attributeFilter: ["class", "data-theme", "theme"]
1986
+ });
1987
+ }
1988
+ }
1989
+ cleanupThemeListeners() {
1990
+ if (this.themeObserver) {
1991
+ this.themeObserver.disconnect();
1992
+ this.themeObserver = null;
1993
+ }
1994
+ if (this.mediaQueryListener) {
1995
+ this.mediaQueryListener = null;
1996
+ }
1997
+ }
1998
+ updateTheme() {
1999
+ const themeAttribute = this.getAttribute("theme") || "auto";
2000
+ if (themeAttribute === "auto") {
2001
+ const card = this.shadow.querySelector(".skhema-element-card");
2002
+ if (card) {
2003
+ const newTheme = this.getActualTheme("auto");
2004
+ card.setAttribute("data-theme", newTheme);
2005
+ }
2006
+ }
2007
+ }
2008
+ }
2009
+ function registerSkhemaElement() {
2010
+ if (typeof window !== "undefined" && !customElements.get("skhema-element")) {
2011
+ customElements.define(
2012
+ "skhema-element",
2013
+ SkhemaElement
2014
+ );
2015
+ }
2016
+ }
2017
+ function registerSkhemaComponent() {
2018
+ if (typeof window !== "undefined" && !customElements.get("skhema-component")) {
2019
+ customElements.define(
2020
+ "skhema-component",
2021
+ SkhemaComponent
2022
+ );
2023
+ }
2024
+ }
2025
+ if (typeof window !== "undefined") {
2026
+ if (!customElements.get("skhema-element")) {
2027
+ customElements.define(
2028
+ "skhema-element",
2029
+ SkhemaElement
2030
+ );
2031
+ }
2032
+ if (!customElements.get("skhema-component")) {
2033
+ customElements.define(
2034
+ "skhema-component",
2035
+ SkhemaComponent
2036
+ );
2037
+ }
2038
+ }
2039
+ exports.SkhemaComponent = SkhemaComponent;
2040
+ exports.SkhemaElement = SkhemaElement;
2041
+ exports.default = SkhemaElement;
2042
+ exports.generateComponentHash = generateComponentHash;
2043
+ exports.generateComponentRedirectUrl = generateComponentRedirectUrl;
2044
+ exports.generateComponentStructuredData = generateComponentStructuredData;
2045
+ exports.generateRedirectUrl = generateRedirectUrl;
2046
+ exports.generateStructuredData = generateStructuredData;
2047
+ exports.getComponentTypeAcronym = getComponentTypeAcronym;
2048
+ exports.getComponentTypeLabel = getComponentTypeLabel;
2049
+ exports.getElementTypeAcronym = getElementTypeAcronym;
2050
+ exports.getElementTypeLabel = getElementTypeLabel;
2051
+ exports.isValidComponentType = isValidComponentType;
2052
+ exports.isValidElementType = isValidElementType;
2053
+ exports.registerSkhemaComponent = registerSkhemaComponent;
2054
+ exports.registerSkhemaElement = registerSkhemaElement;
2055
+ exports.resolveComponentType = resolveComponentType;
2056
+ exports.shouldTrackAnalytics = shouldTrackAnalytics;
2057
+ exports.trackComponentClick = trackComponentClick;
2058
+ exports.trackComponentEmbedLoad = trackComponentEmbedLoad;
2059
+ exports.validateAttributes = validateAttributes;
2060
+ exports.validateElementBelongsToComponent = validateElementBelongsToComponent;
2061
+ //# sourceMappingURL=index.cjs.map