@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/README.md +57 -0
- package/dist/cdn.d.ts +4 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/components/SkhemaComponent.d.ts +45 -0
- package/dist/components/SkhemaComponent.d.ts.map +1 -0
- package/dist/components/SkhemaElement.d.ts +46 -0
- package/dist/components/SkhemaElement.d.ts.map +1 -0
- package/dist/components/types.d.ts +96 -0
- package/dist/components/types.d.ts.map +1 -0
- package/dist/embed.min.js +2 -0
- package/dist/embed.min.js.map +1 -0
- package/dist/index.cjs +2061 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.es.js +2061 -0
- package/dist/index.es.js.map +1 -0
- package/dist/styles/design-tokens.d.ts +65 -0
- package/dist/styles/design-tokens.d.ts.map +1 -0
- package/dist/utils/analytics.d.ts +7 -0
- package/dist/utils/analytics.d.ts.map +1 -0
- package/dist/utils/component-validation.d.ts +27 -0
- package/dist/utils/component-validation.d.ts.map +1 -0
- package/dist/utils/hash.d.ts +10 -0
- package/dist/utils/hash.d.ts.map +1 -0
- package/dist/utils/sanitization.d.ts +31 -0
- package/dist/utils/sanitization.d.ts.map +1 -0
- package/dist/utils/seo.d.ts +21 -0
- package/dist/utils/seo.d.ts.map +1 -0
- package/dist/utils/validation.d.ts +11 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/package.json +81 -0
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">—</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
|