Skip to content

Feat/plugins #398

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

Merged
merged 13 commits into from
Jun 12, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions i18n/en_US.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
Expand Down Expand Up @@ -1317,13 +1320,11 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}


admin:
admin_header:
Expand Down
7 changes: 2 additions & 5 deletions i18n/zh_CN.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ backend:
description:
other: 等级 3 (成熟社区所需的高声望)
rank_question_add_label:
other: 提问s
other: 提问
rank_answer_add_label:
other: 写入答案
rank_comment_add_label:
Expand Down Expand Up @@ -1274,13 +1274,10 @@ ui:
plugins: 插件
installed_plugins: 已安装插件
website_welcome: 欢迎来到 {{site_name}}
plugins:
user_center:
login: 登录
qrcode_login_tip: 请使用 {{ agentName }} 扫描二维码并登录。
login_failed_email_tip: 登录失败,请允许此应用访问您的邮箱信息,然后重试。
oauth:
connect: 连接到 {{ auth_name }}
remove: 移除 {{ auth_name }}
admin:
admin_header:
title: 后台管理
Expand Down
151 changes: 128 additions & 23 deletions internal/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package cli

import (
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
Expand Down Expand Up @@ -101,8 +102,10 @@ func BuildNewAnswer(outputPath string, plugins []string, originalAnswerInfo Orig
builder := newAnswerBuilder(outputPath, plugins, originalAnswerInfo)
builder.DoTask(createMainGoFile)
builder.DoTask(downloadGoModFile)
builder.DoTask(copyUIFiles)
builder.DoTask(overwriteIndexTs)
builder.DoTask(buildUI)
builder.DoTask(mergeI18nFiles)
builder.DoTask(replaceNecessaryFile)
builder.DoTask(buildBinary)
builder.DoTask(cleanByproduct)
return builder.BuildError
Expand All @@ -120,6 +123,7 @@ func formatPlugins(plugins []string) (formatted []*pluginInfo) {
return formatted
}

// createMainGoFile creates main.go file in tmp dir that content is mainGoTpl
func createMainGoFile(b *buildingMaterial) (err error) {
fmt.Printf("[build] tmp dir: %s\n", b.tmpDir)
err = dir.CreateDirIfNotExist(b.tmpDir)
Expand Down Expand Up @@ -169,6 +173,7 @@ func createMainGoFile(b *buildingMaterial) (err error) {
return
}

// downloadGoModFile run go mod commands to download dependencies
func downloadGoModFile(b *buildingMaterial) (err error) {
// If user specify a module replacement, use it. Otherwise, use the latest version.
if len(b.answerModuleReplacement) > 0 {
Expand All @@ -191,13 +196,89 @@ func downloadGoModFile(b *buildingMaterial) (err error) {
return
}

// copyUIFiles copy ui files from answer module to tmp dir
func copyUIFiles(b *buildingMaterial) (err error) {
goListCmd := b.newExecCmd("go", "list", "-mod=mod", "-m", "-f", "{{.Dir}}", "github.com/answerdev/answer")
buf := new(bytes.Buffer)
goListCmd.Stdout = buf
if err = goListCmd.Run(); err != nil {
return fmt.Errorf("failed to run go list: %w", err)
}

goModUIDir := filepath.Join(strings.TrimSpace(buf.String()), "ui")
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui/")
if err = copyDirEntries(os.DirFS(goModUIDir), ".", localUIBuildDir); err != nil {
return fmt.Errorf("failed to copy ui files: %w", err)
}
return nil
}

// overwriteIndexTs overwrites index.ts file in ui/src/plugins/ dir
func overwriteIndexTs(b *buildingMaterial) (err error) {
localUIPluginDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui/src/plugins/")

folders, err := getFolders(localUIPluginDir)
if err != nil {
return fmt.Errorf("failed to get folders: %w", err)
}

content := generateIndexTsContent(folders)
err = os.WriteFile(filepath.Join(localUIPluginDir, "index.ts"), []byte(content), 0644)
if err != nil {
return fmt.Errorf("failed to write index.ts: %w", err)
}
return nil
}

func getFolders(dir string) ([]string, error) {
var folders []string
files, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, file := range files {
if file.IsDir() && file.Name() != "builtin" {
folders = append(folders, file.Name())
}
}
return folders, nil
}

func generateIndexTsContent(folders []string) string {
builder := &strings.Builder{}
builder.WriteString("export default null;\n\n")
for _, folder := range folders {
builder.WriteString(fmt.Sprintf("export { default as %s } from './%s';\n", folder, folder))
}
return builder.String()
}

// buildUI run pnpm install and pnpm build commands to build ui
func buildUI(b *buildingMaterial) (err error) {
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui")

pnpmInstallCmd := b.newExecCmd("pnpm", "install")
pnpmInstallCmd.Dir = localUIBuildDir
if err = pnpmInstallCmd.Run(); err != nil {
return err
}

pnpmBuildCmd := b.newExecCmd("pnpm", "build")
pnpmBuildCmd.Dir = localUIBuildDir
if err = pnpmBuildCmd.Run(); err != nil {
return err
}
return nil
}

func replaceNecessaryFile(b *buildingMaterial) (err error) {
fmt.Printf("try to replace ui build directory\n")
uiBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui")
err = copyDirEntries(ui.Build, ".", uiBuildDir)
return err
}

// mergeI18nFiles merge i18n files
func mergeI18nFiles(b *buildingMaterial) (err error) {
fmt.Printf("try to merge i18n files\n")

Expand Down Expand Up @@ -285,37 +366,60 @@ func mergeI18nFiles(b *buildingMaterial) (err error) {
return err
}

func copyDirEntries(sourceFs embed.FS, sourceDir string, targetDir string) (err error) {
entries, err := ui.Build.ReadDir(sourceDir)
if err != nil {
return err
}

func copyDirEntries(sourceFs fs.FS, sourceDir string, targetDir string) (err error) {
err = dir.CreateDirIfNotExist(targetDir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
err = copyDirEntries(sourceFs, filepath.Join(sourceDir, entry.Name()), filepath.Join(targetDir, entry.Name()))
if err != nil {
return err
}
continue
}
file, err := sourceFs.ReadFile(filepath.Join(sourceDir, entry.Name()))
err = fs.WalkDir(sourceFs, sourceDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
filename := filepath.Join(targetDir, entry.Name())
err = os.WriteFile(filename, file, 0666)
if err != nil {
return err

// Convert the path to use forward slashes, important because we use embedded FS which always uses forward slashes
path = filepath.ToSlash(path)

// Construct the absolute path for the source file/directory
srcPath := filepath.Join(sourceDir, path)

// Construct the absolute path for the destination file/directory
dstPath := filepath.Join(targetDir, path)

if d.IsDir() {
// Create the directory in the destination
err := os.MkdirAll(dstPath, os.ModePerm)
if err != nil {
return fmt.Errorf("failed to create directory %s: %w", dstPath, err)
}
} else {
// Open the source file
srcFile, err := sourceFs.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file %s: %w", srcPath, err)
}
defer srcFile.Close()

// Create the destination file
dstFile, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("failed to create destination file %s: %w", dstPath, err)
}
defer dstFile.Close()

// Copy the file contents
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("failed to copy file contents from %s to %s: %w", srcPath, dstPath, err)
}
}
}
return nil

return nil
})

return err
}

// buildBinary build binary file
func buildBinary(b *buildingMaterial) (err error) {
versionInfo := b.originalAnswerInfo
cmdPkg := "github.com/answerdev/answer/cmd"
Expand All @@ -329,6 +433,7 @@ func buildBinary(b *buildingMaterial) (err error) {
return
}

// cleanByproduct delete tmp dir
func cleanByproduct(b *buildingMaterial) (err error) {
return os.RemoveAll(b.tmpDir)
}
Expand Down
5 changes: 5 additions & 0 deletions ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,8 @@ yarn.lock
package-lock.json
.eslintcache
/.vscode/

/* !/src/plugins
/src/plugins/*
!/src/plugins/builtin
!/src/plugins/Demo
1 change: 0 additions & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"build": "react-app-rewired build",
"lint": "eslint . --cache --fix --ext .ts,.tsx",
"prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
"prepare": "cd .. && husky install",
"preinstall": "node ./scripts/preinstall.js",
"pre-commit": "lint-staged"
},
Expand Down
5 changes: 1 addition & 4 deletions ui/src/common/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,13 +560,10 @@ export interface OauthBindEmailReq {
must: boolean;
}

export interface OauthConnectorItem {
export interface UserOauthConnectorItem {
icon: string;
name: string;
link: string;
}

export interface UserOauthConnectorItem extends OauthConnectorItem {
binding: boolean;
external_id: string;
}
72 changes: 72 additions & 0 deletions ui/src/components/PluginRender/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { FC, ReactNode, memo } from 'react';

import builtin from '@/plugins/builtin';
import * as plugins from '@/plugins';
import { Plugin, PluginType } from '@/utils/pluginKit';

/**
* Note:Please set at least either of the `slug_name` and `type` attributes, otherwise no plugins will be rendered.
*
* @field slug_name: The `slug_name` of the plugin needs to be rendered.
* If this property is set, `PluginRender` will use it first (regardless of whether `type` is set)
* to find the corresponding plugin and render it.
* @field type: Used to formulate the rendering of all plugins of this type.
* (if the `slug_name` attribute is set, it will be ignored)
* @field prop: Any attribute you want to configure, e.g. `className`
*/

interface Props {
slug_name?: string;
type?: PluginType;
children?: ReactNode;
[prop: string]: any;
}

const findPlugin: (s, k: 'slug_name' | 'type', v) => Plugin[] = (
source,
k,
v,
) => {
const ret: Plugin[] = [];
if (source) {
Object.keys(source).forEach((i) => {
const p = source[i];
if (p && p.component && p.info && p.info[k] === v) {
ret.push(p);
}
});
}
return ret;
};

const Index: FC<Props> = ({ slug_name, type, children, ...props }) => {
const fk = slug_name ? 'slug_name' : 'type';
const fv = fk === 'slug_name' ? slug_name : type;
const bp = findPlugin(builtin, fk, fv);
const vp = findPlugin(plugins, fk, fv);
const pluginSlice = [...bp, ...vp];

if (!pluginSlice.length) {
return null;
}
/**
* TODO: Rendering control for non-builtin plug-ins
* ps: Logic such as version compatibility determination can be placed here
*/

return (
<>
{pluginSlice.map((ps) => {
const PluginFC = ps.component;
return (
// @ts-ignore
<PluginFC key={ps.info.slug_name} {...props}>
{children}
</PluginFC>
);
})}
</>
);
};

export default memo(Index);
Loading