@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/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.7.1';
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
- /** GET a path, resolving to the parsed JSON body. */
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
- /** POST a JSON body, resolving to the parsed JSON response. */
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
- /** The subpath below `/p/<pluginId>/`; empty when the plugin is not rendered as a page. */
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
  /**