diff --git a/graphql/documents/queries/plugins.graphql b/graphql/documents/queries/plugins.graphql
index 901e1722b27..4c8bd00955a 100644
--- a/graphql/documents/queries/plugins.graphql
+++ b/graphql/documents/queries/plugins.graphql
@@ -24,6 +24,11 @@ query Plugins {
description
type
}
+
+ paths {
+ css
+ javascript
+ }
}
}
diff --git a/graphql/schema/types/plugin.graphql b/graphql/schema/types/plugin.graphql
index 45a2f55f1c1..a18f6651981 100644
--- a/graphql/schema/types/plugin.graphql
+++ b/graphql/schema/types/plugin.graphql
@@ -1,3 +1,10 @@
+type PluginPaths {
+ # path to javascript files
+ javascript: [String!]
+ # path to css files
+ css: [String!]
+}
+
type Plugin {
id: ID!
name: String!
@@ -10,6 +17,8 @@ type Plugin {
tasks: [PluginTask!]
hooks: [PluginHook!]
settings: [PluginSetting!]
+
+ paths: PluginPaths!
}
type PluginTask {
diff --git a/internal/api/context_keys.go b/internal/api/context_keys.go
index 8731f75c301..a8ab0afb50e 100644
--- a/internal/api/context_keys.go
+++ b/internal/api/context_keys.go
@@ -13,4 +13,5 @@ const (
tagKey
downloadKey
imageKey
+ pluginKey
)
diff --git a/internal/api/resolver.go b/internal/api/resolver.go
index 8c82be334e1..4698add7062 100644
--- a/internal/api/resolver.go
+++ b/internal/api/resolver.go
@@ -84,6 +84,9 @@ func (r *Resolver) Tag() TagResolver {
func (r *Resolver) SavedFilter() SavedFilterResolver {
return &savedFilterResolver{r}
}
+func (r *Resolver) Plugin() PluginResolver {
+ return &pluginResolver{r}
+}
func (r *Resolver) ConfigResult() ConfigResultResolver {
return &configResultResolver{r}
}
@@ -102,6 +105,7 @@ type studioResolver struct{ *Resolver }
type movieResolver struct{ *Resolver }
type tagResolver struct{ *Resolver }
type savedFilterResolver struct{ *Resolver }
+type pluginResolver struct{ *Resolver }
type configResultResolver struct{ *Resolver }
func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {
diff --git a/internal/api/resolver_model_plugin.go b/internal/api/resolver_model_plugin.go
new file mode 100644
index 00000000000..644e5e004c9
--- /dev/null
+++ b/internal/api/resolver_model_plugin.go
@@ -0,0 +1,57 @@
+package api
+
+import (
+ "context"
+
+ "github.com/stashapp/stash/pkg/plugin"
+)
+
+type pluginURLBuilder struct {
+ BaseURL string
+ Plugin *plugin.Plugin
+}
+
+func (b pluginURLBuilder) javascript() []string {
+ ui := b.Plugin.UI
+ if len(ui.Javascript) == 0 && len(ui.ExternalScript) == 0 {
+ return nil
+ }
+
+ var ret []string
+
+ ret = append(ret, ui.ExternalScript...)
+ ret = append(ret, b.BaseURL+"/plugin/"+b.Plugin.ID+"/javascript")
+
+ return ret
+}
+
+func (b pluginURLBuilder) css() []string {
+ ui := b.Plugin.UI
+ if len(ui.CSS) == 0 && len(ui.ExternalCSS) == 0 {
+ return nil
+ }
+
+ var ret []string
+
+ ret = append(ret, b.Plugin.UI.ExternalCSS...)
+ ret = append(ret, b.BaseURL+"/plugin/"+b.Plugin.ID+"/css")
+ return ret
+}
+
+func (b *pluginURLBuilder) paths() *PluginPaths {
+ return &PluginPaths{
+ Javascript: b.javascript(),
+ CSS: b.css(),
+ }
+}
+
+func (r *pluginResolver) Paths(ctx context.Context, obj *plugin.Plugin) (*PluginPaths, error) {
+ baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
+
+ b := pluginURLBuilder{
+ BaseURL: baseURL,
+ Plugin: obj,
+ }
+
+ return b.paths(), nil
+}
diff --git a/internal/api/routes_custom.go b/internal/api/routes_custom.go
index 5c32d23b2c8..cd14375d7ad 100644
--- a/internal/api/routes_custom.go
+++ b/internal/api/routes_custom.go
@@ -5,15 +5,14 @@ import (
"strings"
"github.com/go-chi/chi/v5"
-
- "github.com/stashapp/stash/internal/manager/config"
+ "github.com/stashapp/stash/pkg/utils"
)
type customRoutes struct {
- servedFolders config.URLMap
+ servedFolders utils.URLMap
}
-func getCustomRoutes(servedFolders config.URLMap) chi.Router {
+func getCustomRoutes(servedFolders utils.URLMap) chi.Router {
return customRoutes{servedFolders: servedFolders}.Routes()
}
diff --git a/internal/api/routes_plugin.go b/internal/api/routes_plugin.go
new file mode 100644
index 00000000000..a844552e902
--- /dev/null
+++ b/internal/api/routes_plugin.go
@@ -0,0 +1,107 @@
+package api
+
+import (
+ "context"
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/stashapp/stash/pkg/plugin"
+)
+
+type pluginRoutes struct {
+ pluginCache *plugin.Cache
+}
+
+func getPluginRoutes(pluginCache *plugin.Cache) chi.Router {
+ return pluginRoutes{
+ pluginCache: pluginCache,
+ }.Routes()
+}
+
+func (rs pluginRoutes) Routes() chi.Router {
+ r := chi.NewRouter()
+
+ r.Route("/{pluginId}", func(r chi.Router) {
+ r.Use(rs.PluginCtx)
+ r.Get("/assets/*", rs.Assets)
+ r.Get("/javascript", rs.Javascript)
+ r.Get("/css", rs.CSS)
+ })
+
+ return r
+}
+
+func (rs pluginRoutes) Assets(w http.ResponseWriter, r *http.Request) {
+ p := r.Context().Value(pluginKey).(*plugin.Plugin)
+
+ if !p.Enabled {
+ http.Error(w, "plugin disabled", http.StatusBadRequest)
+ return
+ }
+
+ prefix := "/plugin/" + chi.URLParam(r, "pluginId") + "/assets"
+
+ r.URL.Path = strings.Replace(r.URL.Path, prefix, "", 1)
+
+ // http.FileServer redirects to / if the path ends with index.html
+ r.URL.Path = strings.TrimSuffix(r.URL.Path, "/index.html")
+
+ pluginDir := filepath.Dir(p.ConfigPath)
+
+ // map the path to the applicable filesystem location
+ var dir string
+ r.URL.Path, dir = p.UI.Assets.GetFilesystemLocation(r.URL.Path)
+ if dir == "" {
+ http.NotFound(w, r)
+ }
+
+ dir = filepath.Join(pluginDir, filepath.FromSlash(dir))
+
+ // ensure directory is still within the plugin directory
+ if !strings.HasPrefix(dir, pluginDir) {
+ http.NotFound(w, r)
+ return
+ }
+
+ http.FileServer(http.Dir(dir)).ServeHTTP(w, r)
+}
+
+func (rs pluginRoutes) Javascript(w http.ResponseWriter, r *http.Request) {
+ p := r.Context().Value(pluginKey).(*plugin.Plugin)
+
+ if !p.Enabled {
+ http.Error(w, "plugin disabled", http.StatusBadRequest)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/javascript")
+ serveFiles(w, r, p.UI.Javascript)
+}
+
+func (rs pluginRoutes) CSS(w http.ResponseWriter, r *http.Request) {
+ p := r.Context().Value(pluginKey).(*plugin.Plugin)
+
+ if !p.Enabled {
+ http.Error(w, "plugin disabled", http.StatusBadRequest)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/css")
+ serveFiles(w, r, p.UI.CSS)
+}
+
+func (rs pluginRoutes) PluginCtx(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ p := rs.pluginCache.GetPlugin(chi.URLParam(r, "pluginId"))
+ if p == nil {
+ http.Error(w, http.StatusText(404), 404)
+ return
+ }
+
+ ctx := context.WithValue(r.Context(), pluginKey, p)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index fa605777785..bd5bcc2c1a6 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -138,7 +138,7 @@ func Start() error {
r.HandleFunc(gqlEndpoint, gqlHandlerFunc)
r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
- setPageSecurityHeaders(w, r)
+ setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
endpoint := getProxyPrefix(r) + gqlEndpoint
gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
})
@@ -150,9 +150,10 @@ func Start() error {
r.Mount("/movie", getMovieRoutes(repo))
r.Mount("/tag", getTagRoutes(repo))
r.Mount("/downloads", getDownloadsRoutes())
+ r.Mount("/plugin", getPluginRoutes(pluginCache))
- r.HandleFunc("/css", cssHandler(c, pluginCache))
- r.HandleFunc("/javascript", javascriptHandler(c, pluginCache))
+ r.HandleFunc("/css", cssHandler(c))
+ r.HandleFunc("/javascript", javascriptHandler(c))
r.HandleFunc("/customlocales", customLocalesHandler(c))
staticLoginUI := statigz.FileServer(loginUIBox.(fs.ReadDirFS))
@@ -201,7 +202,7 @@ func Start() error {
indexHtml = strings.Replace(indexHtml, ` {pluginDir}/foo/file.txt
+ // /plugin/{pluginId}/assets/bar/file.txt -> {pluginDir}/baz/file.txt
+ // /plugin/{pluginId}/assets/file.txt -> {pluginDir}/root/file.txt
+ Assets utils.URLMap `yaml:"assets"`
+}
+
+func isURL(s string) bool {
+ return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
func (c UIConfig) getCSSFiles(parent Config) []string {
- ret := make([]string, len(c.CSS))
- for i, v := range c.CSS {
- ret[i] = filepath.Join(parent.getConfigPath(), v)
+ var ret []string
+ for _, v := range c.CSS {
+ if !isURL(v) {
+ ret = append(ret, filepath.Join(parent.getConfigPath(), v))
+ }
+ }
+
+ return ret
+}
+
+func (c UIConfig) getExternalCSS() []string {
+ var ret []string
+ for _, v := range c.CSS {
+ if isURL(v) {
+ ret = append(ret, v)
+ }
}
return ret
}
func (c UIConfig) getJavascriptFiles(parent Config) []string {
- ret := make([]string, len(c.Javascript))
- for i, v := range c.Javascript {
- ret[i] = filepath.Join(parent.getConfigPath(), v)
+ var ret []string
+ for _, v := range c.Javascript {
+ if !isURL(v) {
+ ret = append(ret, filepath.Join(parent.getConfigPath(), v))
+ }
+ }
+
+ return ret
+}
+
+func (c UIConfig) getExternalScripts() []string {
+ var ret []string
+ for _, v := range c.Javascript {
+ if isURL(v) {
+ ret = append(ret, v)
+ }
}
return ret
@@ -184,10 +239,14 @@ func (c Config) toPlugin() *Plugin {
Tasks: c.getPluginTasks(false),
Hooks: c.getPluginHooks(false),
UI: PluginUI{
- Javascript: c.UI.getJavascriptFiles(c),
- CSS: c.UI.getCSSFiles(c),
+ ExternalScript: c.UI.getExternalScripts(),
+ ExternalCSS: c.UI.getExternalCSS(),
+ Javascript: c.UI.getJavascriptFiles(c),
+ CSS: c.UI.getCSSFiles(c),
+ Assets: c.UI.Assets,
},
- Settings: c.getPluginSettings(),
+ Settings: c.getPluginSettings(),
+ ConfigPath: c.path,
}
}
diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go
index 8aef1b14bef..2003ea5ff92 100644
--- a/pkg/plugin/plugins.go
+++ b/pkg/plugin/plugins.go
@@ -21,6 +21,7 @@ import (
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/txn"
+ "github.com/stashapp/stash/pkg/utils"
)
type Plugin struct {
@@ -35,14 +36,39 @@ type Plugin struct {
Settings []PluginSetting `json:"settings"`
Enabled bool `json:"enabled"`
+
+ // ConfigPath is the path to the plugin's configuration file.
+ ConfigPath string `json:"-"`
}
type PluginUI struct {
+ // Content Security Policy configuration for the plugin.
+ CSP PluginCSP `json:"csp"`
+
+ // External Javascript files that will be injected into the stash UI.
+ ExternalScript []string `json:"external_script"`
+
+ // External CSS files that will be injected into the stash UI.
+ ExternalCSS []string `json:"external_css"`
+
// Javascript files that will be injected into the stash UI.
Javascript []string `json:"javascript"`
// CSS files that will be injected into the stash UI.
CSS []string `json:"css"`
+
+ // Assets is a map of URL prefixes to hosted directories.
+ // This allows plugins to serve static assets from a URL path.
+ // Plugin assets are exposed via the /plugin/{pluginId}/assets path.
+ // For example, if the plugin configuration file contains:
+ // /foo: bar
+ // /bar: baz
+ // /: root
+ // Then the following requests will be mapped to the following files:
+ // /plugin/{pluginId}/assets/foo/file.txt -> {pluginDir}/foo/file.txt
+ // /plugin/{pluginId}/assets/bar/file.txt -> {pluginDir}/baz/file.txt
+ // /plugin/{pluginId}/assets/file.txt -> {pluginDir}/root/file.txt
+ Assets utils.URLMap `json:"assets"`
}
type PluginSetting struct {
@@ -173,6 +199,22 @@ func (c Cache) ListPlugins() []*Plugin {
return ret
}
+// GetPlugin returns the plugin with the given ID.
+// Returns nil if the plugin is not found.
+func (c Cache) GetPlugin(id string) *Plugin {
+ disabledPlugins := c.config.GetDisabledPlugins()
+ plugin := c.getPlugin(id)
+ if plugin != nil {
+ p := plugin.toPlugin()
+
+ disabled := sliceutil.Contains(disabledPlugins, p.ID)
+ p.Enabled = !disabled
+ return p
+ }
+
+ return nil
+}
+
// ListPluginTasks returns all runnable plugin tasks in all loaded plugins.
func (c Cache) ListPluginTasks() []*PluginTask {
var ret []*PluginTask
diff --git a/pkg/utils/urlmap.go b/pkg/utils/urlmap.go
new file mode 100644
index 00000000000..5279b00db3c
--- /dev/null
+++ b/pkg/utils/urlmap.go
@@ -0,0 +1,30 @@
+package utils
+
+import "strings"
+
+// URLMap is a map of URL prefixes to filesystem locations
+type URLMap map[string]string
+
+// GetFilesystemLocation returns the adjusted URL and the filesystem location
+func (m URLMap) GetFilesystemLocation(url string) (newURL string, fsPath string) {
+ newURL = url
+ if m == nil {
+ return
+ }
+
+ root := m["/"]
+ for k, v := range m {
+ if k != "/" && strings.HasPrefix(url, k) {
+ newURL = strings.TrimPrefix(url, k)
+ fsPath = v
+ return
+ }
+ }
+
+ if root != "" {
+ fsPath = root
+ return
+ }
+
+ return
+}
diff --git a/pkg/utils/urlmap_test.go b/pkg/utils/urlmap_test.go
new file mode 100644
index 00000000000..12c7fa29032
--- /dev/null
+++ b/pkg/utils/urlmap_test.go
@@ -0,0 +1,70 @@
+package utils
+
+import (
+ "testing"
+)
+
+func TestURLMap_GetFilesystemLocation(t *testing.T) {
+ // create the URLMap
+ urlMap := make(URLMap)
+ urlMap["/"] = "root"
+ urlMap["/foo"] = "bar"
+
+ empty := make(URLMap)
+ var nilMap URLMap
+
+ tests := []struct {
+ name string
+ urlMap URLMap
+ url string
+ wantNewURL string
+ wantFsPath string
+ }{
+ {
+ name: "simple",
+ urlMap: urlMap,
+ url: "/foo/bar",
+ wantNewURL: "/bar",
+ wantFsPath: "bar",
+ },
+ {
+ name: "root",
+ urlMap: urlMap,
+ url: "/baz",
+ wantNewURL: "/baz",
+ wantFsPath: "root",
+ },
+ {
+ name: "root",
+ urlMap: urlMap,
+ url: "/baz",
+ wantNewURL: "/baz",
+ wantFsPath: "root",
+ },
+ {
+ name: "empty",
+ urlMap: empty,
+ url: "/xyz",
+ wantNewURL: "/xyz",
+ wantFsPath: "",
+ },
+ {
+ name: "nil",
+ urlMap: nilMap,
+ url: "/xyz",
+ wantNewURL: "/xyz",
+ wantFsPath: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotNewURL, gotFsPath := tt.urlMap.GetFilesystemLocation(tt.url)
+ if gotNewURL != tt.wantNewURL {
+ t.Errorf("URLMap.GetFilesystemLocation() gotNewURL = %v, want %v", gotNewURL, tt.wantNewURL)
+ }
+ if gotFsPath != tt.wantFsPath {
+ t.Errorf("URLMap.GetFilesystemLocation() gotFsPath = %v, want %v", gotFsPath, tt.wantFsPath)
+ }
+ })
+ }
+}
diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx
index e85ecb2fb32..d3942b16674 100644
--- a/ui/v2.5/src/App.tsx
+++ b/ui/v2.5/src/App.tsx
@@ -18,6 +18,7 @@ import locales, { registerCountry } from "src/locales";
import {
useConfiguration,
useConfigureUI,
+ usePlugins,
useSystemStatus,
} from "src/core/StashService";
import flattenMessages from "./utils/flattenMessages";
@@ -40,6 +41,9 @@ import { releaseNotes } from "./docs/en/ReleaseNotes";
import { getPlatformURL } from "./core/createClient";
import { lazyComponent } from "./utils/lazyComponent";
import { isPlatformUniquelyRenderedByApple } from "./utils/apple";
+import useScript, { useCSS } from "./hooks/useScript";
+import { useMemoOnce } from "./hooks/state";
+import { uniq } from "lodash-es";
const Performers = lazyComponent(
() => import("./components/Performers/Performers")
@@ -149,6 +153,39 @@ export const App: React.FC = () => {
setLocale();
}, [customMessages, language]);
+ const {
+ data: plugins,
+ loading: pluginsLoading,
+ error: pluginsError,
+ } = usePlugins();
+
+ const pluginJavascripts = useMemoOnce(() => {
+ return [
+ uniq(
+ plugins?.plugins
+ ?.filter((plugin) => plugin.enabled && plugin.paths.javascript)
+ .map((plugin) => plugin.paths.javascript!)
+ .flat() ?? []
+ ),
+ !pluginsLoading && !pluginsError,
+ ];
+ }, [plugins?.plugins, pluginsLoading, pluginsError]);
+
+ const pluginCSS = useMemoOnce(() => {
+ return [
+ uniq(
+ plugins?.plugins
+ ?.filter((plugin) => plugin.enabled && plugin.paths.css)
+ .map((plugin) => plugin.paths.css!)
+ .flat() ?? []
+ ),
+ !pluginsLoading && !pluginsError,
+ ];
+ }, [plugins, pluginsLoading, pluginsError]);
+
+ useScript(pluginJavascripts ?? [], !pluginsLoading && !pluginsError);
+ useCSS(pluginCSS ?? [], !pluginsLoading && !pluginsError);
+
const location = useLocation();
const history = useHistory();
const setupMatch = useRouteMatch(["/setup", "/migrate"]);
diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md
index e7c80ded170..20488cebf0d 100644
--- a/ui/v2.5/src/docs/en/Manual/Plugins.md
+++ b/ui/v2.5/src/docs/en/Manual/Plugins.md
@@ -43,6 +43,22 @@ ui:
javascript:
-
+ # optional list of assets
+ assets:
+ urlPrefix: fsLocation
+ ...
+
+ # content-security policy overrides
+ csp:
+ script-src:
+ - http://alloweddomain.com
+
+ style-src:
+ - http://alloweddomain.com
+
+ connect-src:
+ - http://alloweddomain.com
+
# the following are used for plugin tasks only
exec:
- ...
@@ -56,6 +72,31 @@ The `name`, `description`, `version` and `url` fields are displayed on the plugi
The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins with tasks.
+## UI Configuration
+
+The `css` and `javascript` field values may be relative paths to the plugin configuration file, or
+may be full external URLs.
+
+The `assets` field is a map of URL prefixes to filesystem paths relative to the plugin configuration file.
+Assets are mounted to the `/plugin/{pluginID}/assets` path.
+
+As an example, for a plugin with id `foo` with the following `assets` value:
+```
+assets:
+ foo: bar
+ root: .
+```
+The following URLs will be mapped to these locations:
+`/plugin/foo/assets/foo/file.txt` -> `{pluginDir}/bar/file.txt`
+`/plugin/foo/assets/file.txt` -> `{pluginDir}/file.txt`
+`/plugin/foo/assets/bar/file.txt` -> `{pluginDir}/bar/file.txt` (via the `root` entry)
+
+Mappings that try to go outside of the directory containing the plugin configuration file will be
+ignored.
+
+The `csp` field contains overrides to the content security policies. The URLs in `script-src`,
+`style-src` and `connect-src` will be added to the applicable content security policy.
+
See [External Plugins](/help/ExternalPlugins.md) for details for making plugins with external tasks.
See [Embedded Plugins](/help/EmbeddedPlugins.md) for details for making plugins with embedded tasks.
diff --git a/ui/v2.5/src/hooks/state.ts b/ui/v2.5/src/hooks/state.ts
index 9c10523bd6f..ef91f6904a2 100644
--- a/ui/v2.5/src/hooks/state.ts
+++ b/ui/v2.5/src/hooks/state.ts
@@ -33,6 +33,30 @@ export function useInitialState(
return [value, setValue, setInitialValue];
}
+// useMemoOnce is a hook that returns a value once the ready flag is set to true.
+// The value is only set once, and will not be updated once it has been set.
+/* eslint-disable react-hooks/exhaustive-deps */
+export function useMemoOnce(
+ fn: () => [T, boolean],
+ deps: React.DependencyList
+) {
+ const [storedValue, setStoredValue] = React.useState();
+ const isFirst = React.useRef(true);
+
+ React.useEffect(() => {
+ if (isFirst.current) {
+ const [v, ready] = fn();
+ if (ready) {
+ setStoredValue(v);
+ isFirst.current = false;
+ }
+ }
+ }, deps);
+
+ return storedValue;
+}
+/* eslint-enable react-hooks/exhaustive-deps */
+
// useCompare is a hook that returns true if the value has changed since the last render.
export function useCompare(val: T) {
const prevVal = usePrevious(val);
diff --git a/ui/v2.5/src/hooks/useScript.tsx b/ui/v2.5/src/hooks/useScript.tsx
index 1745a7483da..12dee9ce2df 100644
--- a/ui/v2.5/src/hooks/useScript.tsx
+++ b/ui/v2.5/src/hooks/useScript.tsx
@@ -1,22 +1,72 @@
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
+
+const useScript = (urls: string | string[], condition?: boolean) => {
+ const urlArray = useMemo(() => {
+ if (!Array.isArray(urls)) {
+ return [urls];
+ }
+
+ return urls;
+ }, [urls]);
+
+ useEffect(() => {
+ const scripts = urlArray.map((url) => {
+ const script = document.createElement("script");
+
+ script.src = url;
+ script.async = true;
+ return script;
+ });
+
+ if (condition) {
+ scripts.forEach((script) => {
+ document.head.appendChild(script);
+ });
+ }
+
+ return () => {
+ if (condition) {
+ scripts.forEach((script) => {
+ document.head.removeChild(script);
+ });
+ }
+ };
+ }, [urlArray, condition]);
+};
+
+export const useCSS = (urls: string | string[], condition?: boolean) => {
+ const urlArray = useMemo(() => {
+ if (!Array.isArray(urls)) {
+ return [urls];
+ }
+
+ return urls;
+ }, [urls]);
-const useScript = (url: string, condition?: boolean) => {
useEffect(() => {
- const script = document.createElement("script");
+ const links = urlArray.map((url) => {
+ const link = document.createElement("link");
- script.src = url;
- script.async = true;
+ link.href = url;
+ link.rel = "stylesheet";
+ link.type = "text/css";
+ return link;
+ });
if (condition) {
- document.head.appendChild(script);
+ links.forEach((link) => {
+ document.head.appendChild(link);
+ });
}
return () => {
if (condition) {
- document.head.removeChild(script);
+ links.forEach((link) => {
+ document.head.removeChild(link);
+ });
}
};
- }, [url, condition]);
+ }, [urlArray, condition]);
};
export default useScript;