diff options
author | Chocobozzz <me@florianbigard.com> | 2021-04-09 15:17:46 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2021-04-09 15:21:06 +0200 |
commit | d2466f0ac9c9877df10f62b7ac20bc3253a2a84a (patch) | |
tree | 428fb76968ab854452458962f975066478ec8486 | |
parent | 22820226e54dee61287666a178df2176fafb202a (diff) | |
download | PeerTube-d2466f0ac9c9877df10f62b7ac20bc3253a2a84a.tar.gz PeerTube-d2466f0ac9c9877df10f62b7ac20bc3253a2a84a.tar.zst PeerTube-d2466f0ac9c9877df10f62b7ac20bc3253a2a84a.zip |
Update plugins doc
-rw-r--r-- | shared/models/plugins/register-server-setting.model.ts | 2 | ||||
-rw-r--r-- | support/doc/plugins/guide.md | 314 |
2 files changed, 196 insertions, 120 deletions
diff --git a/shared/models/plugins/register-server-setting.model.ts b/shared/models/plugins/register-server-setting.model.ts index 6bc25b4ae..9f45c3c37 100644 --- a/shared/models/plugins/register-server-setting.model.ts +++ b/shared/models/plugins/register-server-setting.model.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { RegisterClientFormFieldOptions } from './register-client-form-field.model' | 1 | import { RegisterClientFormFieldOptions } from './register-client-form-field.model' |
2 | 2 | ||
3 | export type RegisterServerSettingOptions = RegisterClientFormFieldOptions & { | 3 | export type RegisterServerSettingOptions = RegisterClientFormFieldOptions & { |
4 | // If the setting is not private, anyone can view its value (client code included) | 4 | // If the setting is not private, anyone can view its value (client code included) |
5 | // If the setting is private, only server-side hooks can access it | 5 | // If the setting is private, only server-side hooks can access it |
6 | // Mainly used by the PeerTube client to get admin config | 6 | // Mainly used by the PeerTube client to get admin config |
diff --git a/support/doc/plugins/guide.md b/support/doc/plugins/guide.md index 20cbec5c7..f5e753b79 100644 --- a/support/doc/plugins/guide.md +++ b/support/doc/plugins/guide.md | |||
@@ -8,14 +8,15 @@ | |||
8 | - [Hooks](#hooks) | 8 | - [Hooks](#hooks) |
9 | - [Static files](#static-files) | 9 | - [Static files](#static-files) |
10 | - [CSS](#css) | 10 | - [CSS](#css) |
11 | - [Server helpers (only for plugins)](#server-helpers-only-for-plugins) | 11 | - [Server API (only for plugins)](#server-api-only-for-plugins) |
12 | - [Settings](#settings) | 12 | - [Settings](#settings) |
13 | - [Storage](#storage) | 13 | - [Storage](#storage) |
14 | - [Update video constants](#update-video-constants) | 14 | - [Update video constants](#update-video-constants) |
15 | - [Add custom routes](#add-custom-routes) | 15 | - [Add custom routes](#add-custom-routes) |
16 | - [Add external auth methods](#add-external-auth-methods) | 16 | - [Add external auth methods](#add-external-auth-methods) |
17 | - [Add new transcoding profiles](#add-new-transcoding-profiles) | 17 | - [Add new transcoding profiles](#add-new-transcoding-profiles) |
18 | - [Client helpers (themes & plugins)](#client-helpers-themes--plugins) | 18 | - [Helpers](#helpers) |
19 | - [Client API (themes & plugins)](#client-api-themes--plugins) | ||
19 | - [Plugin static route](#plugin-static-route) | 20 | - [Plugin static route](#plugin-static-route) |
20 | - [Notifier](#notifier) | 21 | - [Notifier](#notifier) |
21 | - [Markdown Renderer](#markdown-renderer) | 22 | - [Markdown Renderer](#markdown-renderer) |
@@ -24,6 +25,7 @@ | |||
24 | - [Get public settings](#get-public-settings) | 25 | - [Get public settings](#get-public-settings) |
25 | - [Get server config](#get-server-config) | 26 | - [Get server config](#get-server-config) |
26 | - [Add custom fields to video form](#add-custom-fields-to-video-form) | 27 | - [Add custom fields to video form](#add-custom-fields-to-video-form) |
28 | - [Register settings script](#register-settings-script) | ||
27 | - [Publishing](#publishing) | 29 | - [Publishing](#publishing) |
28 | - [Write a plugin/theme](#write-a-plugintheme) | 30 | - [Write a plugin/theme](#write-a-plugintheme) |
29 | - [Clone the quickstart repository](#clone-the-quickstart-repository) | 31 | - [Clone the quickstart repository](#clone-the-quickstart-repository) |
@@ -154,31 +156,35 @@ body#custom-css { | |||
154 | } | 156 | } |
155 | ``` | 157 | ``` |
156 | 158 | ||
157 | ### Server helpers (only for plugins) | 159 | ### Server API (only for plugins) |
158 | 160 | ||
159 | #### Settings | 161 | #### Settings |
160 | 162 | ||
161 | Plugins can register settings, that PeerTube will inject in the administration interface. | 163 | Plugins can register settings, that PeerTube will inject in the administration interface. |
164 | The following fields will be automatically translated using the plugin translation files: `label`, `html`, `descriptionHTML`, `options.label`. | ||
165 | **These fields are injected in the plugin settings page as HTML, so pay attention to your translation files.** | ||
162 | 166 | ||
163 | Example: | 167 | Example: |
164 | 168 | ||
165 | ```js | 169 | ```js |
166 | registerSetting({ | 170 | function register (...) { |
167 | name: 'admin-name', | 171 | registerSetting({ |
168 | label: 'Admin name', | 172 | name: 'admin-name', |
169 | type: 'input', | 173 | label: 'Admin name', |
170 | // type: input | input-checkbox | input-password | input-textarea | markdown-text | markdown-enhanced | 174 | type: 'input', |
171 | default: 'my super name' | 175 | // type: input | input-checkbox | input-password | input-textarea | markdown-text | markdown-enhanced | 'select' | 'html' |
172 | }) | 176 | default: 'my super name' |
177 | }) | ||
173 | 178 | ||
174 | const adminName = await settingsManager.getSetting('admin-name') | 179 | const adminName = await settingsManager.getSetting('admin-name') |
175 | 180 | ||
176 | const result = await settingsManager.getSettings([ 'admin-name', 'admin-password' ]) | 181 | const result = await settingsManager.getSettings([ 'admin-name', 'admin-password' ]) |
177 | result['admin-name] | 182 | result['admin-name] |
178 | 183 | ||
179 | settingsManager.onSettingsChange(settings => { | 184 | settingsManager.onSettingsChange(settings => { |
180 | settings['admin-name]) | 185 | settings['admin-name]) |
181 | }) | 186 | }) |
187 | } | ||
182 | ``` | 188 | ``` |
183 | 189 | ||
184 | #### Storage | 190 | #### Storage |
@@ -188,8 +194,10 @@ Plugins can store/load JSON data, that PeerTube will store in its database (so d | |||
188 | Example: | 194 | Example: |
189 | 195 | ||
190 | ```js | 196 | ```js |
191 | const value = await storageManager.getData('mykey') | 197 | function register (...) { |
192 | await storageManager.storeData('mykey', { subkey: 'value' }) | 198 | const value = await storageManager.getData('mykey') |
199 | await storageManager.storeData('mykey', { subkey: 'value' }) | ||
200 | } | ||
193 | ``` | 201 | ``` |
194 | 202 | ||
195 | #### Update video constants | 203 | #### Update video constants |
@@ -197,17 +205,19 @@ await storageManager.storeData('mykey', { subkey: 'value' }) | |||
197 | You can add/delete video categories, licences or languages using the appropriate managers: | 205 | You can add/delete video categories, licences or languages using the appropriate managers: |
198 | 206 | ||
199 | ```js | 207 | ```js |
200 | videoLanguageManager.addLanguage('al_bhed', 'Al Bhed') | 208 | function register (...) { |
201 | videoLanguageManager.deleteLanguage('fr') | 209 | videoLanguageManager.addLanguage('al_bhed', 'Al Bhed') |
210 | videoLanguageManager.deleteLanguage('fr') | ||
202 | 211 | ||
203 | videoCategoryManager.addCategory(42, 'Best category') | 212 | videoCategoryManager.addCategory(42, 'Best category') |
204 | videoCategoryManager.deleteCategory(1) // Music | 213 | videoCategoryManager.deleteCategory(1) // Music |
205 | 214 | ||
206 | videoLicenceManager.addLicence(42, 'Best licence') | 215 | videoLicenceManager.addLicence(42, 'Best licence') |
207 | videoLicenceManager.deleteLicence(7) // Public domain | 216 | videoLicenceManager.deleteLicence(7) // Public domain |
208 | 217 | ||
209 | videoPrivacyManager.deletePrivacy(2) // Remove Unlisted video privacy | 218 | videoPrivacyManager.deletePrivacy(2) // Remove Unlisted video privacy |
210 | playlistPrivacyManager.deletePlaylistPrivacy(3) // Remove Private video playlist privacy | 219 | playlistPrivacyManager.deletePlaylistPrivacy(3) // Remove Private video playlist privacy |
220 | } | ||
211 | ``` | 221 | ``` |
212 | 222 | ||
213 | #### Add custom routes | 223 | #### Add custom routes |
@@ -215,8 +225,10 @@ playlistPrivacyManager.deletePlaylistPrivacy(3) // Remove Private video playlist | |||
215 | You can create custom routes using an [express Router](https://expressjs.com/en/4x/api.html#router) for your plugin: | 225 | You can create custom routes using an [express Router](https://expressjs.com/en/4x/api.html#router) for your plugin: |
216 | 226 | ||
217 | ```js | 227 | ```js |
218 | const router = getRouter() | 228 | function register (...) { |
219 | router.get('/ping', (req, res) => res.json({ message: 'pong' })) | 229 | const router = getRouter() |
230 | router.get('/ping', (req, res) => res.json({ message: 'pong' })) | ||
231 | } | ||
220 | ``` | 232 | ``` |
221 | 233 | ||
222 | The `ping` route can be accessed using: | 234 | The `ping` route can be accessed using: |
@@ -229,80 +241,86 @@ The `ping` route can be accessed using: | |||
229 | If you want to add a classic username/email and password auth method (like [LDAP](https://framagit.org/framasoft/peertube/official-plugins/-/tree/master/peertube-plugin-auth-ldap) for example): | 241 | If you want to add a classic username/email and password auth method (like [LDAP](https://framagit.org/framasoft/peertube/official-plugins/-/tree/master/peertube-plugin-auth-ldap) for example): |
230 | 242 | ||
231 | ```js | 243 | ```js |
232 | registerIdAndPassAuth({ | 244 | function register (...) { |
233 | authName: 'my-auth-method', | ||
234 | 245 | ||
235 | // PeerTube will try all id and pass plugins in the weight DESC order | 246 | registerIdAndPassAuth({ |
236 | // Exposing this value in the plugin settings could be interesting | 247 | authName: 'my-auth-method', |
237 | getWeight: () => 60, | ||
238 | 248 | ||
239 | // Optional function called by PeerTube when the user clicked on the logout button | 249 | // PeerTube will try all id and pass plugins in the weight DESC order |
240 | onLogout: user => { | 250 | // Exposing this value in the plugin settings could be interesting |
241 | console.log('User %s logged out.', user.username') | 251 | getWeight: () => 60, |
242 | }, | ||
243 | 252 | ||
244 | // Optional function called by PeerTube when the access token or refresh token are generated/refreshed | 253 | // Optional function called by PeerTube when the user clicked on the logout button |
245 | hookTokenValidity: ({ token, type }) => { | 254 | onLogout: user => { |
246 | if (type === 'access') return { valid: true } | 255 | console.log('User %s logged out.', user.username') |
247 | if (type === 'refresh') return { valid: false } | 256 | }, |
248 | }, | ||
249 | 257 | ||
250 | // Used by PeerTube when the user tries to authenticate | 258 | // Optional function called by PeerTube when the access token or refresh token are generated/refreshed |
251 | login: ({ id, password }) => { | 259 | hookTokenValidity: ({ token, type }) => { |
252 | if (id === 'user' && password === 'super password') { | 260 | if (type === 'access') return { valid: true } |
253 | return { | 261 | if (type === 'refresh') return { valid: false } |
254 | username: 'user' | 262 | }, |
255 | email: 'user@example.com' | 263 | |
256 | role: 2 | 264 | // Used by PeerTube when the user tries to authenticate |
257 | displayName: 'User display name' | 265 | login: ({ id, password }) => { |
266 | if (id === 'user' && password === 'super password') { | ||
267 | return { | ||
268 | username: 'user' | ||
269 | email: 'user@example.com' | ||
270 | role: 2 | ||
271 | displayName: 'User display name' | ||
272 | } | ||
258 | } | 273 | } |
259 | } | ||
260 | 274 | ||
261 | // Auth failed | 275 | // Auth failed |
262 | return null | 276 | return null |
263 | } | 277 | } |
264 | }) | 278 | }) |
265 | 279 | ||
266 | // Unregister this auth method | 280 | // Unregister this auth method |
267 | unregisterIdAndPassAuth('my-auth-method') | 281 | unregisterIdAndPassAuth('my-auth-method') |
282 | } | ||
268 | ``` | 283 | ``` |
269 | 284 | ||
270 | You can also add an external auth method (like [OpenID](https://framagit.org/framasoft/peertube/official-plugins/-/tree/master/peertube-plugin-auth-openid-connect), [SAML2](https://framagit.org/framasoft/peertube/official-plugins/-/tree/master/peertube-plugin-auth-saml2) etc): | 285 | You can also add an external auth method (like [OpenID](https://framagit.org/framasoft/peertube/official-plugins/-/tree/master/peertube-plugin-auth-openid-connect), [SAML2](https://framagit.org/framasoft/peertube/official-plugins/-/tree/master/peertube-plugin-auth-saml2) etc): |
271 | 286 | ||
272 | ```js | 287 | ```js |
273 | // result contains the userAuthenticated auth method you can call to authenticate a user | 288 | function register (...) { |
274 | const result = registerExternalAuth({ | ||
275 | authName: 'my-auth-method', | ||
276 | 289 | ||
277 | // Will be displayed in a button next to the login form | 290 | // result contains the userAuthenticated auth method you can call to authenticate a user |
278 | authDisplayName: () => 'Auth method' | 291 | const result = registerExternalAuth({ |
292 | authName: 'my-auth-method', | ||
279 | 293 | ||
280 | // If the user click on the auth button, PeerTube will forward the request in this function | 294 | // Will be displayed in a button next to the login form |
281 | onAuthRequest: (req, res) => { | 295 | authDisplayName: () => 'Auth method' |
282 | res.redirect('https://external-auth.example.com/auth') | ||
283 | }, | ||
284 | 296 | ||
285 | // Same than registerIdAndPassAuth option | 297 | // If the user click on the auth button, PeerTube will forward the request in this function |
286 | // onLogout: ... | 298 | onAuthRequest: (req, res) => { |
299 | res.redirect('https://external-auth.example.com/auth') | ||
300 | }, | ||
287 | 301 | ||
288 | // Same than registerIdAndPassAuth option | 302 | // Same than registerIdAndPassAuth option |
289 | // hookTokenValidity: ... | 303 | // onLogout: ... |
290 | }) | ||
291 | 304 | ||
292 | router.use('/external-auth-callback', (req, res) => { | 305 | // Same than registerIdAndPassAuth option |
293 | // Forward the request to PeerTube | 306 | // hookTokenValidity: ... |
294 | result.userAuthenticated({ | 307 | }) |
295 | req, | 308 | |
296 | res, | 309 | router.use('/external-auth-callback', (req, res) => { |
297 | username: 'user' | 310 | // Forward the request to PeerTube |
298 | email: 'user@example.com' | 311 | result.userAuthenticated({ |
299 | role: 2 | 312 | req, |
300 | displayName: 'User display name' | 313 | res, |
314 | username: 'user' | ||
315 | email: 'user@example.com' | ||
316 | role: 2 | ||
317 | displayName: 'User display name' | ||
318 | }) | ||
301 | }) | 319 | }) |
302 | }) | ||
303 | 320 | ||
304 | // Unregister this external auth method | 321 | // Unregister this external auth method |
305 | unregisterExternalAuth('my-auth-method) | 322 | unregisterExternalAuth('my-auth-method) |
323 | } | ||
306 | ``` | 324 | ``` |
307 | 325 | ||
308 | #### Add new transcoding profiles | 326 | #### Add new transcoding profiles |
@@ -393,15 +411,41 @@ async function register ({ | |||
393 | } | 411 | } |
394 | ``` | 412 | ``` |
395 | 413 | ||
396 | ### Client helpers (themes & plugins) | 414 | ### Helpers |
415 | |||
416 | PeerTube provides your plugin some helpers. For example: | ||
417 | |||
418 | ```js | ||
419 | async function register ({ | ||
420 | peertubeHelpers | ||
421 | }) { | ||
422 | // Block a server | ||
423 | { | ||
424 | const serverActor = await peertubeHelpers.server.getServerActor() | ||
425 | |||
426 | await peertubeHelpers.moderation.blockServer({ byAccountId: serverActor.Account.id, hostToBlock: '...' }) | ||
427 | } | ||
428 | |||
429 | // Load a video | ||
430 | { | ||
431 | const video = await peertubeHelpers.videos.loadByUrl('...') | ||
432 | } | ||
433 | } | ||
434 | ``` | ||
435 | |||
436 | See the [plugin API reference](https://docs.joinpeertube.org/api-plugins) to see the complete helpers list. | ||
437 | |||
438 | ### Client API (themes & plugins) | ||
397 | 439 | ||
398 | #### Plugin static route | 440 | #### Plugin static route |
399 | 441 | ||
400 | To get your plugin static route: | 442 | To get your plugin static route: |
401 | 443 | ||
402 | ```js | 444 | ```js |
403 | const baseStaticUrl = peertubeHelpers.getBaseStaticRoute() | 445 | function register (...) { |
404 | const imageUrl = baseStaticUrl + '/images/chocobo.png' | 446 | const baseStaticUrl = peertubeHelpers.getBaseStaticRoute() |
447 | const imageUrl = baseStaticUrl + '/images/chocobo.png' | ||
448 | } | ||
405 | ``` | 449 | ``` |
406 | 450 | ||
407 | #### Notifier | 451 | #### Notifier |
@@ -409,9 +453,11 @@ const imageUrl = baseStaticUrl + '/images/chocobo.png' | |||
409 | To notify the user with the PeerTube ToastModule: | 453 | To notify the user with the PeerTube ToastModule: |
410 | 454 | ||
411 | ```js | 455 | ```js |
412 | const { notifier } = peertubeHelpers | 456 | function register (...) { |
413 | notifier.success('Success message content.') | 457 | const { notifier } = peertubeHelpers |
414 | notifier.error('Error message content.') | 458 | notifier.success('Success message content.') |
459 | notifier.error('Error message content.') | ||
460 | } | ||
415 | ``` | 461 | ``` |
416 | 462 | ||
417 | #### Markdown Renderer | 463 | #### Markdown Renderer |
@@ -419,13 +465,15 @@ notifier.error('Error message content.') | |||
419 | To render a formatted markdown text to HTML: | 465 | To render a formatted markdown text to HTML: |
420 | 466 | ||
421 | ```js | 467 | ```js |
422 | const { markdownRenderer } = peertubeHelpers | 468 | function register (...) { |
469 | const { markdownRenderer } = peertubeHelpers | ||
423 | 470 | ||
424 | await markdownRenderer.textMarkdownToHTML('**My Bold Text**') | 471 | await markdownRenderer.textMarkdownToHTML('**My Bold Text**') |
425 | // return <strong>My Bold Text</strong> | 472 | // return <strong>My Bold Text</strong> |
426 | 473 | ||
427 | await markdownRenderer.enhancedMarkdownToHTML('![alt-img](http://.../my-image.jpg)') | 474 | await markdownRenderer.enhancedMarkdownToHTML('![alt-img](http://.../my-image.jpg)') |
428 | // return <img alt=alt-img src=http://.../my-image.jpg /> | 475 | // return <img alt=alt-img src=http://.../my-image.jpg /> |
476 | } | ||
429 | ``` | 477 | ``` |
430 | 478 | ||
431 | #### Custom Modal | 479 | #### Custom Modal |
@@ -433,17 +481,19 @@ await markdownRenderer.enhancedMarkdownToHTML('![alt-img](http://.../my-image.jp | |||
433 | To show a custom modal: | 481 | To show a custom modal: |
434 | 482 | ||
435 | ```js | 483 | ```js |
436 | peertubeHelpers.showModal({ | 484 | function register (...) { |
437 | title: 'My custom modal title', | 485 | peertubeHelpers.showModal({ |
438 | content: '<p>My custom modal content</p>', | 486 | title: 'My custom modal title', |
439 | // Optionals parameters : | 487 | content: '<p>My custom modal content</p>', |
440 | // show close icon | 488 | // Optionals parameters : |
441 | close: true, | 489 | // show close icon |
442 | // show cancel button and call action() after hiding modal | 490 | close: true, |
443 | cancel: { value: 'cancel', action: () => {} }, | 491 | // show cancel button and call action() after hiding modal |
444 | // show confirm button and call action() after hiding modal | 492 | cancel: { value: 'cancel', action: () => {} }, |
445 | confirm: { value: 'confirm', action: () => {} }, | 493 | // show confirm button and call action() after hiding modal |
446 | }) | 494 | confirm: { value: 'confirm', action: () => {} }, |
495 | }) | ||
496 | } | ||
447 | ``` | 497 | ``` |
448 | 498 | ||
449 | #### Translate | 499 | #### Translate |
@@ -451,8 +501,10 @@ To show a custom modal: | |||
451 | You can translate some strings of your plugin (PeerTube will use your `translations` object of your `package.json` file): | 501 | You can translate some strings of your plugin (PeerTube will use your `translations` object of your `package.json` file): |
452 | 502 | ||
453 | ```js | 503 | ```js |
454 | peertubeHelpers.translate('User name') | 504 | function register (...) { |
455 | .then(translation => console.log('Translated User name by ' + translation)) | 505 | peertubeHelpers.translate('User name') |
506 | .then(translation => console.log('Translated User name by ' + translation)) | ||
507 | } | ||
456 | ``` | 508 | ``` |
457 | 509 | ||
458 | #### Get public settings | 510 | #### Get public settings |
@@ -460,24 +512,28 @@ peertubeHelpers.translate('User name') | |||
460 | To get your public plugin settings: | 512 | To get your public plugin settings: |
461 | 513 | ||
462 | ```js | 514 | ```js |
463 | peertubeHelpers.getSettings() | 515 | function register (...) { |
464 | .then(s => { | 516 | peertubeHelpers.getSettings() |
465 | if (!s || !s['site-id'] || !s['url']) { | 517 | .then(s => { |
466 | console.error('Matomo settings are not set.') | 518 | if (!s || !s['site-id'] || !s['url']) { |
467 | return | 519 | console.error('Matomo settings are not set.') |
468 | } | 520 | return |
521 | } | ||
469 | 522 | ||
470 | // ... | 523 | // ... |
471 | }) | 524 | }) |
525 | } | ||
472 | ``` | 526 | ``` |
473 | 527 | ||
474 | #### Get server config | 528 | #### Get server config |
475 | 529 | ||
476 | ```js | 530 | ```js |
477 | peertubeHelpers.getServerConfig() | 531 | function register (...) { |
478 | .then(config => { | 532 | peertubeHelpers.getServerConfig() |
479 | console.log('Fetched server config.', config) | 533 | .then(config => { |
480 | }) | 534 | console.log('Fetched server config.', config) |
535 | }) | ||
536 | } | ||
481 | ``` | 537 | ``` |
482 | 538 | ||
483 | #### Add custom fields to video form | 539 | #### Add custom fields to video form |
@@ -540,6 +596,26 @@ async function register ({ | |||
540 | }) | 596 | }) |
541 | } | 597 | } |
542 | ``` | 598 | ``` |
599 | |||
600 | #### Register settings script | ||
601 | |||
602 | To hide some fields in your settings plugin page depending on the form state: | ||
603 | |||
604 | ```js | ||
605 | async function register ({ registerSettingsScript }) { | ||
606 | registerSettingsScript({ | ||
607 | isSettingHidden: options => { | ||
608 | if (options.setting.name === 'my-setting' && options.formValues['field45'] === '2') { | ||
609 | return true | ||
610 | } | ||
611 | |||
612 | return false | ||
613 | } | ||
614 | }) | ||
615 | } | ||
616 | ``` | ||
617 | |||
618 | |||
543 | ### Publishing | 619 | ### Publishing |
544 | 620 | ||
545 | PeerTube plugins and themes should be published on [NPM](https://www.npmjs.com/) so that PeerTube indexes | 621 | PeerTube plugins and themes should be published on [NPM](https://www.npmjs.com/) so that PeerTube indexes |