Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui/plugins): support dynamic loading of ui plug-in scripts #774

Merged
merged 1 commit into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions conf/artalk.example.simple.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,4 @@ frontend:
scrollable: false
reqTimeout: 15000
versionCheck: true
pluginURLs: []
2 changes: 2 additions & 0 deletions conf/artalk.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,5 @@ frontend:
reqTimeout: 15000
# Version check
versionCheck: true
# Plugins
pluginURLs: []
2 changes: 2 additions & 0 deletions conf/artalk.example.zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,5 @@ frontend:
reqTimeout: 15000
# 版本检测
versionCheck: true
# 插件
pluginURLs: []
25 changes: 25 additions & 0 deletions server/common/conf.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package common

import (
"fmt"
"strings"

"github.com/ArtalkJS/Artalk/internal/config"
"github.com/ArtalkJS/Artalk/internal/core"
"github.com/ArtalkJS/Artalk/internal/utils"
"github.com/ArtalkJS/Artalk/server/middleware"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
)

type ApiVersionData struct {
Expand Down Expand Up @@ -48,8 +51,30 @@ func GetApiPublicConfDataMap(app *core.App, c *fiber.Ctx) ConfData {
frontendConf["locale"] = app.Conf().Locale
}

if pluginURLs, ok := frontendConf["pluginURLs"].([]any); ok {
frontendConf["pluginURLs"] = handlePluginURLs(app,
lo.Map[any, string](pluginURLs, func(u any, _ int) string {
return strings.TrimSpace(fmt.Sprintf("%v", u))
}))
}

return ConfData{
FrontendConf: frontendConf,
Version: GetApiVersionDataMap(),
}
}

func handlePluginURLs(app *core.App, urls []string) []string {
return lo.Filter[string](urls, func(u string, _ int) bool {
if strings.TrimSpace(u) == "" {
return false
}
if !utils.ValidateURL(u) {
return true
}
if trusted, _, _ := middleware.CheckURLTrusted(app, u); trusted {
return true
}
return false
})
}
19 changes: 4 additions & 15 deletions ui/artalk/src/artalk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@ import type { EventHandler } from './lib/event-manager'
import Context from './context'
import { handelCustomConf, convertApiOptions } from './config'
import Services from './service'
import { DefaultPlugins } from './plugins'
import * as Stat from './plugins/stat'
import { Api } from './api'
import type { TInjectedServices } from './service'

/** Global Plugins for all instances */
const GlobalPlugins: ArtalkPlugin[] = [ ...DefaultPlugins ]
import { GlobalPlugins, load } from './load'

/**
* Artalk
Expand All @@ -21,9 +18,6 @@ const GlobalPlugins: ArtalkPlugin[] = [ ...DefaultPlugins ]
export default class Artalk {
public ctx!: ContextApi

/** Plugins */
protected plugins: ArtalkPlugin[] = [ ...GlobalPlugins ]

constructor(conf: Partial<ArtalkConfig>) {
// Init Config
const handledConf = handelCustomConf(conf, true)
Expand All @@ -34,16 +28,11 @@ export default class Artalk {
// Init Services
Object.entries(Services).forEach(([name, initService]) => {
const obj = initService(this.ctx)
if (obj) this.ctx.inject(name as keyof TInjectedServices, obj) // auto inject deps to ctx
})

// Init Plugins
this.plugins.forEach(plugin => {
if (typeof plugin === 'function') plugin(this.ctx)
obj && this.ctx.inject(name as keyof TInjectedServices, obj) // auto inject deps to ctx
})

// Trigger created event
this.ctx.trigger('created')
if (import.meta.env.DEV && import.meta.env.VITEST) global.devLoadArtalk = () => load(this.ctx)
else load(this.ctx)
}

/** Get the config of Artalk */
Expand Down
113 changes: 113 additions & 0 deletions ui/artalk/src/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { ArtalkConfig, ArtalkPlugin, ContextApi } from '@/types'
import { handleConfFormServer } from '@/config'
import { showErrorDialog } from '@/components/error-dialog'
import { DefaultPlugins } from './plugins'

/**
* Global Plugins for all Artalk instances
*/
export const GlobalPlugins: ArtalkPlugin[] = [ ...DefaultPlugins ]

export async function load(ctx: ContextApi) {
const loadedPlugins: ArtalkPlugin[] = []
const loadPlugins = (plugins: ArtalkPlugin[]) => {
plugins.forEach((plugin) => {
if (typeof plugin === 'function' && !loadedPlugins.includes(plugin)) {
plugin(ctx)
loadedPlugins.push(plugin)
}
})
}

// Load local plugins
loadPlugins(GlobalPlugins)

// Get conf from server
const { data } = await ctx.getApi().conf.conf().catch((err) => {
onLoadErr(ctx, err)
throw err
})

// Initial config
let conf: Partial<ArtalkConfig> = {
apiVersion: data.version?.version, // version info
}

// Reference conf from backend
if (ctx.conf.useBackendConf) {
if (!data.frontend_conf) throw new Error('The remote backend does not respond to the frontend conf, but `useBackendConf` conf is enabled')
conf = { ...conf, ...handleConfFormServer(data.frontend_conf) }
}

// Apply conf modifier
ctx.conf.remoteConfModifier && ctx.conf.remoteConfModifier(conf)

// Dynamically load network plugins
conf.pluginURLs && await loadNetworkPlugins(conf.pluginURLs, ctx.conf.server).then((plugins) => {
loadPlugins(plugins)
}).catch((err) => {
console.error('Failed to load plugin', err)
})

// After all plugins are loaded
ctx.trigger('created')

// Apply conf updating
ctx.updateConf(conf)

// Trigger mounted event
ctx.trigger('mounted')

// Load comment list
if (!ctx.conf.remoteConfModifier) { // only auto fetch when no remoteConfModifier
ctx.fetch({ offset: 0 })
}
}

/**
* Dynamically load plugins from Network
*/
async function loadNetworkPlugins(scripts: string[], apiBase: string): Promise<ArtalkPlugin[]> {
if (!scripts || !Array.isArray(scripts)) return []

const tasks: Promise<void>[] = []

scripts.forEach((url) => {
// check url valid
if (!/^(http|https):\/\//.test(url))
url = `${apiBase.replace(/\/$/, '')}/${url.replace(/^\//, '')}`

tasks.push(new Promise<void>((resolve, reject) => {
// load artalk-plugin-auth.js
const script = document.createElement('script')
script.src = url
document.head.appendChild(script)
script.onload = () => resolve()
script.onerror = (err) => reject(err)
}))
})

await Promise.all(tasks)

return Object.values(window.ArtalkPlugins || {})
}

export function onLoadErr(ctx: ContextApi, err: any) {
let sidebarOpenView = ''

// if response err_no_site, modify the sidebar open view to create site
if (err.data?.err_no_site) {
const viewLoadParam = { create_name: ctx.conf.site, create_urls: `${window.location.protocol}//${window.location.host}` }
sidebarOpenView = `sites|${JSON.stringify(viewLoadParam)}`
}

showErrorDialog({
$err: ctx.get('list').$el,
errMsg: err.msg || String(err),
errData: err.data,
retryFn: () => load(ctx),
onOpenSidebar: ctx.get('user').getData().isAdmin ? () => ctx.showSidebar({
view: sidebarOpenView as any
}) : undefined // only show open sidebar button when user is admin
})
}
63 changes: 0 additions & 63 deletions ui/artalk/src/plugins/conf-remoter.ts

This file was deleted.

2 changes: 0 additions & 2 deletions ui/artalk/src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ArtalkPlugin } from '@/types'
import { ConfRemoter } from './conf-remoter'
import { Markdown } from './markdown'
import { EditorKit } from './editor-kit'
import { ListPlugins } from './list'
Expand All @@ -10,7 +9,6 @@ import { AdminOnlyElem } from './admin-only-elem'
import { DarkMode } from './dark-mode'

export const DefaultPlugins: ArtalkPlugin[] = [
ConfRemoter,
Markdown, EditorKit, AdminOnlyElem,
...ListPlugins,
Notifies,
Expand Down
4 changes: 3 additions & 1 deletion ui/artalk/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ export interface ArtalkConfig {
/** 后端版本 (系统数据,用户不允许更改) */
apiVersion?: string

/** Plugin script urls */
pluginURLs?: string[]

/** Replacer for marked */
markedReplacers?: ((raw: string) => string)[]

Expand All @@ -130,7 +133,6 @@ export interface ArtalkConfig {
remoteConfModifier?: (conf: Partial<ArtalkConfig>) => void
listUnreadHighlight?: boolean
scrollRelativeTo?: () => HTMLElement
immediateFetch?: boolean
pvAdd?: boolean
beforeSubmit?: (editor: EditorApi, next: () => void) => void
}
Expand Down
1 change: 0 additions & 1 deletion ui/artalk/src/types/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export interface EventPayloadMap {
'updated': ArtalkConfig
'unmounted': undefined

'conf-fetch': undefined // 配置请求时
'list-fetch': Partial<ListFetchParams> // 评论列表请求时
'list-fetched': ListFetchedArgs // 评论列表请求后
'list-load': CommentData[] // 评论装载前 (list-load payload is partial comments)
Expand Down
9 changes: 9 additions & 0 deletions ui/artalk/src/types/window.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ArtalkPlugin } from '.'

export {}

declare global {
interface Window {
ArtalkPlugins?: { [name: string]: ArtalkPlugin }
}
}
9 changes: 3 additions & 6 deletions ui/artalk/tests/ui-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ describe('Artalk instance', () => {
artalk = Artalk.init({
...InitConf,
el,
immediateFetch: false, // for testing
})

expect(artalk).toBeInstanceOf(Artalk)
Expand All @@ -81,19 +80,17 @@ describe('Artalk instance', () => {
expect(conf.site).toBe(InitConf.site)
expect(conf.darkMode).toBe(InitConf.darkMode)

expect(artalk.getEl().classList.contains('atk-dark-mode')).toBe(true)

confCopy = JSON.parse(JSON.stringify(conf))
})

it('should can listen to events and the conf-remoter works (artalk.trigger, artalk.on, conf-remoter)', async () => {
artalk.trigger('conf-fetch')
global.devLoadArtalk()

const fn = vi.fn()

await new Promise(resolve => {
await new Promise<void>(resolve => {
artalk.on('mounted', (conf) => {
resolve(null)
resolve()
fn()
})
})
Expand Down