@mosaicast/plugin-sdk 0.3.0 → 0.5.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 +194 -6
- package/dist/index.d.ts +382 -38
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -4
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +76 -7
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +90 -9
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,11 @@ src/testing.ts TS: @mosaicast/plugin-sdk/testing — makeMockCtx
|
|
|
29
29
|
|
|
30
30
|
The contract version is a **single SemVer anchor** mirrored in four places that MUST move together
|
|
31
31
|
(CI enforces it): `build.gradle.kts` · `package.json` · `PlatformApi.VERSION` · `PLATFORM_API_VERSION`.
|
|
32
|
-
|
|
32
|
+
|
|
33
|
+
**How the host matches it:** core compares `major.minor` **exactly**. A plugin declaring `0.3.x` is
|
|
34
|
+
rejected the moment the host runs `0.5.0` — with the reason shown in the admin log viewer. While the SDK
|
|
35
|
+
is pre-1.0 a breaking change is therefore a **minor** bump (`0.4.0` → `0.5.0`), not a major one; from
|
|
36
|
+
`1.0.0` on, normal SemVer applies and breaking means major.
|
|
33
37
|
|
|
34
38
|
## Build & test
|
|
35
39
|
```bash
|
|
@@ -45,8 +49,8 @@ npm ci && npm run build # TypeScript: src → dist (.js + .d.ts)
|
|
|
45
49
|
- Released: from **GitHub Packages** (see below).
|
|
46
50
|
```kotlin
|
|
47
51
|
dependencies {
|
|
48
|
-
compileOnly("dev.mosaicast:plugin-api:0.
|
|
49
|
-
testImplementation("dev.mosaicast:plugin-testkit:0.
|
|
52
|
+
compileOnly("dev.mosaicast:plugin-api:0.5.0") // contract, provided by the host
|
|
53
|
+
testImplementation("dev.mosaicast:plugin-testkit:0.5.0") // test doubles only
|
|
50
54
|
}
|
|
51
55
|
```
|
|
52
56
|
Sources + Javadoc JARs give IDE hover docs automatically.
|
|
@@ -95,21 +99,205 @@ DELETE /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
|
|
|
95
99
|
| upsert | `store().put(scope, key, value)` | `ctx.api.put('data/…/{key}', value)` |
|
|
96
100
|
| remove | `store().delete(scope, key)` → `boolean` | `ctx.api.delete('data/…/{key}')` |
|
|
97
101
|
|
|
98
|
-
- `scopeType` is `site | feed | season | episode` and `scopeId` is that entity's id — mirroring `Scope`/`ScopeType`, the same addressing the backend uses.
|
|
99
|
-
- `key` must match `DocStore.KEY_PATTERN` = `^[A-Za-z0-9._:-]{1,200}$` and is the final path segment, verbatim — no `/`, and no percent-encoded slash either (servlet containers reject `%2F` there). Structure keys with `:` / `.` / `-` instead, e.g. `mark:
|
|
102
|
+
- `scopeType` is `site | feed | season | episode | user` and `scopeId` is that entity's id — mirroring `Scope`/`ScopeType`, the same addressing the backend uses. Two are **singletons** whose id the SDK and the host both pin: `site`'s is always `main` (`Scope.SITE_ID`, one site) and `user`'s always `me` (`Scope.SELF_ID`, the calling user). So `Scope.site()` and `…/data/site/main/{key}` address the same document, and the path always has four non-empty segments.
|
|
103
|
+
- `key` must match `DocStore.KEY_PATTERN` = `^[A-Za-z0-9._:-]{1,200}$` and is the final path segment, verbatim — no `/`, and no percent-encoded slash either (servlet containers reject `%2F` there). Structure keys with `:` / `.` / `-` instead, e.g. `mark:s2e04:b3` (entity ids are slugs, not UUIDs). The host answers 400 on a bad key, and `InMemoryDocStore` throws `IllegalArgumentException` — so it fails in your tests, not in production.
|
|
100
104
|
- The list is **paginated** (core's standard `PagedResponse` envelope) and **carries keys** (`DocEntry`), because neither end can address a doc without one.
|
|
101
105
|
- `delete` is **idempotent**: removing an absent doc is not an error. The Java call returns whether anything was actually removed.
|
|
102
106
|
|
|
107
|
+
### Per-user data — the `user` scope (since 0.5.0)
|
|
108
|
+
|
|
109
|
+
**Per-user data belongs in the `user` scope, never in the key.** A key is client-supplied, so the convention this README used to suggest — `mark:<userId>:cell` under an episode scope — was an access-control decision the host could not enforce: any caller past the plugin's read floor could address someone else's key directly, and scope ids are public slugs, so nothing had to be guessed.
|
|
110
|
+
|
|
111
|
+
`Scope.user()` / `…/data/user/me/{key}` fixes that by construction:
|
|
112
|
+
|
|
113
|
+
- The id is always the sentinel `me` (`Scope.SELF_ID`), resolved **server-side from the session**. The canonical `Scope` constructor normalizes any `USER` id to it, so there is no expression in the Java API that names another person's partition — and there is deliberately **no `Scope.user(String)` overload**.
|
|
114
|
+
- Naming any other `user` id over HTTP is a **400**, never a silent substitution. An anonymous `user` request is a **401** whatever the read floor says: no session, no partition.
|
|
115
|
+
- The partition is **flat** — one per user, not one per user and entity — so the entity goes in the key: `mark:<episodeSlug>:cell`.
|
|
116
|
+
- `readableBy` does not apply to it. No floor makes someone else's partition readable.
|
|
117
|
+
|
|
118
|
+
**A backend has no calling user**, so every `DocStore` method throws `UnsupportedOperationException` for a `USER` scope — reads included, since resolving "me" without a caller would have to pick someone. Aggregate instead:
|
|
119
|
+
|
|
120
|
+
```java
|
|
121
|
+
// Backend-only, read-only, and no HTTP surface: no visitor's request can reach another's data.
|
|
122
|
+
List<OwnedDocEntry> marks = ctx.store().queryAcrossUsers("mark:");
|
|
123
|
+
// record OwnedDocEntry(UUID userId, String key, JsonNode value) — the owner is host-resolved, never
|
|
124
|
+
// a value the browser supplied, which is what makes a leaderboard built from it true.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Write the aggregate back to an entity scope (`…/data/episode/s2e04/leaderboard`) and let the component read it there. In tests, `InMemoryDocStore.asUser(uuid)` stands in for the host resolving `me`, so you can seed what a frontend would have written and then assert on `queryAcrossUsers`.
|
|
128
|
+
|
|
103
129
|
**The two ends see one store.** The doc a backend writes with `ctx.store().put(scope, key, value)` is exactly what the frontend reads at `GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}`.
|
|
104
130
|
|
|
105
131
|
**What the host enforces**, so a plugin doesn't have to:
|
|
106
132
|
- Data is **hard-scoped to the plugin id** — a plugin can only ever see its own data.
|
|
107
|
-
- **
|
|
133
|
+
- **Access is what the manifest declares**, not what the slots imply: `"data": { "readableBy": "fan", "writableBy": "podcaster" }`. Values are `anonymous | fan | podcaster | admin`; `writableBy` may not be `anonymous`, and an absent block defaults `readableBy` to the **write** floor rather than to anonymous — saying nothing gets the safe answer. A slot's `visibleTo` governs **rendering only**; deriving the data floor from unrelated UI slots is what once let a plugin with one anonymous slot expose its whole store.
|
|
134
|
+
- The host validates that the scope exists (and that the feed is enabled). `ctx.api` carries the user's auth (session or personal access token).
|
|
108
135
|
|
|
109
136
|
**No request-time server logic.** A write is plain persistence — no plugin code runs on the request. Anything derived, validated or aggregated server-side is **precomputed** in `register`/`onSchedule` and read back from the store.
|
|
110
137
|
|
|
111
138
|
> Roadmap: custom plugin-defined server routes may arrive in a later `platformApi` version; plugins use the doc store.
|
|
112
139
|
|
|
140
|
+
## Relational storage — `ctx.schema()` (since 0.4.0)
|
|
141
|
+
|
|
142
|
+
The doc store is the default and covers nearly everything. Declare a **schema** when you need what a JSON
|
|
143
|
+
document cannot give you: full-text search, revisions, backlinks. Any plugin may; most declare nothing.
|
|
144
|
+
|
|
145
|
+
You declare entities and fields in the manifest, and the **platform** provisions namespaced tables
|
|
146
|
+
(`plugin_<id>_*`) through its managed migration runner, dropping them when an admin purges the plugin.
|
|
147
|
+
**The plugin never writes DDL and never names a table:**
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
"storage": { "schema": { "page": {
|
|
151
|
+
"slug": "string:indexed:unique", "title": "string",
|
|
152
|
+
"markdown": "text:fulltext", "updatedAt": "timestamp:indexed" } } }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```java
|
|
156
|
+
record Page(long id, String slug, String title, String markdown, Instant updatedAt) {}
|
|
157
|
+
|
|
158
|
+
SchemaStore schema = ctx.schema(); // null unless the manifest declares one
|
|
159
|
+
|
|
160
|
+
long id = schema.insert("page", Map.of(
|
|
161
|
+
"slug", "getting-started", "title", "Getting started",
|
|
162
|
+
"markdown", "# Hello", "updatedAt", Instant.now()));
|
|
163
|
+
|
|
164
|
+
Optional<Page> byId = schema.find("page", id, Page.class);
|
|
165
|
+
List<Page> recent = schema.select("page",
|
|
166
|
+
Criteria.all().orderBy("updatedAt", Direction.DESC).limit(20), Page.class);
|
|
167
|
+
List<Page> hits = schema.search("page", "markdown", "lighthouse", Criteria.all(), Page.class);
|
|
168
|
+
long total = schema.count("page", Criteria.all());
|
|
169
|
+
schema.update("page", id, Map.of("markdown", "# Hello again"));
|
|
170
|
+
schema.delete("page", Criteria.where("slug", Op.EQ, "getting-started"));
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- Everything is addressed by **declared entity and field name — never SQL, never a table**. That is the
|
|
174
|
+
scoping guarantee: reaching another plugin's tables isn't blocked, it's inexpressible. The host binds
|
|
175
|
+
every value as a JDBC parameter and checks every field name against your manifest.
|
|
176
|
+
- Rows map to **your own record types**, component names matching field names — the same convention as
|
|
177
|
+
`store().get(...)` and `config().get(...)`. Each entity carries a platform-assigned `long id`.
|
|
178
|
+
- `Criteria` is immutable; predicates combine with **AND** (no `or` in 0.4.0). `search` needs a field
|
|
179
|
+
declared `:fulltext`; for a plain match use `Op.LIKE` with `select`.
|
|
180
|
+
- Naming an undeclared entity or field throws `IllegalArgumentException` — against the test kit too, so a
|
|
181
|
+
manifest that drifted from the code fails in your tests.
|
|
182
|
+
|
|
183
|
+
Test it with `FakeSchemaStore`, declaring the same entities your manifest does. Its `search` is a
|
|
184
|
+
substring match, **not** Postgres full-text: no stemming, no ranking. Assert on which rows come back, not
|
|
185
|
+
on their order.
|
|
186
|
+
|
|
187
|
+
## Logging — `ctx.logger()` / `ctx.log()` (since 0.4.0)
|
|
188
|
+
|
|
189
|
+
```java
|
|
190
|
+
ctx.logger().info("indexed {} pages", count); // org.slf4j.Logger, named plugin.<pluginId>
|
|
191
|
+
```
|
|
192
|
+
```ts
|
|
193
|
+
ctx.log('warn', `no board for episode ${ctx.scope.id}`);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
A plain SLF4J `Logger` on the backend: authors know the API, and parameterised messages and throwables
|
|
197
|
+
come free. **Attribution rides in the logger name**, which is why the host hands you a named logger rather
|
|
198
|
+
than offering `log(level, message)` — the name travels with the event, so output is still attributed to
|
|
199
|
+
your plugin from a thread you started or from an `onSchedule` task, where a thread-local MDC arrives
|
|
200
|
+
empty. Don't build your own `LoggerFactory.getLogger(...)`: it won't sit under the `plugin.` prefix and
|
|
201
|
+
the host can't attribute it.
|
|
202
|
+
|
|
203
|
+
The host stores `info` and above, surfaces `warn` and above in the admin log viewer, and **rate-limits**
|
|
204
|
+
this path. On the frontend, `ctx.log` replaces POSTing to `/api/plugins/{id}/log` through `ctx.api`.
|
|
205
|
+
`FakePluginContext.logger()` returns a `RecordingLogger`, so a test asserts on logging the way it asserts
|
|
206
|
+
on stored documents; `makeMockCtx` collects `ctx.logs`.
|
|
207
|
+
|
|
208
|
+
## Consent & the manifest (core-owned schema)
|
|
209
|
+
|
|
210
|
+
Core runs **banner-free** — strictly necessary cookies need no consent. The only reason a visitor sees a
|
|
211
|
+
consent prompt is a plugin that asked for one, so treat it as the cost it is.
|
|
212
|
+
|
|
213
|
+
### `ctx.consent` (frontend)
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
if (ctx.consent.has('analytics')) mountChart(root);
|
|
217
|
+
else button.onclick = async () => {
|
|
218
|
+
if (await ctx.consent.request('analytics')) mountChart(root); // from a user gesture, never on mount
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const off = ctx.consent.onChange(() => rerender()); // fires on every change
|
|
222
|
+
return off; // detach on cleanup
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`request(category)` opens the host's settings for that one purpose and resolves with the visitor's answer
|
|
226
|
+
(`false` if dismissed). `granted()` lists everything currently granted. `onChange` fires on **every**
|
|
227
|
+
change — including a withdrawal made in the settings page while you are mounted — and returns an
|
|
228
|
+
unsubscribe. Since 0.4.0 `filter`, `route`, `locale` and `player.on` return one too.
|
|
229
|
+
|
|
230
|
+
**Services describe, categories decide.** The manifest is per-service (`provider`, `privacyUrl`,
|
|
231
|
+
`thirdCountryTransfer`, each `storage[]` item) but every `ctx.consent` method takes a **category**. The
|
|
232
|
+
per-service detail exists so the notice can name who stores what for how long; what the visitor toggles is
|
|
233
|
+
the category. So if two plugins each declare an `analytics` service with different providers, the visitor
|
|
234
|
+
sees **one** decision listing both plugins, and granting it grants both — there is no way to accept one
|
|
235
|
+
provider and refuse the other. If you need your own services to be refusable independently, declare them
|
|
236
|
+
under **different categories** (a plugin-declared category is fine; the shell just has no translated label
|
|
237
|
+
for it). That is the only lever the contract gives you.
|
|
238
|
+
|
|
239
|
+
**`necessary` is never asked about.** `has('necessary')` is always `true` and the host never prompts for
|
|
240
|
+
it — it is the category the core itself uses, and a banner-free site stays banner-free. Declaring a
|
|
241
|
+
service `necessary` means "this loads unconditionally"; use it only for what genuinely cannot be refused.
|
|
242
|
+
|
|
243
|
+
**Concurrent `request()` calls.** A page of plugin tiles will produce them, so the contract is explicit:
|
|
244
|
+
there is **one consent surface host-wide** (a second call joins the one in flight rather than opening
|
|
245
|
+
another); the visitor decides **all** categories in one interaction, so other categories may move too;
|
|
246
|
+
every call resolves exactly once and is never dropped; and grants are **shared across plugins** — another
|
|
247
|
+
plugin's accepted `request('analytics')` resolves yours too. Don't serialize calls or build a queue, and
|
|
248
|
+
re-read `has(...)` after a change instead of caching what a request resolved with.
|
|
249
|
+
|
|
250
|
+
### `consent.services[]` in `plugin.json`
|
|
251
|
+
|
|
252
|
+
The manifest type is **core-owned** — the SDK has no manifest type and will not grow one — but this is
|
|
253
|
+
where plugin authors look, so the required shape is documented here. **From `0.4.0` the legacy
|
|
254
|
+
`{ "categories": [...], "externalSources": [...] }` form is rejected** and the plugin will not load.
|
|
255
|
+
|
|
256
|
+
```json
|
|
257
|
+
"consent": {
|
|
258
|
+
"services": [{
|
|
259
|
+
"id": "plausible",
|
|
260
|
+
"name": "Plausible Analytics",
|
|
261
|
+
"provider": "Plausible Insights OÜ",
|
|
262
|
+
"category": "necessary | functional | analytics | <plugin-declared>",
|
|
263
|
+
"privacyUrl": "https://plausible.io/privacy",
|
|
264
|
+
"hosts": ["https://plausible.example"],
|
|
265
|
+
"thirdCountryTransfer": false,
|
|
266
|
+
"storage": [
|
|
267
|
+
{ "name": "plausible_ignore", "type": "cookie | localStorage | sessionStorage",
|
|
268
|
+
"purpose": "Remembers that you opted out of statistics", "duration": "persistent | session | 12 months" }
|
|
269
|
+
]
|
|
270
|
+
}]
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
| Field | Meaning |
|
|
275
|
+
|---|---|
|
|
276
|
+
| `id` | Stable identifier for this service within your plugin. |
|
|
277
|
+
| `name` | The service as a visitor would recognise it, e.g. "Plausible Analytics". |
|
|
278
|
+
| `provider` | The **legal entity** operating it — the company name, not your plugin's. |
|
|
279
|
+
| `category` | The consent category the service falls under; what `ctx.consent.has(...)` gates on. **`necessary` is always granted and never prompted for.** Two services sharing a category are decided together. |
|
|
280
|
+
| `privacyUrl` | The provider's own privacy policy. |
|
|
281
|
+
| `hosts` | Every origin the service is contacted on. **Also the CSP allow-list** — see below. |
|
|
282
|
+
| `thirdCountryTransfer` | Whether personal data leaves the EU/EEA. |
|
|
283
|
+
| `storage[]` | Each item the service stores on the device: `name`, `type`, `purpose`, `duration`. |
|
|
284
|
+
|
|
285
|
+
**Why the shape changed.** A cookie notice that satisfies §25 TDDDG / Art. 5(3) ePD has to name each
|
|
286
|
+
stored item, what it is for, how long it lasts, who the provider is, and whether data leaves the country.
|
|
287
|
+
Category slugs and bare hostnames cannot produce that — and they force the notice to talk about "plugins"
|
|
288
|
+
to visitors who only care about cookies and named companies.
|
|
289
|
+
|
|
290
|
+
**`hosts` doubles as the CSP allow-list.** Core widens `script-src` / `frame-src` / `connect-src` by
|
|
291
|
+
exactly these origins. An origin you did not declare **stays blocked even after consent is given** — if a
|
|
292
|
+
third-party embed silently fails to load with consent granted, an undeclared host is the first thing to
|
|
293
|
+
check.
|
|
294
|
+
|
|
295
|
+
**Authoring help:** the SDK exports `ConsentServiceDeclaration` (and `ConsentStorageDeclaration`) as a
|
|
296
|
+
**documentation-only** type. Nothing in the SDK reads `plugin.json` or validates it — the host owns and
|
|
297
|
+
enforces the manifest — but typing a literal against it gives you completion and catches a typo before
|
|
298
|
+
core rejects the plugin at load, which is otherwise your first feedback. If the type and core ever
|
|
299
|
+
disagree, **core wins**; treat the mismatch as an SDK bug. The manifest as a whole stays core-owned.
|
|
300
|
+
|
|
113
301
|
## Releasing (maintainers)
|
|
114
302
|
Publishing is automated and fires on a **published GitHub Release**, not on PR merge
|
|
115
303
|
(`.github/workflows/release.yml`).
|
package/dist/index.d.ts
CHANGED
|
@@ -11,19 +11,65 @@
|
|
|
11
11
|
* The plugin contract version (SemVer).
|
|
12
12
|
*
|
|
13
13
|
* Mirror of the Java `dev.mosaicast.plugin.api.PlatformApi.VERSION` constant and the npm package
|
|
14
|
-
* version
|
|
15
|
-
*
|
|
14
|
+
* version — **these move together**, and CI fails the build if they drift.
|
|
15
|
+
*
|
|
16
|
+
* The host compares a plugin manifest's `platformApi` against this value on **`major.minor` exactly** and
|
|
17
|
+
* rejects a mismatch at startup (ARCHITECTURE §7.2). While the SDK is pre-1.0 a breaking change is
|
|
18
|
+
* therefore a *minor* bump; from `1.0.0` on, breaking means major.
|
|
16
19
|
*/
|
|
17
|
-
export declare const PLATFORM_API_VERSION: '0.
|
|
20
|
+
export declare const PLATFORM_API_VERSION: '0.5.0';
|
|
18
21
|
/** A user's role (ARCHITECTURE §8.5). Anonymous visitors have no role (`user` is `null`). */
|
|
19
22
|
export type Role = 'admin' | 'podcaster' | 'fan';
|
|
20
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The severity of a {@link PluginContext.log} call.
|
|
25
|
+
*
|
|
26
|
+
* Mirrors the SLF4J levels a plugin backend gets through the Java `ctx.logger()`, minus `trace` — a
|
|
27
|
+
* browser has no use for it, and the host would drop it anyway.
|
|
28
|
+
*/
|
|
29
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
30
|
+
/**
|
|
31
|
+
* Unsubscribes a callback registered through one of the `onChange` handles.
|
|
32
|
+
*
|
|
33
|
+
* Call it when your component tears down — the cleanup callback returned from your
|
|
34
|
+
* {@link MosaicastRender} is the natural place. A subscription that outlives its render keeps a
|
|
35
|
+
* reference to a detached DOM tree and keeps firing into it.
|
|
36
|
+
*/
|
|
37
|
+
export type Unsubscribe = () => void;
|
|
38
|
+
/**
|
|
39
|
+
* The level plus concrete entity a view is scoped to (ARCHITECTURE §6.1).
|
|
40
|
+
*
|
|
41
|
+
* This is the **slot** scope — the page your component was mounted on. It never carries the `user` level
|
|
42
|
+
* of {@link DataScopeType}: a slot lives in a named region of a page, and there is no user page. A
|
|
43
|
+
* component reading per-user data addresses `data/user/me/…` explicitly while `ctx.scope` stays whatever
|
|
44
|
+
* page it is on.
|
|
45
|
+
*/
|
|
21
46
|
export interface Scope {
|
|
22
47
|
/** The scope level. */
|
|
23
48
|
type: 'site' | 'feed' | 'season' | 'episode';
|
|
24
|
-
/** The id of the entity at that level (e.g. the `EpisodeRef`
|
|
49
|
+
/** The id of the entity at that level (e.g. the `EpisodeRef` slug for `episode`). */
|
|
25
50
|
id: string;
|
|
26
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The scope levels the host's **data** surface addresses — the four page levels of {@link Scope} plus
|
|
54
|
+
* `user`, the caller's own private partition (`ScopeType` in the Java SDK).
|
|
55
|
+
*
|
|
56
|
+
* Deliberately a second type: `user` is a storage partition, never a slot scope, so it can appear in a
|
|
57
|
+
* `data/{scopeType}/{scopeId}/…` path but never in `ctx.scope`.
|
|
58
|
+
*
|
|
59
|
+
* @since 0.5.0
|
|
60
|
+
*/
|
|
61
|
+
export type DataScopeType = Scope['type'] | 'user';
|
|
62
|
+
/**
|
|
63
|
+
* The id every `user` data path carries: the literal `me`, mirroring the Java `Scope.SELF_ID`.
|
|
64
|
+
*
|
|
65
|
+
* The host resolves it from the session, so `data/user/me/{key}` reaches the calling user's partition and
|
|
66
|
+
* no one else's. **Any other `user` id is a 400**, never a silent substitution, and an anonymous request
|
|
67
|
+
* to a `user` path is a 401 — with no session there is no partition. Per-user data belongs here rather
|
|
68
|
+
* than in a key like `mark:<userId>:cell`, which the host cannot enforce because the client supplies it.
|
|
69
|
+
*
|
|
70
|
+
* @since 0.5.0
|
|
71
|
+
*/
|
|
72
|
+
export declare const SELF_SCOPE_ID: 'me';
|
|
27
73
|
/**
|
|
28
74
|
* The host-owned filter axes for the current view (ARCHITECTURE §6.1).
|
|
29
75
|
*
|
|
@@ -117,38 +163,73 @@ export interface PagedDocs<T = unknown> {
|
|
|
117
163
|
* → remove; idempotent
|
|
118
164
|
* ```
|
|
119
165
|
*
|
|
120
|
-
* `scopeType` is
|
|
121
|
-
*
|
|
122
|
-
*
|
|
166
|
+
* `scopeType` is a {@link DataScopeType} and `scopeId` the id of that entity — i.e. the `Scope` the
|
|
167
|
+
* backend addresses with. Two of them are singletons whose id is fixed: `site` is always the literal
|
|
168
|
+
* `main` (one site), and `user` always {@link SELF_SCOPE_ID} (`me`), which the host resolves to the
|
|
169
|
+
* calling user. So the path always has four non-empty segments. `key` must match
|
|
123
170
|
* `^[A-Za-z0-9._:-]{1,200}$` — the host answers 400 otherwise — and is the final path segment verbatim:
|
|
124
|
-
* no `/`, so structure keys with `:` / `.` / `-` (e.g. `mark:
|
|
125
|
-
* ({@link PagedDocs}) and carries each doc's key ({@link DocEntry}), since you cannot
|
|
126
|
-
* without it.
|
|
171
|
+
* no `/`, so structure keys with `:` / `.` / `-` (e.g. `mark:s2e04:b3`; entity ids are slugs). The list
|
|
172
|
+
* is paginated ({@link PagedDocs}) and carries each doc's key ({@link DocEntry}), since you cannot
|
|
173
|
+
* address a doc without it.
|
|
127
174
|
*
|
|
128
175
|
* The doc a backend writes with `ctx.store().put(scope, key, value)` is the one read here at
|
|
129
|
-
* `GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}`: one store, two ends.
|
|
176
|
+
* `GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}`: one store, two ends. The `user` scope is the
|
|
177
|
+
* exception — it exists only here. A backend has no calling user, so it cannot write a user partition at
|
|
178
|
+
* all and reads them only in aggregate, through the Java `DocStore.queryAcrossUsers(prefix)`.
|
|
179
|
+
*
|
|
180
|
+
* ## Where per-user data goes
|
|
181
|
+
*
|
|
182
|
+
* **In the `user` scope, not in the key.** A key is client-supplied, so the older convention
|
|
183
|
+
* `mark:<userId>:cell` under an episode scope was an access-control decision the host could not check:
|
|
184
|
+
* any caller past the plugin's read floor could address someone else's key, and scope ids are public
|
|
185
|
+
* slugs, so nothing had to be guessed. `data/user/me/…` is resolved from the session instead — the only
|
|
186
|
+
* partition a caller can name is their own. Any other `user` id is a **400**; an anonymous `user` request
|
|
187
|
+
* is a **401**, whatever the read floor says. The partition is flat, so the entity goes in the key:
|
|
188
|
+
* `mark:<episodeSlug>:cell`.
|
|
189
|
+
*
|
|
190
|
+
* ## What the host enforces
|
|
191
|
+
*
|
|
192
|
+
* Data is hard-scoped to the plugin id — a plugin only ever sees its own — and the host validates that the
|
|
193
|
+
* scope exists. Access is what your **manifest** declares:
|
|
130
194
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* the
|
|
195
|
+
* ```json
|
|
196
|
+
* "data": { "readableBy": "fan", "writableBy": "podcaster" }
|
|
197
|
+
* ```
|
|
198
|
+
*
|
|
199
|
+
* Values are `anonymous | fan | podcaster | admin` ({@link Role} plus `anonymous`, the absence of one);
|
|
200
|
+
* `writableBy` may not be `anonymous`, and if the block is absent `readableBy` falls back to the *write*
|
|
201
|
+
* floor rather than to anonymous — saying nothing gets the safe answer. A slot's `visibleTo` governs
|
|
202
|
+
* **rendering only**; it never governed data access, and inferring the data floor from unrelated UI slots
|
|
203
|
+
* is exactly what once let a plugin with one anonymous slot expose its whole store. Watch the default: a
|
|
204
|
+
* plugin with an anonymous slot and no `data` block loses its anonymous reads (403) until it declares
|
|
205
|
+
* `"readableBy": "anonymous"`. **Neither floor applies to the `user` scope:** no floor makes someone
|
|
206
|
+
* else's partition readable, none stands between a caller and their own, and `writableBy` does not gate it
|
|
207
|
+
* either — a write floor protects the *shared* surface, and a user partition is unshared. Any
|
|
208
|
+
* authenticated caller reads and writes their own `data/user/me/…` whatever the manifest declares.
|
|
209
|
+
*
|
|
210
|
+
* A write is plain persistence: no plugin code runs at request time, so anything derived or validated
|
|
211
|
+
* server-side must be precomputed in the backend's `register`/`onSchedule` and read back from the store.
|
|
136
212
|
*
|
|
137
213
|
* Custom plugin-defined server routes may arrive in a later `platformApi` version; v1 plugins use the
|
|
138
214
|
* doc store.
|
|
139
215
|
*
|
|
140
216
|
* @example Read, list, write and remove docs from a Web Component
|
|
141
217
|
* ```ts
|
|
142
|
-
* const
|
|
143
|
-
* const
|
|
218
|
+
* const mine = `data/user/${SELF_SCOPE_ID}`; // the caller's own partition
|
|
219
|
+
* const shared = `data/${ctx.scope.type}/${ctx.scope.id}`; // scope.id is `main` on the site scope
|
|
220
|
+
* const key = `mark:${ctx.scope.id}`; // no `/` in keys
|
|
144
221
|
*
|
|
145
|
-
* const
|
|
146
|
-
* await ctx.api.put(`${
|
|
222
|
+
* const marks = await ctx.api.get<Marks>(`${mine}/${key}`); // rejects with a 404 problem if absent
|
|
223
|
+
* await ctx.api.put(`${mine}/${key}`, { ...marks, b3: true }); // upsert, last-write-wins
|
|
147
224
|
*
|
|
148
|
-
* const page = await ctx.api.get<PagedDocs<
|
|
225
|
+
* const page = await ctx.api.get<PagedDocs<Marks>>(`${mine}?prefix=mark:&page=0&size=50`);
|
|
149
226
|
* page.items.forEach(({ key, value }) => render(key, value));
|
|
150
227
|
*
|
|
151
|
-
* await ctx.api.delete(`${
|
|
228
|
+
* await ctx.api.delete(`${mine}/${key}`); // idempotent
|
|
229
|
+
*
|
|
230
|
+
* // A leaderboard is not built here: the backend aggregates every user's marks with
|
|
231
|
+
* // queryAcrossUsers(...) and writes the result to `${shared}/leaderboard` for this component to read.
|
|
232
|
+
* const board = await ctx.api.get<Leaderboard>(`${shared}/leaderboard`);
|
|
152
233
|
* ```
|
|
153
234
|
*/
|
|
154
235
|
export interface PluginApiClient {
|
|
@@ -200,15 +281,230 @@ export interface DisplaySnapshot {
|
|
|
200
281
|
* @returns the resolved artwork URL, or `undefined` when neither the episode nor the feed declares one
|
|
201
282
|
*/
|
|
202
283
|
export declare function resolveArtwork(snapshot: DisplaySnapshot): string | undefined;
|
|
284
|
+
/**
|
|
285
|
+
* The consent gate for anything that stores data on the visitor's device or talks to a third party
|
|
286
|
+
* (ARCHITECTURE §12.5).
|
|
287
|
+
*
|
|
288
|
+
* Consent is **denied until granted**. The core itself runs banner-free — strictly necessary cookies need
|
|
289
|
+
* no consent — so the only reason a visitor ever sees a consent prompt is a plugin that asked for one.
|
|
290
|
+
* Treat that as the cost it is.
|
|
291
|
+
*
|
|
292
|
+
* The categories are declared per service in your manifest's `consent.services[]` (`necessary`,
|
|
293
|
+
* `functional`, `analytics`, or one you declare yourself). The host builds the cookie notice from those
|
|
294
|
+
* declarations, and the `hosts` you list there are also the CSP allow-list: **an origin you did not
|
|
295
|
+
* declare stays blocked even after consent is given.**
|
|
296
|
+
*
|
|
297
|
+
* ## The decision is per category, not per service
|
|
298
|
+
*
|
|
299
|
+
* `consent.services[]` is richly per-service — `provider`, `privacyUrl`, `thirdCountryTransfer`, each
|
|
300
|
+
* `storage[]` item — but every method here takes a **category**. That asymmetry is deliberate and worth
|
|
301
|
+
* stating plainly, because the schema implies otherwise: **services describe, categories decide.** The
|
|
302
|
+
* per-service detail exists so the notice can name who stores what for how long; the thing a visitor
|
|
303
|
+
* actually toggles is the category.
|
|
304
|
+
*
|
|
305
|
+
* The consequence: if two plugins each declare a service under `analytics` with different providers, the
|
|
306
|
+
* visitor sees **one** decision listing both plugins, and granting it grants both. There is no way to
|
|
307
|
+
* consent to one provider and withhold the other. Do not build a UI that implies otherwise, and don't
|
|
308
|
+
* assume `has('analytics')` says anything about *which* provider was accepted — it says the category was.
|
|
309
|
+
*
|
|
310
|
+
* If you need a visitor to be able to accept one of your services and refuse another, declare them under
|
|
311
|
+
* **different categories** (a plugin-declared category is allowed, it just has no translated label in the
|
|
312
|
+
* shell). That is the only lever the contract gives you.
|
|
313
|
+
*
|
|
314
|
+
* ## `necessary` is never asked about
|
|
315
|
+
*
|
|
316
|
+
* `has('necessary')` is **always `true`** and the host never prompts for it — it is the category the core
|
|
317
|
+
* itself uses, and a banner-free site stays banner-free. Declaring a service as `necessary` therefore
|
|
318
|
+
* means "this loads unconditionally"; use it only for what genuinely cannot be refused, and expect
|
|
319
|
+
* `request('necessary')` to resolve `true` without showing the visitor anything.
|
|
320
|
+
*
|
|
321
|
+
* @example The click-to-load placeholder this exists for
|
|
322
|
+
* ```ts
|
|
323
|
+
* function render({ ctx, root }: { ctx: PluginContext; root: HTMLElement }) {
|
|
324
|
+
* if (ctx.consent.has('analytics')) {
|
|
325
|
+
* mountChart(root);
|
|
326
|
+
* return;
|
|
327
|
+
* }
|
|
328
|
+
*
|
|
329
|
+
* const button = document.createElement('button');
|
|
330
|
+
* button.textContent = 'Load chart (sets a cookie)';
|
|
331
|
+
* button.onclick = async () => {
|
|
332
|
+
* if (await ctx.consent.request('analytics')) mountChart(root);
|
|
333
|
+
* };
|
|
334
|
+
* root.append(button);
|
|
335
|
+
*
|
|
336
|
+
* // Someone may flip the purpose in the settings page while this is mounted.
|
|
337
|
+
* return ctx.consent.onChange(() => rerender());
|
|
338
|
+
* }
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
export interface ConsentApi {
|
|
342
|
+
/**
|
|
343
|
+
* Whether the visitor has granted a consent category.
|
|
344
|
+
*
|
|
345
|
+
* Check it before every load of a consent-requiring resource, not once at startup — consent can be
|
|
346
|
+
* withdrawn mid-session from the host's settings page.
|
|
347
|
+
*
|
|
348
|
+
* @param category the consent category, as declared on a service in your manifest
|
|
349
|
+
* @returns `true` if the category is currently granted
|
|
350
|
+
*/
|
|
351
|
+
has(category: string): boolean;
|
|
352
|
+
/**
|
|
353
|
+
* Every category currently granted.
|
|
354
|
+
*
|
|
355
|
+
* For rendering a summary ("statistics: on, embeds: off"). Prefer {@link has} for a single gate.
|
|
356
|
+
*
|
|
357
|
+
* Includes `necessary`, which is always granted. These are categories, not service ids — a granted
|
|
358
|
+
* category covers every service declared under it, across all plugins.
|
|
359
|
+
*
|
|
360
|
+
* @returns the granted category names, in no guaranteed order
|
|
361
|
+
*/
|
|
362
|
+
granted(): string[];
|
|
363
|
+
/**
|
|
364
|
+
* Asks the visitor to grant a category, and resolves with their answer.
|
|
365
|
+
*
|
|
366
|
+
* The host opens its consent settings for this one purpose and resolves once the visitor decides;
|
|
367
|
+
* dismissing it resolves `false`. **Call this from a user gesture** — the click on your placeholder —
|
|
368
|
+
* and never on mount: an unprompted call turns a banner-free site into one with a banner, which is
|
|
369
|
+
* exactly what §12.5 is arranged to avoid.
|
|
370
|
+
*
|
|
371
|
+
* Resolving `true` means the category is granted from that moment on; it does not load anything for
|
|
372
|
+
* you. Load the resource yourself afterwards.
|
|
373
|
+
*
|
|
374
|
+
* ## What a caller may rely on
|
|
375
|
+
*
|
|
376
|
+
* A page full of plugin tiles will produce concurrent calls, so the contract is explicit about them:
|
|
377
|
+
*
|
|
378
|
+
* - **There is one consent surface, host-wide.** Calling this does not open a dialog of your own, and a
|
|
379
|
+
* second call while one is already open does not open a second — it joins the one in flight. You are
|
|
380
|
+
* asking the host to surface *its* settings, not opening a modal.
|
|
381
|
+
* - **The visitor decides every category at once.** The host's settings cover all declared categories,
|
|
382
|
+
* so one interaction can change several. Your promise still resolves with the state of *the category
|
|
383
|
+
* you asked for* — but other categories may have moved too, which is why {@link onChange} fires for
|
|
384
|
+
* every change rather than only yours.
|
|
385
|
+
* - **Every call resolves exactly once, and always.** Concurrent calls are never dropped or left
|
|
386
|
+
* pending: each resolves when the visitor completes the decision, including calls made while the
|
|
387
|
+
* surface was already open.
|
|
388
|
+
* - **Grants are shared across plugins.** If another plugin's `request('analytics')` is what the
|
|
389
|
+
* visitor accepted, your pending `request('analytics')` resolves `true` too — the decision is per
|
|
390
|
+
* category and site-wide, not per plugin (see the note on granularity above).
|
|
391
|
+
*
|
|
392
|
+
* So: don't serialize your calls, don't build a queue, and don't assume the visitor only answered you.
|
|
393
|
+
* Re-read {@link has} after any change rather than caching what a request resolved with.
|
|
394
|
+
*
|
|
395
|
+
* @param category the consent category to ask for
|
|
396
|
+
* @returns whether the category is granted after the visitor decided
|
|
397
|
+
*/
|
|
398
|
+
request(category: string): Promise<boolean>;
|
|
399
|
+
/**
|
|
400
|
+
* Subscribes to consent changes.
|
|
401
|
+
*
|
|
402
|
+
* Fires on **every** change to any category — including one made in the settings page while your
|
|
403
|
+
* component is mounted, and including a withdrawal. It carries no payload: re-read {@link has} or
|
|
404
|
+
* {@link granted} for the current state.
|
|
405
|
+
*
|
|
406
|
+
* @param cb called after each change
|
|
407
|
+
* @returns an {@link Unsubscribe} — return it from your render callback so the subscription dies with
|
|
408
|
+
* the component
|
|
409
|
+
*/
|
|
410
|
+
onChange(cb: () => void): Unsubscribe;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* One item a service stores on the visitor's device, as declared in `plugin.json`.
|
|
414
|
+
*
|
|
415
|
+
* Part of {@link ConsentServiceDeclaration} — see the caveats there before using either type.
|
|
416
|
+
*/
|
|
417
|
+
export interface ConsentStorageDeclaration {
|
|
418
|
+
/** The cookie or storage key exactly as it appears on the device, e.g. `plausible_ignore`. */
|
|
419
|
+
name: string;
|
|
420
|
+
/** Where it is stored. */
|
|
421
|
+
type: 'cookie' | 'localStorage' | 'sessionStorage';
|
|
422
|
+
/** What it is for, in language a visitor reads — not an internal description. */
|
|
423
|
+
purpose: string;
|
|
424
|
+
/** How long it lasts: `session`, `persistent`, or a human duration such as `12 months`. */
|
|
425
|
+
duration: string;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The shape of one entry in your manifest's `consent.services[]`.
|
|
429
|
+
*
|
|
430
|
+
* **This type is documentation, not enforcement.** The manifest is owned and validated by the host — the
|
|
431
|
+
* SDK does not read `plugin.json`, and nothing here runs at build or load time. It exists so an author
|
|
432
|
+
* writing the declaration gets IDE completion and catches a typo before core rejects the plugin at load,
|
|
433
|
+
* which is otherwise the first feedback you get. Two consequences worth knowing:
|
|
434
|
+
*
|
|
435
|
+
* - **The host is authoritative.** If this type and core disagree, core wins. It is kept in step by hand,
|
|
436
|
+
* so treat a mismatch as a bug in the SDK rather than permission to ignore the host.
|
|
437
|
+
* - **It is not a manifest type.** The manifest as a whole stays core-owned and the SDK will not grow one;
|
|
438
|
+
* this covers a single nested shape that got deep enough in 0.4.0 to be worth typing.
|
|
439
|
+
*
|
|
440
|
+
* Use it by typing a literal you keep next to your manifest, or as a reference while writing the JSON:
|
|
441
|
+
*
|
|
442
|
+
* ```ts
|
|
443
|
+
* const services: ConsentServiceDeclaration[] = [{
|
|
444
|
+
* id: 'plausible',
|
|
445
|
+
* name: 'Plausible Analytics',
|
|
446
|
+
* provider: 'Plausible Insights OÜ',
|
|
447
|
+
* category: 'analytics',
|
|
448
|
+
* privacyUrl: 'https://plausible.io/privacy',
|
|
449
|
+
* hosts: ['https://plausible.example'],
|
|
450
|
+
* thirdCountryTransfer: false,
|
|
451
|
+
* storage: [{
|
|
452
|
+
* name: 'plausible_ignore', type: 'localStorage',
|
|
453
|
+
* purpose: 'Remembers that you opted out of statistics', duration: 'persistent',
|
|
454
|
+
* }],
|
|
455
|
+
* }];
|
|
456
|
+
* ```
|
|
457
|
+
*/
|
|
458
|
+
export interface ConsentServiceDeclaration {
|
|
459
|
+
/** Stable identifier for this service within your plugin. */
|
|
460
|
+
id: string;
|
|
461
|
+
/** The service as a visitor would recognise it, e.g. `Plausible Analytics`. */
|
|
462
|
+
name: string;
|
|
463
|
+
/** The **legal entity** operating the service — the company, not your plugin. */
|
|
464
|
+
provider: string;
|
|
465
|
+
/**
|
|
466
|
+
* The consent category this service falls under, and therefore what {@link ConsentApi.has} gates on.
|
|
467
|
+
*
|
|
468
|
+
* `necessary` is never prompted for and always granted. Remember that the category — not the service —
|
|
469
|
+
* is what the visitor decides: two services sharing a category are accepted or refused together.
|
|
470
|
+
*/
|
|
471
|
+
category: 'necessary' | 'functional' | 'analytics' | (string & {});
|
|
472
|
+
/** The provider's own privacy policy. */
|
|
473
|
+
privacyUrl: string;
|
|
474
|
+
/**
|
|
475
|
+
* Every origin the service is contacted on, scheme included (`https://plausible.example`).
|
|
476
|
+
*
|
|
477
|
+
* **Also the CSP allow-list**: core widens `script-src`/`frame-src`/`connect-src` by exactly these, so
|
|
478
|
+
* an undeclared or bare-hostname origin stays blocked even after consent is given.
|
|
479
|
+
*/
|
|
480
|
+
hosts: string[];
|
|
481
|
+
/** Whether personal data leaves the EU/EEA. */
|
|
482
|
+
thirdCountryTransfer: boolean;
|
|
483
|
+
/** Each item the service stores on the visitor's device. */
|
|
484
|
+
storage: ConsentStorageDeclaration[];
|
|
485
|
+
}
|
|
203
486
|
/**
|
|
204
487
|
* Everything a frontend plugin is given, set by the host on the mounted custom element
|
|
205
488
|
* (ARCHITECTURE §7.5). This is the **entire** interface a plugin author must learn.
|
|
206
489
|
*/
|
|
207
490
|
export interface PluginContext {
|
|
208
|
-
/**
|
|
491
|
+
/**
|
|
492
|
+
* The scope this plugin instance is mounted in. For the `episode` scope, `scope.id` is the episode's
|
|
493
|
+
* **public slug** (e.g. `the-sample-cast-s01e06`) — the same id used in its URL and as the doc-store
|
|
494
|
+
* partition (`data/episode/{slug}/…`), not the internal UUID.
|
|
495
|
+
*/
|
|
209
496
|
scope: Scope;
|
|
210
|
-
/**
|
|
497
|
+
/**
|
|
498
|
+
* The episode ids in scope, resolved (and access-filtered) by the host — the public **slugs** (the same
|
|
499
|
+
* values used in URLs and doc-store paths). Pair with {@link episodeLabels} for display.
|
|
500
|
+
*/
|
|
211
501
|
episodes: string[];
|
|
502
|
+
/**
|
|
503
|
+
* Human-readable labels for {@link episodes}, keyed by slug (e.g. `"S01E06 · The Lighthouse…"`). The host
|
|
504
|
+
* builds them from the feed's season/episode + title; use them in pickers so users see titles, not slugs.
|
|
505
|
+
* Optional: absent (or partial) when the host does not provide a label for a given episode.
|
|
506
|
+
*/
|
|
507
|
+
episodeLabels?: Record<string, string>;
|
|
212
508
|
/** Present on the `episode` scope: lifecycle status of the current episode. */
|
|
213
509
|
episode?: {
|
|
214
510
|
status: 'PLANNED' | 'PUBLISHED' | 'WITHDRAWN';
|
|
@@ -224,31 +520,68 @@ export interface PluginContext {
|
|
|
224
520
|
* uses via `ctx.store()`. See {@link PluginApiClient} for the endpoint shape and access rules.
|
|
225
521
|
*/
|
|
226
522
|
api: PluginApiClient;
|
|
227
|
-
/**
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
523
|
+
/**
|
|
524
|
+
* Writes one line to the host's log, attributed to this plugin.
|
|
525
|
+
*
|
|
526
|
+
* The counterpart of the backend's `ctx.logger()`, and the **only** supported way for a frontend
|
|
527
|
+
* component to log to the host — do not POST to `/api/plugins/{id}/log` through {@link api}, which is a
|
|
528
|
+
* data endpoint. The host attributes the entry to your plugin, stores `info` and above, surfaces `warn`
|
|
529
|
+
* and above in the admin log viewer, and rate-limits this path: a render loop logging per frame will be
|
|
530
|
+
* throttled, not stored.
|
|
531
|
+
*
|
|
532
|
+
* Messages are read by site operators, not by you — they land next to core's own output. Keep them
|
|
533
|
+
* short, and keep personal data out of them.
|
|
534
|
+
*
|
|
535
|
+
* @param level the severity
|
|
536
|
+
* @param message the message; already-formatted, since there is no placeholder syntax here
|
|
537
|
+
*
|
|
538
|
+
* @example
|
|
539
|
+
* ```ts
|
|
540
|
+
* ctx.log('warn', `no board for episode ${ctx.scope.id}`);
|
|
541
|
+
* ```
|
|
542
|
+
*/
|
|
543
|
+
log(level: LogLevel, message: string): void;
|
|
544
|
+
/** Cookie/consent gate for third-party resources — see {@link ConsentApi} (ARCHITECTURE §12.5). */
|
|
545
|
+
consent: ConsentApi;
|
|
546
|
+
/**
|
|
547
|
+
* Read-only access to the host's URL filter state (ARCHITECTURE §6.1).
|
|
548
|
+
*
|
|
549
|
+
* Plugins *consume* filters, they never define them: the axes (season, tags, sorting) belong to the
|
|
550
|
+
* host and live in the URL. `onChange` returns an {@link Unsubscribe}.
|
|
551
|
+
*/
|
|
233
552
|
filter: {
|
|
234
553
|
current(): FilterState;
|
|
235
|
-
onChange(cb: (f: FilterState) => void):
|
|
554
|
+
onChange(cb: (f: FilterState) => void): Unsubscribe;
|
|
236
555
|
};
|
|
237
|
-
/**
|
|
556
|
+
/**
|
|
557
|
+
* Player position + control, for sync plugins (ARCHITECTURE §6.5).
|
|
558
|
+
*
|
|
559
|
+
* `on` returns an {@link Unsubscribe} — player events outlive a single render, so detaching matters
|
|
560
|
+
* here more than anywhere else on this context.
|
|
561
|
+
*/
|
|
238
562
|
player: {
|
|
239
563
|
currentTime(): number;
|
|
240
564
|
seekTo(s: number): void;
|
|
241
|
-
on(ev: string, cb: (...args: unknown[]) => void):
|
|
565
|
+
on(ev: string, cb: (...args: unknown[]) => void): Unsubscribe;
|
|
242
566
|
};
|
|
243
|
-
/**
|
|
567
|
+
/**
|
|
568
|
+
* The subpath under `/p/<pluginId>/`, for deep-linkable plugin content (ARCHITECTURE §6.4).
|
|
569
|
+
*
|
|
570
|
+
* `onChange` returns an {@link Unsubscribe}.
|
|
571
|
+
*/
|
|
244
572
|
route: {
|
|
245
573
|
path: string;
|
|
246
|
-
onChange(cb: (p: string) => void):
|
|
574
|
+
onChange(cb: (p: string) => void): Unsubscribe;
|
|
247
575
|
};
|
|
248
|
-
/**
|
|
576
|
+
/**
|
|
577
|
+
* The active UI locale (ARCHITECTURE §12.7).
|
|
578
|
+
*
|
|
579
|
+
* `onChange` returns an {@link Unsubscribe}. {@link createPluginI18n} subscribes to this for you and
|
|
580
|
+
* hands back a `dispose` to undo it.
|
|
581
|
+
*/
|
|
249
582
|
locale: {
|
|
250
583
|
current(): string;
|
|
251
|
-
onChange(cb: (l: string) => void):
|
|
584
|
+
onChange(cb: (l: string) => void): Unsubscribe;
|
|
252
585
|
};
|
|
253
586
|
/** Core listening progress in seconds, or `null` if unknown (ARCHITECTURE §6.5). */
|
|
254
587
|
progress: {
|
|
@@ -306,6 +639,14 @@ export interface PluginI18n {
|
|
|
306
639
|
t(key: string, params?: Record<string, string | number>): string;
|
|
307
640
|
/** The currently active locale code. */
|
|
308
641
|
readonly locale: string;
|
|
642
|
+
/**
|
|
643
|
+
* Detaches the translator from `ctx.locale`.
|
|
644
|
+
*
|
|
645
|
+
* A translator subscribes to locale changes for its whole life. Call this when the component that owns
|
|
646
|
+
* it goes away — from the cleanup callback your render returns — or the subscription keeps a dead
|
|
647
|
+
* translator alive.
|
|
648
|
+
*/
|
|
649
|
+
dispose(): void;
|
|
309
650
|
}
|
|
310
651
|
/**
|
|
311
652
|
* Creates a plugin-local translator bound to the host's active locale (ARCHITECTURE §12.7).
|
|
@@ -314,6 +655,9 @@ export interface PluginI18n {
|
|
|
314
655
|
* change — the same convention as the shell, no extra i18n library required. English (`en`) is the
|
|
315
656
|
* source language and the fallback.
|
|
316
657
|
*
|
|
658
|
+
* The translator subscribes to `locale.onChange` for as long as it lives — call {@link PluginI18n.dispose}
|
|
659
|
+
* from your render's cleanup callback when the component goes away.
|
|
660
|
+
*
|
|
317
661
|
* @param catalogs catalogs keyed by locale code
|
|
318
662
|
* @param locale the host locale handle, i.e. `ctx.locale`; its `onChange` drives re-selection
|
|
319
663
|
* @returns a translator whose `t` and `locale` reflect the currently active locale
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AAEH
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -13,10 +13,24 @@
|
|
|
13
13
|
* The plugin contract version (SemVer).
|
|
14
14
|
*
|
|
15
15
|
* Mirror of the Java `dev.mosaicast.plugin.api.PlatformApi.VERSION` constant and the npm package
|
|
16
|
-
* version
|
|
17
|
-
*
|
|
16
|
+
* version — **these move together**, and CI fails the build if they drift.
|
|
17
|
+
*
|
|
18
|
+
* The host compares a plugin manifest's `platformApi` against this value on **`major.minor` exactly** and
|
|
19
|
+
* rejects a mismatch at startup (ARCHITECTURE §7.2). While the SDK is pre-1.0 a breaking change is
|
|
20
|
+
* therefore a *minor* bump; from `1.0.0` on, breaking means major.
|
|
18
21
|
*/
|
|
19
|
-
export const PLATFORM_API_VERSION = '0.
|
|
22
|
+
export const PLATFORM_API_VERSION = '0.5.0';
|
|
23
|
+
/**
|
|
24
|
+
* The id every `user` data path carries: the literal `me`, mirroring the Java `Scope.SELF_ID`.
|
|
25
|
+
*
|
|
26
|
+
* The host resolves it from the session, so `data/user/me/{key}` reaches the calling user's partition and
|
|
27
|
+
* no one else's. **Any other `user` id is a 400**, never a silent substitution, and an anonymous request
|
|
28
|
+
* to a `user` path is a 401 — with no session there is no partition. Per-user data belongs here rather
|
|
29
|
+
* than in a key like `mark:<userId>:cell`, which the host cannot enforce because the client supplies it.
|
|
30
|
+
*
|
|
31
|
+
* @since 0.5.0
|
|
32
|
+
*/
|
|
33
|
+
export const SELF_SCOPE_ID = 'me';
|
|
20
34
|
/**
|
|
21
35
|
* The artwork to display for an episode: its own {@link DisplaySnapshot.imageUrl} if present, otherwise
|
|
22
36
|
* the {@link DisplaySnapshot.feedImageUrl feed cover}, otherwise `undefined`.
|
|
@@ -120,19 +134,27 @@ function interpolate(template, params) {
|
|
|
120
134
|
* change — the same convention as the shell, no extra i18n library required. English (`en`) is the
|
|
121
135
|
* source language and the fallback.
|
|
122
136
|
*
|
|
137
|
+
* The translator subscribes to `locale.onChange` for as long as it lives — call {@link PluginI18n.dispose}
|
|
138
|
+
* from your render's cleanup callback when the component goes away.
|
|
139
|
+
*
|
|
123
140
|
* @param catalogs catalogs keyed by locale code
|
|
124
141
|
* @param locale the host locale handle, i.e. `ctx.locale`; its `onChange` drives re-selection
|
|
125
142
|
* @returns a translator whose `t` and `locale` reflect the currently active locale
|
|
126
143
|
*/
|
|
127
144
|
export function createPluginI18n(catalogs, locale) {
|
|
128
145
|
let active = locale.current();
|
|
129
|
-
locale.onChange((l) => {
|
|
146
|
+
const unsubscribe = locale.onChange((l) => {
|
|
130
147
|
active = l;
|
|
131
148
|
});
|
|
132
149
|
return {
|
|
133
150
|
get locale() {
|
|
134
151
|
return active;
|
|
135
152
|
},
|
|
153
|
+
dispose() {
|
|
154
|
+
// Optional call: a host built against 0.3.x returns nothing here, and a translator that cannot be
|
|
155
|
+
// disposed is better than one that throws while a component is tearing down.
|
|
156
|
+
unsubscribe?.();
|
|
157
|
+
},
|
|
136
158
|
t(key, params) {
|
|
137
159
|
const template = catalogs[active]?.[key] ?? catalogs[SOURCE_LOCALE]?.[key] ?? key;
|
|
138
160
|
return interpolate(template, params);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,qDAAqD;AAErD;;;;;;;;GAQG;AAEH
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/testing.d.ts
CHANGED
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
|
-
import type { PluginApiClient, PluginContext, ThemeTokens } from './index.js';
|
|
9
|
+
import type { ConsentApi, LogLevel, PluginApiClient, PluginContext, ThemeTokens } from './index.js';
|
|
10
|
+
/** One recorded {@link PluginContext.log} call. */
|
|
11
|
+
export interface LogRecord {
|
|
12
|
+
/** The severity it was logged at. */
|
|
13
|
+
level: LogLevel;
|
|
14
|
+
/** The logged message. */
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
10
17
|
/** One recorded {@link MockApiClient} call. */
|
|
11
18
|
export interface RecordedCall {
|
|
12
19
|
/** HTTP method, lowercase. */
|
|
@@ -26,8 +33,66 @@ export interface MockApiClient extends PluginApiClient {
|
|
|
26
33
|
*/
|
|
27
34
|
responses: Record<string, unknown>;
|
|
28
35
|
}
|
|
36
|
+
/** A {@link ConsentApi} whose grants a test can drive. */
|
|
37
|
+
export interface MockConsent extends ConsentApi {
|
|
38
|
+
/** Grants a category and notifies subscribers. Granting an already-granted category does nothing. */
|
|
39
|
+
grant(category: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Withdraws a category and notifies subscribers, as the host's settings page can mid-session.
|
|
42
|
+
*
|
|
43
|
+
* Revoking `necessary` does nothing — the host cannot withdraw it either.
|
|
44
|
+
*/
|
|
45
|
+
revoke(category: string): void;
|
|
46
|
+
/** Every category passed to {@link ConsentApi.request}, in order. */
|
|
47
|
+
readonly requests: string[];
|
|
48
|
+
/**
|
|
49
|
+
* What {@link ConsentApi.request} resolves with — i.e. what the visitor decides.
|
|
50
|
+
*
|
|
51
|
+
* `false` by default: the visitor says no. Set it to `true` to test the accepted path, which also
|
|
52
|
+
* grants the category as the host would.
|
|
53
|
+
*/
|
|
54
|
+
autoGrantOnRequest: boolean;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Builds a {@link ConsentApi} for tests, **denying everything** unless told otherwise.
|
|
58
|
+
*
|
|
59
|
+
* Deny is the default on purpose. The host denies until granted, so a component written against a
|
|
60
|
+
* permissive mock is a component whose placeholder path was never exercised — and that path is the entire
|
|
61
|
+
* point of the consent contract (ARCHITECTURE §12.5).
|
|
62
|
+
*
|
|
63
|
+
* The one exception is `necessary`, which is granted from the start and cannot be revoked in the host
|
|
64
|
+
* either — it is the category the core itself uses.
|
|
65
|
+
*
|
|
66
|
+
* @param initial categories granted from the start, on top of `necessary`
|
|
67
|
+
* @returns a consent double with `grant`/`revoke` and a recorded `requests` list
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* const consent = makeMockConsent();
|
|
72
|
+
* const ctx = makeMockCtx({ consent });
|
|
73
|
+
*
|
|
74
|
+
* mount(ctx); // renders the click-to-load placeholder
|
|
75
|
+
* consent.autoGrantOnRequest = true;
|
|
76
|
+
* await clickPlaceholder();
|
|
77
|
+
* expect(consent.requests).toEqual(['analytics']);
|
|
78
|
+
*
|
|
79
|
+
* consent.revoke('analytics'); // fires onChange; the component should go back to the placeholder
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare function makeMockConsent(initial?: string[]): MockConsent;
|
|
29
83
|
/** Default theme tokens (neutral light values) used when a test does not override them. */
|
|
30
84
|
export declare const DEFAULT_THEME: ThemeTokens;
|
|
85
|
+
/** A {@link PluginContext} wired for tests: the mock `api` and the recorded `log` calls are reachable. */
|
|
86
|
+
export type MockPluginContext = PluginContext & {
|
|
87
|
+
/** The recording client every `api` call goes through. */
|
|
88
|
+
api: MockApiClient;
|
|
89
|
+
/**
|
|
90
|
+
* Every {@link PluginContext.log} call, in order.
|
|
91
|
+
*
|
|
92
|
+
* Stays empty if you override `log` yourself — the recording lives in the default implementation.
|
|
93
|
+
*/
|
|
94
|
+
logs: LogRecord[];
|
|
95
|
+
};
|
|
31
96
|
/** Overrides accepted by {@link makeMockCtx}. */
|
|
32
97
|
export interface MockCtxOverrides extends Partial<Omit<PluginContext, 'api'>> {
|
|
33
98
|
/** Canned responses for the mock `api`, keyed as described on {@link MockApiClient.responses}. */
|
|
@@ -38,14 +103,18 @@ export interface MockCtxOverrides extends Partial<Omit<PluginContext, 'api'>> {
|
|
|
38
103
|
/**
|
|
39
104
|
* Builds a {@link PluginContext} wired with in-memory doubles for unit tests.
|
|
40
105
|
*
|
|
41
|
-
* Defaults: `site` scope, no episodes, anonymous user, all consent denied
|
|
106
|
+
* Defaults: `site` scope, no episodes, anonymous user, **all consent denied** (a {@link makeMockConsent}
|
|
107
|
+
* double — pass your own to drive grants), empty filter, a player
|
|
42
108
|
* parked at 0s, empty route, `en` locale, unknown progress, and {@link DEFAULT_THEME}. Pass overrides to
|
|
43
|
-
* change any field; the returned `api` is a {@link MockApiClient} recording every call
|
|
109
|
+
* change any field; the returned `api` is a {@link MockApiClient} recording every call, and `logs`
|
|
110
|
+
* collects every `ctx.log(...)`.
|
|
111
|
+
*
|
|
112
|
+
* `episodeLabels` is deliberately **absent** by default. It is optional on the real context and the host
|
|
113
|
+
* may supply it partially, so a component that renders labels has to survive their absence — the mock
|
|
114
|
+
* makes you face that unless you pass labels in.
|
|
44
115
|
*
|
|
45
116
|
* @param overrides partial context plus optional `apiResponses`
|
|
46
|
-
* @returns a full context whose `api` is a {@link MockApiClient}
|
|
117
|
+
* @returns a full context whose `api` is a {@link MockApiClient} and whose `log` calls are recorded
|
|
47
118
|
*/
|
|
48
|
-
export declare function makeMockCtx(overrides?: MockCtxOverrides):
|
|
49
|
-
api: MockApiClient;
|
|
50
|
-
};
|
|
119
|
+
export declare function makeMockCtx(overrides?: MockCtxOverrides): MockPluginContext;
|
|
51
120
|
//# sourceMappingURL=testing.d.ts.map
|
package/dist/testing.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAEV,eAAe,EACf,aAAa,EACb,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB,+CAA+C;AAC/C,MAAM,WAAW,YAAY;IAC3B,8BAA8B;IAC9B,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,iFAAiF;AACjF,MAAM,WAAW,aAAc,SAAQ,eAAe;IACpD,qDAAqD;IACrD,QAAQ,CAAC,KAAK,EAAE,YAAY,EAAE,CAAC;IAC/B;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,UAAU,EAEV,QAAQ,EACR,eAAe,EACf,aAAa,EACb,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB,mDAAmD;AACnD,MAAM,WAAW,SAAS;IACxB,qCAAqC;IACrC,KAAK,EAAE,QAAQ,CAAC;IAChB,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,+CAA+C;AAC/C,MAAM,WAAW,YAAY;IAC3B,8BAA8B;IAC9B,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,iFAAiF;AACjF,MAAM,WAAW,aAAc,SAAQ,eAAe;IACpD,qDAAqD;IACrD,QAAQ,CAAC,KAAK,EAAE,YAAY,EAAE,CAAC;IAC/B;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAuBD,0DAA0D;AAC1D,MAAM,WAAW,WAAY,SAAQ,UAAU;IAC7C,qGAAqG;IACrG,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B;;;;OAIG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAC5B;;;;;OAKG;IACH,kBAAkB,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,MAAM,EAAO,GAAG,WAAW,CAwCnE;AAED,2FAA2F;AAC3F,eAAO,MAAM,aAAa,EAAE,WAS3B,CAAC;AAEF,0GAA0G;AAC1G,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG;IAC9C,0DAA0D;IAC1D,GAAG,EAAE,aAAa,CAAC;IACnB;;;;OAIG;IACH,IAAI,EAAE,SAAS,EAAE,CAAC;CACnB,CAAC;AAEF,iDAAiD;AACjD,MAAM,WAAW,gBAAiB,SAAQ,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;IAC3E,kGAAkG;IAClG,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,kFAAkF;IAClF,GAAG,CAAC,EAAE,aAAa,CAAC;CACrB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CAAC,SAAS,GAAE,gBAAqB,GAAG,iBAAiB,CA2B/E"}
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 The Mosaicast Authors
|
|
3
|
+
/** The unsubscribe returned by the inert `onChange` doubles — there is nothing to detach. */
|
|
4
|
+
const noop = () => { };
|
|
3
5
|
function makeMockApi(responses) {
|
|
4
6
|
const calls = [];
|
|
5
7
|
const resolve = (method, path, body) => {
|
|
@@ -17,6 +19,72 @@ function makeMockApi(responses) {
|
|
|
17
19
|
};
|
|
18
20
|
return api;
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Builds a {@link ConsentApi} for tests, **denying everything** unless told otherwise.
|
|
24
|
+
*
|
|
25
|
+
* Deny is the default on purpose. The host denies until granted, so a component written against a
|
|
26
|
+
* permissive mock is a component whose placeholder path was never exercised — and that path is the entire
|
|
27
|
+
* point of the consent contract (ARCHITECTURE §12.5).
|
|
28
|
+
*
|
|
29
|
+
* The one exception is `necessary`, which is granted from the start and cannot be revoked in the host
|
|
30
|
+
* either — it is the category the core itself uses.
|
|
31
|
+
*
|
|
32
|
+
* @param initial categories granted from the start, on top of `necessary`
|
|
33
|
+
* @returns a consent double with `grant`/`revoke` and a recorded `requests` list
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const consent = makeMockConsent();
|
|
38
|
+
* const ctx = makeMockCtx({ consent });
|
|
39
|
+
*
|
|
40
|
+
* mount(ctx); // renders the click-to-load placeholder
|
|
41
|
+
* consent.autoGrantOnRequest = true;
|
|
42
|
+
* await clickPlaceholder();
|
|
43
|
+
* expect(consent.requests).toEqual(['analytics']);
|
|
44
|
+
*
|
|
45
|
+
* consent.revoke('analytics'); // fires onChange; the component should go back to the placeholder
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function makeMockConsent(initial = []) {
|
|
49
|
+
// `necessary` is what the core itself uses: always granted, never prompted for. Seeding it means a
|
|
50
|
+
// component gated on it behaves in tests the way it will in the host.
|
|
51
|
+
const granted = new Set(['necessary', ...initial]);
|
|
52
|
+
const subscribers = new Set();
|
|
53
|
+
const requests = [];
|
|
54
|
+
const notify = () => {
|
|
55
|
+
subscribers.forEach((cb) => cb());
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
requests,
|
|
59
|
+
autoGrantOnRequest: false,
|
|
60
|
+
has: (category) => granted.has(category),
|
|
61
|
+
granted: () => [...granted],
|
|
62
|
+
request(category) {
|
|
63
|
+
requests.push(category);
|
|
64
|
+
if (this.autoGrantOnRequest && !granted.has(category)) {
|
|
65
|
+
granted.add(category);
|
|
66
|
+
notify();
|
|
67
|
+
}
|
|
68
|
+
return Promise.resolve(granted.has(category));
|
|
69
|
+
},
|
|
70
|
+
grant(category) {
|
|
71
|
+
if (!granted.has(category)) {
|
|
72
|
+
granted.add(category);
|
|
73
|
+
notify();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
revoke(category) {
|
|
77
|
+
// The host cannot withdraw `necessary`, so neither can the double.
|
|
78
|
+
if (category !== 'necessary' && granted.delete(category)) {
|
|
79
|
+
notify();
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
onChange(cb) {
|
|
83
|
+
subscribers.add(cb);
|
|
84
|
+
return () => subscribers.delete(cb);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
20
88
|
/** Default theme tokens (neutral light values) used when a test does not override them. */
|
|
21
89
|
export const DEFAULT_THEME = {
|
|
22
90
|
bg: '#ffffff',
|
|
@@ -31,30 +99,43 @@ export const DEFAULT_THEME = {
|
|
|
31
99
|
/**
|
|
32
100
|
* Builds a {@link PluginContext} wired with in-memory doubles for unit tests.
|
|
33
101
|
*
|
|
34
|
-
* Defaults: `site` scope, no episodes, anonymous user, all consent denied
|
|
102
|
+
* Defaults: `site` scope, no episodes, anonymous user, **all consent denied** (a {@link makeMockConsent}
|
|
103
|
+
* double — pass your own to drive grants), empty filter, a player
|
|
35
104
|
* parked at 0s, empty route, `en` locale, unknown progress, and {@link DEFAULT_THEME}. Pass overrides to
|
|
36
|
-
* change any field; the returned `api` is a {@link MockApiClient} recording every call
|
|
105
|
+
* change any field; the returned `api` is a {@link MockApiClient} recording every call, and `logs`
|
|
106
|
+
* collects every `ctx.log(...)`.
|
|
107
|
+
*
|
|
108
|
+
* `episodeLabels` is deliberately **absent** by default. It is optional on the real context and the host
|
|
109
|
+
* may supply it partially, so a component that renders labels has to survive their absence — the mock
|
|
110
|
+
* makes you face that unless you pass labels in.
|
|
37
111
|
*
|
|
38
112
|
* @param overrides partial context plus optional `apiResponses`
|
|
39
|
-
* @returns a full context whose `api` is a {@link MockApiClient}
|
|
113
|
+
* @returns a full context whose `api` is a {@link MockApiClient} and whose `log` calls are recorded
|
|
40
114
|
*/
|
|
41
115
|
export function makeMockCtx(overrides = {}) {
|
|
42
116
|
const { apiResponses, api, ...rest } = overrides;
|
|
43
117
|
const mockApi = api ?? makeMockApi(apiResponses ?? {});
|
|
44
118
|
const filter = rest.filter?.current() ?? {};
|
|
119
|
+
const logs = [];
|
|
45
120
|
const base = {
|
|
46
121
|
scope: { type: 'site', id: 'main' },
|
|
47
122
|
episodes: [],
|
|
48
123
|
user: null,
|
|
49
124
|
api: mockApi,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
125
|
+
logs,
|
|
126
|
+
log: (level, message) => {
|
|
127
|
+
logs.push({ level, message });
|
|
128
|
+
},
|
|
129
|
+
consent: makeMockConsent(),
|
|
130
|
+
// The no-op handles still return a working unsubscribe, so a component that detaches on cleanup
|
|
131
|
+
// behaves the same against the mock as against the host.
|
|
132
|
+
filter: { current: () => filter, onChange: () => noop },
|
|
133
|
+
player: { currentTime: () => 0, seekTo: () => { }, on: () => noop },
|
|
134
|
+
route: { path: '', onChange: () => noop },
|
|
135
|
+
locale: { current: () => 'en', onChange: () => noop },
|
|
55
136
|
progress: { get: () => Promise.resolve(null) },
|
|
56
137
|
theme: DEFAULT_THEME,
|
|
57
138
|
};
|
|
58
|
-
return { ...base, ...rest, api: mockApi };
|
|
139
|
+
return { ...base, ...rest, api: mockApi, logs };
|
|
59
140
|
}
|
|
60
141
|
//# sourceMappingURL=testing.js.map
|
package/dist/testing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,qDAAqD;
|
|
1
|
+
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mosaicast/plugin-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Versioned plugin contract for Mosaicast: the frontend PluginContext types, a Web Component base helper, an i18n helper, plus a test kit under the /testing subpath.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|