emdash-plugin-avdeb 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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +56 -0
- package/src/admin.ts +273 -0
- package/src/astro/AvdebButton.astro +24 -0
- package/src/astro/AvdebProduct.astro +49 -0
- package/src/astro/AvdebProducts.astro +68 -0
- package/src/astro/Button.astro +131 -0
- package/src/astro/ButtonBlock.astro +15 -0
- package/src/astro/Embed.astro +1192 -0
- package/src/astro/ProductBlock.astro +24 -0
- package/src/astro/ProductsBlock.astro +15 -0
- package/src/astro/index.ts +15 -0
- package/src/astro/load.ts +68 -0
- package/src/blocks.ts +258 -0
- package/src/catalog.ts +292 -0
- package/src/client.ts +304 -0
- package/src/constants.ts +53 -0
- package/src/index.ts +125 -0
- package/src/payload.ts +78 -0
- package/src/routes.ts +100 -0
- package/src/settings.ts +243 -0
- package/src/ui.ts +14 -0
- package/src/view.ts +167 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cached AVDEB catalog client.
|
|
3
|
+
*
|
|
4
|
+
* Every query is cached in plugin KV on its own. When AVDEB can't be reached the last good answer
|
|
5
|
+
* (kept for a week) is served and retried after a back-off. Because the render route is public,
|
|
6
|
+
* network calls are also capped per minute so nobody can burn the site's API quota by sending
|
|
7
|
+
* random queries. Pure module: KV and fetch are injected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
apiErrorMessage,
|
|
12
|
+
parseKeyResponse,
|
|
13
|
+
parseProductsResponse,
|
|
14
|
+
productsUrl,
|
|
15
|
+
queryKey,
|
|
16
|
+
type CatalogQuery,
|
|
17
|
+
type KeyOwner,
|
|
18
|
+
type Product,
|
|
19
|
+
type Tier,
|
|
20
|
+
} from "./catalog.ts";
|
|
21
|
+
import { API_BASE, VERSION } from "./constants.ts";
|
|
22
|
+
|
|
23
|
+
export interface KVLike {
|
|
24
|
+
get<T>(key: string): Promise<T | null>;
|
|
25
|
+
set(key: string, value: unknown): Promise<void>;
|
|
26
|
+
delete(key: string): Promise<boolean>;
|
|
27
|
+
list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
31
|
+
|
|
32
|
+
export interface LogLike {
|
|
33
|
+
warn(message: string, data?: unknown): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ClientOptions {
|
|
37
|
+
kv: KVLike;
|
|
38
|
+
fetch: FetchLike;
|
|
39
|
+
apiKey: string;
|
|
40
|
+
refCode: string;
|
|
41
|
+
cacheMinutes: number;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
/** Max network calls per minute across all queries (default 30). */
|
|
44
|
+
budgetPerMinute?: number;
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
log?: LogLike;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ProductsSource = "cache" | "network" | "stale" | "empty";
|
|
50
|
+
|
|
51
|
+
export interface ProductsOutcome {
|
|
52
|
+
products: Product[];
|
|
53
|
+
ref: string;
|
|
54
|
+
tier: Tier;
|
|
55
|
+
source: ProductsSource;
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type KeyInfo = { ok: true; owner: KeyOwner } | { ok: false; error: string };
|
|
60
|
+
|
|
61
|
+
export interface ClientStats {
|
|
62
|
+
requests: number;
|
|
63
|
+
errors: number;
|
|
64
|
+
lastFetchAt: number | null;
|
|
65
|
+
lastError: string | null;
|
|
66
|
+
lastErrorAt: number | null;
|
|
67
|
+
cachedQueries: number;
|
|
68
|
+
cacheVersion: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ProductsEntry {
|
|
72
|
+
key: string;
|
|
73
|
+
products: Product[];
|
|
74
|
+
ref: string;
|
|
75
|
+
tier: Tier;
|
|
76
|
+
fetchedAt: number;
|
|
77
|
+
freshUntil: number;
|
|
78
|
+
staleUntil: number;
|
|
79
|
+
error?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface KeyEntry {
|
|
83
|
+
info: KeyInfo;
|
|
84
|
+
until: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface StoredStats {
|
|
88
|
+
requests: number;
|
|
89
|
+
errors: number;
|
|
90
|
+
lastFetchAt: number | null;
|
|
91
|
+
lastError: string | null;
|
|
92
|
+
lastErrorAt: number | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const MINUTE = 60_000;
|
|
96
|
+
export const STALE_MS = 7 * 24 * 60 * MINUTE;
|
|
97
|
+
export const BACKOFF_MS = 15 * MINUTE;
|
|
98
|
+
export const EMPTY_BACKOFF_MS = 5 * MINUTE;
|
|
99
|
+
export const KEY_OK_MS = 10 * MINUTE;
|
|
100
|
+
export const KEY_FAIL_MS = 5 * MINUTE;
|
|
101
|
+
|
|
102
|
+
const K_VERSION = "state:version";
|
|
103
|
+
const K_BUDGET = "state:budget";
|
|
104
|
+
const K_STATS = "state:stats";
|
|
105
|
+
const P_PRODUCTS = "cache:p:";
|
|
106
|
+
const P_KEY = "cache:key:";
|
|
107
|
+
|
|
108
|
+
/** FNV-1a (32-bit) as hex — short, stable KV key suffix. Entries also store the full key. */
|
|
109
|
+
export function hash(input: string): string {
|
|
110
|
+
let h = 0x811c9dc5;
|
|
111
|
+
for (let i = 0; i < input.length; i++) {
|
|
112
|
+
h ^= input.charCodeAt(i);
|
|
113
|
+
h = Math.imul(h, 0x01000193);
|
|
114
|
+
}
|
|
115
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
119
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createCatalogClient(opts: ClientOptions) {
|
|
123
|
+
const now = opts.now ?? Date.now;
|
|
124
|
+
const kv = opts.kv;
|
|
125
|
+
const budget = opts.budgetPerMinute ?? 30;
|
|
126
|
+
const cacheMs = Math.max(5, opts.cacheMinutes) * MINUTE;
|
|
127
|
+
const identity = opts.apiKey ? `k:${hash(opts.apiKey)}` : `r:${opts.refCode}`;
|
|
128
|
+
|
|
129
|
+
function headers(): Record<string, string> {
|
|
130
|
+
const h: Record<string, string> = {
|
|
131
|
+
Accept: "application/json",
|
|
132
|
+
"User-Agent": `emdash-plugin-avdeb/${VERSION}`,
|
|
133
|
+
};
|
|
134
|
+
if (opts.apiKey) h.Authorization = `Bearer ${opts.apiKey}`;
|
|
135
|
+
return h;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function request(url: string): Promise<{ ok: true; json: unknown } | { ok: false; error: string }> {
|
|
139
|
+
try {
|
|
140
|
+
const init: RequestInit = { method: "GET", headers: headers() };
|
|
141
|
+
if (typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) {
|
|
142
|
+
init.signal = AbortSignal.timeout(opts.timeoutMs ?? 8000);
|
|
143
|
+
}
|
|
144
|
+
const res = await opts.fetch(url, init);
|
|
145
|
+
const json: unknown = await res.json().catch(() => null);
|
|
146
|
+
return res.ok ? { ok: true, json } : { ok: false, error: apiErrorMessage(res.status, json) };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function version(): Promise<number> {
|
|
153
|
+
const v = await kv.get<number>(K_VERSION);
|
|
154
|
+
return typeof v === "number" && v > 0 ? v : 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Approximate per-minute limiter (KV round-trips are not atomic; good enough as a quota guard). */
|
|
158
|
+
async function takeBudget(): Promise<boolean> {
|
|
159
|
+
const minute = Math.floor(now() / MINUTE);
|
|
160
|
+
const cur = await kv.get<{ minute: number; count: number }>(K_BUDGET);
|
|
161
|
+
const count = cur && cur.minute === minute ? cur.count : 0;
|
|
162
|
+
if (count >= budget) return false;
|
|
163
|
+
await kv.set(K_BUDGET, { minute, count: count + 1 });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function record(error: string | null): Promise<void> {
|
|
168
|
+
const prev = (await kv.get<StoredStats>(K_STATS)) ?? {
|
|
169
|
+
requests: 0,
|
|
170
|
+
errors: 0,
|
|
171
|
+
lastFetchAt: null,
|
|
172
|
+
lastError: null,
|
|
173
|
+
lastErrorAt: null,
|
|
174
|
+
};
|
|
175
|
+
const t = now();
|
|
176
|
+
await kv.set(K_STATS, {
|
|
177
|
+
requests: prev.requests + 1,
|
|
178
|
+
errors: prev.errors + (error ? 1 : 0),
|
|
179
|
+
lastFetchAt: t,
|
|
180
|
+
lastError: error ?? prev.lastError,
|
|
181
|
+
lastErrorAt: error ? t : prev.lastErrorAt,
|
|
182
|
+
} satisfies StoredStats);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function products(query: CatalogQuery): Promise<ProductsOutcome> {
|
|
186
|
+
const key = `${identity}|${queryKey(query)}`;
|
|
187
|
+
const id = `${P_PRODUCTS}${await version()}:${hash(key)}`;
|
|
188
|
+
const stored = await kv.get<ProductsEntry>(id);
|
|
189
|
+
const entry = isRecord(stored) && stored.key === key ? stored : null;
|
|
190
|
+
const t = now();
|
|
191
|
+
|
|
192
|
+
if (entry && t < entry.freshUntil) {
|
|
193
|
+
return { products: entry.products, ref: entry.ref, tier: entry.tier, source: "cache", error: entry.error };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const fallbackTier: Tier = opts.apiKey ? "secret" : "anonymous";
|
|
197
|
+
if (!(await takeBudget())) {
|
|
198
|
+
return entry && t < entry.staleUntil
|
|
199
|
+
? { products: entry.products, ref: entry.ref, tier: entry.tier, source: "stale", error: "rate limited" }
|
|
200
|
+
: { products: [], ref: "", tier: fallbackTier, source: "empty", error: "rate limited" };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const res = await request(productsUrl(query, opts.apiKey ? "" : opts.refCode));
|
|
204
|
+
const parsed = res.ok ? parseProductsResponse(res.json) : null;
|
|
205
|
+
const error = res.ok ? (parsed ? null : "Unexpected response from AVDEB") : res.error;
|
|
206
|
+
await record(error);
|
|
207
|
+
|
|
208
|
+
if (parsed) {
|
|
209
|
+
const ref = parsed.ref || (opts.apiKey ? "" : opts.refCode);
|
|
210
|
+
await kv.set(id, {
|
|
211
|
+
key,
|
|
212
|
+
products: parsed.products,
|
|
213
|
+
ref,
|
|
214
|
+
tier: parsed.tier,
|
|
215
|
+
fetchedAt: t,
|
|
216
|
+
freshUntil: t + cacheMs,
|
|
217
|
+
staleUntil: t + STALE_MS,
|
|
218
|
+
} satisfies ProductsEntry);
|
|
219
|
+
return { products: parsed.products, ref, tier: parsed.tier, source: "network" };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
opts.log?.warn("AVDEB catalog request failed", { error, query });
|
|
223
|
+
if (entry && t < entry.staleUntil) {
|
|
224
|
+
await kv.set(id, { ...entry, freshUntil: t + BACKOFF_MS, error: error! } satisfies ProductsEntry);
|
|
225
|
+
return { products: entry.products, ref: entry.ref, tier: entry.tier, source: "stale", error: error! };
|
|
226
|
+
}
|
|
227
|
+
await kv.set(id, {
|
|
228
|
+
key,
|
|
229
|
+
products: [],
|
|
230
|
+
ref: "",
|
|
231
|
+
tier: fallbackTier,
|
|
232
|
+
fetchedAt: t,
|
|
233
|
+
freshUntil: t + EMPTY_BACKOFF_MS,
|
|
234
|
+
staleUntil: t + EMPTY_BACKOFF_MS,
|
|
235
|
+
error: error!,
|
|
236
|
+
} satisfies ProductsEntry);
|
|
237
|
+
return { products: [], ref: "", tier: fallbackTier, source: "empty", error: error! };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Who the secret key belongs to. `null` without a key. */
|
|
241
|
+
async function keyInfo(): Promise<KeyInfo | null> {
|
|
242
|
+
if (!opts.apiKey) return null;
|
|
243
|
+
const id = `${P_KEY}${hash(opts.apiKey)}`;
|
|
244
|
+
const cached = await kv.get<KeyEntry>(id);
|
|
245
|
+
const t = now();
|
|
246
|
+
if (isRecord(cached) && typeof cached.until === "number" && t < cached.until) return cached.info;
|
|
247
|
+
|
|
248
|
+
const res = await request(`${API_BASE}/key`);
|
|
249
|
+
const owner = res.ok ? parseKeyResponse(res.json) : null;
|
|
250
|
+
const info: KeyInfo = owner
|
|
251
|
+
? { ok: true, owner }
|
|
252
|
+
: { ok: false, error: res.ok ? "Unexpected response from AVDEB" : res.error };
|
|
253
|
+
await record(info.ok ? null : info.error);
|
|
254
|
+
await kv.set(id, { info, until: t + (info.ok ? KEY_OK_MS : KEY_FAIL_MS) } satisfies KeyEntry);
|
|
255
|
+
return info;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The referral code links carry: the key owner's with a key, else the manual code. */
|
|
259
|
+
async function ref(): Promise<string> {
|
|
260
|
+
if (!opts.apiKey) return opts.refCode;
|
|
261
|
+
const info = await keyInfo();
|
|
262
|
+
return info?.ok ? info.owner.ref : "";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** "Refresh products now": invalidate every cached query and the key check. */
|
|
266
|
+
async function flush(): Promise<void> {
|
|
267
|
+
await kv.set(K_VERSION, (await version()) + 1);
|
|
268
|
+
for (const { key } of await kv.list(P_KEY)) await kv.delete(key);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Delete expired and superseded cache entries (daily cron). Returns how many were removed. */
|
|
272
|
+
async function cleanup(): Promise<number> {
|
|
273
|
+
const v = await version();
|
|
274
|
+
const t = now();
|
|
275
|
+
let removed = 0;
|
|
276
|
+
for (const { key, value } of await kv.list("cache:")) {
|
|
277
|
+
const rec = isRecord(value) ? value : {};
|
|
278
|
+
const expired = key.startsWith(P_PRODUCTS)
|
|
279
|
+
? !key.startsWith(`${P_PRODUCTS}${v}:`) || !(typeof rec.staleUntil === "number" && t < rec.staleUntil)
|
|
280
|
+
: !(typeof rec.until === "number" && t < rec.until);
|
|
281
|
+
if (expired && (await kv.delete(key))) removed++;
|
|
282
|
+
}
|
|
283
|
+
return removed;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function stats(): Promise<ClientStats> {
|
|
287
|
+
const v = await version();
|
|
288
|
+
const s = await kv.get<StoredStats>(K_STATS);
|
|
289
|
+
const cached = await kv.list(`${P_PRODUCTS}${v}:`);
|
|
290
|
+
return {
|
|
291
|
+
requests: s?.requests ?? 0,
|
|
292
|
+
errors: s?.errors ?? 0,
|
|
293
|
+
lastFetchAt: s?.lastFetchAt ?? null,
|
|
294
|
+
lastError: s?.lastError ?? null,
|
|
295
|
+
lastErrorAt: s?.lastErrorAt ?? null,
|
|
296
|
+
cachedQueries: cached.length,
|
|
297
|
+
cacheVersion: v,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { products, keyInfo, ref, flush, cleanup, stats };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export type CatalogClient = ReturnType<typeof createCatalogClient>;
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Shared identifiers and limits. Pure module: no EmDash or Astro imports. */
|
|
2
|
+
|
|
3
|
+
export const PLUGIN_ID = "avdeb-products";
|
|
4
|
+
export const VERSION = "0.1.0";
|
|
5
|
+
|
|
6
|
+
export const API_HOST = "api.avdeb.com";
|
|
7
|
+
export const API_BASE = `https://${API_HOST}/v1/catalog`;
|
|
8
|
+
export const SITE_ORIGIN = "https://avdeb.com";
|
|
9
|
+
export const DOCS_URL = "https://avdeb.com/developers/#emdash";
|
|
10
|
+
export const PARTNER_DASHBOARD_URL = "https://avdeb.com/affiliate/dashboard/developers/";
|
|
11
|
+
export const AFFILIATE_URL = "https://avdeb.com/affiliate/";
|
|
12
|
+
|
|
13
|
+
/** Secret keys are issued as `avdeb_sk_` + 32 letters/digits (partner dashboard → Embeds & API). */
|
|
14
|
+
export const SECRET_KEY_RE = /^avdeb_sk_[A-Za-z0-9]{32}$/;
|
|
15
|
+
export const PUBLISHABLE_KEY_PREFIX = "avdeb_pk_";
|
|
16
|
+
|
|
17
|
+
/** Catalog API tier limits (api.avdeb.com docs/catalog.md). */
|
|
18
|
+
export const MAX_LIMIT_ANONYMOUS = 12;
|
|
19
|
+
export const MAX_LIMIT_SECRET = 48;
|
|
20
|
+
export const DEFAULT_LIMIT = 6;
|
|
21
|
+
|
|
22
|
+
export const BLOCK_TYPES = {
|
|
23
|
+
products: "avdeb-products",
|
|
24
|
+
product: "avdeb-product",
|
|
25
|
+
button: "avdeb-button",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export const LAYOUTS = ["grid", "carousel", "list", "spotlight"] as const;
|
|
29
|
+
export type Layout = (typeof LAYOUTS)[number];
|
|
30
|
+
|
|
31
|
+
export const CARD_STYLES = ["elevated", "outline", "minimal"] as const;
|
|
32
|
+
export type CardStyle = (typeof CARD_STYLES)[number];
|
|
33
|
+
|
|
34
|
+
export const THEMES = ["inherit", "light", "dark", "auto"] as const;
|
|
35
|
+
export type Theme = (typeof THEMES)[number];
|
|
36
|
+
|
|
37
|
+
export const IMAGE_RATIOS = ["square", "portrait", "landscape"] as const;
|
|
38
|
+
export type ImageRatio = (typeof IMAGE_RATIOS)[number];
|
|
39
|
+
|
|
40
|
+
export const PRODUCT_VARIANTS = ["card", "horizontal", "hero"] as const;
|
|
41
|
+
export type ProductVariant = (typeof PRODUCT_VARIANTS)[number];
|
|
42
|
+
|
|
43
|
+
export const BUTTON_VARIANTS = ["solid", "outline", "link"] as const;
|
|
44
|
+
export type ButtonVariant = (typeof BUTTON_VARIANTS)[number];
|
|
45
|
+
|
|
46
|
+
export const ALIGNMENTS = ["start", "center", "end"] as const;
|
|
47
|
+
export type Alignment = (typeof ALIGNMENTS)[number];
|
|
48
|
+
|
|
49
|
+
export function oneOf<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
|
|
50
|
+
return typeof value === "string" && (allowed as readonly string[]).includes(value)
|
|
51
|
+
? (value as T)
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AVDEB Products — EmDash plugin.
|
|
3
|
+
*
|
|
4
|
+
* Embed AVDEB products (a creator's shop, a category, a search or single products) in Portable Text
|
|
5
|
+
* content and Astro templates, with the site owner's referral code on every link.
|
|
6
|
+
*
|
|
7
|
+
* This is a trusted (native) plugin: it contributes Portable Text block types and Astro render
|
|
8
|
+
* components, which the sandbox/registry format cannot. It only talks to api.avdeb.com.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* // astro.config.mjs
|
|
13
|
+
* import { avdebProducts } from "emdash-plugin-avdeb";
|
|
14
|
+
*
|
|
15
|
+
* emdash({ plugins: [avdebProducts()] });
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { PluginDescriptor, ResolvedPlugin } from "emdash";
|
|
20
|
+
import { definePlugin } from "emdash";
|
|
21
|
+
import { z } from "astro/zod";
|
|
22
|
+
|
|
23
|
+
import { adminRoute, WIDGET_ID } from "./admin.ts";
|
|
24
|
+
import { portableTextBlocks } from "./blocks.ts";
|
|
25
|
+
import { API_HOST, PLUGIN_ID, VERSION } from "./constants.ts";
|
|
26
|
+
import { cleanupCache, contextRoute, productOptionsRoute, productsRoute, searchRoute } from "./routes.ts";
|
|
27
|
+
import { settingsSchema } from "./settings.ts";
|
|
28
|
+
|
|
29
|
+
// eslint-disable-next-line typescript/no-empty-object-type -- reserved for future options
|
|
30
|
+
export interface AvdebProductsOptions {}
|
|
31
|
+
|
|
32
|
+
const capabilities = ["network:request"] as const;
|
|
33
|
+
const allowedHosts = [API_HOST];
|
|
34
|
+
const adminPages = [{ path: "/", label: "AVDEB Products", icon: "storefront" }];
|
|
35
|
+
const adminWidgets = [{ id: WIDGET_ID, title: "AVDEB Products", size: "third" as const }];
|
|
36
|
+
|
|
37
|
+
/** Descriptor for `emdash({ plugins: [avdebProducts()] })` in astro.config. */
|
|
38
|
+
export function avdebProducts(
|
|
39
|
+
options: AvdebProductsOptions = {},
|
|
40
|
+
): PluginDescriptor<AvdebProductsOptions> {
|
|
41
|
+
return {
|
|
42
|
+
id: PLUGIN_ID,
|
|
43
|
+
version: VERSION,
|
|
44
|
+
format: "native",
|
|
45
|
+
entrypoint: "emdash-plugin-avdeb",
|
|
46
|
+
componentsEntry: "emdash-plugin-avdeb/astro",
|
|
47
|
+
options,
|
|
48
|
+
capabilities: [...capabilities],
|
|
49
|
+
allowedHosts,
|
|
50
|
+
adminPages,
|
|
51
|
+
adminWidgets,
|
|
52
|
+
settingsSchema,
|
|
53
|
+
portableTextBlocks,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const searchInput = z.object({
|
|
58
|
+
q: z.string().max(100).optional().describe("Search phrase, e.g. 'cat stickers'"),
|
|
59
|
+
creator: z.string().max(100).optional().describe("Creator slug"),
|
|
60
|
+
tag: z.string().max(100).optional().describe("Category slug"),
|
|
61
|
+
type: z.string().max(40).optional().describe("Product type, e.g. 'Sticker'"),
|
|
62
|
+
limit: z.number().int().min(1).max(48).optional(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export function createPlugin(_options: AvdebProductsOptions = {}): ResolvedPlugin {
|
|
66
|
+
return definePlugin({
|
|
67
|
+
id: PLUGIN_ID,
|
|
68
|
+
version: VERSION,
|
|
69
|
+
capabilities: [...capabilities],
|
|
70
|
+
allowedHosts,
|
|
71
|
+
|
|
72
|
+
hooks: {
|
|
73
|
+
"plugin:activate": {
|
|
74
|
+
handler: async (_event, ctx) => {
|
|
75
|
+
await ctx.cron?.schedule("cleanup", { schedule: "@daily" });
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
cron: {
|
|
79
|
+
handler: async (event, ctx) => {
|
|
80
|
+
if (event.name !== "cleanup") return;
|
|
81
|
+
const removed = await cleanupCache(ctx);
|
|
82
|
+
if (removed > 0) ctx.log.info("Removed expired AVDEB cache entries", { removed });
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
routes: {
|
|
88
|
+
// Called in-process by the site renderer (getPublicPluginApiRouteHandler). Returns public
|
|
89
|
+
// product data only; network calls are cached and capped per minute.
|
|
90
|
+
products: { public: true, handler: productsRoute },
|
|
91
|
+
context: { public: true, handler: contextRoute },
|
|
92
|
+
|
|
93
|
+
// Editor: the "pick a product" select in the AVDEB Product block.
|
|
94
|
+
"options/products": { permission: "content:create", handler: productOptionsRoute },
|
|
95
|
+
"mcp/search": { permission: "content:create", input: searchInput, handler: searchRoute },
|
|
96
|
+
|
|
97
|
+
admin: { permission: "plugins:manage", handler: adminRoute },
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
mcp: {
|
|
101
|
+
tools: {
|
|
102
|
+
search_avdeb_products: {
|
|
103
|
+
description:
|
|
104
|
+
"Search AVDEB products to embed in content. Returns handles plus a ready Portable Text block ({ _type: 'avdeb-product', id }) for each.",
|
|
105
|
+
route: "mcp/search",
|
|
106
|
+
input: searchInput,
|
|
107
|
+
destructive: false,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
admin: {
|
|
113
|
+
settingsSchema,
|
|
114
|
+
pages: adminPages,
|
|
115
|
+
widgets: adminWidgets,
|
|
116
|
+
portableTextBlocks,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default createPlugin;
|
|
122
|
+
|
|
123
|
+
export { BLOCK_TYPES, PLUGIN_ID } from "./constants.ts";
|
|
124
|
+
export type { Product, CatalogQuery } from "./catalog.ts";
|
|
125
|
+
export type { DisplaySettings } from "./settings.ts";
|
package/src/payload.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation of the route payload on the rendering side (defence in depth: everything printed in
|
|
3
|
+
* HTML is re-checked here, even though the route already normalised it). Pure module.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { cleanRef, cleanSlug, isAvdebUrl, sanitizeQuery, type CatalogQuery, type Product } from "./catalog.ts";
|
|
7
|
+
import { MAX_LIMIT_SECRET } from "./constants.ts";
|
|
8
|
+
import { parseDisplay, type DisplaySettings } from "./settings.ts";
|
|
9
|
+
|
|
10
|
+
export interface EmbedData {
|
|
11
|
+
products: Product[];
|
|
12
|
+
ref: string;
|
|
13
|
+
query: CatalogQuery;
|
|
14
|
+
display: DisplaySettings;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ContextData {
|
|
18
|
+
ref: string;
|
|
19
|
+
display: DisplaySettings;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
23
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function num(v: unknown): number | null {
|
|
27
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function text(v: unknown, max: number): string {
|
|
31
|
+
return typeof v === "string" ? v.trim().slice(0, max) : "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function reviveProduct(v: unknown): Product | null {
|
|
35
|
+
if (!isRecord(v)) return null;
|
|
36
|
+
const handle = cleanSlug(v.handle);
|
|
37
|
+
const title = text(v.title, 200);
|
|
38
|
+
if (!handle || !title || !isAvdebUrl(v.url)) return null;
|
|
39
|
+
const image = typeof v.image === "string" && v.image.startsWith("https://") ? v.image : null;
|
|
40
|
+
const currency = typeof v.currency === "string" && /^[A-Z]{3}$/.test(v.currency) ? v.currency : "USD";
|
|
41
|
+
let creator: Product["creator"] = null;
|
|
42
|
+
if (isRecord(v.creator) && cleanSlug(v.creator.slug)) {
|
|
43
|
+
creator = {
|
|
44
|
+
slug: cleanSlug(v.creator.slug),
|
|
45
|
+
name: text(v.creator.name, 120) || cleanSlug(v.creator.slug),
|
|
46
|
+
url: isAvdebUrl(v.creator.url) ? v.creator.url : null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const createdAt = typeof v.createdAt === "string" && !Number.isNaN(Date.parse(v.createdAt)) ? v.createdAt : null;
|
|
50
|
+
return {
|
|
51
|
+
handle,
|
|
52
|
+
title,
|
|
53
|
+
url: v.url,
|
|
54
|
+
image,
|
|
55
|
+
price: num(v.price),
|
|
56
|
+
compareAtPrice: num(v.compareAtPrice),
|
|
57
|
+
currency,
|
|
58
|
+
type: text(v.type, 40) || null,
|
|
59
|
+
tags: Array.isArray(v.tags) ? v.tags.map(cleanSlug).filter(Boolean).slice(0, 20) : [],
|
|
60
|
+
creator,
|
|
61
|
+
createdAt,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseEmbedPayload(data: unknown): EmbedData | null {
|
|
66
|
+
if (!isRecord(data) || !Array.isArray(data.products)) return null;
|
|
67
|
+
return {
|
|
68
|
+
products: data.products.map(reviveProduct).filter((p): p is Product => p !== null),
|
|
69
|
+
ref: cleanRef(data.ref),
|
|
70
|
+
query: sanitizeQuery(data.query, MAX_LIMIT_SECRET),
|
|
71
|
+
display: parseDisplay(data.display),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseContextPayload(data: unknown): ContextData | null {
|
|
76
|
+
if (!isRecord(data)) return null;
|
|
77
|
+
return { ref: cleanRef(data.ref), display: parseDisplay(data.display) };
|
|
78
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin route handlers. Only type imports from EmDash, so the handlers run in tests with a fake
|
|
3
|
+
* context.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PluginContext, RouteContext } from "emdash";
|
|
7
|
+
|
|
8
|
+
import { maxLimit, sanitizeQuery, type Product } from "./catalog.ts";
|
|
9
|
+
import { createCatalogClient, type CatalogClient } from "./client.ts";
|
|
10
|
+
import { displayOf, readSettings, type ResolvedSettings, type SettingKey } from "./settings.ts";
|
|
11
|
+
import { formatPrice } from "./view.ts";
|
|
12
|
+
|
|
13
|
+
type Ctx = Pick<PluginContext, "kv" | "settings" | "http" | "log">;
|
|
14
|
+
|
|
15
|
+
export async function loadSettings(ctx: Ctx): Promise<ResolvedSettings> {
|
|
16
|
+
return readSettings((key: SettingKey) => ctx.settings.get(key));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function clientFor(ctx: Ctx, settings: ResolvedSettings, now?: () => number): CatalogClient {
|
|
20
|
+
const http = ctx.http;
|
|
21
|
+
if (!http) throw new Error("AVDEB Products needs the network:request capability for api.avdeb.com");
|
|
22
|
+
return createCatalogClient({
|
|
23
|
+
kv: ctx.kv,
|
|
24
|
+
fetch: (url, init) => http.fetch(url, init),
|
|
25
|
+
apiKey: settings.apiKey,
|
|
26
|
+
refCode: settings.refCode,
|
|
27
|
+
cacheMinutes: settings.cacheMinutes,
|
|
28
|
+
log: ctx.log,
|
|
29
|
+
now,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Public: products + presentation settings for the site renderer. Never returns credentials. */
|
|
34
|
+
export async function productsRoute(ctx: RouteContext) {
|
|
35
|
+
const settings = await loadSettings(ctx);
|
|
36
|
+
const query = sanitizeQuery(ctx.input, maxLimit(settings.apiKey !== ""));
|
|
37
|
+
const out = await clientFor(ctx, settings).products(query);
|
|
38
|
+
return {
|
|
39
|
+
products: out.products,
|
|
40
|
+
ref: out.ref,
|
|
41
|
+
tier: out.tier,
|
|
42
|
+
query,
|
|
43
|
+
display: displayOf(settings),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Public: referral code + presentation settings (for buttons and links, no product lookup). */
|
|
48
|
+
export async function contextRoute(ctx: RouteContext) {
|
|
49
|
+
const settings = await loadSettings(ctx);
|
|
50
|
+
return { ref: await clientFor(ctx, settings).ref(), display: displayOf(settings) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function optionLabel(p: Product): string {
|
|
54
|
+
const price = formatPrice(p.price, p.currency);
|
|
55
|
+
return price ? `${p.title} · ${price}` : p.title;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Editor: options for the "pick a product" select — the key owner's own products for creators,
|
|
60
|
+
* otherwise the newest products. Shape required by the editor: `{ items: [{ id, name }] }`.
|
|
61
|
+
*/
|
|
62
|
+
export async function productOptionsRoute(ctx: RouteContext) {
|
|
63
|
+
const settings = await loadSettings(ctx);
|
|
64
|
+
const client = clientFor(ctx, settings);
|
|
65
|
+
const info = await client.keyInfo();
|
|
66
|
+
const creator = info?.ok ? info.owner.creatorSlug : "";
|
|
67
|
+
const query = sanitizeQuery(
|
|
68
|
+
{ creator, limit: maxLimit(settings.apiKey !== "") },
|
|
69
|
+
maxLimit(settings.apiKey !== ""),
|
|
70
|
+
);
|
|
71
|
+
const out = await client.products(query);
|
|
72
|
+
return { items: out.products.map((p) => ({ id: p.handle, name: optionLabel(p) })) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Editor / MCP: search the catalog and get ready-to-insert block data. */
|
|
76
|
+
export async function searchRoute(ctx: RouteContext) {
|
|
77
|
+
const settings = await loadSettings(ctx);
|
|
78
|
+
const query = sanitizeQuery(ctx.input, maxLimit(settings.apiKey !== ""));
|
|
79
|
+
const out = await clientFor(ctx, settings).products(query);
|
|
80
|
+
return {
|
|
81
|
+
products: out.products.map((p) => ({
|
|
82
|
+
handle: p.handle,
|
|
83
|
+
title: p.title,
|
|
84
|
+
price: p.price,
|
|
85
|
+
currency: p.currency,
|
|
86
|
+
url: p.url,
|
|
87
|
+
image: p.image,
|
|
88
|
+
creator: p.creator?.name ?? null,
|
|
89
|
+
block: { _type: "avdeb-product", id: p.handle },
|
|
90
|
+
})),
|
|
91
|
+
ref: out.ref,
|
|
92
|
+
...(out.error ? { warning: out.error } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Daily cron: drop expired cache entries so random public queries can't pile up in KV. */
|
|
97
|
+
export async function cleanupCache(ctx: Ctx): Promise<number> {
|
|
98
|
+
const settings = await loadSettings(ctx);
|
|
99
|
+
return clientFor(ctx, settings).cleanup();
|
|
100
|
+
}
|