@shisho/plugin-sdk 0.0.21

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.
@@ -0,0 +1,443 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import { parseHTML } from "linkedom";
3
+ function statusText(status) {
4
+ const texts = {
5
+ 200: "OK",
6
+ 201: "Created",
7
+ 204: "No Content",
8
+ 301: "Moved Permanently",
9
+ 302: "Found",
10
+ 304: "Not Modified",
11
+ 400: "Bad Request",
12
+ 401: "Unauthorized",
13
+ 403: "Forbidden",
14
+ 404: "Not Found",
15
+ 500: "Internal Server Error",
16
+ 502: "Bad Gateway",
17
+ 503: "Service Unavailable",
18
+ };
19
+ return texts[status] || "Unknown";
20
+ }
21
+ function createMockFetchResponse(url, mock) {
22
+ const status = mock.status ?? 200;
23
+ const body = mock.body ?? "";
24
+ const headers = mock.headers ?? {};
25
+ return {
26
+ ok: status >= 200 && status < 300,
27
+ status,
28
+ statusText: statusText(status),
29
+ headers,
30
+ text() {
31
+ return body;
32
+ },
33
+ json() {
34
+ try {
35
+ return JSON.parse(body);
36
+ }
37
+ catch {
38
+ throw new Error(`Failed to parse response body as JSON for ${url}: ${body.slice(0, 100)}`);
39
+ }
40
+ },
41
+ arrayBuffer() {
42
+ const encoder = new TextEncoder();
43
+ return encoder.encode(body).buffer;
44
+ },
45
+ };
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // XML implementation (pure JS, matches Go's namespace-aware tag matching)
49
+ // ---------------------------------------------------------------------------
50
+ function parseXML(content) {
51
+ const parser = new XMLParser({
52
+ ignoreAttributes: false,
53
+ attributeNamePrefix: "",
54
+ preserveOrder: true,
55
+ textNodeName: "#text",
56
+ cdataPropName: "#cdata",
57
+ commentPropName: "#comment",
58
+ });
59
+ const parsed = parser.parse(content);
60
+ return convertXMLNode(parsed[0] ?? {});
61
+ }
62
+ function convertXMLNode(node) {
63
+ const keys = Object.keys(node).filter((k) => k !== ":@" && k !== "#text" && k !== "#cdata" && k !== "#comment");
64
+ const tagWithNs = keys[0] ?? "";
65
+ const colonIdx = tagWithNs.indexOf(":");
66
+ const tag = colonIdx >= 0 ? tagWithNs.slice(colonIdx + 1) : tagWithNs;
67
+ const nsPrefix = colonIdx >= 0 ? tagWithNs.slice(0, colonIdx) : "";
68
+ const attrs = node[":@"] ?? {};
69
+ const attributes = {};
70
+ let namespace = "";
71
+ for (const [k, v] of Object.entries(attrs)) {
72
+ if (k === `xmlns:${nsPrefix}` && nsPrefix) {
73
+ namespace = String(v);
74
+ }
75
+ else if (k === "xmlns" && !nsPrefix) {
76
+ namespace = String(v);
77
+ }
78
+ attributes[k] = String(v);
79
+ }
80
+ const childNodes = node[tagWithNs] ?? [];
81
+ let text = "";
82
+ const children = [];
83
+ for (const child of childNodes) {
84
+ if (typeof child === "object" && child !== null) {
85
+ const c = child;
86
+ if ("#text" in c) {
87
+ text += String(c["#text"]);
88
+ }
89
+ else {
90
+ children.push(convertXMLNode(c));
91
+ }
92
+ }
93
+ }
94
+ return { tag, namespace, text, attributes, children };
95
+ }
96
+ function xmlQuerySelector(root, selector, namespaces) {
97
+ const { nsURI, tagName, hasNS } = parseXMLSelector(selector, namespaces);
98
+ if (matchesXMLSelector(root, nsURI, tagName, hasNS))
99
+ return root;
100
+ for (const child of root.children) {
101
+ const result = xmlQuerySelector(child, selector, namespaces);
102
+ if (result)
103
+ return result;
104
+ }
105
+ return null;
106
+ }
107
+ function xmlQuerySelectorAll(root, selector, namespaces) {
108
+ const results = [];
109
+ const { nsURI, tagName, hasNS } = parseXMLSelector(selector, namespaces);
110
+ xmlCollectMatches(root, nsURI, tagName, hasNS, results);
111
+ return results;
112
+ }
113
+ function xmlCollectMatches(elem, nsURI, tagName, hasNS, results) {
114
+ if (matchesXMLSelector(elem, nsURI, tagName, hasNS))
115
+ results.push(elem);
116
+ for (const child of elem.children) {
117
+ xmlCollectMatches(child, nsURI, tagName, hasNS, results);
118
+ }
119
+ }
120
+ function parseXMLSelector(selector, namespaces) {
121
+ const idx = selector.indexOf("|");
122
+ if (idx >= 0) {
123
+ const prefix = selector.slice(0, idx);
124
+ const tagName = selector.slice(idx + 1);
125
+ const nsURI = namespaces?.[prefix] ?? "";
126
+ return { nsURI, tagName, hasNS: true };
127
+ }
128
+ return { nsURI: "", tagName: selector, hasNS: false };
129
+ }
130
+ function matchesXMLSelector(elem, nsURI, tagName, hasNS) {
131
+ if (elem.tag !== tagName)
132
+ return false;
133
+ if (hasNS)
134
+ return elem.namespace === nsURI;
135
+ return true;
136
+ }
137
+ function createXMLImpl() {
138
+ return {
139
+ parse(content) {
140
+ return parseXML(content);
141
+ },
142
+ querySelector(doc, selector, namespaces) {
143
+ return xmlQuerySelector(doc, selector, namespaces);
144
+ },
145
+ querySelectorAll(doc, selector, namespaces) {
146
+ return xmlQuerySelectorAll(doc, selector, namespaces);
147
+ },
148
+ };
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // HTML implementation (linkedom for DOM parsing + CSS selectors)
152
+ // ---------------------------------------------------------------------------
153
+ function nodeToHtmlElement(node) {
154
+ const attributes = {};
155
+ for (const attr of node.attributes) {
156
+ attributes[attr.name] = attr.value;
157
+ }
158
+ const children = [];
159
+ for (const child of node.children) {
160
+ children.push(nodeToHtmlElement(child));
161
+ }
162
+ return {
163
+ tag: node.tagName.toLowerCase(),
164
+ attributes,
165
+ text: node.textContent ?? "",
166
+ innerHTML: node.innerHTML,
167
+ children,
168
+ };
169
+ }
170
+ function createHTMLImpl() {
171
+ return {
172
+ parse(html) {
173
+ const { document } = parseHTML(html);
174
+ const root = document.documentElement;
175
+ const elem = nodeToHtmlElement(root);
176
+ // Store the original document for querySelector reuse
177
+ Object.defineProperty(elem, "__document", {
178
+ value: document,
179
+ enumerable: false,
180
+ });
181
+ Object.defineProperty(elem, "__node", {
182
+ value: root,
183
+ enumerable: false,
184
+ });
185
+ return elem;
186
+ },
187
+ querySelector(doc, selector) {
188
+ // Try to use stored DOM node for efficient querying
189
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
190
+ const node = doc["__node"];
191
+ if (node) {
192
+ const match = node.querySelector(selector);
193
+ if (!match)
194
+ return null;
195
+ const elem = nodeToHtmlElement(match);
196
+ Object.defineProperty(elem, "__node", {
197
+ value: match,
198
+ enumerable: false,
199
+ });
200
+ return elem;
201
+ }
202
+ // Fallback: re-parse innerHTML (for elements not from parse())
203
+ const { document } = parseHTML(`<html><body>${doc.innerHTML}</body></html>`);
204
+ const match = document.querySelector(selector);
205
+ if (!match)
206
+ return null;
207
+ return nodeToHtmlElement(match);
208
+ },
209
+ querySelectorAll(doc, selector) {
210
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
211
+ const node = doc["__node"];
212
+ if (node) {
213
+ const matches = node.querySelectorAll(selector);
214
+ return Array.from(matches).map((m) => {
215
+ const elem = nodeToHtmlElement(m);
216
+ Object.defineProperty(elem, "__node", {
217
+ value: m,
218
+ enumerable: false,
219
+ });
220
+ return elem;
221
+ });
222
+ }
223
+ // Fallback: re-parse
224
+ const { document } = parseHTML(`<html><body>${doc.innerHTML}</body></html>`);
225
+ const matches = document.querySelectorAll(selector);
226
+ return Array.from(matches).map((m) => nodeToHtmlElement(m));
227
+ },
228
+ };
229
+ }
230
+ // ---------------------------------------------------------------------------
231
+ // Main factory
232
+ // ---------------------------------------------------------------------------
233
+ /**
234
+ * Create a mock `shisho` host API object for testing plugins.
235
+ *
236
+ * Provides mock implementations of log, config, http, url, and fs.
237
+ * Provides real implementations of xml and html (backed by fast-xml-parser and linkedom).
238
+ *
239
+ * @example
240
+ * ```ts
241
+ * const shisho = createMockShisho({
242
+ * fetch: {
243
+ * "https://api.example.com/search?q=test": {
244
+ * status: 200,
245
+ * body: JSON.stringify({ results: [] }),
246
+ * },
247
+ * },
248
+ * config: { apiKey: "test-key" },
249
+ * });
250
+ * ```
251
+ */
252
+ export function createMockShisho(options = {}) {
253
+ const fetchRoutes = options.fetch ?? {};
254
+ const configMap = options.config ?? {};
255
+ const fsMap = options.fs ?? {};
256
+ // --- log: silent no-ops ---
257
+ const log = {
258
+ debug() { },
259
+ info() { },
260
+ warn() { },
261
+ error() { },
262
+ };
263
+ // --- config: map-based ---
264
+ const config = {
265
+ get(key) {
266
+ return configMap[key];
267
+ },
268
+ getAll() {
269
+ return { ...configMap };
270
+ },
271
+ };
272
+ // --- http: route-based mock ---
273
+ const http = {
274
+ fetch(url) {
275
+ const mock = fetchRoutes[url];
276
+ if (!mock) {
277
+ const definedRoutes = Object.keys(fetchRoutes);
278
+ const routeList = definedRoutes.length > 0
279
+ ? definedRoutes.map((r) => ` - ${r}`).join("\n")
280
+ : " (none)";
281
+ throw new Error(`Mock fetch: no route defined for URL "${url}".\n\nDefined routes:\n${routeList}`);
282
+ }
283
+ return createMockFetchResponse(url, mock);
284
+ },
285
+ };
286
+ // --- url: real implementations ---
287
+ const url = {
288
+ encodeURIComponent(str) {
289
+ return encodeURIComponent(str);
290
+ },
291
+ decodeURIComponent(str) {
292
+ return decodeURIComponent(str);
293
+ },
294
+ searchParams(params) {
295
+ const keys = Object.keys(params).sort();
296
+ const parts = [];
297
+ for (const key of keys) {
298
+ const value = params[key];
299
+ if (value === null || value === undefined) {
300
+ continue;
301
+ }
302
+ if (Array.isArray(value)) {
303
+ for (const item of value) {
304
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`);
305
+ }
306
+ }
307
+ else {
308
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
309
+ }
310
+ }
311
+ return parts.join("&");
312
+ },
313
+ parse(urlStr) {
314
+ const parsed = new URL(urlStr);
315
+ // Build query map: single values as string, repeated keys as array
316
+ const query = {};
317
+ parsed.searchParams.forEach((value, key) => {
318
+ const existing = query[key];
319
+ if (existing === undefined) {
320
+ query[key] = value;
321
+ }
322
+ else if (Array.isArray(existing)) {
323
+ existing.push(value);
324
+ }
325
+ else {
326
+ query[key] = [existing, value];
327
+ }
328
+ });
329
+ return {
330
+ href: parsed.href,
331
+ protocol: parsed.protocol.replace(/:$/, ""),
332
+ host: parsed.host,
333
+ hostname: parsed.hostname,
334
+ port: parsed.port,
335
+ pathname: parsed.pathname,
336
+ search: parsed.search,
337
+ hash: parsed.hash,
338
+ username: parsed.username,
339
+ password: parsed.password,
340
+ query,
341
+ };
342
+ },
343
+ };
344
+ // --- fs: path-based mock ---
345
+ const fs = {
346
+ readFile(path) {
347
+ const entry = fsMap[path];
348
+ if (entry === undefined) {
349
+ const definedPaths = Object.keys(fsMap);
350
+ const pathList = definedPaths.length > 0
351
+ ? definedPaths.map((p) => ` - ${p}`).join("\n")
352
+ : " (none)";
353
+ throw new Error(`Mock fs.readFile: no entry for path "${path}".\n\nDefined paths:\n${pathList}`);
354
+ }
355
+ if (Array.isArray(entry)) {
356
+ throw new Error(`Mock fs.readFile: path "${path}" is a directory (string[]), not a file.`);
357
+ }
358
+ if (typeof entry === "string") {
359
+ const encoder = new TextEncoder();
360
+ return encoder.encode(entry).buffer;
361
+ }
362
+ // Buffer
363
+ return entry.buffer.slice(entry.byteOffset, entry.byteOffset + entry.byteLength);
364
+ },
365
+ readTextFile(path) {
366
+ const entry = fsMap[path];
367
+ if (entry === undefined) {
368
+ const definedPaths = Object.keys(fsMap);
369
+ const pathList = definedPaths.length > 0
370
+ ? definedPaths.map((p) => ` - ${p}`).join("\n")
371
+ : " (none)";
372
+ throw new Error(`Mock fs.readTextFile: no entry for path "${path}".\n\nDefined paths:\n${pathList}`);
373
+ }
374
+ if (Array.isArray(entry)) {
375
+ throw new Error(`Mock fs.readTextFile: path "${path}" is a directory (string[]), not a file.`);
376
+ }
377
+ if (typeof entry === "string") {
378
+ return entry;
379
+ }
380
+ // Buffer -> string
381
+ const decoder = new TextDecoder();
382
+ return decoder.decode(entry);
383
+ },
384
+ writeFile() {
385
+ // no-op
386
+ },
387
+ writeTextFile() {
388
+ // no-op
389
+ },
390
+ exists(path) {
391
+ return path in fsMap;
392
+ },
393
+ mkdir() {
394
+ // no-op
395
+ },
396
+ listDir(path) {
397
+ const entry = fsMap[path];
398
+ if (entry === undefined) {
399
+ const definedPaths = Object.keys(fsMap);
400
+ const pathList = definedPaths.length > 0
401
+ ? definedPaths.map((p) => ` - ${p}`).join("\n")
402
+ : " (none)";
403
+ throw new Error(`Mock fs.listDir: no entry for path "${path}".\n\nDefined paths:\n${pathList}`);
404
+ }
405
+ if (!Array.isArray(entry)) {
406
+ throw new Error(`Mock fs.listDir: path "${path}" is a file, not a directory (string[]).`);
407
+ }
408
+ return entry;
409
+ },
410
+ tempDir() {
411
+ return "/tmp/shisho-mock-temp";
412
+ },
413
+ };
414
+ // --- Stubs for APIs not covered by mock ---
415
+ const notImplemented = (api) => () => {
416
+ throw new Error(`Mock ${api}: not implemented. Use MockShishoOptions to configure the APIs you need, ` +
417
+ `or provide your own mock for ${api}.`);
418
+ };
419
+ return {
420
+ dataDir: "/tmp/shisho-mock-data",
421
+ log,
422
+ config,
423
+ http,
424
+ url,
425
+ fs,
426
+ archive: {
427
+ extractZip: notImplemented("archive.extractZip"),
428
+ createZip: notImplemented("archive.createZip"),
429
+ readZipEntry: notImplemented("archive.readZipEntry"),
430
+ listZipEntries: notImplemented("archive.listZipEntries"),
431
+ },
432
+ xml: createXMLImpl(),
433
+ html: createHTMLImpl(),
434
+ ffmpeg: {
435
+ transcode: notImplemented("ffmpeg.transcode"),
436
+ probe: notImplemented("ffmpeg.probe"),
437
+ version: notImplemented("ffmpeg.version"),
438
+ },
439
+ shell: {
440
+ exec: notImplemented("shell.exec"),
441
+ },
442
+ };
443
+ }