ability to expose plugin's config on frontend css
Massimo Melina committed
Feb 27, 2023 at 10:20 UTC
fdd6bb32d04c3fe6ff24ec2dba7e7d46ff3412d3
8 files changed
+49
-30
dev-plugins.md
+3
-1
@@ -68,6 +68,7 @@ All the following properties are essentially optional.
68
When necessary your plugin will read its value using `api.getConfig('message')`.
69
70
- `configDialog: FormDialog` object to override dialog options. Please refer to sources for details.
71
+- `onFrontendConfig: (config: object) => void | object` manipulate config values exposed to front-end
72
73
### FieldDescriptor
74
@@ -76,7 +77,8 @@ Currently, these properties are supported:
77
- `label: string` what name to display next to the field. Default is based on `key`.
78
- `defaultValue: any` value to be used when nothing is set.
79
- `helperText: string` extra text printed next to the field.
79
-- `frontend: boolean` expose this setting on the frontend, so that javascript can access it as HFS.plugins[PLUGIN_NAME][CONFIG_KEY]
80
+- `frontend: boolean` expose this setting on the frontend, so that javascript can access it as
81
+ `HFS.plugins[PLUGIN_NAME][CONFIG_KEY]` but also css can access it as `var(--PLUGIN_NAME-CONFIG_KEY)`
82
83
Based on `type`, other properties are supported:
84
- `string`
frontend/src/index.scss
+1
-1
@@ -351,7 +351,7 @@ button label {
351
}
352
}
353
354
-@media (min-width: 2em) {
354
+@media (min-width: 42em) {
355
body {
356
/* Works on Firefox */
357
& {
src/api.plugins.ts
+2
-2
@@ -11,7 +11,7 @@ import {
11
} from './plugins'
12
import _ from 'lodash'
13
import assert from 'assert'
14
-import { objSameKeys, onOff, wait } from './misc'
14
+import { newObj, onOff, wait } from './misc'
15
import { ApiHandlers, SendListReadable } from './apiMiddleware'
16
import events from './events'
17
import { rm } from 'fs/promises'
@@ -70,7 +70,7 @@ const apis: ApiHandlers = {
70
return {
71
enabled: enablePlugins.get().includes(id),
72
config: {
73
- ...objSameKeys(getPluginConfigFields(id) ||{}, v => v?.defaultValue),
73
+ ...newObj(getPluginConfigFields(id) ||{}, v => v?.defaultValue),
74
...pluginsConfig.get()[id]
75
}
76
}
src/api.vfs.ts
+2
-2
@@ -5,7 +5,7 @@ import _ from 'lodash'
5
import { stat } from 'fs/promises'
6
import { ApiError, ApiHandlers } from './apiMiddleware'
7
import { dirname, join, resolve } from 'path'
8
-import { dirStream, isWindowsDrive, objSameKeys } from './misc'
8
+import { dirStream, isWindowsDrive, newObj } from './misc'
9
import {
10
IS_WINDOWS,
11
HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_ACCEPTABLE,
@@ -86,7 +86,7 @@ const apis: ApiHandlers = {
86
if (parent?.children?.find(x => getNodeName(x) === props.name))
87
return new ApiError(HTTP_CONFLICT, 'name already present')
88
}
89
- props = objSameKeys(props, v => v === null ? undefined : v) // null is a way to serialize undefined, that will restore default values
89
+ props = newObj(props, v => v === null ? undefined : v) // null is a way to serialize undefined, that will restore default values
90
if (props.masks && typeof props.masks !== 'object')
91
delete props.masks
92
Object.assign(n, props)
src/config.ts
+3
-3
@@ -5,7 +5,7 @@ import { APP_PATH, argv, ORIGINAL_CWD } from './const'
5
import { watchLoad } from './watchLoad'
6
import yaml from 'yaml'
7
import _ from 'lodash'
8
-import { debounceAsync, same, objSameKeys, onOff, wait, with_ } from './misc'
8
+import { debounceAsync, same, newObj, onOff, wait, with_ } from './misc'
9
import { copyFileSync, existsSync, renameSync, statSync } from 'fs'
10
import { join, resolve } from 'path'
11
import events from './events'
@@ -99,7 +99,7 @@ export function getConfig(k:string) {
99
}
100
101
export function getWholeConfig({ omit, only }: { omit?:string[], only?:string[] }) {
102
- const defs = objSameKeys(configProps, x => x.defaultValue)
102
+ const defs = newObj(configProps, x => x.defaultValue)
103
let copy = _.defaults({}, state, defs)
104
if (omit?.length)
105
copy = _.omit(copy, omit)
@@ -111,7 +111,7 @@ export function getWholeConfig({ omit, only }: { omit?:string[], only?:string[]
111
// pass a value to `save` to force saving decision, or leave undefined for auto. Passing false will also reset previously loaded configs.
112
export function setConfig(newCfg: Record<string,any>, save?: boolean) {
113
if (!started) { // first time we consider also CLI args
114
- const argCfg = _.pickBy(objSameKeys(configProps, (x,k) => argv[k]), x => x !== undefined)
114
+ const argCfg = _.pickBy(newObj(configProps, (x, k) => argv[k]), x => x !== undefined)
115
if (! _.isEmpty(argCfg)) {
116
saveConfigAsap().then() // don't set `save` argument, as it would interfere below at check `save===false`
117
Object.assign(newCfg, argCfg)
src/middlewares.ts
+2
-2
@@ -12,7 +12,7 @@ import {
12
} from './const'
13
import { FRONTEND_URI } from './const'
14
import { cantReadStatusCode, hasPermission, nodeIsDirectory, urlToNode, vfs } from './vfs'
15
-import { dirTraversal, objSameKeys, stream2string, tryJson } from './misc'
15
+import { dirTraversal, newObj, stream2string, tryJson } from './misc'
16
import { zipStreamFromFolder } from './zip'
17
import { serveFile, serveFileNode } from './serveFile'
18
import { serveGuiFiles } from './serveGuiFiles'
@@ -201,6 +201,6 @@ async function srpCheck(username: string, password: string) {
201
export const paramsDecoder: Koa.Middleware = async (ctx, next) => {
202
ctx.params = ctx.method === 'POST' && ctx.originalUrl.startsWith(API_URI)
203
? tryJson(await stream2string(ctx.req))
204
- : objSameKeys(ctx.query, x => Array.isArray(x) ? x : tryJson(x))
204
+ : newObj(ctx.query, x => Array.isArray(x) ? x : tryJson(x))
205
await next()
206
}
src/misc.ts
+13
-9
@@ -25,25 +25,29 @@ export function prefix(pre:string, v:string|number, post:string='') {
25
}
26
27
export function setHidden<T, ADD>(dest: T, src: ADD) {
28
- return Object.defineProperties(dest, objSameKeys(src as any, value => ({
28
+ return Object.defineProperties(dest, newObj(src as any, value => ({
29
enumerable: false,
30
writable: true,
31
value,
32
}))) as T & ADD
33
}
34
35
-export function objSameKeys<S extends object,VR=any>(
35
+export function newObj<S extends object,VR=any>(
36
src: S,
37
- newValue: (value:Truthy<S[keyof S]>, key:keyof S, skip:()=>void)=>any
37
+ newValue: (value:Truthy<S[keyof S]>, key: Exclude<keyof S, symbol>, setK:(newK?: string)=>true)=>any
38
) {
39
- let skipped = false
39
+ if (!src)
40
+ return {}
41
const pairs = Object.entries(src).map( ([k,v]) => {
41
- skipped = false
42
- const newV = newValue(v, k as keyof S, skip)
43
- return !skipped && [k, newV]
42
+ if (typeof k === 'symbol') return
43
+ let _k: undefined | typeof k = k
44
+ const newV = newValue(v, k as Exclude<keyof S, symbol>, (newK) => {
45
+ _k = newK
46
+ return true // for convenient expression concatenation
47
+ })
48
+ return _k !== undefined && [_k, newV]
49
})
45
- return Object.fromEntries(pairs.filter(Boolean)) as { [K in keyof S]:VR }
46
- function skip() { skipped = true }
50
+ return Object.fromEntries(onlyTruthy(pairs)) as { [K in keyof S]:VR }
51
}
52
53
export function wait(ms: number) {
src/serveGuiFiles.ts
+23
-10
@@ -12,11 +12,11 @@ import {
12
VERSION
13
} from './const'
14
import { serveFile } from './serveFile'
15
-import { getPluginConfigFields, mapPlugins, pluginsConfig } from './plugins'
15
+import { getPluginConfigFields, getPluginInfo, mapPlugins, pluginsConfig } from './plugins'
16
import { refresh_session } from './api.auth'
17
import { ApiError } from './apiMiddleware'
18
import { join, extname } from 'path'
19
-import { getOrSet, objSameKeys, onlyTruthy } from './misc'
19
+import { getOrSet, newObj, onlyTruthy } from './misc'
20
import { favicon, title } from './adminApis'
21
import _ from 'lodash'
22
@@ -73,6 +73,15 @@ async function treatIndex(ctx: Koa.Context, body: string, filesUri: string) {
73
const js = mapPlugins((plug,k) =>
74
(isFrontend ? plug.frontend_js : null)?.map(f => PLUGINS_PUB_URI + k + '/' + f)).flat().filter(Boolean)
75
76
+ // expose plugins' configs that are declared with 'frontend' attribute
77
+ const plugins = Object.fromEntries(onlyTruthy(mapPlugins((pl,name) => {
78
+ let configs = newObj(getPluginConfigFields(name), (v, k, skip) =>
79
+ !v.frontend ? skip() :
80
+ (pluginsConfig.get()?.[name]?.[k] ?? pl.getData().config?.[k]?.defaultValue)
81
+ )
82
+ configs = getPluginInfo(name).onFrontendConfig?.(configs) || configs
83
+ return !_.isEmpty(configs) && [name, configs]
84
+ })))
85
return body
86
.replace(/((?:src|href) *= *['"])\/?(?![a-z]+:\/\/)/g, '$1' + filesUri)
87
.replace('<HFS/>', () => `
@@ -85,22 +94,26 @@ async function treatIndex(ctx: Koa.Context, body: string, filesUri: string) {
94
VERSION,
95
API_VERSION,
96
session: session instanceof ApiError ? null : session,
88
- // expose plugins' configs that were declared with 'frontend' attribute
89
- plugins: Object.fromEntries(onlyTruthy(mapPlugins((pl,name) => {
90
- const configs = objSameKeys(getPluginConfigFields(name), (v,k,skip) =>
91
- !v.frontend ? skip() :
92
- (pluginsConfig.get()?.[name]?.[k] ?? pl.getData().config?.[k]?.defaultValue)
93
- )
94
- return !_.isEmpty(configs) && [name, configs]
95
- })))
97
+ plugins
98
}, null, 4)}
99
document.documentElement.setAttribute('ver', '${VERSION.split('-')[0] /*for style selectors*/}')
100
</script>
101
+ <style>
102
+ :root {
103
+ ${_.map(plugins, (configs, pluginName) =>
104
+ _.map(configs, (v,k) => `--${pluginName}-${k}: ${serializeCss(v)};`).join('\n')).join('')}
105
+ }
106
+ </style>
107
${css.map(uri => `<link rel='stylesheet' type='text/css' href='${uri}'/>`).join('\n')}
108
${js.map(uri => `<script defer src='${uri}'></script>`).join('\n')}
109
`)
110
}
111
112
+function serializeCss(v: any) {
113
+ return typeof v === 'string' && /^#[0-9a-fA-F]{3,8}|rgba?\(.+\)$/.test(v) ? v
114
+ : JSON.stringify(v)
115
+}
116
+
117
function serveProxied(port: string | undefined, uri: string) { // used for development only
118
if (!port)
119
return