diff --git a/.gitignore b/.gitignore index 6357251..b81c6e4 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ yarn.lock pnpm-lock.yaml auto-imports.d.ts components.d.ts + +.wxt +.output +web-ext.config.ts \ No newline at end of file diff --git a/index.html b/index.html index 890f130..8ac1e47 100644 --- a/index.html +++ b/index.html @@ -86,7 +86,7 @@ } + + + + diff --git a/src/entrypoints/popup/index.html b/src/entrypoints/popup/index.html new file mode 100644 index 0000000..613ac9c --- /dev/null +++ b/src/entrypoints/popup/index.html @@ -0,0 +1,13 @@ + + + + + + MpMd编辑器 + + + +
+ + + diff --git a/src/entrypoints/popup/popup.ts b/src/entrypoints/popup/popup.ts new file mode 100644 index 0000000..996f891 --- /dev/null +++ b/src/entrypoints/popup/popup.ts @@ -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`) diff --git a/src/modules/build-extension.ts b/src/modules/build-extension.ts new file mode 100644 index 0000000..ca3b2c0 --- /dev/null +++ b/src/modules/build-extension.ts @@ -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 = {} +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[] = [] + const inlineScripts = document.querySelectorAll(`script[src^=http]`) + inlineScripts.forEach(async (script) => { + promises.push(new Promise((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?" + 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[] = [] + 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(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(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 { + 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 +} diff --git a/src/utils/file.ts b/src/utils/file.ts index 1b24051..2e89423 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -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(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(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(url, requestOptions) + resolve(res.url) + } + catch (e) { + reject(e) + } + }) +} + // ----------------------------------------------------------------------- // formCustom File Upload // ----------------------------------------------------------------------- @@ -354,6 +421,8 @@ function fileUpload(content: string, file: File) { return giteeUpload(content, file.name) case `github`: return ghFileUpload(content, file.name) + case `mp`: + return mpFileUpload(file) case `formCustom`: return formCustomUpload(content, file) default: diff --git a/wxt.config.ts b/wxt.config.ts new file mode 100644 index 0000000..e2b9417 --- /dev/null +++ b/wxt.config.ts @@ -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: [``], + }, + ], + }, + analysis: { + open: true, + }, + vite: () => ({ + ...ViteConfig, + base: `/`, + }), +})