feat: add support for wxt as to build chrome extension (#458)
All checks were successful
Build and Deploy / build-and-deploy (push) Has been skipped

This commit is contained in:
Honwhy Wang 2024-12-08 09:16:20 +08:00 committed by GitHub
parent 1e887fe87e
commit 57829696dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 5170 additions and 5 deletions

4
.gitignore vendored
View File

@ -52,3 +52,7 @@ yarn.lock
pnpm-lock.yaml pnpm-lock.yaml
auto-imports.d.ts auto-imports.d.ts
components.d.ts components.d.ts
.wxt
.output
web-ext.config.ts

View File

@ -86,7 +86,7 @@
} }
</script> </script>
<script> <script>
MathJax = { window.MathJax = {
loader: { load: ['[tex]/ams'] }, loader: { load: ['[tex]/ams'] },
tex: { packages: { '[+]': ['ams'] }, tags: 'ams' }, tex: { packages: { '[+]': ['ams'] }, tags: 'ams' },
svg: { fontCache: 'none' }, svg: { fontCache: 'none' },

4606
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,11 @@
"build:analyze": "cross-env ANALYZE=true vite build", "build:analyze": "cross-env ANALYZE=true vite build",
"preview": "npm run build && vite preview", "preview": "npm run build && vite preview",
"release:cli": "node ./bin/release.js", "release:cli": "node ./bin/release.js",
"ext:dev": "wxt",
"ext:zip": "wxt zip",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"type-check": "vue-tsc --build --force", "type-check": "vue-tsc --build --force",
"postinstall": "simple-git-hooks" "postinstall": "simple-git-hooks && wxt prepare"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
@ -77,7 +79,8 @@
"vite": "^5.4.7", "vite": "^5.4.7",
"vite-plugin-node-polyfills": "^0.22.0", "vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-vue-devtools": "^7.6.5", "vite-plugin-vue-devtools": "^7.6.5",
"vue-tsc": "^2.1.10" "vue-tsc": "^2.1.10",
"wxt": "^0.19.19"
}, },
"simple-git-hooks": { "simple-git-hooks": {
"pre-commit": "npx lint-staged" "pre-commit": "npx lint-staged"

BIN
public/mpmd/icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

36
public/mpmd/logo.svg Normal file
View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
width="72"
height="72"
id="svg3"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs3" /><g
id="g4"
transform="matrix(0.89070732,0,0,0.89070732,3.9003714,5.4983284)"><path
d="M 0,0 C 0.99,0.33 1.98,0.66 3,1 3.7382813,2.7851563 3.7382813,2.7851563 4.3125,5.0625 6.0622761,10.836761 9.0921103,16.042265 14.175781,19.464844 16,21 16,21 16.390625,23.042969 16.260417,25.028646 16.130208,27.014323 16,29 16.827578,28.351602 17.655156,27.703203 18.507812,27.035156 22.139055,24.616813 25.605347,24.453162 29.875,24.4375 35.870349,24.257564 40.524681,23.469262 46,21 c 0.99,0 1.98,0 3,0 -2.061302,7.973985 -5.265779,13.949118 -12.375,18.4375 -7.043602,3.115439 -16.325927,2.722302 -23.636719,0.75 C 5.8581906,37.042835 1.2546288,31.546961 -2.1679688,24.644531 -4.7216005,16.528059 -3.6280724,7.5584841 0,0 Z"
fill="#07c060"
transform="translate(5,28)"
id="path1" /><path
d="M 0,0 C 4.8892635,2.9504177 9.7332481,6.9497443 11.5625,12.4375 10.854805,12.278945 10.147109,12.120391 9.4179688,11.957031 -0.4326775,10.16478 -9.0324953,11.282238 -17.472656,16.757812 -23.701001,21.597673 -26.217108,28.036194 -28.4375,35.4375 c -0.66,0 -1.32,0 -2,0 -4.191048,-6.900261 -6.497995,-12.876728 -5,-21 2.597739,-6.6259514 7.958231,-11.9894438 14.292969,-15.140625 C -14.663002,-2.7607534 -6.2555903,-2.8579462 0,0 Z"
fill="#07c060"
transform="translate(47.4375,4.5625)"
id="path2" /><path
d="m 0,0 c 5.4175769,3.8087207 9.3081619,8.166598 10.6875,14.8125 0.948039,8.109576 -0.267493,14.278469 -5,21 -4.3896225,4.958988 -8.4852635,7.778487 -15,9 0.3493359,-0.603281 0.6986719,-1.206562 1.0585937,-1.828125 C -7.7988672,42.185156 -7.3438281,41.385937 -6.875,40.5625 -6.4225391,39.773594 -5.9700781,38.984687 -5.5039062,38.171875 -2.1468435,31.52379 -2.0393497,22.997339 -4.1835937,15.949219 -7.3870448,9.0276638 -11.630773,4.2851908 -18.75,1.4375 c -0.845625,-0.20625 -1.69125,-0.4125 -2.5625,-0.625 0,-0.66 0,-1.32 0,-2 C -13.984552,-3.8956547 -6.8997006,-4.0356739 0,0 Z"
fill="#07c060"
transform="translate(60.3125,22.1875)"
id="path3" /></g><g
id="layer1"><g
style="fill:#07c060;fill-opacity:1"
id="g3"
transform="translate(55.918367,-0.87921667)"><path
d="M 14.4184,12.699 12.1173,15.0002 9.99599,12.8788 12.2629,10.6119 C 12.6579,10.2169 13,9.5652 13,8.99613 V 7.99611 l -4,-2e-5 V 0.996095 h 7 V 8.99613 c 0,1.48047 -0.6056,2.72697 -1.5816,3.70287 z"
fill="#000000"
id="path1-8"
style="fill:#07c060;fill-opacity:1" /><path
d="M 5.41845,12.699 3.11731,15.0002 0.99599,12.8788 3.26295,10.6119 C 3.65789,10.2169 4,9.5652 4,8.99612 V 7.99611 L 0,7.99609 6.11959e-7,0.996094 H 7 V 8.99613 C 7,10.4766 6.39441,11.7231 5.41845,12.699 Z"
fill="#000000"
id="path2-4"
style="fill:#07c060;fill-opacity:1" /></g></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -62,6 +62,11 @@ const minioOSS = ref({
secretKey: ``, secretKey: ``,
}) })
const formMp = ref({
appID: ``,
appsecret: ``,
})
const mpDisabled = ref(window.location.href.startsWith(`http`))
const formCustom = ref<{ code: string, editor: CodeMirror.EditorFromTextArea | null }>({ const formCustom = ref<{ code: string, editor: CodeMirror.EditorFromTextArea | null }>({
code: code:
localStorage.getItem(`formCustomConfig`) localStorage.getItem(`formCustomConfig`)
@ -109,6 +114,11 @@ const options = [
value: `minio`, value: `minio`,
label: `MinIO`, label: `MinIO`,
}, },
{
value: `mp`,
label: `公众号素材`,
disabled: mpDisabled.value,
},
{ {
value: `formCustom`, value: `formCustom`,
label: `自定义代码`, label: `自定义代码`,
@ -160,6 +170,9 @@ onBeforeMount(() => {
if (localStorage.getItem(`imgHost`)) { if (localStorage.getItem(`imgHost`)) {
imgHost.value = localStorage.getItem(`imgHost`)! imgHost.value = localStorage.getItem(`imgHost`)!
} }
if (localStorage.getItem(`mpConfig`)) {
formMp.value = JSON.parse(localStorage.getItem(`mpConfig`)!)
}
}) })
function changeImgHost() { function changeImgHost() {
@ -249,6 +262,20 @@ function saveQiniuConfiguration() {
ElMessage.success(`保存成功`) ElMessage.success(`保存成功`)
} }
function saveMpConfiguration() {
if (
!(
formMp.value.appID
&& formMp.value.appsecret
)
) {
ElMessage.error(`公众号图床 参数配置不全`)
return
}
localStorage.setItem(`mpConfig`, JSON.stringify(formMp.value))
ElMessage.success(`保存成功`)
}
function formCustomSave() { function formCustomSave() {
const str = formCustom.value.editor!.getValue() const str = formCustom.value.editor!.getValue()
localStorage.setItem(`formCustomConfig`, str) localStorage.setItem(`formCustomConfig`, str)
@ -301,6 +328,7 @@ function uploadImage(params: { file: any }) {
:key="item.value" :key="item.value"
:label="item.label" :label="item.label"
:value="item.value" :value="item.value"
:disabled="item?.disabled"
/> />
</el-select> </el-select>
<el-upload <el-upload
@ -614,6 +642,57 @@ function uploadImage(params: { file: any }) {
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
<el-tab-pane class="github-panel" label="公众号 图床" name="mp">
<template #label>
<el-tooltip placement="top">
<template #content>
由于接口请求跨域问题请在浏览器插件形式中使用
</template>
<div>公众号 图床</div>
</el-tooltip>
</template>
<el-form
class="setting-form"
:model="formMp"
label-position="right"
label-width="150px"
:disabled="mpDisabled"
>
<el-form-item label="appID" :required="true">
<el-input
v-model.trim="formMp.appID"
placeholder="如wx6e1234567890efa3"
/>
</el-form-item>
<el-form-item label="appsecret">
<el-input
v-model.trim="formMp.appsecret"
placeholder="如d9f1abcdef01234567890abcdef82397"
/>
<el-link
type="primary"
href="https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Getting_Started_Guide.html"
target="_blank"
>
如何开启公众号开发者模式并获取应用账号密钥
</el-link>
</el-form-item>
<el-form-item style="margin-top: -26px;">
<el-link
type="primary"
href="https://mpmd.pages.dev/tutorial/"
target="_blank"
>
如何在浏览器插件中使用公众号图床
</el-link>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveMpConfiguration">
保存配置
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane class="github-panel formCustom" label="自定义代码" name="formCustom"> <el-tab-pane class="github-panel formCustom" label="自定义代码" name="formCustom">
<el-form class="setting-form" :model="formCustom" label-position="right"> <el-form class="setting-form" :model="formCustom" label-position="right">
<el-form-item label="" :required="true"> <el-form-item label="" :required="true">

View File

@ -0,0 +1,20 @@
import { browser } from 'wxt/browser'
import { defineBackground } from 'wxt/sandbox'
export default defineBackground({
type: `module`,
main() {
browser.runtime.onInstalled.addListener((detail) => {
if (import.meta.env.COMMAND === `serve`) {
browser.runtime.openOptionsPage()
return
}
if (detail.reason === `install`) {
browser.tabs.create({ url: `https://mpmd.pages.dev/welcome` })
}
else if (detail.reason === `update`) {
browser.runtime.openOptionsPage()
}
})
},
})

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { browser } from 'wxt/browser'
import logo from '/mpmd/logo.svg'
function onOpenOption() {
browser.runtime.openOptionsPage()
}
</script>
<template>
<div class="container popup-body">
<div
class="title"
style="height: 40px; display: inline-flex; padding-left: 60px;"
>
<img style="height: 40px" :src="logo">
<span
style="
font-size: 16px;
line-height: 40px;
font-weight: bold;
margin-left: 8px;
"
>使用必读</span>
</div>
<section style="margin-top: 12px; line-height: 28px">
<div>如果您希望使用微信公众号素材库作为图床功能需要进行以下配置</div>
<div>
1.开启公众号开发者模式
<span><el-link
type="primary"
href="https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Getting_Started_Guide.html"
target="_blank"
>查看文档</el-link></span>
</div>
<div>
2.配置IP白名单<span><el-link type="primary" href="https://mpmd.pages.dev/tutorial" target="_blank">使用教程</el-link></span>
</div>
<div>
<el-link type="primary" href="#" @click="onOpenOption">
开始使用
</el-link>
</div>
</section>
</div>
</template>
<style scoped lang="less">
.popup-body {
min-width: 300px;
scroll-behavior: auto;
margin-top: 20px;
}
.container {
width: 100%;
box-sizing: border-box;
padding-right: 15px;
padding-left: 15px;
padding-bottom: 15px;
margin-right: auto;
margin-left: auto;
font-size: 14px;
}
</style>

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MpMd编辑器</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./popup.ts"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import 'virtual:uno.css'
/* 每个页面公共css */
import '@/assets/index.css'
import '@/assets/less/theme.less'
createApp(App).mount(`#app`)

View File

@ -0,0 +1,228 @@
import type * as vite from 'vite'
import type * as wxt from 'wxt'
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
import { parseHTML } from 'linkedom'
import { murmurHash } from 'ohash'
import {
addViteConfig,
defineWxtModule,
} from 'wxt/modules'
export default defineWxtModule({
async setup(wxt) {
wxt.config.alias[`/src/main.ts`] = `./src/main.ts`
wxt.config.manifest.options_page = `options.html`
wxt.hook(`entrypoints:grouped`, (_, groups) => {
groups.push([{
type: `options`,
name: `options`,
options: { openInTab: true },
inputPath: path.resolve(wxt.config.root, `./index.html`),
outputDir: wxt.config.outDir,
skipped: false,
}])
})
addViteConfig(wxt, () => ({
plugins: [
htmlScriptToVirtual(wxt.config, () => wxt.server),
vueDevtoolsHack(wxt.config, () => wxt.server),
wxt.config.command === `build`
? htmlScriptToLocal(wxt)
: undefined,
],
}))
},
})
// Stored outside the plugin to effect all instances of the htmlScriptToVirtual plugin.
const inlineScriptContents: Record<number, string> = {}
export function htmlScriptToVirtual(
config: wxt.ResolvedConfig,
getWxtDevServer: () => wxt.WxtDevServer | undefined,
): vite.PluginOption {
const virtualInlineScript = `virtual:md-inline-script`
const resolvedVirtualInlineScript = `\0${virtualInlineScript}`
const server = getWxtDevServer?.()
return [
{
name: `md:dev-html-prerender`,
apply: `build`,
async transform(code, id) {
if (
server == null
|| !id.endsWith(`.html`)
) {
return
}
const { document } = parseHTML(code)
// Replace inline script with virtual module served via dev server.
// Extension CSP blocks inline scripts, so that's why we're pulling them out.
const promises: Promise<void>[] = []
const inlineScripts = document.querySelectorAll(`script[src^=http]`)
inlineScripts.forEach(async (script) => {
promises.push(new Promise<void>((resolve) => {
const url = script.getAttribute(`src`) ?? ``
doFetch(url).then((textContent) => {
const hash = murmurHash(textContent)
inlineScriptContents[hash] = textContent
script.setAttribute(`src`, `${server.origin}/@id/${virtualInlineScript}?${hash}`)
if (script.hasAttribute(`id`)) {
script.setAttribute(`type`, `module`)
}
resolve()
})
}))
})
await Promise.all(promises)
const newHtml = document.toString()
config.logger.debug(`transform ${id}`)
config.logger.debug(`Old HTML:\n${code}`)
config.logger.debug(`New HTML:\n${newHtml}`)
return newHtml
},
},
{
name: `md:virtualize-react-refresh`,
apply: `serve`,
resolveId(id) {
// Resolve inline scripts
if (id.startsWith(virtualInlineScript)) {
return `\0${id}`
}
// Ignore chunks during HTML file pre-rendering
if (id.startsWith(`/chunks/`)) {
return `\0noop`
}
},
load(id) {
// Resolve virtualized inline scripts
if (id.startsWith(resolvedVirtualInlineScript)) {
// id="virtual:md-inline-script?<hash>"
const hash = Number(id.substring(id.indexOf(`?`) + 1))
return inlineScriptContents[hash]
}
// Ignore chunks during HTML file pre-rendering
if (id === `\0noop`) {
return ``
}
},
},
]
}
export function htmlScriptToLocal(
wxt: wxt.Wxt,
): vite.Plugin {
return {
name: `md:build-html-prerender`,
apply: `build`,
transformIndexHtml: {
order: `pre`,
async handler(html) {
const { document } = parseHTML(html)
const promises: Promise<void>[] = []
const httpScripts = document.querySelectorAll(`script[src^=http]`)
if (httpScripts.length > 0) {
httpScripts.forEach(async (script) => {
/* eslint-disable no-async-promise-executor */
promises.push(new Promise<void>(async (resolve) => {
const url = script.getAttribute(`src`) ?? ``
if (url?.startsWith(`http://localhost`)) {
resolve()
return
}
const textContent = await doFetch(url)
const hash = murmurHash(textContent)
const jsName = url.match(/\/([^/]+)\.js$/)?.[1] ?? `.js`
const fileName = `${jsName.split(`.`)[0]}-${hash}.js`
// write to file
const outFile = path.resolve(wxt.config.outDir, `./${fileName}`)
await writeFile(outFile, textContent, `utf8`)
script.setAttribute(`src`, `/${fileName}`)
// script.setAttribute(`type`, `module`)
resolve()
}))
})
}
// Replace inline script with virtual module served via dev server.
// Extension CSP blocks inline scripts, so that's why we're pulling them
// out.
const inlineScripts = document.querySelectorAll(`script:not([src])`)
if (inlineScripts.length > 0) {
inlineScripts.forEach(async (script) => {
promises.push(new Promise<void>(async (resolve) => {
// Save the text content for later
const textContent = script.textContent ?? ``
const hash = murmurHash(textContent)
const fileName = `md-inline-${hash}.js`
// write to file
const outFile = path.resolve(wxt.config.outDir, `./${fileName}`)
await writeFile(outFile, textContent, `utf8`)
// Replace unsafe inline script
const virtualScript = document.createElement(`script`)
// virtualScript.type = `module`
virtualScript.src = `/${fileName}`
script.replaceWith(virtualScript)
resolve()
}),
)
})
}
await Promise.all(promises)
const newHtml = document.toString()
wxt.config.logger.debug(`Old HTML:\n${html}`)
wxt.config.logger.debug(`New HTML:\n${newHtml}`)
return newHtml
},
},
}
}
export function vueDevtoolsHack(
config: wxt.ResolvedConfig,
getWxtDevServer: () => wxt.WxtDevServer | undefined,
): vite.Plugin {
const server = getWxtDevServer?.()
return {
name: `md:vue-devtools-hack`,
apply: `build`,
transformIndexHtml: {
order: `post`,
handler(html) {
const { document } = parseHTML(html)
const inlineScripts = document.querySelectorAll(`script[src^='/@id/virtual:']`)
inlineScripts.forEach((script) => {
const src = script.getAttribute(`src`)
const newSrc = `${server?.origin}${src}`
script.setAttribute(`src`, newSrc)
})
const newHtml = document.toString()
config.logger.debug(`Old HTML:\n${html}`)
config.logger.debug(`New HTML:\n${newHtml}`)
return newHtml
},
},
}
}
async function doFetch(
url: string,
): Promise<string> {
let content: string = ``
const res = await fetch(url)
if (res.status < 300) {
content = await res.text()
}
else {
throw new Error(
`Failed to fetch "${url}". `,
)
}
return content
}

View File

@ -299,6 +299,73 @@ async function minioFileUpload(content: string, filename: string) {
}) })
} }
// -----------------------------------------------------------------------
// mp File Upload
// -----------------------------------------------------------------------
interface MpResponse {
access_token: string
expires_in: number
errcode: number
errmsg: string
}
async function getMpToken(appID: string, appsecret: string) {
const data = localStorage.getItem(`mpToken:${appID}`)
if (data) {
const token = JSON.parse(data)
if (token.expire && token.expire > new Date().getTime()) {
return token.access_token
}
}
const requestOptions = {
method: `POST`,
data: {
grant_type: `client_credential`,
appid: appID,
secret: appsecret,
},
}
const url = `https://api.weixin.qq.com/cgi-bin/stable_token`
const res = await fetch<any, MpResponse>(url, requestOptions)
if (res.access_token) {
const tokenInfo = {
...res,
expire: new Date().getTime() + res.expires_in * 1000,
}
localStorage.setItem(`mpToken:${appID}`, JSON.stringify(tokenInfo))
return res.access_token
}
return ``
}
async function mpFileUpload(file: File) {
const { appID, appsecret } = JSON.parse(
localStorage.getItem(`mpConfig`)!,
)
/* eslint-disable no-async-promise-executor */
return new Promise<string>(async (resolve, reject) => {
try {
const access_token = await getMpToken(appID, appsecret).catch(e => console.error(e))
if (!access_token) {
reject(new Error(`获取 access_token 失败请检查console日志`))
return
}
const formdata = new FormData()
formdata.append(`media`, file, file.name)
const requestOptions = {
method: `POST`,
data: formdata,
}
const url = `https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${access_token}&type=image`
const res = await fetch<any, {
url: string
}>(url, requestOptions)
resolve(res.url)
}
catch (e) {
reject(e)
}
})
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// formCustom File Upload // formCustom File Upload
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -354,6 +421,8 @@ function fileUpload(content: string, file: File) {
return giteeUpload(content, file.name) return giteeUpload(content, file.name)
case `github`: case `github`:
return ghFileUpload(content, file.name) return ghFileUpload(content, file.name)
case `mp`:
return mpFileUpload(file)
case `formCustom`: case `formCustom`:
return formCustomUpload(content, file) return formCustomUpload(content, file)
default: default:

37
wxt.config.ts Normal file
View File

@ -0,0 +1,37 @@
import { defineConfig } from 'wxt'
import ViteConfig from './vite.config'
export default defineConfig({
srcDir: `src`,
publicDir: `../public`,
extensionApi: `chrome`,
manifest: {
name: `公众号文章编辑器`,
version: `0.0.6`,
icons: {
256: `/mpmd/icon-256.png`,
},
permissions: [`storage`],
host_permissions: [
`https://*.github.com/*`,
`https://*.githubusercontent.com/*`,
`https://*.gitee.com/*`,
`https://*.weixin.qq.com/*`,
// 微信公众号图片
`https://*.qpic.cn/*`,
],
web_accessible_resources: [
{
resources: [`*.png`, `*.svg`],
matches: [`<all_urls>`],
},
],
},
analysis: {
open: true,
},
vite: () => ({
...ViteConfig,
base: `/`,
}),
})