@mosaicast/plugin-sdk 0.7.1 → 0.9.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 +357 -0
- package/dist/index.d.ts +994 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +341 -1
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +222 -9
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +499 -8
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* rejects a mismatch at startup (ARCHITECTURE §7.2). While the SDK is pre-1.0 a breaking change is
|
|
18
18
|
* therefore a *minor* bump; from `1.0.0` on, breaking means major.
|
|
19
19
|
*/
|
|
20
|
-
export declare const PLATFORM_API_VERSION: '0.
|
|
20
|
+
export declare const PLATFORM_API_VERSION: '0.9.0';
|
|
21
21
|
/** A user's role (ARCHITECTURE §8.5). Anonymous visitors have no role (`user` is `null`). */
|
|
22
22
|
export type Role = 'admin' | 'podcaster' | 'fan';
|
|
23
23
|
/**
|
|
@@ -259,15 +259,192 @@ export interface PagedDocs<T = unknown> {
|
|
|
259
259
|
* ```
|
|
260
260
|
*/
|
|
261
261
|
export interface PluginApiClient {
|
|
262
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* GET a path, resolving to the parsed JSON body.
|
|
264
|
+
*
|
|
265
|
+
* Rejects with a {@link PluginApiError} on any non-2xx response — **including 404**, which is the
|
|
266
|
+
* normal answer for a document that does not exist yet. Prefer {@link getOrNull} when absence is an
|
|
267
|
+
* expected outcome rather than a failure.
|
|
268
|
+
*/
|
|
263
269
|
get<T = unknown>(path: string): Promise<T>;
|
|
264
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* Like {@link get}, but resolves **`null`** on a 404 instead of rejecting.
|
|
272
|
+
*
|
|
273
|
+
* "Nothing saved yet" is the ordinary state of a doc-store key, so every plugin ends up writing
|
|
274
|
+
* `get(path).catch(() => undefined)` — which also swallows the 500, the 403 from the read floor and
|
|
275
|
+
* the network failure, and reports all four to the visitor as an empty widget. This makes absence an
|
|
276
|
+
* answer, so the `catch` that remains is a real error path again.
|
|
277
|
+
*
|
|
278
|
+
* Every other non-2xx still rejects with a {@link PluginApiError}.
|
|
279
|
+
*
|
|
280
|
+
* @param path the path relative to the plugin's base
|
|
281
|
+
* @returns the parsed body, or `null` when the host answered 404
|
|
282
|
+
* @since 0.9.0
|
|
283
|
+
*/
|
|
284
|
+
getOrNull<T = unknown>(path: string): Promise<T | null>;
|
|
285
|
+
/** POST a JSON body, resolving to the parsed JSON response. Rejects with a {@link PluginApiError}. */
|
|
265
286
|
post<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
266
|
-
/** PUT a JSON body, resolving to the parsed JSON response. */
|
|
287
|
+
/** PUT a JSON body, resolving to the parsed JSON response. Rejects with a {@link PluginApiError}. */
|
|
267
288
|
put<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
268
|
-
/** DELETE a path, resolving to the parsed JSON response. */
|
|
289
|
+
/** DELETE a path, resolving to the parsed JSON response. Rejects with a {@link PluginApiError}. */
|
|
269
290
|
delete<T = unknown>(path: string): Promise<T>;
|
|
270
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* The RFC 7807 `application/problem+json` body the host sends with a refusal.
|
|
294
|
+
*
|
|
295
|
+
* Every field is optional: the host is entitled to answer with a bare status, and a plugin that
|
|
296
|
+
* destructures blindly breaks on the day it does.
|
|
297
|
+
*
|
|
298
|
+
* @since 0.9.0
|
|
299
|
+
*/
|
|
300
|
+
export interface ProblemDetail {
|
|
301
|
+
/** A URI identifying the problem type. */
|
|
302
|
+
type?: string;
|
|
303
|
+
/** A short, human-readable summary of the problem type. */
|
|
304
|
+
title?: string;
|
|
305
|
+
/** An explanation specific to this occurrence — the field worth showing an operator. */
|
|
306
|
+
detail?: string;
|
|
307
|
+
/** A URI identifying this specific occurrence. */
|
|
308
|
+
instance?: string;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* What every {@link PluginApiClient} method rejects with on a non-2xx response.
|
|
312
|
+
*
|
|
313
|
+
* The status is the point. Without it a plugin cannot tell the 403 that means *the manifest's read floor
|
|
314
|
+
* refused you* from the 403 that means *this key is `backendOwned`* — the contract words those two
|
|
315
|
+
* differently on purpose, and an untyped rejection throws that distinction away. Nor can it tell either
|
|
316
|
+
* of them from a 500, which is the failure a plugin should surface rather than swallow.
|
|
317
|
+
*
|
|
318
|
+
* Test it with {@link isPluginApiError} rather than `instanceof`: the error crosses a bundle boundary
|
|
319
|
+
* from the host, so it is not guaranteed to share a constructor with anything in your plugin.
|
|
320
|
+
*
|
|
321
|
+
* ```ts
|
|
322
|
+
* try {
|
|
323
|
+
* await ctx.api.put(`data/site/main/stats`, computed);
|
|
324
|
+
* } catch (e) {
|
|
325
|
+
* if (isPluginApiError(e) && e.status === 403) {
|
|
326
|
+
* ctx.log('warn', e.problem?.detail ?? 'refused'); // backendOwned, or the write floor
|
|
327
|
+
* return;
|
|
328
|
+
* }
|
|
329
|
+
* throw e; // a real failure — do not swallow it
|
|
330
|
+
* }
|
|
331
|
+
* ```
|
|
332
|
+
*
|
|
333
|
+
* @since 0.9.0
|
|
334
|
+
*/
|
|
335
|
+
export interface PluginApiError extends Error {
|
|
336
|
+
/** The HTTP status the host answered with — `404`, `403`, `415`, `500`, … */
|
|
337
|
+
readonly status: number;
|
|
338
|
+
/** The RFC 7807 body, when the host sent one. */
|
|
339
|
+
readonly problem?: ProblemDetail;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Whether a caught value is a {@link PluginApiError}.
|
|
343
|
+
*
|
|
344
|
+
* A **structural** check, deliberately: the error is constructed by the host and reaches your plugin
|
|
345
|
+
* across a bundle boundary, so `instanceof` against your own copy of a class would answer `false` for a
|
|
346
|
+
* genuine one. This tests what the contract actually promises — an `Error` carrying a numeric `status`.
|
|
347
|
+
*
|
|
348
|
+
* @param e the caught value
|
|
349
|
+
* @returns whether it carries the API error shape
|
|
350
|
+
* @since 0.9.0
|
|
351
|
+
*/
|
|
352
|
+
export declare function isPluginApiError(e: unknown): e is PluginApiError;
|
|
353
|
+
/**
|
|
354
|
+
* The pattern every doc-store key must match — mirror of the Java `DocStore.KEY_PATTERN`.
|
|
355
|
+
*
|
|
356
|
+
* Note what is **not** in it: `/`. A key travels as the final path segment verbatim, so structure one
|
|
357
|
+
* with `:` / `.` / `-` (`mark:s2e04:b3`), never with a slash.
|
|
358
|
+
*
|
|
359
|
+
* @since 0.9.0
|
|
360
|
+
*/
|
|
361
|
+
export declare const DOC_KEY_PATTERN: RegExp;
|
|
362
|
+
/**
|
|
363
|
+
* Which partition a {@link DocClient} call addresses.
|
|
364
|
+
*
|
|
365
|
+
* A {@link Scope} for a page-level partition — `ctx.scope` is usually the one you want — or one of two
|
|
366
|
+
* shorthands for the singletons whose id is fixed:
|
|
367
|
+
*
|
|
368
|
+
* - **`'self'`** → `data/user/me`, the calling user's own partition.
|
|
369
|
+
* - **`'site'`** → `data/site/main`, the one site.
|
|
370
|
+
*
|
|
371
|
+
* @since 0.9.0
|
|
372
|
+
*/
|
|
373
|
+
export type DocTarget = Scope | 'self' | 'site';
|
|
374
|
+
/**
|
|
375
|
+
* A typed client for this plugin's doc store — the same endpoints {@link PluginApiClient} reaches, with
|
|
376
|
+
* the path building, the key validation and the 404 handling done for you.
|
|
377
|
+
*
|
|
378
|
+
* Every doc access before this was string concatenation against a four-segment path, with the plugin
|
|
379
|
+
* responsible for `encodeURIComponent`, for {@link DOC_KEY_PATTERN}, for knowing that `site` is always
|
|
380
|
+
* `main` and `user` always `me`, and for remembering that a key cannot contain `/`:
|
|
381
|
+
*
|
|
382
|
+
* ```ts
|
|
383
|
+
* `data/episode/${encodeURIComponent(episodeSlug)}/favourites` // every call site, every plugin
|
|
384
|
+
* ```
|
|
385
|
+
*
|
|
386
|
+
* ## Why `'self'` is the shortest thing to write
|
|
387
|
+
*
|
|
388
|
+
* Per-user data belongs in the `user` scope, never in a key — a key is client-supplied, so
|
|
389
|
+
* `mark:<userId>:cell` is an access-control decision the host cannot enforce, and scope ids are public
|
|
390
|
+
* slugs so nothing has to be guessed. That is the most security-relevant convention in the whole
|
|
391
|
+
* contract, and making it the *shortest* call is the only reliable way to make a convention stick:
|
|
392
|
+
*
|
|
393
|
+
* ```ts
|
|
394
|
+
* await ctx.docs.put('self', `mark:${ctx.scope.id}`, marks); // the caller's own partition
|
|
395
|
+
* ```
|
|
396
|
+
*
|
|
397
|
+
* ## What it validates, and what it does not
|
|
398
|
+
*
|
|
399
|
+
* A key that fails {@link DOC_KEY_PATTERN} **throws at the call site**, with the pattern in the message,
|
|
400
|
+
* instead of costing a 400 round-trip you then have to read the body of. Everything else is the host's
|
|
401
|
+
* to enforce and is unchanged — the `readableBy`/`writableBy` floors, `backendOwned`, the 400 on an
|
|
402
|
+
* unknown scope, the 401 on an anonymous `user` request. See {@link PluginApiClient} for all of it;
|
|
403
|
+
* that client stays available as the escape hatch for anything this does not cover.
|
|
404
|
+
*
|
|
405
|
+
* @since 0.9.0
|
|
406
|
+
*/
|
|
407
|
+
export interface DocClient {
|
|
408
|
+
/**
|
|
409
|
+
* One document, or **`null`** when there is none.
|
|
410
|
+
*
|
|
411
|
+
* Null rather than a rejection: absence is the ordinary state of a key nothing has written yet, and
|
|
412
|
+
* making it an exception is what produced the `catch` that also swallowed every real failure. Other
|
|
413
|
+
* refusals still reject with a {@link PluginApiError}.
|
|
414
|
+
*
|
|
415
|
+
* @param target which partition — a {@link Scope}, `'self'` or `'site'`
|
|
416
|
+
* @param key the document key; must match {@link DOC_KEY_PATTERN}
|
|
417
|
+
* @throws Error synchronously-thrown-as-rejection if the key is malformed
|
|
418
|
+
*/
|
|
419
|
+
get<T = unknown>(target: DocTarget, key: string): Promise<T | null>;
|
|
420
|
+
/**
|
|
421
|
+
* Upserts a document. Last-write-wins, as everywhere on this store.
|
|
422
|
+
*
|
|
423
|
+
* @param target which partition
|
|
424
|
+
* @param key the document key; must match {@link DOC_KEY_PATTERN}
|
|
425
|
+
* @param value the JSON value to store
|
|
426
|
+
*/
|
|
427
|
+
put<T = unknown>(target: DocTarget, key: string, value: T): Promise<void>;
|
|
428
|
+
/**
|
|
429
|
+
* One page of documents in a partition, each carrying its key.
|
|
430
|
+
*
|
|
431
|
+
* @param target which partition
|
|
432
|
+
* @param opts `prefix` filters by key prefix; `page` is zero-based and `size` defaults to 50 (the
|
|
433
|
+
* host caps it at 200)
|
|
434
|
+
*/
|
|
435
|
+
list<T = unknown>(target: DocTarget, opts?: {
|
|
436
|
+
prefix?: string;
|
|
437
|
+
page?: number;
|
|
438
|
+
size?: number;
|
|
439
|
+
}): Promise<PagedDocs<T>>;
|
|
440
|
+
/**
|
|
441
|
+
* Removes a document. Idempotent — removing what is already gone resolves.
|
|
442
|
+
*
|
|
443
|
+
* @param target which partition
|
|
444
|
+
* @param key the document key; must match {@link DOC_KEY_PATTERN}
|
|
445
|
+
*/
|
|
446
|
+
remove(target: DocTarget, key: string): Promise<void>;
|
|
447
|
+
}
|
|
271
448
|
/**
|
|
272
449
|
* How a declared field is compared to a value in a {@link SchemaQuery}.
|
|
273
450
|
*
|
|
@@ -447,6 +624,257 @@ export interface SchemaClient {
|
|
|
447
624
|
*/
|
|
448
625
|
count(entity: string, query?: Pick<SchemaQuery, 'where'>): Promise<number>;
|
|
449
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* One stored file (ARCHITECTURE §11), as the host describes it.
|
|
629
|
+
*
|
|
630
|
+
* `ref` is the file's whole identity — opaque, host-assigned. Store *that*, never the URL: the URL is
|
|
631
|
+
* derived from it and the host is entitled to change how, while the ref stays true.
|
|
632
|
+
*
|
|
633
|
+
* `mime` is what the host determined the file to be from its bytes, not what was claimed on upload. The
|
|
634
|
+
* two differ exactly when someone lied, which is why this is the value worth keeping.
|
|
635
|
+
*
|
|
636
|
+
* @since 0.8.0
|
|
637
|
+
*/
|
|
638
|
+
export interface BlobInfo {
|
|
639
|
+
/** The host-assigned identifier; opaque, stable, never reused. Do not parse or construct one. */
|
|
640
|
+
ref: string;
|
|
641
|
+
/** The original filename, for display; `null` when none was supplied. Never a path the host resolves. */
|
|
642
|
+
filename: string | null;
|
|
643
|
+
/** The content type the host determined from the bytes. */
|
|
644
|
+
mime: string;
|
|
645
|
+
/** Size in bytes. */
|
|
646
|
+
size: number;
|
|
647
|
+
/** When the file was stored, as an ISO-8601 instant. */
|
|
648
|
+
updatedAt: string;
|
|
649
|
+
}
|
|
650
|
+
/** One page of {@link BlobInfo}, in the host's usual paging shape. @since 0.8.0 */
|
|
651
|
+
export interface BlobPage {
|
|
652
|
+
items: BlobInfo[];
|
|
653
|
+
page: number;
|
|
654
|
+
size: number;
|
|
655
|
+
total: number;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* How much room this plugin has for files, as the host currently sees it. @since 0.8.0
|
|
659
|
+
*
|
|
660
|
+
* These are the **effective** numbers, not what the manifest asked for: an operator caps both, so a plugin
|
|
661
|
+
* that declared more than the install allows gets the install's answer. Worth reading before letting
|
|
662
|
+
* someone pick a file — telling them up front beats a refusal after the upload.
|
|
663
|
+
*/
|
|
664
|
+
export interface BlobQuota {
|
|
665
|
+
usedBytes: number;
|
|
666
|
+
quotaBytes: number;
|
|
667
|
+
maxFileBytes: number;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* File storage for a plugin that declares a `blobs` block in its manifest (ARCHITECTURE §11) — **`null`**
|
|
671
|
+
* on `ctx` when it does not, exactly like {@link SchemaClient}.
|
|
672
|
+
*
|
|
673
|
+
* Unlike the schema surface, **writes are the point here**. A schema write has relational invariants a
|
|
674
|
+
* client cannot be trusted with; a file has none, so the manifest's `data.writableBy` floor plus a quota
|
|
675
|
+
* is the whole authorization story, and a plugin's own editing UI can upload directly.
|
|
676
|
+
*
|
|
677
|
+
* ```ts
|
|
678
|
+
* const blobs = ctx.blobs;
|
|
679
|
+
* if (!blobs) return; // this plugin declared no `blobs` block
|
|
680
|
+
*
|
|
681
|
+
* const stored = await blobs.upload(file);
|
|
682
|
+
* img.src = blobs.urlFor(stored.ref);
|
|
683
|
+
* await ctx.api.put(`data/site/main/logo`, { ref: stored.ref }); // keep the ref, not the URL
|
|
684
|
+
* ```
|
|
685
|
+
*
|
|
686
|
+
* The host checks the size against your effective ceiling, the type against the effective allow-list, and
|
|
687
|
+
* then the *actual* type read from the leading bytes — so a file whose content contradicts its extension is
|
|
688
|
+
* refused, and SVG is never accepted at all. A refusal rejects the promise; show it, do not swallow it,
|
|
689
|
+
* because the person who picked the file is the only one who can pick a different one.
|
|
690
|
+
*
|
|
691
|
+
* Nothing collects orphans: a file outlives the document that referred to it, and only your plugin knows
|
|
692
|
+
* which those are. Delete what you stop pointing at, or clean up from the backend on a schedule.
|
|
693
|
+
*
|
|
694
|
+
* @since 0.8.0
|
|
695
|
+
*/
|
|
696
|
+
export interface BlobClient {
|
|
697
|
+
/**
|
|
698
|
+
* Stores a file.
|
|
699
|
+
*
|
|
700
|
+
* The declared content type is **normalised by default** ({@link declaredTypeFor}), because the
|
|
701
|
+
* browser's own answer is not reliable across engines — see that function for the failure it fixes.
|
|
702
|
+
* Pass `declaredType: 'preserve'` to send `file.type` exactly as the browser reported it.
|
|
703
|
+
*
|
|
704
|
+
* @param file the file or blob to store — a `File` from an `<input type="file">` carries its own name
|
|
705
|
+
* @param opts `filename` overrides the name and supplies one for a bare `Blob`; `declaredType`
|
|
706
|
+
* chooses between the normalised claim (default) and the browser's raw one
|
|
707
|
+
* @returns the stored file's identity, carrying the host-determined MIME type
|
|
708
|
+
*/
|
|
709
|
+
upload(file: File | Blob, opts?: {
|
|
710
|
+
filename?: string;
|
|
711
|
+
declaredType?: 'normalize' | 'preserve';
|
|
712
|
+
}): Promise<BlobInfo>;
|
|
713
|
+
/**
|
|
714
|
+
* Lists this plugin's files, newest first.
|
|
715
|
+
*
|
|
716
|
+
* @param opts `page` from 0 and `size` (the host caps it)
|
|
717
|
+
*/
|
|
718
|
+
list(opts?: {
|
|
719
|
+
page?: number;
|
|
720
|
+
size?: number;
|
|
721
|
+
}): Promise<BlobPage>;
|
|
722
|
+
/**
|
|
723
|
+
* Deletes a file. Idempotent — removing what is already gone resolves rather than rejecting.
|
|
724
|
+
*
|
|
725
|
+
* @param ref the identifier from {@link upload}
|
|
726
|
+
*/
|
|
727
|
+
remove(ref: string): Promise<void>;
|
|
728
|
+
/**
|
|
729
|
+
* The URL to load this file from — host-served, same origin, so no CSP host and no consent decision.
|
|
730
|
+
*
|
|
731
|
+
* Derive it at render time; do not store it.
|
|
732
|
+
*
|
|
733
|
+
* @param ref the identifier from {@link upload}
|
|
734
|
+
*/
|
|
735
|
+
urlFor(ref: string): string;
|
|
736
|
+
/** How much room is left, as the host currently sees it. */
|
|
737
|
+
quota(): Promise<BlobQuota>;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* The content type to claim for a file, restoring the one the browser was supposed to report.
|
|
741
|
+
*
|
|
742
|
+
* ## The bug this exists for
|
|
743
|
+
*
|
|
744
|
+
* Chromium fills `File.type` from its own built-in extension table. **Firefox asks the operating
|
|
745
|
+
* system's MIME database**, and where that lookup fails — a sparse `shared-mime-info` on Linux, a missing
|
|
746
|
+
* or hijacked registry association on Windows — it hands over `File.type === ''`. `FormData` then sends
|
|
747
|
+
* the part as `application/octet-stream`, and the host refuses on the *declared* type before it ever
|
|
748
|
+
* sniffs the bytes (§11.1 checks size, declared type, actual type, quota — in that order). The result is
|
|
749
|
+
* a valid PNG rejected as "not a type this plugin may store", **in one browser only**, on a machine the
|
|
750
|
+
* plugin author cannot reproduce.
|
|
751
|
+
*
|
|
752
|
+
* ## Why guessing is safe here
|
|
753
|
+
*
|
|
754
|
+
* Specifically because the host still reads the leading bytes. A wrong guess becomes the same 415 it
|
|
755
|
+
* would have been, never a stored file of the wrong kind — this restores a claim, it does not grant
|
|
756
|
+
* anything. An extension nothing maps stays untouched, so the refusal still arrives in the host's own
|
|
757
|
+
* wording rather than one the SDK invented.
|
|
758
|
+
*
|
|
759
|
+
* {@link BlobClient.upload} calls this by default; use it directly only to show someone what will be
|
|
760
|
+
* sent before they commit to the upload.
|
|
761
|
+
*
|
|
762
|
+
* @param file the file or blob about to be uploaded
|
|
763
|
+
* @returns the browser's `type` when it gave one, else the type its extension implies, else `''`
|
|
764
|
+
* @since 0.9.0
|
|
765
|
+
*/
|
|
766
|
+
export declare function declaredTypeFor(file: File | Blob): string;
|
|
767
|
+
/**
|
|
768
|
+
* The host icon names known when this SDK was published — **a hint, never a limit**.
|
|
769
|
+
*
|
|
770
|
+
* The `(string & {})` arm keeps the set open: autocomplete offers these, and any other string is still
|
|
771
|
+
* accepted without a type error. Closing this union would pin the icon set to the SDK version and undo
|
|
772
|
+
* the exact property the design protects (see {@link iconCss}).
|
|
773
|
+
*
|
|
774
|
+
* @since 0.9.0
|
|
775
|
+
*/
|
|
776
|
+
export type KnownIconName = 'star' | 'clock' | 'search' | 'play' | 'pause' | 'close' | 'check' | 'chevron-left' | 'chevron-right' | 'external' | 'info' | 'warning' | (string & {});
|
|
777
|
+
/**
|
|
778
|
+
* A `mask-image` value for one host icon, with the fallback that keeps a missing icon invisible.
|
|
779
|
+
*
|
|
780
|
+
* ## The failure mode this prevents
|
|
781
|
+
*
|
|
782
|
+
* An unresolved `var()` makes the whole declaration invalid at computed-value time, so `mask-image`
|
|
783
|
+
* falls back to its *initial* value — `none` — leaving an unmasked element painting `currentColor`
|
|
784
|
+
* across its entire box. **A missing icon renders as a solid square, not as blank space.** Writing
|
|
785
|
+
* `mask-image: none` as the fallback is the obvious guess and produces exactly that; the fallback has to
|
|
786
|
+
* be a real, blank image, which is what this returns.
|
|
787
|
+
*
|
|
788
|
+
* @param name the icon name; must be a plain CSS identifier (`chevron-left`), since it is spliced into a
|
|
789
|
+
* custom-property name
|
|
790
|
+
* @returns `var(--mc-icon-<name>, <blank svg>)`, ready to assign to `mask-image`
|
|
791
|
+
* @throws Error if the name is not a plain CSS identifier
|
|
792
|
+
* @since 0.9.0
|
|
793
|
+
*/
|
|
794
|
+
export declare function iconMask(name: KnownIconName): string;
|
|
795
|
+
/**
|
|
796
|
+
* A block of CSS rules for the host icons you name, to concatenate into your component's own `<style>`.
|
|
797
|
+
*
|
|
798
|
+
* ## Why this is a string helper and not something on `ctx`
|
|
799
|
+
*
|
|
800
|
+
* `--mc-icon-*` is deliberately not on the context (§12.3), and that decision is right: custom
|
|
801
|
+
* properties inherit through the shadow boundary, so a plugin built against **this** SDK version picks
|
|
802
|
+
* up an icon the day core publishes it — no SDK release, no manifest bump, no version skew. Putting the
|
|
803
|
+
* icon set on `ctx` would trade that away. So does typing the names as a closed union, which is why
|
|
804
|
+
* {@link KnownIconName} stays open.
|
|
805
|
+
*
|
|
806
|
+
* What was worth owning here is the *mechanics*, which are subtle enough to get wrong three ways:
|
|
807
|
+
*
|
|
808
|
+
* 1. **Mask, never `background-image`.** `background: currentColor` behind a mask is what makes an icon
|
|
809
|
+
* re-theme with the text beside it.
|
|
810
|
+
* 2. **Every reference needs a blank-image fallback** — see {@link iconMask}, where a missing icon
|
|
811
|
+
* otherwise paints a solid square.
|
|
812
|
+
* 3. **Don't declare into `--mc-*` yourself.** Those are the host's namespace.
|
|
813
|
+
*
|
|
814
|
+
* ## Put it in the shadow root
|
|
815
|
+
*
|
|
816
|
+
* The returned string belongs in your component's own `<style>` element. A bundled `.css` file lands in
|
|
817
|
+
* the host document, where it **cannot reach any shadow root** — that is the other thing everyone tries
|
|
818
|
+
* first, and it silently does nothing.
|
|
819
|
+
*
|
|
820
|
+
* ```ts
|
|
821
|
+
* const style = document.createElement('style');
|
|
822
|
+
* style.textContent = iconCss(['star', 'clock']);
|
|
823
|
+
* root.append(style);
|
|
824
|
+
* root.insertAdjacentHTML('beforeend', '<i class="mc-icon mc-icon-star" aria-hidden="true"></i>');
|
|
825
|
+
* ```
|
|
826
|
+
*
|
|
827
|
+
* @param names the icons to emit rules for
|
|
828
|
+
* @param opts `className` renames the base class (default `mc-icon`); each icon gets
|
|
829
|
+
* `<className>-<name>`
|
|
830
|
+
* @returns the CSS block — a base rule plus one rule per icon
|
|
831
|
+
* @since 0.9.0
|
|
832
|
+
*/
|
|
833
|
+
export declare function iconCss(names: readonly KnownIconName[], opts?: {
|
|
834
|
+
className?: string;
|
|
835
|
+
}): string;
|
|
836
|
+
/**
|
|
837
|
+
* Builders for links to the host's own pages (ARCHITECTURE §6.4). @since 0.8.0
|
|
838
|
+
*
|
|
839
|
+
* **Strings only.** Nothing here navigates, and nothing here is a capability: a plugin can already put any
|
|
840
|
+
* `href` in its own markup. What it could not do was know the *shape* of a core URL without hardcoding it,
|
|
841
|
+
* so every plugin that wanted to link to an episode wrote `` `/episodes/${slug}` `` and became a thing that
|
|
842
|
+
* breaks when the host changes a route. This moves that knowledge back to the host.
|
|
843
|
+
*
|
|
844
|
+
* It is deliberately not part of {@link PluginRoute}, which is namespace-confined by construction —
|
|
845
|
+
* `navigate` cannot name a core route and that is a property worth keeping. Producing a link is not
|
|
846
|
+
* navigating: the visitor still clicks, and a real `href` is what middle-click, "open in new tab" and
|
|
847
|
+
* crawlers need.
|
|
848
|
+
*
|
|
849
|
+
* ```ts
|
|
850
|
+
* // "…discussed in The Kraken, from 12:04"
|
|
851
|
+
* const href = ctx.links.episode('kraken', { t: 724 });
|
|
852
|
+
* ```
|
|
853
|
+
*/
|
|
854
|
+
export interface PluginLinks {
|
|
855
|
+
/**
|
|
856
|
+
* A link to an episode's detail page, optionally at a position inside it.
|
|
857
|
+
*
|
|
858
|
+
* @param slug the episode's public slug — the one in `ctx.episodes`
|
|
859
|
+
* @param opts `t` is a start position in seconds; a negative or non-finite value is ignored
|
|
860
|
+
* @returns a root-relative URL
|
|
861
|
+
*/
|
|
862
|
+
episode(slug: string, opts?: {
|
|
863
|
+
t?: number;
|
|
864
|
+
}): string;
|
|
865
|
+
/**
|
|
866
|
+
* A link to a feed tab, optionally filtered.
|
|
867
|
+
*
|
|
868
|
+
* @param slug the feed's public slug
|
|
869
|
+
* @param opts the host's filter axes (§6.1); omitted or default values are left out of the URL
|
|
870
|
+
* @returns a root-relative URL
|
|
871
|
+
*/
|
|
872
|
+
feed(slug: string, opts?: {
|
|
873
|
+
season?: string;
|
|
874
|
+
tag?: string;
|
|
875
|
+
order?: 'newest' | 'oldest';
|
|
876
|
+
}): string;
|
|
877
|
+
}
|
|
450
878
|
/**
|
|
451
879
|
* The plugin's own URL subtree (ARCHITECTURE §6.4): where it is, when that changes, and how to move
|
|
452
880
|
* within it.
|
|
@@ -458,8 +886,31 @@ export interface SchemaClient {
|
|
|
458
886
|
* @since 0.7.0 — `navigate`; `path` and `onChange` have been here since 0.1.0
|
|
459
887
|
*/
|
|
460
888
|
export interface PluginRoute {
|
|
461
|
-
/**
|
|
889
|
+
/**
|
|
890
|
+
* The subpath below `/p/<pluginId>/`; empty when the plugin is not rendered as a page.
|
|
891
|
+
*
|
|
892
|
+
* The path only — the query string and fragment are {@link query} and {@link hash}.
|
|
893
|
+
*/
|
|
462
894
|
path: string;
|
|
895
|
+
/**
|
|
896
|
+
* The current subtree's query string, parsed by the host.
|
|
897
|
+
*
|
|
898
|
+
* {@link navigate} has always accepted a `?query`, and until 0.9.0 there was no way to read back what
|
|
899
|
+
* it wrote: the only route was `location.search`, which is exactly the "do not reach past this handle"
|
|
900
|
+
* that `navigate` forbids, for the reason it gives. Filters, sort order and pagination are the obvious
|
|
901
|
+
* shareable-link state, so a plugin either invented path segments for them or broke the rule.
|
|
902
|
+
*
|
|
903
|
+
* Treat it as read-only — mutating it changes nothing. Write with `navigate('page?sort=new')`.
|
|
904
|
+
*
|
|
905
|
+
* @since 0.9.0
|
|
906
|
+
*/
|
|
907
|
+
readonly query: URLSearchParams;
|
|
908
|
+
/**
|
|
909
|
+
* The current fragment, **without** the leading `#`; empty when there is none.
|
|
910
|
+
*
|
|
911
|
+
* @since 0.9.0
|
|
912
|
+
*/
|
|
913
|
+
readonly hash: string;
|
|
463
914
|
/**
|
|
464
915
|
* Subscribes to subpath changes — a back button, a shared link, your own {@link navigate}.
|
|
465
916
|
*
|
|
@@ -490,6 +941,49 @@ export interface PluginRoute {
|
|
|
490
941
|
replace?: boolean;
|
|
491
942
|
}): void;
|
|
492
943
|
}
|
|
944
|
+
/**
|
|
945
|
+
* What {@link matchRoute} returns when a pattern matched.
|
|
946
|
+
*
|
|
947
|
+
* @typeParam P the literal pattern type, so `match.pattern` narrows against the array you passed
|
|
948
|
+
* @since 0.9.0
|
|
949
|
+
*/
|
|
950
|
+
export interface RouteMatch<P extends string = string> {
|
|
951
|
+
/** The pattern that matched, verbatim from the list you gave. */
|
|
952
|
+
pattern: P;
|
|
953
|
+
/** The `:name` segments, keyed by name and already `decodeURIComponent`-ed. */
|
|
954
|
+
params: Record<string, string>;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Matches a {@link PluginRoute.path} against a list of patterns, first match wins.
|
|
958
|
+
*
|
|
959
|
+
* Every page plugin hand-rolls this — a prefix check for `highlight/<slug>`, an `else` for the index —
|
|
960
|
+
* and the hand-rolled version is usually a `startsWith`, which matches `highlights-archive` too.
|
|
961
|
+
*
|
|
962
|
+
* A pattern is segments separated by `/`. A segment beginning with `:` captures one **non-empty**
|
|
963
|
+
* segment into {@link RouteMatch.params}; every other segment must match literally. The whole path must
|
|
964
|
+
* be consumed, so `moments` does not match `moments/3`. The empty pattern `''` is the plugin root.
|
|
965
|
+
*
|
|
966
|
+
* Leading and trailing slashes are ignored, and anything from a `?` or `#` is dropped — so passing a raw
|
|
967
|
+
* subpath works even where the host has not split it for you.
|
|
968
|
+
*
|
|
969
|
+
* **Order is yours**: list the specific pattern before the general one, since the first match wins.
|
|
970
|
+
*
|
|
971
|
+
* ```ts
|
|
972
|
+
* const match = matchRoute(ctx.route.path, ['', 'moments', 'highlight/:slug'] as const);
|
|
973
|
+
* switch (match?.pattern) {
|
|
974
|
+
* case 'highlight/:slug': return renderDetail(match.params.slug);
|
|
975
|
+
* case 'moments': return renderMoments();
|
|
976
|
+
* case '': return renderIndex();
|
|
977
|
+
* default: return renderNotFound(); // null: nothing matched
|
|
978
|
+
* }
|
|
979
|
+
* ```
|
|
980
|
+
*
|
|
981
|
+
* @param path the subpath to match, typically `ctx.route.path`
|
|
982
|
+
* @param patterns the patterns to try, in priority order
|
|
983
|
+
* @returns the first match, or **`null`** when none matched — which is your not-found branch
|
|
984
|
+
* @since 0.9.0
|
|
985
|
+
*/
|
|
986
|
+
export declare function matchRoute<P extends string>(path: string, patterns: readonly P[]): RouteMatch<P> | null;
|
|
493
987
|
/**
|
|
494
988
|
* The presentation layer of an episode — the feed-derived snapshot the core UI shows (ARCHITECTURE §4.2),
|
|
495
989
|
* mirrored from the Java `dev.mosaicast.plugin.api.DisplaySnapshot` record.
|
|
@@ -529,6 +1023,182 @@ export interface DisplaySnapshot {
|
|
|
529
1023
|
* @returns the resolved artwork URL, or `undefined` when neither the episode nor the feed declares one
|
|
530
1024
|
*/
|
|
531
1025
|
export declare function resolveArtwork(snapshot: DisplaySnapshot): string | undefined;
|
|
1026
|
+
/**
|
|
1027
|
+
* The most slugs {@link FeedsClient.displayMany} will resolve in one call.
|
|
1028
|
+
*
|
|
1029
|
+
* Matching the host's existing clamp on its scope-episodes endpoint. Asking for more is not an error —
|
|
1030
|
+
* the extras are simply not in the answer, so check what came back rather than assuming.
|
|
1031
|
+
*
|
|
1032
|
+
* @since 0.9.0
|
|
1033
|
+
*/
|
|
1034
|
+
export declare const DISPLAY_BATCH_LIMIT = 200;
|
|
1035
|
+
/**
|
|
1036
|
+
* Read access to episode display snapshots — the frontend half of the Java `FeedAccess`.
|
|
1037
|
+
*
|
|
1038
|
+
* The Java contract could read a snapshot and the frontend could not, so a plugin that wanted to draw an
|
|
1039
|
+
* episode card copied the host's own data into its doc store and kept it fresh on a schedule. That cost
|
|
1040
|
+
* a backend, a scheduled ingest, a `backendOwned` key and a copy that is stale between runs — for fields
|
|
1041
|
+
* the host already has. `DisplaySnapshot` and {@link resolveArtwork} have shipped since 0.1 with nothing
|
|
1042
|
+
* in the contract that hands a frontend one; this is what they were for.
|
|
1043
|
+
*
|
|
1044
|
+
* ## What you get, and what you don't
|
|
1045
|
+
*
|
|
1046
|
+
* **The host filters, the plugin consumes** (ARCHITECTURE §7). The answer contains only what the caller
|
|
1047
|
+
* may see, so a `WITHDRAWN` or tier-gated episode is **absent** rather than redacted — and this cannot
|
|
1048
|
+
* be used to enumerate episodes {@link PluginContext.episodes} did not already give you.
|
|
1049
|
+
*
|
|
1050
|
+
* Unlike the doc store this has **no `readableBy` gate of its own**: it returns host data the same
|
|
1051
|
+
* visitor can already read from `/api/episodes/*`. It exists so a plugin need not know that URL shape —
|
|
1052
|
+
* the same argument {@link PluginLinks} makes.
|
|
1053
|
+
*
|
|
1054
|
+
* **Not authoritative, and it should keep saying so.** The snapshot is overwritten on every feed
|
|
1055
|
+
* refetch. That is a feature — a feed edit propagates — and the reason to read it live rather than copy
|
|
1056
|
+
* it. Cache per render, never per install.
|
|
1057
|
+
*
|
|
1058
|
+
* ```ts
|
|
1059
|
+
* const cards = await ctx.feeds.displayMany(ctx.episodes.slice(0, 20));
|
|
1060
|
+
* for (const slug of ctx.episodes) {
|
|
1061
|
+
* const snap = cards[slug];
|
|
1062
|
+
* if (!snap) continue; // filtered out for this visitor — not an error
|
|
1063
|
+
* render(slug, snap.title, resolveArtwork(snap));
|
|
1064
|
+
* }
|
|
1065
|
+
* ```
|
|
1066
|
+
*
|
|
1067
|
+
* @since 0.9.0
|
|
1068
|
+
*/
|
|
1069
|
+
export interface FeedsClient {
|
|
1070
|
+
/**
|
|
1071
|
+
* The display snapshot for one episode.
|
|
1072
|
+
*
|
|
1073
|
+
* @param slug the episode's public slug — one of {@link PluginContext.episodes}
|
|
1074
|
+
* @returns the snapshot, or **`null`** when the host has none or the caller may not see it. The two
|
|
1075
|
+
* are deliberately indistinguishable: telling them apart would confirm the existence of an
|
|
1076
|
+
* episode this visitor was not shown
|
|
1077
|
+
*/
|
|
1078
|
+
display(slug: string): Promise<DisplaySnapshot | null>;
|
|
1079
|
+
/**
|
|
1080
|
+
* The same, for many slugs in one request.
|
|
1081
|
+
*
|
|
1082
|
+
* Use this whenever you are drawing more than one card — the N-request version is the thing this
|
|
1083
|
+
* surface exists to prevent.
|
|
1084
|
+
*
|
|
1085
|
+
* @param slugs the episode slugs; more than {@link DISPLAY_BATCH_LIMIT} is **clamped**, not rejected
|
|
1086
|
+
* @returns a map from slug to snapshot, containing only the episodes the caller may see — so a missing
|
|
1087
|
+
* key is normal and must not be treated as a failure
|
|
1088
|
+
*/
|
|
1089
|
+
displayMany(slugs: string[]): Promise<Record<string, DisplaySnapshot>>;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* One entry of the site's shared tag vocabulary — mirror of the Java `TagInfo` record.
|
|
1093
|
+
*
|
|
1094
|
+
* `tag` is the host's **canonical key** (trim, collapse internal whitespace, casefold), applied on every
|
|
1095
|
+
* path into the vocabulary including feed ingest; `label` is presentation, kept from first use. Send any
|
|
1096
|
+
* spelling, store and compare on the key, show the label.
|
|
1097
|
+
*
|
|
1098
|
+
* The two counts are scoped differently on purpose: `episodes` is site-wide, `subjects` counts only
|
|
1099
|
+
* **your own** plugin's subjects — you cannot see the size of a store you cannot read.
|
|
1100
|
+
*
|
|
1101
|
+
* @since 0.9.0
|
|
1102
|
+
*/
|
|
1103
|
+
export interface TagInfo {
|
|
1104
|
+
/** The canonical key. */
|
|
1105
|
+
tag: string;
|
|
1106
|
+
/** The display label the host kept from first use. */
|
|
1107
|
+
label: string;
|
|
1108
|
+
/** How many episodes carry this tag, across every source. */
|
|
1109
|
+
episodes: number;
|
|
1110
|
+
/** How many of *this plugin's* subjects carry it. */
|
|
1111
|
+
subjects: number;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* The site's shared tag vocabulary, and this plugin's assignments against it — the frontend half of the
|
|
1115
|
+
* Java `Tags` (ARCHITECTURE §6.1).
|
|
1116
|
+
*
|
|
1117
|
+
* `ctx.tags` is **`null`** unless the manifest declares a `tags` block, mirroring `ctx.schema` and
|
|
1118
|
+
* `ctx.blobs`. Tags existed in core only as a feed-derived filter axis with no vocabulary and no plugin
|
|
1119
|
+
* surface, so every plugin that wanted them grew a private free-text column — and a wiki's `lore` and an
|
|
1120
|
+
* episode's `lore` were unrelated strings that could not be linked, suggested or counted together.
|
|
1121
|
+
*
|
|
1122
|
+
* ```ts
|
|
1123
|
+
* const tags = ctx.tags;
|
|
1124
|
+
* if (!tags) return; // no `tags` block in this plugin's manifest
|
|
1125
|
+
*
|
|
1126
|
+
* for (const t of await tags.all()) suggest(t.label, t.tag); // the site's real vocabulary
|
|
1127
|
+
* const href = ctx.links.feed('the-sample-cast', { tag: 'kraken' }); // and it links to the feed view
|
|
1128
|
+
* ```
|
|
1129
|
+
*
|
|
1130
|
+
* ## Two writes that look alike and are not
|
|
1131
|
+
*
|
|
1132
|
+
* {@link tagSubject} touches only keys you invented in your own namespace; `data.writableBy` is the
|
|
1133
|
+
* whole authorization story. {@link tagEpisode} changes the shell's filter options **and** what core
|
|
1134
|
+
* recommends beside that episode, so it needs `"tags": { "writesEpisodes": true }` in the manifest and
|
|
1135
|
+
* rejects without it.
|
|
1136
|
+
*
|
|
1137
|
+
* ## What no plugin may do
|
|
1138
|
+
*
|
|
1139
|
+
* Delete a tag from the vocabulary (it is shared), rename one (that is a vocabulary-wide edit, and
|
|
1140
|
+
* belongs in admin), or remove another writer's assignment — the feed's included. Every write of yours
|
|
1141
|
+
* is recorded with your plugin as its source, which is what makes the last one enforceable rather than
|
|
1142
|
+
* merely discouraged.
|
|
1143
|
+
*
|
|
1144
|
+
* @since 0.9.0
|
|
1145
|
+
*/
|
|
1146
|
+
export interface TagsClient {
|
|
1147
|
+
/** The whole site vocabulary, most used first. */
|
|
1148
|
+
all(): Promise<TagInfo[]>;
|
|
1149
|
+
/**
|
|
1150
|
+
* The episode slugs carrying a tag, filtered to what this visitor may see.
|
|
1151
|
+
*
|
|
1152
|
+
* @param tag any spelling; the host canonicalises it. An unknown tag resolves to an empty list
|
|
1153
|
+
*/
|
|
1154
|
+
episodesWith(tag: string): Promise<string[]>;
|
|
1155
|
+
/**
|
|
1156
|
+
* The canonical keys on one episode, whatever put them there.
|
|
1157
|
+
*
|
|
1158
|
+
* @param episodeSlug the episode's public slug
|
|
1159
|
+
*/
|
|
1160
|
+
tagsOn(episodeSlug: string): Promise<string[]>;
|
|
1161
|
+
/**
|
|
1162
|
+
* Tags that tend to appear alongside this one, best first — "what else on this site is about this".
|
|
1163
|
+
*
|
|
1164
|
+
* The ranking is the host's and is **not** part of the contract: treat the order as advice, and do not
|
|
1165
|
+
* display or compare the counts as a similarity score.
|
|
1166
|
+
*
|
|
1167
|
+
* @param tag any spelling of the tag
|
|
1168
|
+
* @param limit the most entries to return; the host clamps rather than failing
|
|
1169
|
+
*/
|
|
1170
|
+
similarTo(tag: string, limit?: number): Promise<TagInfo[]>;
|
|
1171
|
+
/** This plugin's own subject keys carrying a tag. */
|
|
1172
|
+
subjectsWith(tag: string): Promise<string[]>;
|
|
1173
|
+
/** The canonical keys on one of this plugin's subjects. */
|
|
1174
|
+
tagsOnSubject(subjectKey: string): Promise<string[]>;
|
|
1175
|
+
/**
|
|
1176
|
+
* Tags one of this plugin's own subjects, adding the tag to the vocabulary if it is new. Idempotent.
|
|
1177
|
+
*
|
|
1178
|
+
* `subjectKey` is opaque and yours to invent — the same namespacing property `ctx.schema` has for
|
|
1179
|
+
* tables and `ctx.route.navigate` has for URLs. Use the same key a `SearchProvider` hit resolves to,
|
|
1180
|
+
* so a tag and a search result name one object rather than two.
|
|
1181
|
+
*
|
|
1182
|
+
* @param subjectKey the subject in your namespace
|
|
1183
|
+
* @param tag any spelling; your spelling becomes the display label if the tag is new
|
|
1184
|
+
*/
|
|
1185
|
+
tagSubject(subjectKey: string, tag: string): Promise<void>;
|
|
1186
|
+
/** Removes a tag from one of this plugin's subjects. Idempotent; never removes the tag itself. */
|
|
1187
|
+
untagSubject(subjectKey: string, tag: string): Promise<void>;
|
|
1188
|
+
/**
|
|
1189
|
+
* Tags an episode — **a capability, not a convenience**.
|
|
1190
|
+
*
|
|
1191
|
+
* Needs `tags.writesEpisodes` in the manifest; without it the host answers 403. Additive: an episode
|
|
1192
|
+
* already carrying the tag from its feed keeps the feed's assignment and gains yours.
|
|
1193
|
+
*/
|
|
1194
|
+
tagEpisode(episodeSlug: string, tag: string): Promise<void>;
|
|
1195
|
+
/**
|
|
1196
|
+
* Removes **this plugin's** assignment from an episode, and only that one.
|
|
1197
|
+
*
|
|
1198
|
+
* If the feed or a podcaster also put this tag there, the episode keeps carrying it.
|
|
1199
|
+
*/
|
|
1200
|
+
untagEpisode(episodeSlug: string, tag: string): Promise<void>;
|
|
1201
|
+
}
|
|
532
1202
|
/**
|
|
533
1203
|
* The consent gate for anything that stores data on the visitor's device or talks to a third party
|
|
534
1204
|
* (ARCHITECTURE §12.5).
|
|
@@ -778,6 +1448,203 @@ export interface ConsentServiceDeclaration {
|
|
|
778
1448
|
/** Each item the service stores on the visitor's device. */
|
|
779
1449
|
storage: ConsentStorageDeclaration[];
|
|
780
1450
|
}
|
|
1451
|
+
/**
|
|
1452
|
+
* The shape of your manifest's `tags` block — whether this plugin reads the site vocabulary, and whether
|
|
1453
|
+
* it may tag **episodes**.
|
|
1454
|
+
*
|
|
1455
|
+
* Documentation, not enforcement, on the same terms as {@link PluginDataDeclaration}. Typed because the
|
|
1456
|
+
* second flag is a capability: a plugin that tags episodes changes the shell's filter options and what
|
|
1457
|
+
* core recommends, so what it may do should be readable off its manifest.
|
|
1458
|
+
*
|
|
1459
|
+
* ```ts
|
|
1460
|
+
* const tags: PluginTagsDeclaration = { readsVocabulary: true, writesEpisodes: false };
|
|
1461
|
+
* ```
|
|
1462
|
+
*
|
|
1463
|
+
* @since 0.9.0
|
|
1464
|
+
*/
|
|
1465
|
+
export interface PluginTagsDeclaration {
|
|
1466
|
+
/**
|
|
1467
|
+
* Whether this plugin may read the vocabulary and tag its own subjects.
|
|
1468
|
+
*
|
|
1469
|
+
* Declaring the block at all is what makes `ctx.tags` non-`null`; this is the read-and-own-subjects
|
|
1470
|
+
* half, and `data.writableBy` governs the writes.
|
|
1471
|
+
*/
|
|
1472
|
+
readsVocabulary?: boolean;
|
|
1473
|
+
/**
|
|
1474
|
+
* Whether this plugin may tag and untag **episodes**.
|
|
1475
|
+
*
|
|
1476
|
+
* Off by default. Without it `tagEpisode` / `untagEpisode` are refused — the host does not silently
|
|
1477
|
+
* drop the write. Ask for it only if the plugin genuinely classifies episodes.
|
|
1478
|
+
*/
|
|
1479
|
+
writesEpisodes?: boolean;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* One entry of `slots[]`: which component the host mounts, where, and for whom (ARCHITECTURE §7.3).
|
|
1483
|
+
*
|
|
1484
|
+
* @since 0.9.0
|
|
1485
|
+
*/
|
|
1486
|
+
export interface PluginSlotDeclaration {
|
|
1487
|
+
/** The scope level the slot appears on. */
|
|
1488
|
+
scope: Scope['type'];
|
|
1489
|
+
/** The custom-element tag to mount — must also appear in `frontend.elements`. */
|
|
1490
|
+
element: string;
|
|
1491
|
+
/** The named region of the host shell (`main`, `sidebar`, `card`, `admin`, `feed`, `site`, …). */
|
|
1492
|
+
placement: string;
|
|
1493
|
+
/** The minimum role that sees it. **Rendering only** — it never governs data access. */
|
|
1494
|
+
visibleTo?: DataAccessRole;
|
|
1495
|
+
/** Sort order within a region; ties break on plugin id. */
|
|
1496
|
+
order?: number;
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* One entry of `nav[]`: an entrance to this plugin in the host's menu.
|
|
1500
|
+
*
|
|
1501
|
+
* @since 0.9.0
|
|
1502
|
+
*/
|
|
1503
|
+
export interface PluginNavDeclaration {
|
|
1504
|
+
/** The subpath below `/p/<pluginId>/` this entry opens. */
|
|
1505
|
+
path: string;
|
|
1506
|
+
/**
|
|
1507
|
+
* The menu label.
|
|
1508
|
+
*
|
|
1509
|
+
* **Not translatable**, and that is a real constraint rather than an oversight: core has no plugin
|
|
1510
|
+
* catalogs, so it cannot translate a label a plugin supplied. Your own in-page tab bar *can* translate
|
|
1511
|
+
* it, which is exactly where the two legitimately diverge — pin `path`, `icon` and `role` between the
|
|
1512
|
+
* two lists, and let the label differ.
|
|
1513
|
+
*/
|
|
1514
|
+
label: string;
|
|
1515
|
+
/** A host icon name (`--mc-icon-*`), if the menu should show one. */
|
|
1516
|
+
icon?: string;
|
|
1517
|
+
/** The minimum role that sees this entry. */
|
|
1518
|
+
role?: DataAccessRole;
|
|
1519
|
+
}
|
|
1520
|
+
/** One declared config field, rendered by core as a generic admin form (§7.2). @since 0.9.0 */
|
|
1521
|
+
export interface PluginConfigField {
|
|
1522
|
+
/** The value type the admin form renders. */
|
|
1523
|
+
type: 'string' | 'number' | 'boolean';
|
|
1524
|
+
/** The value used until an operator changes it. */
|
|
1525
|
+
default?: string | number | boolean;
|
|
1526
|
+
/** The minimum role that may edit it. */
|
|
1527
|
+
editableBy?: DataAccessRole;
|
|
1528
|
+
/** A short explanation shown beside the field. */
|
|
1529
|
+
description?: string;
|
|
1530
|
+
}
|
|
1531
|
+
/** The shape of the manifest's `blobs` block (ARCHITECTURE §11.1). @since 0.9.0 */
|
|
1532
|
+
export interface PluginBlobsDeclaration {
|
|
1533
|
+
/** The per-file ceiling you are asking for; the operator may grant less. */
|
|
1534
|
+
maxFileBytes: number;
|
|
1535
|
+
/** The total quota you are asking for; the operator may grant less. */
|
|
1536
|
+
quotaBytes: number;
|
|
1537
|
+
/** The content types you want to store. `image/svg+xml` is refused at load — SVG is never storable. */
|
|
1538
|
+
mimeTypes: string[];
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* The whole of `plugin.json`, typed.
|
|
1542
|
+
*
|
|
1543
|
+
* ## Read this before relying on it
|
|
1544
|
+
*
|
|
1545
|
+
* **Documentation, not enforcement** — the same caveat {@link PluginDataDeclaration} and
|
|
1546
|
+
* {@link ConsentServiceDeclaration} have carried since 0.4.0, now extended to the rest of the file. The
|
|
1547
|
+
* manifest is owned and validated by the **host**; the SDK does not read `plugin.json`, and nothing here
|
|
1548
|
+
* runs at build or load time. If this type and core disagree, **core wins** — treat a mismatch as a bug
|
|
1549
|
+
* in the SDK, not as permission to ignore the host. Unknown fields are ignored by the host, so this type
|
|
1550
|
+
* permits them too.
|
|
1551
|
+
*
|
|
1552
|
+
* The argument for typing the two nested blocks was that a typo in a *security declaration* protects
|
|
1553
|
+
* nothing and says nothing. The argument for the rest is drift: `nav[]` and a plugin's own in-page tab
|
|
1554
|
+
* bar are the same list of entrances declared twice — once here for the host's menu, once in code — and
|
|
1555
|
+
* a renamed path otherwise becomes a menu entry leading to an empty view with nothing to catch it. With
|
|
1556
|
+
* a full type you can generate `plugin.json` from a TypeScript module at build time and delete the
|
|
1557
|
+
* reconciliation test; even without generating it, the type alone catches an element listed in `slots[]`
|
|
1558
|
+
* but missing from `frontend.elements`, which is currently a load-time failure found by hand.
|
|
1559
|
+
*
|
|
1560
|
+
* @since 0.9.0
|
|
1561
|
+
*/
|
|
1562
|
+
export interface PluginManifest {
|
|
1563
|
+
/** The plugin id — the namespace for its data, its routes (`/p/<id>/…`) and its tables. */
|
|
1564
|
+
id: string;
|
|
1565
|
+
/** The plugin's own version. */
|
|
1566
|
+
version: string;
|
|
1567
|
+
/**
|
|
1568
|
+
* The contract version this plugin was built against — {@link PLATFORM_API_VERSION}.
|
|
1569
|
+
*
|
|
1570
|
+
* The host matches `major.minor` **exactly** and rejects a mismatch at startup. There is no forward or
|
|
1571
|
+
* backward tolerance, so this moves with every SDK minor.
|
|
1572
|
+
*/
|
|
1573
|
+
platformApi: string;
|
|
1574
|
+
/** The plugin's display name. */
|
|
1575
|
+
name: string;
|
|
1576
|
+
/** SPDX licence id. Never validated — credit is not a correctness concern. */
|
|
1577
|
+
license?: string;
|
|
1578
|
+
/** Who wrote it. */
|
|
1579
|
+
author?: string;
|
|
1580
|
+
/** Where the plugin lives. */
|
|
1581
|
+
homepage?: string;
|
|
1582
|
+
/** Who deserves credit — borrowed data, artwork, an upstream library. Separate from `homepage`. */
|
|
1583
|
+
attribution?: string;
|
|
1584
|
+
/** The backend half: where its API lives and which classes implement its extension points. */
|
|
1585
|
+
backend?: {
|
|
1586
|
+
/** The base path the host serves this plugin's data surface on. */
|
|
1587
|
+
basePath?: string;
|
|
1588
|
+
/**
|
|
1589
|
+
* Fully-qualified class names implementing `PluginBackend` and any optional extension points —
|
|
1590
|
+
* `SitemapProvider`, `ShareMetadataProvider`, `SearchProvider`, `UserDataHandler`.
|
|
1591
|
+
*/
|
|
1592
|
+
extensions: string[];
|
|
1593
|
+
};
|
|
1594
|
+
/** The frontend half: the bundle and the custom elements it registers. */
|
|
1595
|
+
frontend?: {
|
|
1596
|
+
/** The ES module entry, relative to the plugin's bundle. */
|
|
1597
|
+
entry: string;
|
|
1598
|
+
/** Every custom-element tag the entry registers. Each `slots[].element` must be one of these. */
|
|
1599
|
+
elements: string[];
|
|
1600
|
+
};
|
|
1601
|
+
/** Where this plugin's components mount. */
|
|
1602
|
+
slots?: PluginSlotDeclaration[];
|
|
1603
|
+
/** Entrances in the host's menu, for a plugin with a `page` placement. */
|
|
1604
|
+
nav?: PluginNavDeclaration[];
|
|
1605
|
+
/** `"doc"` for the generic JSON store, or a schema declaration for provisioned tables (§7.6). */
|
|
1606
|
+
storage?: 'doc' | {
|
|
1607
|
+
schema: Record<string, Record<string, string>>;
|
|
1608
|
+
};
|
|
1609
|
+
/** Who may read and write the doc store, and which keys only the backend writes. */
|
|
1610
|
+
data?: PluginDataDeclaration;
|
|
1611
|
+
/** Opt-in file storage. Absent means no file storage at all. */
|
|
1612
|
+
blobs?: PluginBlobsDeclaration;
|
|
1613
|
+
/** Opt-in tag surface. Absent means `ctx.tags` is `null`. */
|
|
1614
|
+
tags?: PluginTagsDeclaration;
|
|
1615
|
+
/** Config fields core renders as an admin form; plugins never build their own config UI. */
|
|
1616
|
+
config?: Record<string, PluginConfigField>;
|
|
1617
|
+
/** Third-party services this plugin loads. Omit entirely when it loads none. */
|
|
1618
|
+
consent?: {
|
|
1619
|
+
services: ConsentServiceDeclaration[];
|
|
1620
|
+
};
|
|
1621
|
+
/** The host ignores fields it does not know, so this type does too. */
|
|
1622
|
+
[field: string]: unknown;
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Identity function that type-checks a manifest literal.
|
|
1626
|
+
*
|
|
1627
|
+
* It exists for the inference: writing `const manifest = { … }` gives you a widened object with no
|
|
1628
|
+
* checking, while `defineManifest({ … })` checks the shape and still infers the literal types. Emit the
|
|
1629
|
+
* result as `plugin.json` from a build step and the manifest stops being a second, unchecked copy of
|
|
1630
|
+
* what your code already knows.
|
|
1631
|
+
*
|
|
1632
|
+
* **It validates nothing at runtime** — see {@link PluginManifest}. The host is the validator.
|
|
1633
|
+
*
|
|
1634
|
+
* ```ts
|
|
1635
|
+
* export default defineManifest({
|
|
1636
|
+
* id: 'sample', version: '1.0.0', platformApi: PLATFORM_API_VERSION, name: 'Sample',
|
|
1637
|
+
* frontend: { entry: 'sample.es.js', elements: ['sample-card'] },
|
|
1638
|
+
* slots: [{ scope: 'episode', element: 'sample-card', placement: 'main', visibleTo: 'anonymous' }],
|
|
1639
|
+
* data: { writableBy: 'podcaster', readableBy: 'anonymous' },
|
|
1640
|
+
* });
|
|
1641
|
+
* ```
|
|
1642
|
+
*
|
|
1643
|
+
* @param m the manifest
|
|
1644
|
+
* @returns the same object, unchanged
|
|
1645
|
+
* @since 0.9.0
|
|
1646
|
+
*/
|
|
1647
|
+
export declare function defineManifest<const T extends PluginManifest>(m: T): T;
|
|
781
1648
|
/**
|
|
782
1649
|
* Everything a frontend plugin is given, set by the host on the mounted custom element
|
|
783
1650
|
* (ARCHITECTURE §7.5). This is the **entire** interface a plugin author must learn.
|
|
@@ -815,6 +1682,36 @@ export interface PluginContext {
|
|
|
815
1682
|
* uses via `ctx.store()`. See {@link PluginApiClient} for the endpoint shape and access rules.
|
|
816
1683
|
*/
|
|
817
1684
|
api: PluginApiClient;
|
|
1685
|
+
/**
|
|
1686
|
+
* A typed client for the same doc store {@link api} reaches — path building, key validation and
|
|
1687
|
+
* null-on-404 done for you.
|
|
1688
|
+
*
|
|
1689
|
+
* Never `null`: every plugin has a doc store. `ctx.api` remains the escape hatch for anything this
|
|
1690
|
+
* does not cover. See {@link DocClient}, and note `'self'` for the caller's own partition.
|
|
1691
|
+
*
|
|
1692
|
+
* @since 0.9.0
|
|
1693
|
+
*/
|
|
1694
|
+
docs: DocClient;
|
|
1695
|
+
/**
|
|
1696
|
+
* Episode display snapshots, resolved and access-filtered by the host — the frontend half of the Java
|
|
1697
|
+
* `FeedAccess` (ARCHITECTURE §4.2).
|
|
1698
|
+
*
|
|
1699
|
+
* Never `null`, and gated by no manifest declaration: it returns host data the visitor can already
|
|
1700
|
+
* read. See {@link FeedsClient}, and read it live — the snapshot is not authoritative.
|
|
1701
|
+
*
|
|
1702
|
+
* @since 0.9.0
|
|
1703
|
+
*/
|
|
1704
|
+
feeds: FeedsClient;
|
|
1705
|
+
/**
|
|
1706
|
+
* The site's shared tag vocabulary, or **`null`** when the manifest declares no `tags` block
|
|
1707
|
+
* (ARCHITECTURE §6.1).
|
|
1708
|
+
*
|
|
1709
|
+
* The frontend half of the Java `ctx.tags()`, `null` for the same reason `schema` and `blobs` are.
|
|
1710
|
+
* See {@link TagsClient} — and note that tagging an *episode* is a second, separate declaration.
|
|
1711
|
+
*
|
|
1712
|
+
* @since 0.9.0
|
|
1713
|
+
*/
|
|
1714
|
+
tags: TagsClient | null;
|
|
818
1715
|
/**
|
|
819
1716
|
* Read access to this plugin's provisioned relational tables, or **`null`** when the manifest declares
|
|
820
1717
|
* no `storage.schema` (ARCHITECTURE §7.6).
|
|
@@ -826,6 +1723,19 @@ export interface PluginContext {
|
|
|
826
1723
|
* @since 0.7.0
|
|
827
1724
|
*/
|
|
828
1725
|
schema: SchemaClient | null;
|
|
1726
|
+
/**
|
|
1727
|
+
* File storage for this plugin, or **`null`** when the manifest declares no `blobs` block
|
|
1728
|
+
* (ARCHITECTURE §11).
|
|
1729
|
+
*
|
|
1730
|
+
* The frontend half of the Java `ctx.blobs()`, `null` for the same reason `schema` is: what a plugin may
|
|
1731
|
+
* store is decided in the manifest and nowhere else. Check it before use — TypeScript will make you.
|
|
1732
|
+
*
|
|
1733
|
+
* This is what lets a plugin accept a file from the site's own podcaster instead of sending them to find
|
|
1734
|
+
* an image host first. See {@link BlobClient}.
|
|
1735
|
+
*
|
|
1736
|
+
* @since 0.8.0
|
|
1737
|
+
*/
|
|
1738
|
+
blobs: BlobClient | null;
|
|
829
1739
|
/**
|
|
830
1740
|
* Writes one line to the host's log, attributed to this plugin.
|
|
831
1741
|
*
|
|
@@ -877,6 +1787,15 @@ export interface PluginContext {
|
|
|
877
1787
|
* {@link PluginRoute}.
|
|
878
1788
|
*/
|
|
879
1789
|
route: PluginRoute;
|
|
1790
|
+
/**
|
|
1791
|
+
* Builders for links to the host's own pages — episodes, feed tabs (ARCHITECTURE §6.4).
|
|
1792
|
+
*
|
|
1793
|
+
* Strings, not navigation: put the result in an `href` and let the visitor click. See
|
|
1794
|
+
* {@link PluginLinks} for why this is separate from {@link route}.
|
|
1795
|
+
*
|
|
1796
|
+
* @since 0.8.0
|
|
1797
|
+
*/
|
|
1798
|
+
links: PluginLinks;
|
|
880
1799
|
/**
|
|
881
1800
|
* The active UI locale (ARCHITECTURE §12.7).
|
|
882
1801
|
*
|
|
@@ -941,6 +1860,75 @@ export interface PluginI18n {
|
|
|
941
1860
|
* @param params values for `{{placeholder}}` interpolation
|
|
942
1861
|
*/
|
|
943
1862
|
t(key: string, params?: Record<string, string | number>): string;
|
|
1863
|
+
/**
|
|
1864
|
+
* Translates a **count-dependent** key, picking the plural form the active locale actually needs.
|
|
1865
|
+
*
|
|
1866
|
+
* A catalog could not express "1 highlight" / "5 highlights" at all, so plugins shipped an
|
|
1867
|
+
* English-shaped `if (n === 1)` — wrong in Polish, Russian and Arabic, which have three to six forms —
|
|
1868
|
+
* or wrote two keys and chose between them by hand.
|
|
1869
|
+
*
|
|
1870
|
+
* Catalog keys are the base key plus a dot and a CLDR category: `zero`, `one`, `two`, `few`, `many`,
|
|
1871
|
+
* `other`. Only `other` is required; a locale that needs a form you did not write falls back to it.
|
|
1872
|
+
*
|
|
1873
|
+
* ```json
|
|
1874
|
+
* { "moments.one": "{{count}} moment", "moments.other": "{{count}} moments" }
|
|
1875
|
+
* ```
|
|
1876
|
+
* ```ts
|
|
1877
|
+
* i18n.plural('moments', list.length); // `count` is interpolated for you
|
|
1878
|
+
* ```
|
|
1879
|
+
*
|
|
1880
|
+
* Resolution walks the same path as {@link t}: active locale → `en` → the base key itself. `count` is
|
|
1881
|
+
* added to `params` automatically, and a `count` you pass explicitly wins.
|
|
1882
|
+
*
|
|
1883
|
+
* @param key the base message key, without the category suffix
|
|
1884
|
+
* @param count how many
|
|
1885
|
+
* @param params values for `{{placeholder}}` interpolation, on top of `count`
|
|
1886
|
+
* @since 0.9.0
|
|
1887
|
+
*/
|
|
1888
|
+
plural(key: string, count: number, params?: Record<string, string | number>): string;
|
|
1889
|
+
/**
|
|
1890
|
+
* Formats a number for the active locale.
|
|
1891
|
+
*
|
|
1892
|
+
* @param value the number
|
|
1893
|
+
* @param opts passed straight to `Intl.NumberFormat`
|
|
1894
|
+
* @since 0.9.0
|
|
1895
|
+
*/
|
|
1896
|
+
n(value: number, opts?: Intl.NumberFormatOptions): string;
|
|
1897
|
+
/**
|
|
1898
|
+
* Formats a date for the active locale.
|
|
1899
|
+
*
|
|
1900
|
+
* Accepts the ISO-8601 instant strings the contract hands over — `DisplaySnapshot.publishedAt`,
|
|
1901
|
+
* `BlobInfo.updatedAt` — as well as a `Date`.
|
|
1902
|
+
*
|
|
1903
|
+
* @param value an ISO-8601 instant or a `Date`
|
|
1904
|
+
* @param opts passed straight to `Intl.DateTimeFormat`; defaults to `{ dateStyle: 'medium' }`
|
|
1905
|
+
* @returns the formatted date, or the input unchanged when it is not a readable date
|
|
1906
|
+
* @since 0.9.0
|
|
1907
|
+
*/
|
|
1908
|
+
date(value: string | Date, opts?: Intl.DateTimeFormatOptions): string;
|
|
1909
|
+
/**
|
|
1910
|
+
* Formats a runtime as `H:MM:SS` (or `M:SS` under an hour), with the active locale's digits.
|
|
1911
|
+
*
|
|
1912
|
+
* Contract-adjacent by design: `DisplaySnapshot.duration` is an **ISO-8601 duration string**, so this
|
|
1913
|
+
* takes one directly as well as a plain number of seconds. Every plugin was hand-rolling the `90` →
|
|
1914
|
+
* `"1:30"` conversion.
|
|
1915
|
+
*
|
|
1916
|
+
* @param value seconds, or an ISO-8601 duration such as `PT1H2M3S`
|
|
1917
|
+
* @returns the formatted runtime, or `''` when the value cannot be read as one
|
|
1918
|
+
* @since 0.9.0
|
|
1919
|
+
*/
|
|
1920
|
+
duration(value: number | string): string;
|
|
1921
|
+
/**
|
|
1922
|
+
* Formats a byte count for the active locale — for showing a `BlobQuota` to a podcaster.
|
|
1923
|
+
*
|
|
1924
|
+
* **Decimal units** (1 kB = 1000 B), so the number agrees with what the visitor's own file manager
|
|
1925
|
+
* showed them. Locale-correct throughout, including the decimal separator — the hand-rolled version
|
|
1926
|
+
* hardcodes `.`, which is simply wrong in `de`.
|
|
1927
|
+
*
|
|
1928
|
+
* @param value a size in bytes
|
|
1929
|
+
* @since 0.9.0
|
|
1930
|
+
*/
|
|
1931
|
+
bytes(value: number): string;
|
|
944
1932
|
/** The currently active locale code. */
|
|
945
1933
|
readonly locale: string;
|
|
946
1934
|
/**
|