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