@mosaicast/plugin-sdk 0.14.0 → 0.16.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 +209 -11
- package/dist/index.d.ts +431 -24
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +131 -15
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +50 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +143 -5
- package/dist/testing.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -85,7 +85,9 @@ This is the part plugin authors most often guess wrong, so it is stated plainly.
|
|
|
85
85
|
|
|
86
86
|
```text
|
|
87
87
|
GET /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
|
|
88
|
-
→ one JSON doc;
|
|
88
|
+
→ one JSON doc; 204 (no body) if the key is not set — 404 means a wrong address
|
|
89
|
+
GET /api/plugins/{id}/data/{scopeType}?ids=a,b&keys=x,y (since 0.16.0)
|
|
90
|
+
→ { a: { x: … }, b: {} } — misses absent; ≤ 100 ids and ≤ 100 keys per request
|
|
89
91
|
GET /api/plugins/{id}/data/{scopeType}/{scopeId}?prefix=&page=&size=
|
|
90
92
|
→ { items: [{ key, value }], page, size, totalElements, totalPages }
|
|
91
93
|
PUT /api/plugins/{id}/data/{scopeType}/{scopeId}/{key} (JSON body)
|
|
@@ -109,7 +111,7 @@ DELETE /api/plugins/{id}/data/{scopeType}/{scopeId}/{key}
|
|
|
109
111
|
### The typed client — `ctx.docs` (since 0.9.0)
|
|
110
112
|
|
|
111
113
|
`ctx.api` is the raw surface and stays available. `ctx.docs` is the same endpoints with the path building,
|
|
112
|
-
the key validation and the
|
|
114
|
+
the key validation and the absence handling done for you — which removes a whole class of bug, since every doc
|
|
113
115
|
access above was string concatenation the plugin had to get right four segments at a time:
|
|
114
116
|
|
|
115
117
|
```ts
|
|
@@ -122,8 +124,22 @@ await ctx.docs.remove(ctx.scope, 'draft'); // any Scope addre
|
|
|
122
124
|
- **`'self'` is `data/user/me`** and `'site'` is `data/site/main` — the two singletons. Making the
|
|
123
125
|
per-user partition the *shortest* thing to write is deliberate: it is the most security-relevant
|
|
124
126
|
convention in the contract, and a convention only sticks when it is also the easy path.
|
|
125
|
-
- **`get` resolves `null`
|
|
126
|
-
failure. The same is true of `ctx.api.getOrNull(path)
|
|
127
|
+
- **`get` resolves `null` when the key is not set** (the host's 204), because a key nothing has written
|
|
128
|
+
yet is a normal state rather than a failure. The same is true of `ctx.api.getOrNull(path)`; a raw
|
|
129
|
+
`ctx.api.get` resolves `undefined` there.
|
|
130
|
+
- **`getMany(type, ids, keys)` reads a page of cards in one request** (since 0.16.0), answering
|
|
131
|
+
`{ id: { key: value } }` with misses simply absent. Over 100 ids or keys is split and merged for you.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const docs = await ctx.docs.getMany<Highlight>('episode', ctx.episodes.slice(0, 20), ['highlight']);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- **The client remembers for you (guaranteed since 0.16.0)**, per plugin and signed-in identity, for the
|
|
138
|
+
life of the page: identical `get`s in flight share one request; a miss is remembered (from `getMany`
|
|
139
|
+
too); your own `put`/`remove` forget the address they touched; hits are never cached; errors are never
|
|
140
|
+
remembered. So **delete any cache of misses you wrote yourself** — it is redundant — and keep a cache of
|
|
141
|
+
hits, if at all, no longer than a render: it hides writes made in other sessions. Before this, 98% of
|
|
142
|
+
one measured session's plugin requests were "not set", with one key asked 178 times.
|
|
127
143
|
- **A malformed key throws at the call site**, with `DOC_KEY_PATTERN` in the message, instead of costing a
|
|
128
144
|
400 round-trip whose body you then have to read.
|
|
129
145
|
- Everything else is unchanged and still the host's: both access floors, `backendOwned`, the 400 on an
|
|
@@ -163,16 +179,23 @@ boundary, so it does not share a constructor with anything in your plugin.
|
|
|
163
179
|
- The partition is **flat** — one per user, not one per user and entity — so the entity goes in the key: `mark:<episodeSlug>:cell`.
|
|
164
180
|
- `readableBy` does not apply to it. No floor makes someone else's partition readable.
|
|
165
181
|
|
|
166
|
-
**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:
|
|
182
|
+
**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 — **after declaring it** (since 0.16.0):
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
"data": { "writableBy": "fan", "readableBy": "anonymous", "readsAllUsers": true }
|
|
186
|
+
```
|
|
167
187
|
|
|
168
188
|
```java
|
|
169
189
|
// Backend-only, read-only, and no HTTP surface: no visitor's request can reach another's data.
|
|
170
|
-
|
|
190
|
+
// null without data.readsAllUsers — the one read that crosses an ownership boundary is declared.
|
|
191
|
+
List<OwnedDocEntry> marks = ctx.allUsers().query("mark:");
|
|
171
192
|
// record OwnedDocEntry(UUID userId, String key, JsonNode value) — the owner is host-resolved, never
|
|
172
193
|
// a value the browser supplied, which is what makes a leaderboard built from it true.
|
|
173
194
|
```
|
|
174
195
|
|
|
175
|
-
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
|
|
196
|
+
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; `FakePluginContext.withReadsAllUsers()` stands in for the declaration (off by default, so `ctx.allUsers()` is `null` as it is for an undeclared plugin), and `store.acrossUsers()` reads the partitions directly for your assertions.
|
|
197
|
+
|
|
198
|
+
**Why `readsAllUsers` is declared.** Every other doc-store read is a plugin's own shared scopes or the caller's own partition. This one returns every account's documents with their owners' UUIDs — "can enumerate everyone who ever used me" — which an operator should be able to read off a manifest before installing, exactly as with `identity` and `notifications`. Until 0.16.0 it was `ctx.store().queryAcrossUsers(prefix)` and every plugin had it without asking.
|
|
176
199
|
|
|
177
200
|
**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}`.
|
|
178
201
|
|
|
@@ -327,7 +350,38 @@ const one = await ctx.feeds.display('kraken'); // null when absent or not visi
|
|
|
327
350
|
reason to read it live. Cache per render, never per install.
|
|
328
351
|
- `displayMany` **clamps** at 200 slugs rather than erroring, so check what came back.
|
|
329
352
|
|
|
353
|
+
- **`description` is untrusted third-party HTML** — whatever the podcast host put in the feed, unsanitized.
|
|
354
|
+
Never assign it to `innerHTML` as it stands. Show `descriptionText` (plain text, since 0.16.0) for a card
|
|
355
|
+
or a teaser, or run it through `ctx.sanitize` (below). A Java backend putting show notes into an
|
|
356
|
+
`OgMeta`, a `SearchHit` or a notification uses `descriptionText()` too.
|
|
357
|
+
|
|
330
358
|
Test it with `makeMockFeeds().withDisplay(slug, snapshot)`, mirroring the Java `FakeFeedAccess.withDisplay`.
|
|
359
|
+
The double derives `descriptionText` from `description` when a fixture leaves it out.
|
|
360
|
+
|
|
361
|
+
## Rendering HTML you did not write — `ctx.sanitize` (since 0.16.0)
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
notes.innerHTML = ctx.sanitize(snap.description); // show notes from the feed
|
|
365
|
+
page.innerHTML = ctx.sanitize(marked.parse(markdown)); // a podcaster's Markdown, after rendering
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
`ctx.sanitize` applies **the host's own policy** — the one the shell applies to feed HTML, exported as
|
|
369
|
+
`FEED_HTML_POLICY`. It keeps prose, links, lists, tables and images; drops `<style>`, `<script>`,
|
|
370
|
+
`<iframe>`, forms, and every `style`, `srcset` and event-handler attribute; drops `javascript:`/`data:`
|
|
371
|
+
links; and sends every external link to a new tab with `rel="noopener noreferrer nofollow ugc"`, so
|
|
372
|
+
following one does not stop the player.
|
|
373
|
+
|
|
374
|
+
**Why not `DOMPurify.sanitize(html)`?** Its defaults stop scripts but allow `<style>` and `style=`. The
|
|
375
|
+
plugin contract needs `style-src 'unsafe-inline'`, and `img-src` stays open to any `https:` host for
|
|
376
|
+
artwork, so a stylesheet in someone else's HTML becomes a full-viewport click-jacking overlay or an
|
|
377
|
+
attribute-selector that leaks form values one character at a time. The wiki plugin shipped exactly that.
|
|
378
|
+
With `ctx.sanitize` a plugin cannot end up with a weaker policy than the host by writing less code. If you
|
|
379
|
+
really must sanitize where `ctx` does not reach, pass `FEED_HTML_POLICY`'s lists to your sanitizer rather
|
|
380
|
+
than its defaults.
|
|
381
|
+
|
|
382
|
+
In tests, `makeMockCtx()` puts `sanitizeLikeHost` on `ctx.sanitize`: the same policy walked over a parsed
|
|
383
|
+
tree, so a component test sees the same removals. It needs a DOM (`// @vitest-environment jsdom`) and is a
|
|
384
|
+
test double, never a boundary — ship `ctx.sanitize`.
|
|
331
385
|
|
|
332
386
|
## Site-wide tags — `ctx.tags` / `ctx.tags()` (since 0.9.0)
|
|
333
387
|
|
|
@@ -371,7 +425,7 @@ Test with `makeMockTags({ writesEpisodes })` / `FakeTags`, both of which refuse
|
|
|
371
425
|
|
|
372
426
|
## Who the UUIDs are — `ctx.users` / `ctx.users()` (since 0.13.0)
|
|
373
427
|
|
|
374
|
-
A backend calling `
|
|
428
|
+
A backend calling `ctx.allUsers().query(...)` gets `OwnedDocEntry(userId, …)` — UUIDs and a document. A leaderboard
|
|
375
429
|
built from that had ids and no way to draw a person, and both workarounds were bad: show raw UUIDs, or copy
|
|
376
430
|
display names into the plugin's own store. This is the lookup that fixes it. It is deliberately a lookup
|
|
377
431
|
rather than a wider `ctx.user`: the host still resolves access, and what you learn about somebody else stays
|
|
@@ -448,7 +502,7 @@ if (told.length < participants.length) prune(participants, told);
|
|
|
448
502
|
```
|
|
449
503
|
|
|
450
504
|
- **Only users you already hold `user`-scope data for.** Host-enforced against the same partitions
|
|
451
|
-
`
|
|
505
|
+
`allUsers().query(...)` reads — whether or not you declared `readsAllUsers` — participants have rows, and no plugin can reach a user who never touched it.
|
|
452
506
|
- **`send` answers who actually got it.** An ineligible or erased recipient is left out rather than
|
|
453
507
|
failing the call, so partial sends are normal and the return value is the only way to see one. A plugin
|
|
454
508
|
that ignores it and works from a stale participant list notifies nobody while looking perfectly healthy.
|
|
@@ -603,6 +657,34 @@ ceilings and the allow-list**: a component that has only ever met an accepting d
|
|
|
603
657
|
refusal in front of a podcaster. Neither reads file formats — name the file that should be refused
|
|
604
658
|
(`rejectContent`) to exercise that path.
|
|
605
659
|
|
|
660
|
+
## Theme tokens — `--mc-*` (`--mc-accent-text` since 0.16.0)
|
|
661
|
+
|
|
662
|
+
`defineMosaicastElement` writes `ctx.theme` onto your `:host` as custom properties; the host also sets the
|
|
663
|
+
same names on `:root`, so they inherit into any shadow root.
|
|
664
|
+
|
|
665
|
+
| Token | CSS | Use it for |
|
|
666
|
+
|---|---|---|
|
|
667
|
+
| `bg` / `surface` | `--mc-bg` / `--mc-surface` | page and raised backgrounds |
|
|
668
|
+
| `text` / `textMuted` | `--mc-text` / `--mc-text-muted` | body and secondary text |
|
|
669
|
+
| `accent` | `--mc-accent` | **fills only** — buttons, badges, bars |
|
|
670
|
+
| `accentContrast` | `--mc-accent-contrast` | text and icons *on* an `--mc-accent` fill |
|
|
671
|
+
| `accentText` | `--mc-accent-text` | **text, links and focus rings** in the accent colour |
|
|
672
|
+
| `accent2` | `--mc-accent-2` | an optional secondary accent |
|
|
673
|
+
| `border` | `--mc-border` | dividers and outlines |
|
|
674
|
+
|
|
675
|
+
**Never colour text with `--mc-accent`.** It is the admin's seed, unchecked against the page: a pale seed
|
|
676
|
+
such as `#FFF176` measured 1.12:1 as link text. `--mc-accent-text` is the same accent clamped to WCAG AA
|
|
677
|
+
(4.5:1) against both `--mc-bg` and `--mc-surface`:
|
|
678
|
+
|
|
679
|
+
```css
|
|
680
|
+
a, .link { color: var(--mc-accent-text); }
|
|
681
|
+
:focus-visible { outline: 2px solid var(--mc-accent-text); }
|
|
682
|
+
button.primary { background: var(--mc-accent); color: var(--mc-accent-contrast); }
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
`ctx.theme.accentText` is optional — a host older than 0.16.0 does not send it — and the SDK only writes
|
|
686
|
+
it onto `:host` when present, so the value inherited from `:root` is never shadowed by an empty one.
|
|
687
|
+
|
|
606
688
|
## Host icons — `iconCss` / `iconMask` (since 0.9.0)
|
|
607
689
|
|
|
608
690
|
The host publishes its icon set as `--mc-icon-*` custom properties, which inherit through the shadow
|
|
@@ -766,9 +848,46 @@ export default defineManifest({
|
|
|
766
848
|
identity: { resolvesUsers: true },
|
|
767
849
|
notifications: { sends: true, perUserPerDay: 5 },
|
|
768
850
|
external: { kinds: ['translation'], usedBy: 'podcaster' },
|
|
851
|
+
config: {
|
|
852
|
+
ingestIntervalSeconds: {
|
|
853
|
+
type: 'number', default: 60, editableBy: 'podcaster',
|
|
854
|
+
label: { en: 'Ingest interval', de: 'Abrufintervall' }, // locale map or a plain string
|
|
855
|
+
description: { en: 'Seconds between two ingest runs.' },
|
|
856
|
+
},
|
|
857
|
+
matchMode: {
|
|
858
|
+
type: 'string', default: 'fuzzy',
|
|
859
|
+
options: [{ value: 'fuzzy', label: 'Fuzzy' }, { value: 'exact', label: 'Exact' }],
|
|
860
|
+
},
|
|
861
|
+
},
|
|
769
862
|
});
|
|
770
863
|
```
|
|
771
864
|
|
|
865
|
+
**Config fields say what they are (since 0.15.0).** Plugins may not build their own config UI (§7.2), so
|
|
866
|
+
the generic admin form is the only thing an operator ever sees — and without `label` and `description` it
|
|
867
|
+
shows them `ingestIntervalSeconds` and nothing else, with no room to say what a sane value is or what unit
|
|
868
|
+
it is in. Both take a locale map or a plain string; the key stays visible beside the label, because the key
|
|
869
|
+
is what your own docs name. `options` declares a closed set, rendered as a select and refused outside it.
|
|
870
|
+
|
|
871
|
+
**Config values have bounds (since 0.16.0).** `min`/`max`/`step` on a `number` field, `minLength`/`maxLength`
|
|
872
|
+
on a `string` one. Without them any number an operator can type is legal — including the `0` that switches
|
|
873
|
+
a scheduled task off. The host **refuses** an out-of-range write with a 400 naming the bound (never clamps),
|
|
874
|
+
the form renders the bounds as input constraints, a `default` outside its own bounds is refused at load, and
|
|
875
|
+
a value stored before a bound existed that now breaks it counts as unset. So what `PluginConfig.get` returns
|
|
876
|
+
satisfies the declaration — put the rule in the manifest, not a clamp in code:
|
|
877
|
+
|
|
878
|
+
```json
|
|
879
|
+
"ingestIntervalSeconds": { "type": "number", "default": 60, "min": 10, "max": 3600, "step": 1 }
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
There is no `pattern` yet: Java and JavaScript regex dialects differ, and one field's regex would be two
|
|
883
|
+
rules free to disagree.
|
|
884
|
+
|
|
885
|
+
**`frontend.entry` has a grammar (since 0.16.0).** A relative path under the plugin's own `assets/`: one or
|
|
886
|
+
more `[A-Za-z0-9._-]` segments joined by `/`, no leading slash, no `.` or `..` segment, no query and no
|
|
887
|
+
fragment. The host builds `/plugins/<id>/assets/<entry>` from it and **rejects the plugin at load** when it
|
|
888
|
+
does not match, rather than loading a URL you did not write. `FRONTEND_ENTRY_PATTERN` is the same rule, so a
|
|
889
|
+
test can find out before the host does.
|
|
890
|
+
|
|
772
891
|
**Documentation, not enforcement** — the same caveat `PluginDataDeclaration` and
|
|
773
892
|
`ConsentServiceDeclaration` have carried since 0.4.0. The manifest is owned and validated by the **host**;
|
|
774
893
|
the SDK never reads `plugin.json`, and if this type and core disagree, **core wins**.
|
|
@@ -884,6 +1003,58 @@ var sitemap = new SitemapProviderHarness("wiki", new WikiSitemap(store)).collect
|
|
|
884
1003
|
assertTrue(sitemap.problems().isEmpty()); // outside the namespace, hand-written ?lang=, split groups
|
|
885
1004
|
```
|
|
886
1005
|
|
|
1006
|
+
## Scheduled work — `ctx.onSchedule(...)` (a live period since 0.15.0)
|
|
1007
|
+
|
|
1008
|
+
```java
|
|
1009
|
+
// Fixed forever — fine for work whose cadence is a constant.
|
|
1010
|
+
ctx.onSchedule(Duration.ofMinutes(15), this::ingest);
|
|
1011
|
+
|
|
1012
|
+
// Re-read before every tick — use this whenever the period comes from config.
|
|
1013
|
+
ctx.onSchedule(() -> Duration.ofSeconds(ctx.config().get("ingestIntervalSeconds", Integer.class, 60)),
|
|
1014
|
+
this::ingest);
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
The `Duration` overload takes its period **once**, during `register()`, and the host holds it for the life
|
|
1018
|
+
of the process. So a plugin whose tick rate is configurable — which the manifest actively invites —
|
|
1019
|
+
accepted a new value, stored it, reported success, and went on running at the old cadence until a restart.
|
|
1020
|
+
Nothing in the admin form said so.
|
|
1021
|
+
|
|
1022
|
+
Hand over the *reading* of the period instead and the host consults your supplier before each fire,
|
|
1023
|
+
rescheduling when the answer changes: a saved setting takes effect within one old period. The supplier runs
|
|
1024
|
+
on a scheduler thread, so read config or a field and nothing more. Returning `null`, returning a
|
|
1025
|
+
non-positive `Duration`, or throwing leaves the task on the last period that was valid — logged, never
|
|
1026
|
+
silently dropped. Only the value at registration is strict: it must be positive. The host may clamp very
|
|
1027
|
+
short periods to a floor it owns.
|
|
1028
|
+
|
|
1029
|
+
`FakePluginContext` makes the difference assertable: `scheduledPeriods()` re-reads every registered
|
|
1030
|
+
supplier on demand, so a test changes `MapPluginConfig` and sees the new period — a plugin that captured a
|
|
1031
|
+
`Duration` at `register()` keeps reporting the old one and fails the assertion.
|
|
1032
|
+
|
|
1033
|
+
## Surviving a new `ctx` — `MosaicastHandle` (since 0.15.0)
|
|
1034
|
+
|
|
1035
|
+
```ts
|
|
1036
|
+
defineMosaicastElement({
|
|
1037
|
+
tag: 'bingo-card',
|
|
1038
|
+
render: ({ ctx, root }) => {
|
|
1039
|
+
const app = mountMyFramework(root, ctx);
|
|
1040
|
+
return { update: (next) => app.setCtx(next), destroy: () => app.unmount() };
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
Returning a cleanup callback (or nothing) keeps the original behaviour: every `ctx` assignment runs cleanup,
|
|
1046
|
+
clears `root` and calls `render` again. That is right for a static card and expensive for anything else,
|
|
1047
|
+
because `ctx` changes far more often than "a consent choice or a language switch" — a host that rebuilds
|
|
1048
|
+
its context object on each of its own renders can reassign it several times a second during playback, and
|
|
1049
|
+
each rebuild loses component state, in-flight requests, scroll position and open dialogs, then re-runs
|
|
1050
|
+
every effect behind them.
|
|
1051
|
+
|
|
1052
|
+
Return a `MosaicastHandle` with an `update` and the SDK stops tearing down: it refreshes the `--mc-*` theme
|
|
1053
|
+
variables, calls `update(next)` and leaves `root` alone. `destroy` then runs only when the element actually
|
|
1054
|
+
disconnects — and if the element is moved in the DOM (a disconnect plus a connect), the SDK renders it
|
|
1055
|
+
again rather than leaving it dead. Re-assigning the **same** context object is ignored either way, so a
|
|
1056
|
+
churning host costs you nothing without your having to compare contexts yourself.
|
|
1057
|
+
|
|
887
1058
|
## Logging — `ctx.logger()` / `ctx.log()` (since 0.4.0)
|
|
888
1059
|
|
|
889
1060
|
```java
|
|
@@ -933,8 +1104,8 @@ per-service detail exists so the notice can name who stores what for how long; w
|
|
|
933
1104
|
the category. So if two plugins each declare an `analytics` service with different providers, the visitor
|
|
934
1105
|
sees **one** decision listing both plugins, and granting it grants both — there is no way to accept one
|
|
935
1106
|
provider and refuse the other. If you need your own services to be refusable independently, declare them
|
|
936
|
-
under **different categories** (a plugin-declared category is fine
|
|
937
|
-
|
|
1107
|
+
under **different categories** (a plugin-declared category is fine — label it, see below). That is the only
|
|
1108
|
+
lever the contract gives you.
|
|
938
1109
|
|
|
939
1110
|
**`necessary` is never asked about.** `has('necessary')` is always `true` and the host never prompts for
|
|
940
1111
|
it — it is the category the core itself uses, and a banner-free site stays banner-free. Declaring a
|
|
@@ -971,6 +1142,29 @@ where plugin authors look, so the required shape is documented here. **From `0.4
|
|
|
971
1142
|
}
|
|
972
1143
|
```
|
|
973
1144
|
|
|
1145
|
+
**Label a category you introduce (since 0.16.0).** The category is what the visitor consents to, so it is
|
|
1146
|
+
the one string that cannot fall back to a developer key — a bare `social` between two explained core
|
|
1147
|
+
categories reads as a bug, not a choice. Give it a name and a one-line hint, as a locale map or a plain
|
|
1148
|
+
string:
|
|
1149
|
+
|
|
1150
|
+
```json
|
|
1151
|
+
"consent": {
|
|
1152
|
+
"services": [{ "id": "mastodon", "category": "social", "…": "…" }],
|
|
1153
|
+
"categoryLabels": {
|
|
1154
|
+
"social": {
|
|
1155
|
+
"label": { "en": "Social media", "de": "Soziale Medien" },
|
|
1156
|
+
"hint": { "en": "Posts embedded from social networks." }
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
Declaring a category outside `necessary`/`functional`/`analytics` **obliges you to label it**: an
|
|
1163
|
+
unlabelled one still loads but is shown wrapped in a generic phrase. A label for a **core** category, or for
|
|
1164
|
+
a category none of your services declares, is refused at load. If two plugins label the same category the
|
|
1165
|
+
host picks one, deterministically — the decision is shared, so only one label can be shown. (`categoryLabels`
|
|
1166
|
+
is not the pre-0.4 `categories` array, which stays rejected.)
|
|
1167
|
+
|
|
974
1168
|
| Field | Meaning |
|
|
975
1169
|
|---|---|
|
|
976
1170
|
| `id` | Stable identifier for this service within your plugin. |
|
|
@@ -1016,6 +1210,10 @@ var ctx = new FakePluginContext(); // in-memory store, config, fee
|
|
|
1016
1210
|
myPlugin.register(ctx); // exercise the backend
|
|
1017
1211
|
assertEquals(Optional.of("world"),
|
|
1018
1212
|
ctx.store().get(Scope.site(), "hello", String.class));
|
|
1213
|
+
|
|
1214
|
+
ctx.runScheduled(); // tick again (since 0.15.0)
|
|
1215
|
+
config.with("ingestIntervalSeconds", 10); // and prove the period follows config
|
|
1216
|
+
assertEquals(List.of(Duration.ofSeconds(10)), ctx.scheduledPeriods());
|
|
1019
1217
|
```
|
|
1020
1218
|
|
|
1021
1219
|
**TypeScript** (`@mosaicast/plugin-sdk/testing`, jsdom):
|