@quikturn/logos 0.2.1 → 0.4.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 +74 -0
- package/dist/client/index.cjs +13 -0
- package/dist/client/index.mjs +13 -0
- package/dist/element/index.cjs +288 -0
- package/dist/element/index.d.cts +29 -0
- package/dist/element/index.d.ts +29 -0
- package/dist/element/index.mjs +286 -0
- package/package.json +14 -3
package/README.md
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
> TypeScript SDK for the Quikturn Logos API -- fetch company logos with type safety.
|
|
4
4
|
|
|
5
|
+
## Packages
|
|
6
|
+
|
|
7
|
+
| Package | Description | Install |
|
|
8
|
+
|---------|-------------|---------|
|
|
9
|
+
| [`@quikturn/logos`](https://www.npmjs.com/package/@quikturn/logos) | Core SDK -- URL builder, browser client, server client, web component | `pnpm add @quikturn/logos` |
|
|
10
|
+
| [`@quikturn/logos-react`](https://www.npmjs.com/package/@quikturn/logos-react) | React components -- `<QuikturnLogo>`, `<QuikturnLogoCarousel>`, `<QuikturnLogoGrid>` | `pnpm add @quikturn/logos-react` |
|
|
11
|
+
|
|
5
12
|
## Features
|
|
6
13
|
|
|
7
14
|
- **Zero-dependency URL builder** -- universal, works in any JavaScript runtime
|
|
8
15
|
- **Browser client** -- blob URL management, retry/backoff, scrape polling, event emission
|
|
9
16
|
- **Server client** -- Buffer output, ReadableStream streaming, concurrent batch operations
|
|
17
|
+
- **`<quikturn-logo>` web component** -- zero-effort attribution element with shadow DOM
|
|
18
|
+
- **React components** -- see [`@quikturn/logos-react`](./packages/react/) for `<QuikturnLogo>`, `<QuikturnLogoCarousel>`, and `<QuikturnLogoGrid>`
|
|
10
19
|
- **Full TypeScript support** -- strict types, discriminated union error codes, generic response shapes
|
|
11
20
|
- **Tree-shakeable** -- ESM and CJS dual builds; import only what you need
|
|
12
21
|
|
|
@@ -103,6 +112,59 @@ const stream = await client.getStream("github.com", { format: "png" });
|
|
|
103
112
|
Readable.fromWeb(stream).pipe(createWriteStream("logo.png"));
|
|
104
113
|
```
|
|
105
114
|
|
|
115
|
+
### Web Component
|
|
116
|
+
|
|
117
|
+
The `<quikturn-logo>` custom element renders a logo with built-in attribution. It uses shadow DOM to protect the attribution badge and requires no framework.
|
|
118
|
+
|
|
119
|
+
```html
|
|
120
|
+
<script type="module">
|
|
121
|
+
import "@quikturn/logos/element";
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
<quikturn-logo
|
|
125
|
+
domain="github.com"
|
|
126
|
+
token="qt_abc123"
|
|
127
|
+
size="64"
|
|
128
|
+
format="webp"
|
|
129
|
+
theme="dark"
|
|
130
|
+
></quikturn-logo>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| Attribute | Type | Description |
|
|
134
|
+
|-----------|------|-------------|
|
|
135
|
+
| `domain` | `string` | Domain to fetch logo for (required for rendering) |
|
|
136
|
+
| `token` | `string` | Publishable API key |
|
|
137
|
+
| `size` | `string` | Image width in pixels |
|
|
138
|
+
| `format` | `string` | `"png"`, `"jpeg"`, `"webp"`, or `"avif"` |
|
|
139
|
+
| `greyscale` | presence | When present, applies greyscale transformation |
|
|
140
|
+
| `theme` | `string` | `"light"` or `"dark"` |
|
|
141
|
+
|
|
142
|
+
The element automatically registers as `quikturn-logo` on import and fires an attribution beacon on first render. Attribution styling uses `!important` rules inside the shadow DOM to prevent accidental removal.
|
|
143
|
+
|
|
144
|
+
### React Components
|
|
145
|
+
|
|
146
|
+
For React applications, install the companion package:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pnpm add @quikturn/logos-react
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
import { QuikturnProvider, QuikturnLogo, QuikturnLogoCarousel } from "@quikturn/logos-react";
|
|
154
|
+
|
|
155
|
+
<QuikturnProvider token="qt_your_key">
|
|
156
|
+
<QuikturnLogo domain="github.com" size={64} />
|
|
157
|
+
<QuikturnLogoCarousel
|
|
158
|
+
domains={["github.com", "stripe.com", "vercel.com"]}
|
|
159
|
+
speed={120}
|
|
160
|
+
fadeOut
|
|
161
|
+
pauseOnHover
|
|
162
|
+
/>
|
|
163
|
+
</QuikturnProvider>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
See the full API reference in [`@quikturn/logos-react` README](./packages/react/README.md).
|
|
167
|
+
|
|
106
168
|
## API Reference
|
|
107
169
|
|
|
108
170
|
### Universal (`@quikturn/logos`)
|
|
@@ -417,6 +479,18 @@ client.get("github.com", { format: "webp" });
|
|
|
417
479
|
|
|
418
480
|
Rate limits and monthly quotas are enforced by the API server and vary by plan. The SDK automatically reads rate-limit headers to provide warnings via the event system and retries with backoff when limits are hit. See [Quikturn pricing](https://getquikturn.io/pricing) for details on your plan's limits.
|
|
419
481
|
|
|
482
|
+
## Related Packages
|
|
483
|
+
|
|
484
|
+
### [`@quikturn/logos-react`](https://www.npmjs.com/package/@quikturn/logos-react)
|
|
485
|
+
|
|
486
|
+
Ready-made React components for displaying Quikturn logos. Includes an infinite scrolling carousel, responsive grid, single logo image, context provider for token propagation, and a `useLogoUrl()` hook. Zero CSS dependencies -- inline styles only.
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
pnpm add @quikturn/logos-react @quikturn/logos
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
See the full documentation at [`packages/react/README.md`](./packages/react/README.md).
|
|
493
|
+
|
|
420
494
|
## License
|
|
421
495
|
|
|
422
496
|
MIT
|
package/dist/client/index.cjs
CHANGED
|
@@ -517,6 +517,18 @@ async function handleScrapeResponse(response, originalUrl, fetchFn, options) {
|
|
|
517
517
|
}
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
// src/internal/beacon.ts
|
|
521
|
+
var firedTokens = /* @__PURE__ */ new Set();
|
|
522
|
+
function fireBeacon(token) {
|
|
523
|
+
if (typeof window === "undefined") return;
|
|
524
|
+
if (!token) return;
|
|
525
|
+
if (token.startsWith("sk_")) return;
|
|
526
|
+
if (firedTokens.has(token)) return;
|
|
527
|
+
firedTokens.add(token);
|
|
528
|
+
const img = new Image();
|
|
529
|
+
img.src = `${BASE_URL}/_beacon?token=${token}&page=${encodeURIComponent(location.href)}`;
|
|
530
|
+
}
|
|
531
|
+
|
|
520
532
|
// src/client/index.ts
|
|
521
533
|
var QuikturnLogos = class {
|
|
522
534
|
constructor(options) {
|
|
@@ -592,6 +604,7 @@ var QuikturnLogos = class {
|
|
|
592
604
|
const metadata = parseLogoHeaders(response.headers);
|
|
593
605
|
const objectUrl = URL.createObjectURL(blob);
|
|
594
606
|
this.objectUrls.add(objectUrl);
|
|
607
|
+
fireBeacon(this.token);
|
|
595
608
|
return { url: objectUrl, blob, contentType, metadata };
|
|
596
609
|
}
|
|
597
610
|
/**
|
package/dist/client/index.mjs
CHANGED
|
@@ -515,6 +515,18 @@ async function handleScrapeResponse(response, originalUrl, fetchFn, options) {
|
|
|
515
515
|
}
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
+
// src/internal/beacon.ts
|
|
519
|
+
var firedTokens = /* @__PURE__ */ new Set();
|
|
520
|
+
function fireBeacon(token) {
|
|
521
|
+
if (typeof window === "undefined") return;
|
|
522
|
+
if (!token) return;
|
|
523
|
+
if (token.startsWith("sk_")) return;
|
|
524
|
+
if (firedTokens.has(token)) return;
|
|
525
|
+
firedTokens.add(token);
|
|
526
|
+
const img = new Image();
|
|
527
|
+
img.src = `${BASE_URL}/_beacon?token=${token}&page=${encodeURIComponent(location.href)}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
518
530
|
// src/client/index.ts
|
|
519
531
|
var QuikturnLogos = class {
|
|
520
532
|
constructor(options) {
|
|
@@ -590,6 +602,7 @@ var QuikturnLogos = class {
|
|
|
590
602
|
const metadata = parseLogoHeaders(response.headers);
|
|
591
603
|
const objectUrl = URL.createObjectURL(blob);
|
|
592
604
|
this.objectUrls.add(objectUrl);
|
|
605
|
+
fireBeacon(this.token);
|
|
593
606
|
return { url: objectUrl, blob, contentType, metadata };
|
|
594
607
|
}
|
|
595
608
|
/**
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/constants.ts
|
|
4
|
+
var BASE_URL = "https://logos.getquikturn.io";
|
|
5
|
+
var DEFAULT_WIDTH = 128;
|
|
6
|
+
var MAX_WIDTH = 800;
|
|
7
|
+
var MAX_WIDTH_SERVER = 1200;
|
|
8
|
+
var SUPPORTED_FORMATS = /* @__PURE__ */ new Set([
|
|
9
|
+
"image/png",
|
|
10
|
+
"image/jpeg",
|
|
11
|
+
"image/webp",
|
|
12
|
+
"image/avif"
|
|
13
|
+
]);
|
|
14
|
+
var FORMAT_ALIASES = {
|
|
15
|
+
png: "image/png",
|
|
16
|
+
jpeg: "image/jpeg",
|
|
17
|
+
webp: "image/webp",
|
|
18
|
+
avif: "image/avif"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/errors.ts
|
|
22
|
+
var LogoError = class extends Error {
|
|
23
|
+
constructor(message, code, status) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "LogoError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.status = status;
|
|
28
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var DomainValidationError = class extends LogoError {
|
|
32
|
+
constructor(message, domain) {
|
|
33
|
+
super(message, "DOMAIN_VALIDATION_ERROR");
|
|
34
|
+
this.name = "DomainValidationError";
|
|
35
|
+
this.domain = domain;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/url-builder.ts
|
|
40
|
+
var IP_ADDRESS_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
41
|
+
var LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
42
|
+
function validateDomain(domain) {
|
|
43
|
+
const normalized = domain.trim().toLowerCase();
|
|
44
|
+
const clean = normalized.endsWith(".") ? normalized.slice(0, -1) : normalized;
|
|
45
|
+
if (clean.length === 0) {
|
|
46
|
+
throw new DomainValidationError("Domain must not be empty", domain);
|
|
47
|
+
}
|
|
48
|
+
if (clean.includes("://")) {
|
|
49
|
+
throw new DomainValidationError(
|
|
50
|
+
'Domain must not include a protocol scheme (e.g. remove "https://")',
|
|
51
|
+
domain
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (clean.includes("/")) {
|
|
55
|
+
throw new DomainValidationError(
|
|
56
|
+
"Domain must not include a path \u2014 provide only the hostname",
|
|
57
|
+
domain
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (IP_ADDRESS_RE.test(clean)) {
|
|
61
|
+
throw new DomainValidationError(
|
|
62
|
+
"IP addresses are not supported \u2014 provide a domain name",
|
|
63
|
+
domain
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (clean === "localhost") {
|
|
67
|
+
throw new DomainValidationError(
|
|
68
|
+
'"localhost" is not a valid domain',
|
|
69
|
+
domain
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (clean.length > 253) {
|
|
73
|
+
throw new DomainValidationError(
|
|
74
|
+
`Domain exceeds maximum length of 253 characters (got ${clean.length})`,
|
|
75
|
+
domain
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const labels = clean.split(".");
|
|
79
|
+
if (labels.length < 2) {
|
|
80
|
+
throw new DomainValidationError(
|
|
81
|
+
'Domain must contain at least two labels (e.g. "example.com")',
|
|
82
|
+
domain
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
for (const label of labels) {
|
|
86
|
+
if (label.length === 0) {
|
|
87
|
+
throw new DomainValidationError(
|
|
88
|
+
"Domain contains an empty label (consecutive dots)",
|
|
89
|
+
domain
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (label.length > 63) {
|
|
93
|
+
throw new DomainValidationError(
|
|
94
|
+
`Label "${label}" exceeds maximum length of 63 characters (got ${label.length})`,
|
|
95
|
+
domain
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!LABEL_RE.test(label)) {
|
|
99
|
+
throw new DomainValidationError(
|
|
100
|
+
`Label "${label}" contains invalid characters \u2014 only letters, digits, and hyphens are allowed, and labels must not start or end with a hyphen`,
|
|
101
|
+
domain
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return clean;
|
|
106
|
+
}
|
|
107
|
+
function resolveFormat(format) {
|
|
108
|
+
let candidate = format;
|
|
109
|
+
if (candidate.startsWith("image/")) {
|
|
110
|
+
candidate = candidate.slice(6);
|
|
111
|
+
}
|
|
112
|
+
if (candidate in FORMAT_ALIASES) {
|
|
113
|
+
return candidate;
|
|
114
|
+
}
|
|
115
|
+
if (SUPPORTED_FORMATS.has(format)) {
|
|
116
|
+
const shorthand = format.slice(6);
|
|
117
|
+
if (shorthand in FORMAT_ALIASES) {
|
|
118
|
+
return shorthand;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return void 0;
|
|
122
|
+
}
|
|
123
|
+
function logoUrl(domain, options) {
|
|
124
|
+
const validDomain = validateDomain(domain);
|
|
125
|
+
const {
|
|
126
|
+
token,
|
|
127
|
+
size,
|
|
128
|
+
width,
|
|
129
|
+
greyscale,
|
|
130
|
+
theme,
|
|
131
|
+
format,
|
|
132
|
+
baseUrl
|
|
133
|
+
} = options ?? {};
|
|
134
|
+
const maxWidth = token?.startsWith("sk_") ? MAX_WIDTH_SERVER : MAX_WIDTH;
|
|
135
|
+
let resolvedSize = size ?? width ?? DEFAULT_WIDTH;
|
|
136
|
+
if (resolvedSize <= 0) {
|
|
137
|
+
resolvedSize = DEFAULT_WIDTH;
|
|
138
|
+
}
|
|
139
|
+
resolvedSize = Math.max(1, Math.min(resolvedSize, maxWidth));
|
|
140
|
+
const resolvedFormat = format ? resolveFormat(format) : void 0;
|
|
141
|
+
const effectiveBaseUrl = baseUrl ?? BASE_URL;
|
|
142
|
+
const url = new URL(`${effectiveBaseUrl}/${validDomain}`);
|
|
143
|
+
if (token !== void 0) {
|
|
144
|
+
url.searchParams.set("token", token);
|
|
145
|
+
}
|
|
146
|
+
if (resolvedSize !== DEFAULT_WIDTH) {
|
|
147
|
+
url.searchParams.set("size", String(resolvedSize));
|
|
148
|
+
}
|
|
149
|
+
if (greyscale === true) {
|
|
150
|
+
url.searchParams.set("greyscale", "1");
|
|
151
|
+
}
|
|
152
|
+
if (theme === "light" || theme === "dark") {
|
|
153
|
+
url.searchParams.set("theme", theme);
|
|
154
|
+
}
|
|
155
|
+
if (resolvedFormat !== void 0) {
|
|
156
|
+
url.searchParams.set("format", resolvedFormat);
|
|
157
|
+
}
|
|
158
|
+
url.searchParams.set("autoScrape", "true");
|
|
159
|
+
return url.toString();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/internal/beacon.ts
|
|
163
|
+
var firedTokens = /* @__PURE__ */ new Set();
|
|
164
|
+
function fireBeacon(token) {
|
|
165
|
+
if (typeof window === "undefined") return;
|
|
166
|
+
if (!token) return;
|
|
167
|
+
if (token.startsWith("sk_")) return;
|
|
168
|
+
if (firedTokens.has(token)) return;
|
|
169
|
+
firedTokens.add(token);
|
|
170
|
+
const img = new Image();
|
|
171
|
+
img.src = `${BASE_URL}/_beacon?token=${token}&page=${encodeURIComponent(location.href)}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/element/styles.ts
|
|
175
|
+
var STYLES = (
|
|
176
|
+
/* css */
|
|
177
|
+
`
|
|
178
|
+
:host {
|
|
179
|
+
display: inline-block !important;
|
|
180
|
+
line-height: 0 !important;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.qt-logo-container {
|
|
184
|
+
display: inline-flex !important;
|
|
185
|
+
flex-direction: column !important;
|
|
186
|
+
align-items: center !important;
|
|
187
|
+
gap: 4px !important;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.qt-logo-img {
|
|
191
|
+
display: block !important;
|
|
192
|
+
max-width: 100% !important;
|
|
193
|
+
height: auto !important;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.qt-attribution {
|
|
197
|
+
display: block !important;
|
|
198
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
|
199
|
+
font-size: 10px !important;
|
|
200
|
+
color: #888 !important;
|
|
201
|
+
text-decoration: none !important;
|
|
202
|
+
white-space: nowrap !important;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.qt-attribution:hover {
|
|
206
|
+
color: #555 !important;
|
|
207
|
+
text-decoration: underline !important;
|
|
208
|
+
}
|
|
209
|
+
`
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// src/element/index.ts
|
|
213
|
+
var QuikturnLogo = class extends HTMLElement {
|
|
214
|
+
constructor() {
|
|
215
|
+
super();
|
|
216
|
+
this._beaconFired = false;
|
|
217
|
+
this.attachShadow({ mode: "open" });
|
|
218
|
+
}
|
|
219
|
+
static get observedAttributes() {
|
|
220
|
+
return ["domain", "token", "size", "format", "greyscale", "theme"];
|
|
221
|
+
}
|
|
222
|
+
connectedCallback() {
|
|
223
|
+
this._fireBeaconOnce();
|
|
224
|
+
this._render();
|
|
225
|
+
}
|
|
226
|
+
attributeChangedCallback() {
|
|
227
|
+
if (this.shadowRoot) {
|
|
228
|
+
this._render();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
_fireBeaconOnce() {
|
|
232
|
+
if (this._beaconFired) return;
|
|
233
|
+
const domain = this.getAttribute("domain");
|
|
234
|
+
const token = this.getAttribute("token") ?? "";
|
|
235
|
+
if (!domain) return;
|
|
236
|
+
this._beaconFired = true;
|
|
237
|
+
fireBeacon(token);
|
|
238
|
+
}
|
|
239
|
+
_clearShadow() {
|
|
240
|
+
const shadow = this.shadowRoot;
|
|
241
|
+
if (!shadow) return;
|
|
242
|
+
while (shadow.firstChild) {
|
|
243
|
+
shadow.removeChild(shadow.firstChild);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
_render() {
|
|
247
|
+
const shadow = this.shadowRoot;
|
|
248
|
+
if (!shadow) return;
|
|
249
|
+
const domain = this.getAttribute("domain");
|
|
250
|
+
this._clearShadow();
|
|
251
|
+
const style = document.createElement("style");
|
|
252
|
+
style.textContent = STYLES;
|
|
253
|
+
shadow.appendChild(style);
|
|
254
|
+
if (!domain) return;
|
|
255
|
+
const token = this.getAttribute("token") ?? "";
|
|
256
|
+
const size = this.getAttribute("size");
|
|
257
|
+
const format = this.getAttribute("format");
|
|
258
|
+
const greyscale = this.hasAttribute("greyscale");
|
|
259
|
+
const theme = this.getAttribute("theme");
|
|
260
|
+
const container = document.createElement("div");
|
|
261
|
+
container.className = "qt-logo-container";
|
|
262
|
+
const img = document.createElement("img");
|
|
263
|
+
img.className = "qt-logo-img";
|
|
264
|
+
img.loading = "lazy";
|
|
265
|
+
img.alt = `${domain} logo`;
|
|
266
|
+
img.src = logoUrl(domain, {
|
|
267
|
+
token: token || void 0,
|
|
268
|
+
size: size ? parseInt(size, 10) : void 0,
|
|
269
|
+
format,
|
|
270
|
+
greyscale,
|
|
271
|
+
theme
|
|
272
|
+
});
|
|
273
|
+
container.appendChild(img);
|
|
274
|
+
const link = document.createElement("a");
|
|
275
|
+
link.className = "qt-attribution";
|
|
276
|
+
link.href = `https://getquikturn.io?ref=${domain}`;
|
|
277
|
+
link.target = "_blank";
|
|
278
|
+
link.rel = "noopener noreferrer";
|
|
279
|
+
link.textContent = "Powered by Quikturn";
|
|
280
|
+
container.appendChild(link);
|
|
281
|
+
shadow.appendChild(container);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
if (typeof customElements !== "undefined" && !customElements.get("quikturn-logo")) {
|
|
285
|
+
customElements.define("quikturn-logo", QuikturnLogo);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
exports.QuikturnLogo = QuikturnLogo;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @quikturn/logos SDK — `<quikturn-logo>` Web Component
|
|
3
|
+
*
|
|
4
|
+
* Zero-effort attribution element for free-tier users. Uses shadow DOM to
|
|
5
|
+
* protect the attribution badge from accidental removal.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```html
|
|
9
|
+
* <script type="module" src="@quikturn/logos/element"></script>
|
|
10
|
+
* <quikturn-logo domain="github.com" token="qt_abc123" size="64"></quikturn-logo>
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Custom element that renders a Quikturn logo with attribution.
|
|
15
|
+
*
|
|
16
|
+
* Observed attributes: `domain`, `token`, `size`, `format`, `greyscale`, `theme`.
|
|
17
|
+
*/
|
|
18
|
+
declare class QuikturnLogo extends HTMLElement {
|
|
19
|
+
static get observedAttributes(): string[];
|
|
20
|
+
private _beaconFired;
|
|
21
|
+
constructor();
|
|
22
|
+
connectedCallback(): void;
|
|
23
|
+
attributeChangedCallback(): void;
|
|
24
|
+
private _fireBeaconOnce;
|
|
25
|
+
private _clearShadow;
|
|
26
|
+
private _render;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { QuikturnLogo };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @quikturn/logos SDK — `<quikturn-logo>` Web Component
|
|
3
|
+
*
|
|
4
|
+
* Zero-effort attribution element for free-tier users. Uses shadow DOM to
|
|
5
|
+
* protect the attribution badge from accidental removal.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```html
|
|
9
|
+
* <script type="module" src="@quikturn/logos/element"></script>
|
|
10
|
+
* <quikturn-logo domain="github.com" token="qt_abc123" size="64"></quikturn-logo>
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Custom element that renders a Quikturn logo with attribution.
|
|
15
|
+
*
|
|
16
|
+
* Observed attributes: `domain`, `token`, `size`, `format`, `greyscale`, `theme`.
|
|
17
|
+
*/
|
|
18
|
+
declare class QuikturnLogo extends HTMLElement {
|
|
19
|
+
static get observedAttributes(): string[];
|
|
20
|
+
private _beaconFired;
|
|
21
|
+
constructor();
|
|
22
|
+
connectedCallback(): void;
|
|
23
|
+
attributeChangedCallback(): void;
|
|
24
|
+
private _fireBeaconOnce;
|
|
25
|
+
private _clearShadow;
|
|
26
|
+
private _render;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { QuikturnLogo };
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var BASE_URL = "https://logos.getquikturn.io";
|
|
3
|
+
var DEFAULT_WIDTH = 128;
|
|
4
|
+
var MAX_WIDTH = 800;
|
|
5
|
+
var MAX_WIDTH_SERVER = 1200;
|
|
6
|
+
var SUPPORTED_FORMATS = /* @__PURE__ */ new Set([
|
|
7
|
+
"image/png",
|
|
8
|
+
"image/jpeg",
|
|
9
|
+
"image/webp",
|
|
10
|
+
"image/avif"
|
|
11
|
+
]);
|
|
12
|
+
var FORMAT_ALIASES = {
|
|
13
|
+
png: "image/png",
|
|
14
|
+
jpeg: "image/jpeg",
|
|
15
|
+
webp: "image/webp",
|
|
16
|
+
avif: "image/avif"
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// src/errors.ts
|
|
20
|
+
var LogoError = class extends Error {
|
|
21
|
+
constructor(message, code, status) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "LogoError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.status = status;
|
|
26
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var DomainValidationError = class extends LogoError {
|
|
30
|
+
constructor(message, domain) {
|
|
31
|
+
super(message, "DOMAIN_VALIDATION_ERROR");
|
|
32
|
+
this.name = "DomainValidationError";
|
|
33
|
+
this.domain = domain;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// src/url-builder.ts
|
|
38
|
+
var IP_ADDRESS_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
39
|
+
var LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
40
|
+
function validateDomain(domain) {
|
|
41
|
+
const normalized = domain.trim().toLowerCase();
|
|
42
|
+
const clean = normalized.endsWith(".") ? normalized.slice(0, -1) : normalized;
|
|
43
|
+
if (clean.length === 0) {
|
|
44
|
+
throw new DomainValidationError("Domain must not be empty", domain);
|
|
45
|
+
}
|
|
46
|
+
if (clean.includes("://")) {
|
|
47
|
+
throw new DomainValidationError(
|
|
48
|
+
'Domain must not include a protocol scheme (e.g. remove "https://")',
|
|
49
|
+
domain
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (clean.includes("/")) {
|
|
53
|
+
throw new DomainValidationError(
|
|
54
|
+
"Domain must not include a path \u2014 provide only the hostname",
|
|
55
|
+
domain
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (IP_ADDRESS_RE.test(clean)) {
|
|
59
|
+
throw new DomainValidationError(
|
|
60
|
+
"IP addresses are not supported \u2014 provide a domain name",
|
|
61
|
+
domain
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (clean === "localhost") {
|
|
65
|
+
throw new DomainValidationError(
|
|
66
|
+
'"localhost" is not a valid domain',
|
|
67
|
+
domain
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (clean.length > 253) {
|
|
71
|
+
throw new DomainValidationError(
|
|
72
|
+
`Domain exceeds maximum length of 253 characters (got ${clean.length})`,
|
|
73
|
+
domain
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const labels = clean.split(".");
|
|
77
|
+
if (labels.length < 2) {
|
|
78
|
+
throw new DomainValidationError(
|
|
79
|
+
'Domain must contain at least two labels (e.g. "example.com")',
|
|
80
|
+
domain
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
for (const label of labels) {
|
|
84
|
+
if (label.length === 0) {
|
|
85
|
+
throw new DomainValidationError(
|
|
86
|
+
"Domain contains an empty label (consecutive dots)",
|
|
87
|
+
domain
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (label.length > 63) {
|
|
91
|
+
throw new DomainValidationError(
|
|
92
|
+
`Label "${label}" exceeds maximum length of 63 characters (got ${label.length})`,
|
|
93
|
+
domain
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (!LABEL_RE.test(label)) {
|
|
97
|
+
throw new DomainValidationError(
|
|
98
|
+
`Label "${label}" contains invalid characters \u2014 only letters, digits, and hyphens are allowed, and labels must not start or end with a hyphen`,
|
|
99
|
+
domain
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return clean;
|
|
104
|
+
}
|
|
105
|
+
function resolveFormat(format) {
|
|
106
|
+
let candidate = format;
|
|
107
|
+
if (candidate.startsWith("image/")) {
|
|
108
|
+
candidate = candidate.slice(6);
|
|
109
|
+
}
|
|
110
|
+
if (candidate in FORMAT_ALIASES) {
|
|
111
|
+
return candidate;
|
|
112
|
+
}
|
|
113
|
+
if (SUPPORTED_FORMATS.has(format)) {
|
|
114
|
+
const shorthand = format.slice(6);
|
|
115
|
+
if (shorthand in FORMAT_ALIASES) {
|
|
116
|
+
return shorthand;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return void 0;
|
|
120
|
+
}
|
|
121
|
+
function logoUrl(domain, options) {
|
|
122
|
+
const validDomain = validateDomain(domain);
|
|
123
|
+
const {
|
|
124
|
+
token,
|
|
125
|
+
size,
|
|
126
|
+
width,
|
|
127
|
+
greyscale,
|
|
128
|
+
theme,
|
|
129
|
+
format,
|
|
130
|
+
baseUrl
|
|
131
|
+
} = options ?? {};
|
|
132
|
+
const maxWidth = token?.startsWith("sk_") ? MAX_WIDTH_SERVER : MAX_WIDTH;
|
|
133
|
+
let resolvedSize = size ?? width ?? DEFAULT_WIDTH;
|
|
134
|
+
if (resolvedSize <= 0) {
|
|
135
|
+
resolvedSize = DEFAULT_WIDTH;
|
|
136
|
+
}
|
|
137
|
+
resolvedSize = Math.max(1, Math.min(resolvedSize, maxWidth));
|
|
138
|
+
const resolvedFormat = format ? resolveFormat(format) : void 0;
|
|
139
|
+
const effectiveBaseUrl = baseUrl ?? BASE_URL;
|
|
140
|
+
const url = new URL(`${effectiveBaseUrl}/${validDomain}`);
|
|
141
|
+
if (token !== void 0) {
|
|
142
|
+
url.searchParams.set("token", token);
|
|
143
|
+
}
|
|
144
|
+
if (resolvedSize !== DEFAULT_WIDTH) {
|
|
145
|
+
url.searchParams.set("size", String(resolvedSize));
|
|
146
|
+
}
|
|
147
|
+
if (greyscale === true) {
|
|
148
|
+
url.searchParams.set("greyscale", "1");
|
|
149
|
+
}
|
|
150
|
+
if (theme === "light" || theme === "dark") {
|
|
151
|
+
url.searchParams.set("theme", theme);
|
|
152
|
+
}
|
|
153
|
+
if (resolvedFormat !== void 0) {
|
|
154
|
+
url.searchParams.set("format", resolvedFormat);
|
|
155
|
+
}
|
|
156
|
+
url.searchParams.set("autoScrape", "true");
|
|
157
|
+
return url.toString();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/internal/beacon.ts
|
|
161
|
+
var firedTokens = /* @__PURE__ */ new Set();
|
|
162
|
+
function fireBeacon(token) {
|
|
163
|
+
if (typeof window === "undefined") return;
|
|
164
|
+
if (!token) return;
|
|
165
|
+
if (token.startsWith("sk_")) return;
|
|
166
|
+
if (firedTokens.has(token)) return;
|
|
167
|
+
firedTokens.add(token);
|
|
168
|
+
const img = new Image();
|
|
169
|
+
img.src = `${BASE_URL}/_beacon?token=${token}&page=${encodeURIComponent(location.href)}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/element/styles.ts
|
|
173
|
+
var STYLES = (
|
|
174
|
+
/* css */
|
|
175
|
+
`
|
|
176
|
+
:host {
|
|
177
|
+
display: inline-block !important;
|
|
178
|
+
line-height: 0 !important;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.qt-logo-container {
|
|
182
|
+
display: inline-flex !important;
|
|
183
|
+
flex-direction: column !important;
|
|
184
|
+
align-items: center !important;
|
|
185
|
+
gap: 4px !important;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.qt-logo-img {
|
|
189
|
+
display: block !important;
|
|
190
|
+
max-width: 100% !important;
|
|
191
|
+
height: auto !important;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.qt-attribution {
|
|
195
|
+
display: block !important;
|
|
196
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
|
197
|
+
font-size: 10px !important;
|
|
198
|
+
color: #888 !important;
|
|
199
|
+
text-decoration: none !important;
|
|
200
|
+
white-space: nowrap !important;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.qt-attribution:hover {
|
|
204
|
+
color: #555 !important;
|
|
205
|
+
text-decoration: underline !important;
|
|
206
|
+
}
|
|
207
|
+
`
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// src/element/index.ts
|
|
211
|
+
var QuikturnLogo = class extends HTMLElement {
|
|
212
|
+
constructor() {
|
|
213
|
+
super();
|
|
214
|
+
this._beaconFired = false;
|
|
215
|
+
this.attachShadow({ mode: "open" });
|
|
216
|
+
}
|
|
217
|
+
static get observedAttributes() {
|
|
218
|
+
return ["domain", "token", "size", "format", "greyscale", "theme"];
|
|
219
|
+
}
|
|
220
|
+
connectedCallback() {
|
|
221
|
+
this._fireBeaconOnce();
|
|
222
|
+
this._render();
|
|
223
|
+
}
|
|
224
|
+
attributeChangedCallback() {
|
|
225
|
+
if (this.shadowRoot) {
|
|
226
|
+
this._render();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
_fireBeaconOnce() {
|
|
230
|
+
if (this._beaconFired) return;
|
|
231
|
+
const domain = this.getAttribute("domain");
|
|
232
|
+
const token = this.getAttribute("token") ?? "";
|
|
233
|
+
if (!domain) return;
|
|
234
|
+
this._beaconFired = true;
|
|
235
|
+
fireBeacon(token);
|
|
236
|
+
}
|
|
237
|
+
_clearShadow() {
|
|
238
|
+
const shadow = this.shadowRoot;
|
|
239
|
+
if (!shadow) return;
|
|
240
|
+
while (shadow.firstChild) {
|
|
241
|
+
shadow.removeChild(shadow.firstChild);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
_render() {
|
|
245
|
+
const shadow = this.shadowRoot;
|
|
246
|
+
if (!shadow) return;
|
|
247
|
+
const domain = this.getAttribute("domain");
|
|
248
|
+
this._clearShadow();
|
|
249
|
+
const style = document.createElement("style");
|
|
250
|
+
style.textContent = STYLES;
|
|
251
|
+
shadow.appendChild(style);
|
|
252
|
+
if (!domain) return;
|
|
253
|
+
const token = this.getAttribute("token") ?? "";
|
|
254
|
+
const size = this.getAttribute("size");
|
|
255
|
+
const format = this.getAttribute("format");
|
|
256
|
+
const greyscale = this.hasAttribute("greyscale");
|
|
257
|
+
const theme = this.getAttribute("theme");
|
|
258
|
+
const container = document.createElement("div");
|
|
259
|
+
container.className = "qt-logo-container";
|
|
260
|
+
const img = document.createElement("img");
|
|
261
|
+
img.className = "qt-logo-img";
|
|
262
|
+
img.loading = "lazy";
|
|
263
|
+
img.alt = `${domain} logo`;
|
|
264
|
+
img.src = logoUrl(domain, {
|
|
265
|
+
token: token || void 0,
|
|
266
|
+
size: size ? parseInt(size, 10) : void 0,
|
|
267
|
+
format,
|
|
268
|
+
greyscale,
|
|
269
|
+
theme
|
|
270
|
+
});
|
|
271
|
+
container.appendChild(img);
|
|
272
|
+
const link = document.createElement("a");
|
|
273
|
+
link.className = "qt-attribution";
|
|
274
|
+
link.href = `https://getquikturn.io?ref=${domain}`;
|
|
275
|
+
link.target = "_blank";
|
|
276
|
+
link.rel = "noopener noreferrer";
|
|
277
|
+
link.textContent = "Powered by Quikturn";
|
|
278
|
+
container.appendChild(link);
|
|
279
|
+
shadow.appendChild(container);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
if (typeof customElements !== "undefined" && !customElements.get("quikturn-logo")) {
|
|
283
|
+
customElements.define("quikturn-logo", QuikturnLogo);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export { QuikturnLogo };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quikturn/logos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Quikturn Logo SDK — URL builder, browser client, and server client for the Quikturn Logos API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Quikturn",
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
"sdk"
|
|
21
21
|
],
|
|
22
22
|
"type": "module",
|
|
23
|
-
"sideEffects":
|
|
23
|
+
"sideEffects": [
|
|
24
|
+
"./dist/element/index.mjs",
|
|
25
|
+
"./dist/element/index.cjs"
|
|
26
|
+
],
|
|
24
27
|
"packageManager": "pnpm@10.28.1",
|
|
25
28
|
"engines": {
|
|
26
29
|
"node": ">=18"
|
|
@@ -55,6 +58,14 @@
|
|
|
55
58
|
},
|
|
56
59
|
"import": "./dist/server/index.mjs",
|
|
57
60
|
"require": "./dist/server/index.cjs"
|
|
61
|
+
},
|
|
62
|
+
"./element": {
|
|
63
|
+
"types": {
|
|
64
|
+
"import": "./dist/element/index.d.ts",
|
|
65
|
+
"require": "./dist/element/index.d.cts"
|
|
66
|
+
},
|
|
67
|
+
"import": "./dist/element/index.mjs",
|
|
68
|
+
"require": "./dist/element/index.cjs"
|
|
58
69
|
}
|
|
59
70
|
},
|
|
60
71
|
"publishConfig": {
|
|
@@ -65,7 +76,7 @@
|
|
|
65
76
|
"types": "./dist/index.d.ts",
|
|
66
77
|
"scripts": {
|
|
67
78
|
"build": "tsup",
|
|
68
|
-
"test": "vitest run --project unit --project client --project server",
|
|
79
|
+
"test": "vitest run --project unit --project client --project server --project element",
|
|
69
80
|
"test:integration": "vitest run --project integration",
|
|
70
81
|
"test:watch": "vitest",
|
|
71
82
|
"test:coverage": "vitest run --coverage",
|