Skip to content

Commit

Permalink
feat(ui/plugins): support dynamic loading of ui plug-in scripts
Browse files Browse the repository at this point in the history
  • Loading branch information
qwqcode committed Feb 12, 2024
1 parent 18123f7 commit 67f308a
Show file tree
Hide file tree
Showing 12 changed files with 162 additions and 88 deletions.
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

0 comments on commit 67f308a

Please sign in to comment.