@skhema/web-component 0.0.16 → 0.0.18

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.es.js CHANGED
@@ -8,53 +8,202 @@ function toUrlSafeBase64(str) {
8
8
  );
9
9
  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
10
10
  }
11
- async function trackEmbedLoad(analytics) {
11
+ const TRACKING_COOKIE_NAME = "_sk";
12
+ const TRACKING_EXPIRY_HOURS = 24;
13
+ const MAX_TRACKED_ITEMS = 50;
14
+ function getTrackedEmbeds() {
12
15
  try {
13
- const data = new URLSearchParams({
14
- contributor_id: analytics.contributorId,
15
- element_type: analytics.elementType,
16
- content_hash: analytics.contentHash,
17
- content: toUrlSafeBase64(analytics.content),
18
- page_url: analytics.pageUrl,
19
- page_title: analytics.pageTitle || "",
20
- timestamp: analytics.timestamp.toString(),
21
- user_agent: analytics.userAgent || ""
22
- });
23
- if (navigator.sendBeacon) {
24
- navigator.sendBeacon(
25
- "https://api.skhema.com/api:XGdoUqHx/component/embed",
26
- data
27
- );
28
- } else {
29
- fetch("https://api.skhema.com/api:XGdoUqHx/component/embed", {
16
+ const cookie = document.cookie.split("; ").find((row) => row.startsWith(`${TRACKING_COOKIE_NAME}=`));
17
+ if (!cookie) return [];
18
+ const data = JSON.parse(decodeURIComponent(cookie.split("=")[1]));
19
+ const now = Date.now();
20
+ const cutoff = now - TRACKING_EXPIRY_HOURS * 60 * 60 * 1e3;
21
+ return data.filter((item) => item.timestamp > cutoff);
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+ function setTrackedEmbeds(tracked) {
27
+ try {
28
+ const limited = tracked.slice(-MAX_TRACKED_ITEMS);
29
+ const expires = new Date(
30
+ Date.now() + TRACKING_EXPIRY_HOURS * 60 * 60 * 1e3
31
+ );
32
+ document.cookie = `${TRACKING_COOKIE_NAME}=${encodeURIComponent(
33
+ JSON.stringify(limited)
34
+ )}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
35
+ } catch {
36
+ }
37
+ }
38
+ function hasBeenTracked(contentHash) {
39
+ const tracked = getTrackedEmbeds();
40
+ return tracked.some((item) => item.contentHash === contentHash);
41
+ }
42
+ function markAsTracked(contentHash) {
43
+ const tracked = getTrackedEmbeds();
44
+ tracked.push({
45
+ contentHash,
46
+ timestamp: Date.now()
47
+ });
48
+ setTrackedEmbeds(tracked);
49
+ }
50
+ class AnalyticsBatcher {
51
+ constructor() {
52
+ this.batch = { embeds: [], clicks: [] };
53
+ this.batchTimeout = null;
54
+ this.BATCH_DELAY = 2e3;
55
+ this.MAX_BATCH_SIZE = 10;
56
+ }
57
+ addEmbedLoad(analytics) {
58
+ this.batch.embeds.push(analytics);
59
+ this.scheduleBatchSend();
60
+ }
61
+ addClick(contentData) {
62
+ this.batch.clicks.push(contentData);
63
+ this.scheduleBatchSend();
64
+ }
65
+ scheduleBatchSend() {
66
+ if (this.batchTimeout) {
67
+ clearTimeout(this.batchTimeout);
68
+ }
69
+ if (this.batch.embeds.length >= this.MAX_BATCH_SIZE || this.batch.clicks.length >= this.MAX_BATCH_SIZE) {
70
+ this.sendBatch();
71
+ return;
72
+ }
73
+ this.batchTimeout = window.setTimeout(() => {
74
+ this.sendBatch();
75
+ }, this.BATCH_DELAY);
76
+ }
77
+ async sendBatch() {
78
+ if (this.batchTimeout) {
79
+ clearTimeout(this.batchTimeout);
80
+ this.batchTimeout = null;
81
+ }
82
+ const currentBatch = { ...this.batch };
83
+ this.batch = { embeds: [], clicks: [] };
84
+ if (currentBatch.embeds.length === 0 && currentBatch.clicks.length === 0) {
85
+ return;
86
+ }
87
+ if (currentBatch.embeds.length > 0) {
88
+ await this.sendEmbeds(currentBatch.embeds);
89
+ }
90
+ if (currentBatch.clicks.length > 0) {
91
+ await this.sendClicks(currentBatch.clicks);
92
+ }
93
+ }
94
+ async sendEmbeds(embeds) {
95
+ for (const embed of embeds) {
96
+ try {
97
+ const data = new URLSearchParams({
98
+ contributor_id: embed.contributorId,
99
+ element_type: embed.elementType,
100
+ content_hash: embed.contentHash,
101
+ content: toUrlSafeBase64(embed.content),
102
+ page_url: embed.pageUrl,
103
+ page_title: embed.pageTitle || "",
104
+ timestamp: embed.timestamp.toString(),
105
+ user_agent: embed.userAgent || ""
106
+ });
107
+ if (navigator.sendBeacon) {
108
+ navigator.sendBeacon(
109
+ "https://api.skhema.com/api:XGdoUqHx/component/embed",
110
+ data
111
+ );
112
+ } else {
113
+ await sendWithRetry(
114
+ "https://api.skhema.com/api:XGdoUqHx/component/embed",
115
+ data,
116
+ "urlencoded"
117
+ );
118
+ }
119
+ } catch (error) {
120
+ console.debug("Embed tracking failed:", error);
121
+ }
122
+ }
123
+ }
124
+ async sendClicks(clicks) {
125
+ for (const click of clicks) {
126
+ try {
127
+ const data = {
128
+ contributor_id: click.contributor_id,
129
+ element_type: click.element_type,
130
+ content_hash: click.content_hash,
131
+ source_url: click.source_url,
132
+ timestamp: click.timestamp
133
+ };
134
+ await sendWithRetry(
135
+ "https://api.skhema.com/api:XGdoUqHx/component/click",
136
+ data,
137
+ "json"
138
+ );
139
+ } catch (error) {
140
+ console.debug("Click tracking failed:", error);
141
+ }
142
+ }
143
+ }
144
+ // Ensure batch is sent when page unloads
145
+ flush() {
146
+ if (this.batch.embeds.length > 0 || this.batch.clicks.length > 0) {
147
+ this.sendBatch();
148
+ }
149
+ }
150
+ }
151
+ const analyticsBatcher = new AnalyticsBatcher();
152
+ if (typeof window !== "undefined") {
153
+ window.addEventListener("beforeunload", () => {
154
+ analyticsBatcher.flush();
155
+ });
156
+ document.addEventListener("visibilitychange", () => {
157
+ if (document.visibilityState === "hidden") {
158
+ analyticsBatcher.flush();
159
+ }
160
+ });
161
+ }
162
+ async function sendWithRetry(url, data, contentType = "json", maxRetries = 3) {
163
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
164
+ try {
165
+ const options = {
30
166
  method: "POST",
31
- body: data,
32
167
  credentials: "omit",
33
168
  keepalive: true
34
- }).catch(() => {
35
- });
169
+ };
170
+ if (contentType === "json") {
171
+ options.headers = { "Content-Type": "application/json" };
172
+ options.body = JSON.stringify(data);
173
+ } else {
174
+ options.body = data;
175
+ }
176
+ const response = await fetch(url, options);
177
+ if (response.ok) return;
178
+ if (response.status >= 400 && response.status < 500) {
179
+ break;
180
+ }
181
+ } catch (error) {
182
+ if (attempt === maxRetries - 1) {
183
+ console.debug("Analytics failed after retries:", error);
184
+ return;
185
+ }
186
+ }
187
+ await new Promise(
188
+ (resolve) => setTimeout(resolve, Math.pow(2, attempt) * 1e3)
189
+ );
190
+ }
191
+ }
192
+ async function trackEmbedLoad(analytics) {
193
+ try {
194
+ if (hasBeenTracked(analytics.contentHash)) {
195
+ console.debug("Embed already tracked, skipping:", analytics.contentHash);
196
+ return;
36
197
  }
198
+ markAsTracked(analytics.contentHash);
199
+ analyticsBatcher.addEmbedLoad(analytics);
37
200
  } catch (error) {
38
201
  console.debug("Analytics tracking failed:", error);
39
202
  }
40
203
  }
41
204
  async function trackClick(contentData) {
42
205
  try {
43
- const data = {
44
- contributor_id: contentData.contributor_id,
45
- element_type: contentData.element_type,
46
- content_hash: contentData.content_hash,
47
- source_url: contentData.source_url,
48
- timestamp: contentData.timestamp
49
- };
50
- fetch("https://api.skhema.com/api:XGdoUqHx/component/click", {
51
- method: "POST",
52
- headers: { "Content-Type": "application/json" },
53
- body: JSON.stringify(data),
54
- credentials: "omit",
55
- keepalive: true
56
- }).catch(() => {
57
- });
206
+ analyticsBatcher.addClick(contentData);
58
207
  } catch (error) {
59
208
  console.debug("Click tracking failed:", error);
60
209
  }
@@ -264,8 +413,9 @@ const styles = `
264
413
  color: var(--skhema-text);
265
414
  }
266
415
 
267
- :host([theme="dark"]) {
268
- /* Dark mode colors */
416
+ /* Dark mode styles - applied via data-theme attribute */
417
+ .skhema-insight-card[data-theme="dark"],
418
+ .skhema-skeleton[data-theme="dark"] {
269
419
  --skhema-bg: hsl(222.2 84% 4.9%);
270
420
  --skhema-card: hsl(222.2 84% 4.9%);
271
421
  --skhema-border: hsl(217.2 32.6% 17.5%);
@@ -484,26 +634,81 @@ const styles = `
484
634
  padding-left: 16px;
485
635
  }
486
636
 
487
- /* Loading state */
488
- .skhema-loading {
489
- background: var(--skhema-accent);
637
+ /* Skeleton loading state */
638
+ .skhema-skeleton {
639
+ background: var(--skhema-card);
490
640
  border: 1px solid var(--skhema-border);
491
- padding: 12px;
641
+ border-radius: calc(var(--skhema-radius) * 2);
642
+ padding: 16px;
643
+ box-shadow: var(--skhema-shadow);
644
+ max-width: 600px;
645
+ margin: 8px 0;
646
+ animation: skeletonPulse 1.5s ease-in-out infinite;
647
+ }
648
+
649
+ .skhema-skeleton-header {
650
+ display: flex;
651
+ align-items: center;
652
+ gap: 12px;
653
+ margin-bottom: 12px;
654
+ }
655
+
656
+ .skhema-skeleton-avatar {
657
+ width: 32px;
658
+ height: 32px;
659
+ border-radius: 50%;
660
+ background: linear-gradient(90deg,
661
+ var(--skhema-border) 25%,
662
+ var(--skhema-accent) 50%,
663
+ var(--skhema-border) 75%);
664
+ background-size: 200% 100%;
665
+ animation: shimmer 1.5s infinite;
666
+ }
667
+
668
+ .skhema-skeleton-text {
669
+ flex: 1;
670
+ }
671
+
672
+ .skhema-skeleton-line {
673
+ height: 12px;
674
+ background: linear-gradient(90deg,
675
+ var(--skhema-border) 25%,
676
+ var(--skhema-accent) 50%,
677
+ var(--skhema-border) 75%);
678
+ background-size: 200% 100%;
679
+ animation: shimmer 1.5s infinite;
492
680
  border-radius: var(--skhema-radius);
493
- color: var(--skhema-text-muted);
494
- font-size: 13px;
495
- text-align: center;
681
+ margin: 6px 0;
682
+ }
683
+
684
+ .skhema-skeleton-line.short {
685
+ width: 40%;
686
+ }
687
+
688
+ .skhema-skeleton-line.medium {
689
+ width: 70%;
496
690
  }
497
691
 
498
- .skhema-loading::after {
499
- content: '...';
500
- animation: loading 1.5s infinite;
692
+ .skhema-skeleton-content {
693
+ margin: 16px 0;
501
694
  }
502
695
 
503
- @keyframes loading {
504
- 0%, 33% { content: '...'; }
505
- 66% { content: '..'; }
506
- 100% { content: '.'; }
696
+ @keyframes skeletonPulse {
697
+ 0%, 100% {
698
+ opacity: 1;
699
+ }
700
+ 50% {
701
+ opacity: 0.8;
702
+ }
703
+ }
704
+
705
+ @keyframes shimmer {
706
+ 0% {
707
+ background-position: -200% 0;
708
+ }
709
+ 100% {
710
+ background-position: 200% 0;
711
+ }
507
712
  }
508
713
 
509
714
  /* Responsive design */
@@ -555,7 +760,11 @@ class SkhemaElement extends HTMLElement {
555
760
  super();
556
761
  this.contentData = null;
557
762
  this.componentConnected = false;
763
+ this.hasTrackedLoad = false;
764
+ this.themeObserver = null;
765
+ this.mediaQueryListener = null;
558
766
  this.shadow = this.attachShadow({ mode: "closed" });
767
+ this.renderSkeleton();
559
768
  }
560
769
  static get observedAttributes() {
561
770
  return [
@@ -571,12 +780,19 @@ class SkhemaElement extends HTMLElement {
571
780
  if (this.componentConnected) return;
572
781
  this.componentConnected = true;
573
782
  try {
574
- this.render();
575
- this.trackLoad();
783
+ this.addPreconnectHints();
784
+ requestAnimationFrame(() => {
785
+ this.render();
786
+ this.trackLoad();
787
+ this.setupThemeListeners();
788
+ });
576
789
  } catch (error) {
577
790
  this.renderError("Failed to initialize component", error);
578
791
  }
579
792
  }
793
+ disconnectedCallback() {
794
+ this.cleanupThemeListeners();
795
+ }
580
796
  attributeChangedCallback(_name, oldValue, newValue) {
581
797
  if (oldValue !== newValue && this.componentConnected) {
582
798
  this.render();
@@ -627,7 +843,8 @@ class SkhemaElement extends HTMLElement {
627
843
  element_type,
628
844
  contributor_id
629
845
  );
630
- const theme = this.getAttribute("theme") || "auto";
846
+ const themeAttribute = this.getAttribute("theme") || "auto";
847
+ const actualTheme = this.getActualTheme(themeAttribute);
631
848
  const displayName = this.formatContributorName(contributor_id);
632
849
  const initials = this.getInitials(displayName);
633
850
  const ariaAttrs = createAriaAttributes(element_type);
@@ -636,8 +853,8 @@ class SkhemaElement extends HTMLElement {
636
853
  });
637
854
  this.shadow.innerHTML = `
638
855
  <style>${styles}</style>
639
-
640
- <div class="skhema-insight-card" data-theme="${theme}">
856
+
857
+ <div class="skhema-insight-card" data-theme="${actualTheme}">
641
858
  <div class="skhema-header">
642
859
  <div class="skhema-contributor">
643
860
  <div class="skhema-avatar" title="${displayName}">
@@ -680,17 +897,78 @@ class SkhemaElement extends HTMLElement {
680
897
  });
681
898
  }
682
899
  }
900
+ getActualTheme(themeAttribute) {
901
+ if (themeAttribute === "light" || themeAttribute === "dark") {
902
+ return themeAttribute;
903
+ }
904
+ const htmlElement = document.documentElement;
905
+ const bodyElement = document.body;
906
+ const htmlTheme = htmlElement.getAttribute("data-theme") || htmlElement.getAttribute("theme") || htmlElement.className.match(/theme-(\w+)/)?.[1];
907
+ const bodyTheme = bodyElement.getAttribute("data-theme") || bodyElement.getAttribute("theme") || bodyElement.className.match(/theme-(\w+)/)?.[1];
908
+ const hasDarkClass = htmlElement.classList.contains("dark") || bodyElement.classList.contains("dark") || htmlElement.classList.contains("dark-mode") || bodyElement.classList.contains("dark-mode");
909
+ if (hasDarkClass || htmlTheme === "dark" || bodyTheme === "dark") {
910
+ return "dark";
911
+ }
912
+ const computedStyles = window.getComputedStyle(htmlElement);
913
+ const colorScheme = computedStyles.getPropertyValue("color-scheme");
914
+ if (colorScheme && colorScheme.includes("dark")) {
915
+ return "dark";
916
+ }
917
+ if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
918
+ return "dark";
919
+ }
920
+ return "light";
921
+ }
683
922
  formatContributorName(contributorId) {
684
923
  return contributorId.split(/[_-]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ");
685
924
  }
686
925
  getInitials(name) {
687
926
  return name.split(" ").map((word) => word.charAt(0)).join("").toUpperCase().substring(0, 2);
688
927
  }
928
+ addPreconnectHints() {
929
+ if (document.querySelector('link[rel="preconnect"][href*="api.skhema.com"]')) {
930
+ return;
931
+ }
932
+ try {
933
+ const preconnectApi = document.createElement("link");
934
+ preconnectApi.rel = "preconnect";
935
+ preconnectApi.href = "https://api.skhema.com";
936
+ document.head.appendChild(preconnectApi);
937
+ const dnsPrefetch = document.createElement("link");
938
+ dnsPrefetch.rel = "dns-prefetch";
939
+ dnsPrefetch.href = "https://skhema.com";
940
+ document.head.appendChild(dnsPrefetch);
941
+ } catch (error) {
942
+ console.debug("Failed to add preconnect hints:", error);
943
+ }
944
+ }
945
+ renderSkeleton() {
946
+ const themeAttribute = this.getAttribute("theme") || "auto";
947
+ const actualTheme = this.getActualTheme(themeAttribute);
948
+ this.shadow.innerHTML = `
949
+ <style>${styles}</style>
950
+
951
+ <div class="skhema-skeleton" data-theme="${actualTheme}">
952
+ <div class="skhema-skeleton-header">
953
+ <div class="skhema-skeleton-avatar"></div>
954
+ <div class="skhema-skeleton-text">
955
+ <div class="skhema-skeleton-line medium"></div>
956
+ <div class="skhema-skeleton-line short"></div>
957
+ </div>
958
+ </div>
959
+ <div class="skhema-skeleton-content">
960
+ <div class="skhema-skeleton-line"></div>
961
+ <div class="skhema-skeleton-line"></div>
962
+ <div class="skhema-skeleton-line medium"></div>
963
+ </div>
964
+ </div>
965
+ `;
966
+ }
689
967
  renderError(title, errors) {
690
968
  const errorList = Array.isArray(errors) ? errors : [String(errors)];
691
969
  this.shadow.innerHTML = `
692
970
  <style>${styles}</style>
693
-
971
+
694
972
  <div class="skhema-insight-card">
695
973
  <div class="skhema-error">
696
974
  <div class="skhema-error-title">Skhema Component Error: ${title}</div>
@@ -727,7 +1005,10 @@ class SkhemaElement extends HTMLElement {
727
1005
  document.body.appendChild(metaDiv);
728
1006
  }
729
1007
  async trackLoad() {
730
- if (!shouldTrackAnalytics(this) || !this.contentData) return;
1008
+ if (!shouldTrackAnalytics(this) || !this.contentData || this.hasTrackedLoad) {
1009
+ return;
1010
+ }
1011
+ this.hasTrackedLoad = true;
731
1012
  const analytics = {
732
1013
  contributorId: this.contentData.contributor_id,
733
1014
  elementType: this.contentData.element_type,
@@ -765,6 +1046,46 @@ class SkhemaElement extends HTMLElement {
765
1046
  refresh() {
766
1047
  this.render();
767
1048
  }
1049
+ setupThemeListeners() {
1050
+ const themeAttribute = this.getAttribute("theme");
1051
+ if (themeAttribute === "auto" || !themeAttribute) {
1052
+ if (window.matchMedia) {
1053
+ this.mediaQueryListener = window.matchMedia(
1054
+ "(prefers-color-scheme: dark)"
1055
+ );
1056
+ const handleThemeChange = () => this.updateTheme();
1057
+ this.mediaQueryListener.addEventListener("change", handleThemeChange);
1058
+ }
1059
+ this.themeObserver = new MutationObserver(() => this.updateTheme());
1060
+ this.themeObserver.observe(document.documentElement, {
1061
+ attributes: true,
1062
+ attributeFilter: ["class", "data-theme", "theme"]
1063
+ });
1064
+ this.themeObserver.observe(document.body, {
1065
+ attributes: true,
1066
+ attributeFilter: ["class", "data-theme", "theme"]
1067
+ });
1068
+ }
1069
+ }
1070
+ cleanupThemeListeners() {
1071
+ if (this.themeObserver) {
1072
+ this.themeObserver.disconnect();
1073
+ this.themeObserver = null;
1074
+ }
1075
+ if (this.mediaQueryListener) {
1076
+ this.mediaQueryListener = null;
1077
+ }
1078
+ }
1079
+ updateTheme() {
1080
+ const themeAttribute = this.getAttribute("theme") || "auto";
1081
+ if (themeAttribute === "auto") {
1082
+ const card = this.shadow.querySelector(".skhema-insight-card");
1083
+ if (card) {
1084
+ const newTheme = this.getActualTheme("auto");
1085
+ card.setAttribute("data-theme", newTheme);
1086
+ }
1087
+ }
1088
+ }
768
1089
  }
769
1090
  function registerSkhemaElement() {
770
1091
  if (typeof window !== "undefined" && !customElements.get("skhema-element")) {