feat: add extension manager

This commit is contained in:
purocean
2022-05-03 10:32:05 +08:00
parent 737a1eeee2
commit 219781d0c7
23 changed files with 1456 additions and 24 deletions
+1
View File
@@ -11,5 +11,6 @@ module.exports = {
'@share/(.*)': '<rootDir>/src/share/$1',
'@main/(.*)': '<rootDir>/src/main/$1',
'@fe/(.*)': '<rootDir>/src/renderer/$1',
"^lodash-es$": "lodash"
}
};
+4
View File
@@ -49,6 +49,7 @@
"safe-buffer": "^5.2.1",
"socket.io": "^2.4.0",
"socks-proxy-agent": "^6.1.1",
"tar-stream": "^2.2.0",
"transliteration": "^2.2.0",
"yargs": "^15.3.1"
},
@@ -73,6 +74,7 @@
"@types/request": "^2.48.5",
"@types/socket.io-client": "^1.4.34",
"@types/sortablejs": "^1.10.6",
"@types/tar-stream": "^2.2.2",
"@types/turndown": "^5.0.0",
"@types/yargs": "^15.0.0",
"@typescript-eslint/eslint-plugin": "^4.27.0",
@@ -101,6 +103,7 @@
"husky": "^7.0.4",
"jest": "^27.4.5",
"jest-extended": "^1.2.0",
"js-untar": "^2.0.0",
"juice": "^8.0.0",
"katex": "^0.15.3",
"lodash-es": "^4.17.21",
@@ -117,6 +120,7 @@
"mermaid": "^8.13.8",
"monaco-editor": "^0.31.1",
"normalize.css": "^8.0.1",
"parse-author": "^2.0.0",
"path-browserify": "^1.0.1",
"sass": "^1.35.1",
"semver": "^7.3.7",
+106
View File
@@ -0,0 +1,106 @@
import * as path from 'path'
import * as fs from 'fs-extra'
import request from 'request'
import { unzip } from 'zlib'
import tar from 'tar-stream'
import { USER_EXTENSION_DIR } from './constant'
import { getAction } from './action'
import { Readable } from 'stream'
import config from './config'
const configKey = 'extensions'
function getExtensionPath (id: string) {
if (!/[A-Za-z0-9-_]+/.test(id)) {
throw new Error('Invalid extension id')
}
return path.join(USER_EXTENSION_DIR, id)
}
function changeExtensionConfig (id: string, val: { enabled: boolean }) {
const extensions = config.get(configKey, {}) || {}
extensions[id] = { ...extensions[id], ...val }
config.set(configKey, extensions)
}
export async function list () {
const list = (await fs.readdir(USER_EXTENSION_DIR, { withFileTypes: true }))
.filter(x => (x.isDirectory() || x.isSymbolicLink()) && !x.name.startsWith('.'))
const extensionsSettings = config.get(configKey, {})
Object.keys(extensionsSettings).forEach(key => {
if (!list.some(x => x.name === key)) {
delete extensionsSettings[key]
}
})
config.set(configKey, extensionsSettings)
return list.map(x => {
const ext = extensionsSettings[x.name]
return { id: x.name, enabled: (ext && ext.enabled) }
})
}
export async function install (id: string, url: string) {
console.log('[extension] install', id, url)
const extensionPath = getExtensionPath(id)
if (await fs.pathExists(extensionPath)) {
console.log('[extension] already installed. upgrade:', id)
}
const agent = await getAction('get-proxy-agent')(url)
return new Promise((resolve, reject) => {
request({ url, agent, encoding: null }, (err, _, body) => {
if (err) {
reject(err)
return
}
unzip(body, (err, data) => {
if (err) {
reject(err)
return
}
const extract = tar.extract()
extract.on('entry', (header, stream, next) => {
const filePath = path.join(extensionPath, header.name.replace(/^package/, ''))
console.log('[extension] write file', filePath)
fs.ensureFile(filePath).then(() => {
const fileStream = fs.createWriteStream(filePath)
stream.pipe(fileStream)
stream.on('end', next)
}).catch(reject)
})
extract.on('finish', () => {
resolve(undefined)
})
extract.on('error', reject)
Readable.from(data).pipe(extract)
})
})
})
}
export async function uninstall (id: string) {
const extensionPath = getExtensionPath(id)
if (await fs.pathExists(extensionPath)) {
await fs.remove(extensionPath)
}
}
export async function enable (id: string) {
changeExtensionConfig(id, { enabled: true })
}
export async function disable (id: string) {
changeExtensionConfig(id, { enabled: false })
}
+37 -17
View File
@@ -16,6 +16,7 @@ import shell from '../shell'
import config from '../config'
import * as jwt from '../jwt'
import { getAction } from '../action'
import * as extension from '../extension'
const isLocalhost = (address: string) => {
return ip.isEqual(address, '127.0.0.1') || ip.isEqual(address, '::1')
@@ -307,7 +308,18 @@ const customCss = async (ctx: any, next: any) => {
try {
const filename = config.get(configKey, defaultCss)
ctx.body = await fs.readFile(path.join(USER_THEME_DIR, filename))
if (filename.startsWith('extension:')) {
const extensions = await extension.list()
const extensionName = filename.substring('extension:'.length, filename.indexOf('/'))
if (extensions.some(x => x.enabled && x.id === extensionName)) {
ctx.redirect(`/extensions/${filename.replace('extension:', '')}`)
} else {
throw new Error(`extension not found [${extensionName}]`)
}
} else {
ctx.body = await fs.readFile(path.join(USER_THEME_DIR, filename))
}
} catch (error) {
console.error(error)
@@ -336,6 +348,7 @@ const setting = async (ctx: any, next: any) => {
data.mark = []
delete data['server.jwt-secret']
delete data.license
delete data.extensions
return data
}
}
@@ -434,25 +447,32 @@ const sendFile = async (ctx: any, next: any, filePath: string, fullback = true)
}
const userExtension = async (ctx: any, next: any) => {
if (ctx.path.startsWith('/api/extensions') && ctx.method === 'GET') {
const list = await fs.readdir(USER_EXTENSION_DIR)
if (ctx.method === 'GET') {
if (ctx.path.startsWith('/api/extensions')) {
ctx.body = result('ok', 'success', await extension.list())
} else if (ctx.path.startsWith('/extensions/') && ctx.method === 'GET') {
const filePath = path.join(USER_EXTENSION_DIR, ctx.path.replace('/extensions', ''))
if (await sendFile(ctx, next, filePath)) {
return
}
const extensionsSettings = config.get('extensions', {})
const extensions = list.map(x => {
const ext = extensionsSettings[x]
return { id: x, enabled: !!(ext && ext.enabled) }
})
ctx.body = result('ok', 'success', extensions)
} else if (ctx.path.startsWith('/extensions/') && ctx.method === 'GET') {
const filePath = path.join(USER_EXTENSION_DIR, ctx.path.replace('/extensions', ''))
if (await sendFile(ctx, next, filePath)) {
return
await next()
} else {
await next()
}
await next()
} else {
await next()
const id = ctx.query.id
if (ctx.path.startsWith('/api/extensions/install')) {
ctx.body = result('ok', 'success', await extension.install(id, ctx.query.url))
} else if (ctx.path.startsWith('/api/extensions/uninstall')) {
ctx.body = result('ok', 'success', await extension.uninstall(id))
} else if (ctx.path.startsWith('/api/extensions/enable')) {
ctx.body = result('ok', 'success', await extension.enable(id))
} else if (ctx.path.startsWith('/api/extensions/disable')) {
ctx.body = result('ok', 'success', await extension.disable(id))
} else {
await next()
}
}
}
@@ -0,0 +1,154 @@
import * as extension from '@fe/others/extension'
jest.mock('@fe/support/api', () => ({}))
jest.mock('@fe/services/theme', () => ({}))
jest.mock('js-untar', () => ({}))
jest.mock('@fe/utils', () => ({
getLogger: console.log
}))
jest.mock('@fe/services/i18n', () => ({
getCurrentLanguage: () => 'zh-CN'
}))
jest.mock('@fe/core/action', () => ({
getActionHandler: () => () => 0
}))
;(global as any).__APP_VERSION__ = '3.29.0'
test('readInfoFromJson', () => {
expect(extension.readInfoFromJson(undefined)).toBeNull()
expect(extension.readInfoFromJson({})).toBeNull()
expect(extension.readInfoFromJson({ name: 'test' })).toBeNull()
expect(extension.readInfoFromJson({ name: 'test', version: '1.1.2' })).toStrictEqual({
id: 'test',
author: { name: '' },
displayName: 'test',
main: undefined,
description: '',
version: '1.1.2',
themes: [],
origin: 'unknown',
dist: { tarball: '', unpackedSize: 0 },
icon: '',
homepage: '',
license: '',
compatible: {
reason: 'Not yank note extension.',
value: false,
},
})
expect(extension.readInfoFromJson({
name: 'test',
version: '1.1.2',
license: 'MIT',
engines: {
'yank-note': '>=3.29.0',
},
})).toStrictEqual({
id: 'test',
author: { name: '' },
displayName: 'test',
main: undefined,
description: '',
version: '1.1.2',
themes: [],
origin: 'unknown',
dist: { tarball: '', unpackedSize: 0 },
icon: '',
homepage: '',
license: 'MIT',
compatible: {
reason: 'Compatible',
value: true,
},
})
expect(extension.readInfoFromJson({
name: 'test',
version: '1.1.2',
engines: {
'yank-note': '>=3.30.0',
},
})).toStrictEqual({
id: 'test',
author: { name: '' },
displayName: 'test',
main: undefined,
description: '',
version: '1.1.2',
themes: [],
origin: 'unknown',
dist: { tarball: '', unpackedSize: 0 },
icon: '',
homepage: '',
license: '',
compatible: {
reason: 'Need Yank Note [>=3.30.0].',
value: false,
},
})
expect(extension.readInfoFromJson({
name: 'test',
author: 'test <test@t.t>',
version: '1.1.2',
description: 'HELLO!',
displayName: 'HELLO',
})).toStrictEqual({
id: 'test',
author: { name: 'test', email: 'test@t.t' },
main: undefined,
displayName: 'HELLO',
description: 'HELLO!',
version: '1.1.2',
themes: [],
origin: 'unknown',
dist: { tarball: '', unpackedSize: 0 },
icon: '',
homepage: '',
license: '',
compatible: {
reason: 'Not yank note extension.',
value: false,
},
})
expect(extension.readInfoFromJson({
name: 'test',
version: '1.1.2',
author: { name: 'hello', email: 'xxx@email.com' },
description: 'HELLO!',
displayName: 'HELLO',
'description_ZH-CN': '你好!',
'displayName_ZH-CN': '你好',
themes: [
{ name: 'a', css: './a.css' },
{ name: 'b', css: './b.css' },
],
})).toStrictEqual({
id: 'test',
author: { name: 'hello', email: 'xxx@email.com' },
main: undefined,
displayName: '你好',
description: '你好!',
version: '1.1.2',
themes: [
{ name: 'a', css: './a.css' },
{ name: 'b', css: './b.css' },
],
origin: 'unknown',
dist: { tarball: '', unpackedSize: 0 },
icon: '',
homepage: '',
license: '',
compatible: {
reason: 'Not yank note extension.',
value: false,
},
})
})
@@ -0,0 +1,700 @@
<template>
<XMask :mask-closeable="false" :style="{paddingTop: '7vh'}" :show="!!showManager" @close="hide">
<div class="wrapper">
<div class="body">
<div class="side">
<GroupTabs class="tabs" :tabs="listTypes" v-model="listType" />
<div v-if="extensions.length > 0" class="list">
<div
v-for="item in extensions"
:key="item.id"
:class="{item: true, selected: item.id === currentExtension?.id}"
@click="choose(item.id)">
<div class="left">
<div v-if="item.icon" class="icon-extension" :style="{ 'background-size': '98% 98%', 'background-image': `url(${item.icon})` }" />
<div v-else class="icon-extension" />
</div>
<div class="right">
<div class="title">
<div class="name">{{ item.displayName }}</div>
<div class="version">
<span>{{ item.version }}</span>
<span class="upgradable" v-if="item.upgradable">&nbsp; {{ $t('extension.upgradable') }}</span>
</div>
</div>
<div class="description">{{ item.description }}</div>
<div class="bottom">
<div v-if="item.origin === 'official'" class="author"><i>Yank Note</i></div>
<div v-else class="author" >{{ item.author.name }}</div>
<div class="status-list">
<div v-if="!item.compatible.value" class="status">{{ $t('extension.incompatible') }}</div>
<div v-if="!item.installed" class="status">{{ $t('extension.not-installed') }}</div>
<div v-if="item.installed && item.enabled" class="status">{{ $t('extension.enabled') }}</div>
<div v-if="item.installed && !item.enabled" class="status">{{ $t('extension.disabled') }}</div>
<div v-if="item.dirty" class="status">{{ $t('extension.reload-required') }}</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="list">
<div class="placeholder">{{ $t(registryExtensions ? 'extension.no-extension' : 'loading') }}</div>
</div>
</div>
<div class="detail">
<template v-if="currentExtension">
<div class="info">
<div class="left">
<div v-if="currentExtension.icon" class="icon-extension" :style="{ 'background-size': '94% 94%', 'background-image': `url(${currentExtension.icon})` }" />
<div v-else class="icon-extension" />
</div>
<div class="right">
<div class="title">
<div class="name">{{ currentExtension.displayName }}</div>
<div class="version" v-if="currentExtension.version">
<span>{{ currentExtension.version }}</span>
<span class="upgradable" v-if="currentExtension.upgradable">&nbsp; {{ $t('extension.upgradable') }} {{ currentExtension.latestVersion }}</span>
</div>
</div>
<div class="tags">
<div class="tag">
<span>{{ $t('extension.author') }}</span>
<span v-if="currentExtension.origin === 'official'"><i>Yank Note</i></span>
<span v-else>{{ currentExtension.author.name }}</span>
</div>
<div v-if="currentExtension.latestVersion" class="tag">
<span>{{ $t('extension.latest-version') }}</span>
<span>{{ currentExtension.latestVersion }}</span>
</div>
<div v-if="currentExtension.dist.unpackedSize" class="tag">
<span>{{ $t('extension.unpacked-size') }}</span>
<span>{{ (currentExtension.dist.unpackedSize / 1024).toFixed(2) }}KiB</span>
</div>
<div
v-if="currentExtension.homepage && currentExtension.homepage.split('/')[2]"
class="tag"
style="cursor: pointer;"
@click="openUrl(currentExtension?.homepage)"
>
<span>{{ $t('extension.homepage') }}</span>
<span>{{ currentExtension.homepage.split('/')[2] }}</span>
</div>
<div v-if="currentExtension.license" class="tag">
<span>License</span>
<span>{{ currentExtension.license }}</span>
</div>
<div
v-if="currentExtension.dist.unpackedSize"
class="tag"
style="cursor: pointer;"
@click="openUrl(`https://www.npmjs.com/package/${currentExtension?.id}`)"
>
<img alt="npm" :src="`https://img.shields.io/npm/dy/${currentExtension.id}?color=%234180bd&label=Download&style=flat-square`">
</div>
</div>
<div class="description">{{ currentExtension.description }}</div>
<div v-if="installing" class="actions"><i>{{ $t('extension.installing') }}</i></div>
<div v-else-if="uninstalling" class="actions"><i>{{ $t('extension.uninstalling') }}</i></div>
<div v-else class="actions">
<template v-if="currentExtension.dirty">
<button class="small" @click="reload">{{$t('extension.reload')}}</button>
<i>{{ $t('extension.reload-required') }}</i>
</template>
<template v-else>
<template v-if="!currentExtension.installed">
<button class="small" :disabled="!currentExtension.compatible.value" @click="install(currentExtension)">{{ $t('extension.install') }}</button>
</template>
<template v-else>
<button class="small" @click="uninstall(currentExtension)">{{ $t('extension.uninstall') }}</button>
<button v-if="currentExtension.enabled" class="small" @click="disable(currentExtension)">{{ $t('extension.disable') }}</button>
<button v-else-if="currentExtension.compatible.value" class="small" @click="enable(currentExtension)">{{ $t('extension.enable') }}</button>
<button v-if="currentExtension.upgradable" :disabled="!currentExtension.newVersionCompatible?.value" class="small" @click="upgrade(currentExtension)">{{ $t('extension.upgrade') }}</button>
</template>
<i v-if="!currentExtension.compatible.value">{{currentExtension.compatible.reason}}</i>
<i v-if="currentExtension.upgradable && !currentExtension.newVersionCompatible?.value">{{currentExtension.newVersionCompatible?.reason}}</i>
</template>
</div>
</div>
</div>
<template v-if="currentExtension.dist.unpackedSize">
<div v-show="iframeLoaded" class="content">
<iframe @load="iframeOnload" sandbox="allow-scripts allow-popups allow-same-origin" referrerpolicy="no-referrer" :src="`/api/proxy?url=https://www.npmjs.com/package/${currentExtension.id}`" />
</div>
<div v-if="!iframeLoaded" class="placeholder">{{ $t('loading') }}</div>
</template>
</template>
<div v-else class="placeholder">{{ $t('extension.extension-manager') }}</div>
<div class="dialog-actions">
<div class="left">
<b>{{$t('extension.registry')}}:</b>
<select v-model="currentRegistry">
<option
v-for="hostname in registries"
:key="hostname"
:value="hostname"
:selected="hostname === currentRegistry"
>{{ hostname }}</option>
</select>
</div>
<div class="right">
<template v-if="reloadRequired">
<i>{{ $t('extension.reload-required') }}</i>
<button class="btn" @click="reload">{{$t('extension.reload')}}</button>
</template>
<button class="btn" @click="hide">{{$t('close')}}</button>
</div>
</div>
</div>
</div>
</div>
</XMask>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from '@fe/services/i18n'
import { getLogger } from '@fe/utils'
import { registerAction, removeAction } from '@fe/core/action'
import XMask from '@fe/components/Mask.vue'
import GroupTabs from '@fe/components/GroupTabs.vue'
import * as extensionManager from '@fe/others/extension'
import type { Extension, Compatible } from '@fe/others/extension'
import * as setting from '@fe/services/setting'
import { useModal } from '@fe/support/ui/modal'
import { useToast } from '@fe/support/ui/toast'
const logger = getLogger('extension-manager-component')
const { $t } = useI18n()
const registries = extensionManager.registries
const showManager = ref(false)
const currentId = ref('')
const iframeLoaded = ref(false)
const installing = ref(false)
const uninstalling = ref(false)
const registryExtensions = ref<Extension[] | null>(null)
const installedExtensions = ref<Extension[]>([])
const listType = ref<'all' | 'installed'>('all')
const currentRegistry = ref(setting.getSetting('extension.registry', 'registry.npmjs.org'))
const listTypes = computed(() => [
{ label: $t.value('extension.all'), value: 'all' },
{ label: $t.value('extension.installed'), value: 'installed' },
])
const extensions = computed(() => {
let list: (Extension & {
dirty?: boolean,
upgradable?: boolean,
latestVersion?: string,
newVersionCompatible?: Compatible,
})[] = []
if (registryExtensions.value) {
list = registryExtensions.value.map(item => {
const installedInfo = installedExtensions.value.find(installed => installed.id === item.id)
if (installedInfo) {
return {
...item,
installed: true,
enabled: installedInfo.enabled,
version: installedInfo.version,
compatible: installedInfo.compatible,
newVersionCompatible: item.compatible,
latestVersion: item.version,
upgradable: installedInfo.version !== item.version,
}
}
return item
})
}
installedExtensions.value.forEach(item => {
if (!registryExtensions.value?.some(registry => registry.id === item.id)) {
list.push(item)
}
})
return list
.filter(item => listType.value !== 'installed' || item.installed)
.map(item => {
const loadStatus = extensionManager.getLoadStatus(item.id)
return {
...item,
dirty: (loadStatus.themes || loadStatus.plugin) &&
(!item.installed || !item.enabled || item.version !== loadStatus.version),
}
})
})
const currentExtension = computed(() => {
return extensions.value.find(item => item.id === currentId.value)
})
const reloadRequired = computed(() => {
return extensions.value.some(item => item.dirty)
})
function choose (id: string) {
logger.debug('choose', id)
if (currentId.value === id) {
return
}
currentId.value = id
iframeLoaded.value = false
}
function show (id?: string) {
if (id) {
choose(id)
}
showManager.value = true
}
function hide () {
showManager.value = false
}
async function refreshInstalledExtensions () {
installedExtensions.value = await extensionManager.getInstalledExtensions()
}
async function fetchExtensions () {
try {
registryExtensions.value = null
registryExtensions.value = await extensionManager.getRegistryExtensions(currentRegistry.value)
} finally {
refreshInstalledExtensions()
}
}
async function install (extension?: Extension) {
if (!extension) {
return
}
try {
installing.value = true
await extensionManager.install(extension, currentRegistry.value)
await refreshInstalledExtensions()
} catch (error: any) {
logger.error('install', error)
useToast().show('warning', error.message)
throw error
} finally {
installing.value = false
}
useToast().show('info', $t.value('extension.toast-loaded'))
}
async function uninstall (extension?: Extension) {
if (!extension) {
return
}
logger.debug('uninstall', extension.id)
if (await useModal().confirm({
title: $t.value('extension.uninstall'),
content: $t.value('extension.uninstall-confirm', extension.displayName),
})) {
try {
uninstalling.value = true
await extensionManager.uninstall(extension)
await refreshInstalledExtensions()
} catch (error: any) {
logger.error('uninstall', error)
useToast().show('warning', error.message)
throw error
} finally {
uninstalling.value = false
}
}
}
async function upgrade (extension?: Extension) {
install(extension)
}
function reload () {
window.location.reload()
}
async function enable (extension?: Extension) {
if (!extension) {
return
}
logger.debug('enable', extension.id)
await extensionManager.enable(extension)
refreshInstalledExtensions()
useToast().show('info', $t.value('extension.toast-loaded'))
}
async function disable (extension?: Extension) {
if (!extension) {
return
}
logger.debug('disable', extension.id)
await extensionManager.disable(extension)
refreshInstalledExtensions()
}
function iframeOnload (e: any) {
const win = e.target.contentWindow
win.addEventListener('click', (e: any) => {
e.preventDefault()
e.stopPropagation()
if (e.target.href) {
window.open(e.target.href)
}
}, true)
const article = win.document.querySelector('article')
win.document.body.innerHTML = ''
win.document.body.appendChild(article)
win.document.body.style.padding = '12px'
iframeLoaded.value = true
}
function openUrl (url?: string) {
if (url) {
window.open(url, '_blank')
}
}
watch(extensions, () => {
if (!currentId.value && extensions.value.length > 0) {
choose(extensions.value[0].id)
}
}, { immediate: true })
watch(showManager, (val) => {
if (val) {
fetchExtensions()
}
})
watch(currentRegistry, (val) => {
fetchExtensions()
setting.setSetting('extension.registry', val)
})
onMounted(() => {
registerAction({ name: 'extension.show-manager', handler: show })
})
onUnmounted(() => {
removeAction('extension.show-manager')
})
</script>
<style lang="scss" scoped>
.wrapper {
width: 90vw;
background: var(--g-color-95);
margin: auto;
padding: 10px;
color: var(--g-color-5);
box-shadow: rgba(0, 0, 0 , 0.3) 2px 2px 10px;
border-radius: var(--g-border-radius);
position: relative;
h3 {
margin-top: 0;
margin-bottom: 10px;
}
}
.body {
display: flex;
height: 75vh;
}
.side {
display: flex;
flex-direction: column;
align-items: center;
width: 320px;
flex: none;
}
.list {
overflow-y: auto;
height: 100%;
width: 100%;
box-sizing: border-box;
// font-family: monospace;
font-size: 16px;
background-color: var(--g-color-92);
// padding: 6px;
.item {
cursor: pointer;
position: relative;
// background-color: var(--g-color-92);
border-bottom: 1px var(--g-color-80) solid;
color: var(--g-color-20);
display: flex;
height: 77px;
padding: 6px;
box-sizing: border-box;
.left {
padding-right: 6px;
flex: none;
}
.right {
width: 100%;
overflow: hidden;
padding-top: 4px;
display: flex;
flex-direction: column;
justify-content: space-between;
.status-list {
display: flex;
.status {
font-size: 12px;
color: var(--g-color-30);
white-space: nowrap;
padding-left: 4px;
}
}
.author {
font-size: 13px;
color: var(--g-color-30);
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.version {
font-size: 13px;
color: var(--g-color-30);
white-space: nowrap;
}
.description {
font-size: 13px;
line-height: 1.4;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
}
&:hover {
background-color: var(--g-color-86);
border-radius: var(--g-border-radius);
}
&.selected {
background-color: var(--g-color-86);
border-radius: var(--g-border-radius);
color: var(--g-color-0);
}
}
}
.tabs {
display: inline-flex;
margin-bottom: 8px;
z-index: 1;
flex: none;
::v-deep(.tab) {
line-height: 1.5;
font-size: 14px;
}
}
.icon-extension {
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMiAyMiI+PHBhdGggZD0ibTEzLjU1NSAxMDQzLjgzaC0uOTI3di0yLjQ3M2MwLS42OC0uNTU2LTEuMjM2LTEuMjM2LTEuMjM2aC0yLjQ3MnYtLjkyN2MwLS44NjUtLjY4LTEuNTQ1LTEuNTQ1LTEuNTQ1LS44NjUgMC0xLjU0NS42OC0xLjU0NSAxLjU0NXYuOTI3aC0yLjQ3MmMtLjY4IDAtMS4yMzYuNTU2LTEuMjM2IDEuMjM2djIuMzQ5aC45MjdjLjkyNyAwIDEuNjY5Ljc0MiAxLjY2OSAxLjY2OSAwIC45MjctLjc0MiAxLjY2OS0xLjY2OSAxLjY2OWgtLjkyN3YyLjM0OWMwIC42OC41NTYgMS4yMzYgMS4yMzYgMS4yMzZoMi4zNDl2LS45MjdjMC0uOTI3Ljc0Mi0xLjY2OSAxLjY2OS0xLjY2OS45MjcgMCAxLjY2OS43NDIgMS42NjkgMS42Njl2LjkyN2gyLjM0OWMuNjggMCAxLjIzNi0uNTU2IDEuMjM2LTEuMjM2di0yLjQ3MmguOTI3Yy44NjUgMCAxLjU0NS0uNjggMS41NDUtMS41NDUgMC0uODY1LS42OC0xLjU0NS0xLjU0NS0xLjU0NSIgZmlsbD0iIzg4OCIgdHJhbnNmb3JtPSJtYXRyaXgoMS4yMzI2NSAwIDAgMS4yMzI2NC4zOTctMTI3Ni4wNSkiLz48L3N2Zz4=);
background-size: 80px 80px;
background-position: center;
background-repeat: no-repeat;
width: 64px;
height: 64px;
}
.detail {
width: 100%;
overflow: hidden;
padding: 12px;
padding-right: 0;
padding-bottom: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
.info {
display: flex;
flex: none;
.left {
flex: none;
.icon-extension {
width: 128px;
height: 128px;
background-size: 130px 130px;
}
}
.right {
width: 100%;
overflow: hidden;
padding: 12px;
.title {
align-items: flex-end;
justify-content: start;
overflow: hidden;
margin-top: -6px;
.name {
font-weight: bold;
font-size: 20px;
margin-right: 8px;
}
}
.tags {
display: flex;
flex-wrap: wrap;
.tag {
margin: 3px 0;
border-radius: var(--g-border-radius);
overflow: hidden;
background: rgb(80, 80, 80);
font-size: 12px;
margin-right: 4px;
color: #fefefe;
img {
display: block;
height: 100%;
width: 100%;
}
span {
padding: 4px 6px;
display: inline-block;
&:last-child {
background: rgb(65, 128, 189);
}
}
}
}
.description {
color: var(--g-color-30);
margin-top: 8px;
font-size: 15px;
}
.actions {
margin-top: 10px;
button {
margin-left: 0;
margin-right: 6px;
}
i {
font-size: 14px;
}
}
}
}
.content {
height: 100%;
margin-top: 10px;
border-top: 3px solid var(--g-color-86);
overflow: hidden;
}
}
.title {
margin-bottom: 4px;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.name {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--g-color-0);
}
}
.placeholder {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--g-color-60);
}
iframe {
border: none;
width: 100%;
height: 100%;
}
.dialog-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
.left {
display: flex;
align-items: center;
select {
margin-left: 12px;
padding: 4px;
}
}
.right {
display: flex;
justify-content: flex-end;
align-items: center;
i {
margin-right: 6px;
font-size: 14px;
}
}
}
.upgradable {
color: #4caf50;
}
</style>
+1 -1
View File
@@ -42,7 +42,7 @@
import { computed, defineComponent, nextTick, PropType, ref, toRefs, watch } from 'vue'
import { useStore } from 'vuex'
import { useContextMenu } from '@fe/support/ui/context-menu'
import extensions from '@fe/others/extensions'
import extensions from '@fe/others/file-extensions'
import { triggerHook } from '@fe/core/hook'
import { getContextMenuItems } from '@fe/services/tree'
import type { Components } from '@fe/types'
File diff suppressed because one or more lines are too long
+3
View File
@@ -1,6 +1,7 @@
import * as storage from '@fe/utils/storage'
import * as utils from '@fe/utils/index'
import { showPremium } from '@fe/others/premium'
import * as extension from '@fe/others/extension'
import * as ioc from '@fe/core/ioc'
import * as plugin from '@fe/core/plugin'
import * as hook from '@fe/core/hook'
@@ -56,6 +57,8 @@ const ctx = {
removeHook: hook.removeHook,
triggerHook: hook.triggerHook,
showPremium: showPremium,
showExtensionManager: extension.showManager,
getExtensionLoadStatus: extension.getLoadStatus,
version: __APP_VERSION__,
}
+222
View File
@@ -0,0 +1,222 @@
import parseAuthor from 'parse-author'
import semver from 'semver'
import pako from 'pako'
import untar from 'js-untar'
import { getLogger, path } from '@fe/utils'
import * as api from '@fe/support/api'
import { getActionHandler } from '@fe/core/action'
import type { RegistryHostname } from '@fe/types'
import * as i18n from '@fe/services/i18n'
import * as theme from '@fe/services/theme'
export type Compatible = { value: boolean, reason: string }
export type LoadStatus = { version?: string, themes: boolean, plugin: boolean }
export interface Extension {
id: string;
displayName: string;
description: string;
icon: string;
homepage: string;
license: string;
author: {
name: string;
email?: string;
url?: string;
};
version: string;
themes: { name: string; css: string }[];
compatible: Compatible;
main: string;
enabled?: boolean;
installed: boolean;
origin: 'official' | 'registry' | 'unknown';
dist: { tarball: string, unpackedSize: number };
}
const logger = getLogger('extension')
const loaded = new Map<string, LoadStatus>()
export const registries: RegistryHostname[] = [
'registry.npmjs.org',
'registry.npmmirror.com',
]
function changeRegistryOrigin (hostname: RegistryHostname, url: string) {
const _url = new URL(url)
_url.hostname = hostname
return _url.toString()
}
export function getLoadStatus (id: string): LoadStatus {
return loaded.get(id) || { version: undefined, themes: false, plugin: false }
}
export function getCompatible (engines?: { 'yank-note': string }): Compatible {
if (!engines || !engines['yank-note']) {
return { value: false, reason: 'Not yank note extension.' }
}
const engineVersion = __APP_VERSION__
const value = semver.satisfies(engineVersion, engines['yank-note'])
return {
value,
reason: value ? 'Compatible' : `Need Yank Note [${engines['yank-note']}].`,
}
}
export function readInfoFromJson (json: any): Omit<Extension, 'installed'> | null {
if (!json || !json.name || !json.version) {
return null
}
const language = i18n.getCurrentLanguage().toUpperCase()
return {
id: json.name,
version: json.version,
license: typeof json.license === 'string' ? json.license : '',
author: typeof json.author === 'string'
? parseAuthor(json.author) || { name: '' }
: json.author || { name: '' },
themes: json.themes || [],
main: json.main,
icon: json.icon || '',
displayName: json[`displayName_${language}`] || json.displayName || json.name,
description: json[`description_${language}`] || json.description || '',
compatible: getCompatible(json.engines),
origin: json.origin || 'unknown',
dist: json.dist || { tarball: '', unpackedSize: 0 },
homepage: json.homepage || '',
}
}
export async function getInstalledExtension (id: string): Promise<Extension | null> {
let json
try {
json = await api.fetchHttp(`/extensions/${id}/package.json`)
if (!json.name || !json.version) {
throw new Error('Invalid extension package.json')
}
} catch (error) {
logger.error(error)
return null
}
const info = readInfoFromJson(json)
if (info) {
return { ...info, installed: true }
}
return null
}
export async function getInstalledExtensions () {
const extensions: Extension[] = []
for (const item of await api.fetchInstalledExtensions()) {
const info = await getInstalledExtension(item.id)
if (info) {
extensions.push({
...info,
enabled: item.enabled && info.compatible.value,
icon: path.join('/extensions/', item.id, info.icon),
})
}
}
return extensions
}
export async function getRegistryExtensions (registry: RegistryHostname = 'registry.npmjs.org'): Promise<Extension[]> {
logger.debug('getRegistryExtensions', registry)
const registryUrl = `https://${registry}/yank-note-registry`
const registryJson = await api.proxyRequest(registryUrl).then(r => r.json())
const latest = registryJson['dist-tags'].latest
const tarballUrl = changeRegistryOrigin(registry, registryJson.versions[latest].dist.tarball)
const extensions = await api.proxyRequest(tarballUrl)
.then(r => r.arrayBuffer())
.then(data => pako.inflate(new Uint8Array(data)))
.then(arr => arr.buffer)
.then(buffer => untar(buffer))
.then(files => files.find((x: any) => x.name === 'package/index.json'))
.then(file => new TextDecoder('utf-8').decode(file.buffer))
.then(JSON.parse)
return extensions.map(readInfoFromJson)
}
export function showManager (id?: string) {
getActionHandler('extension.show-manager')(id)
}
export async function enable (extension: Extension) {
await api.enableExtension(extension.id)
extension.enabled = true
load(extension)
}
export async function disable (extension: Pick<Extension, 'id'>) {
await api.disableExtension(extension.id)
}
export async function uninstall (extension: Pick<Extension, 'id'>) {
await api.uninstallExtension(extension.id)
}
export async function install (extension: Extension, registry: RegistryHostname = 'registry.npmjs.org') {
const url = extension.dist.tarball
if (!url) {
throw new Error('No dist url')
}
await api.installExtension(extension.id, changeRegistryOrigin(registry, url))
await enable(extension)
}
function load (extension: Extension) {
if (extension.enabled && extension.compatible) {
logger.debug('load', extension.id)
const loadStatus: LoadStatus = loaded.get(extension.id) || { themes: false, plugin: false }
loadStatus.version = extension.version
if (!loadStatus.themes && extension?.themes && extension.themes.length) {
extension.themes.forEach(style => {
theme.registerThemeStyle({
from: 'extension',
name: `[${extension.id.replace(/^yank-note-extension-/, '')}]: ${style.name}`,
css: `extension:${path.join(extension.id, style.css)}`,
})
})
loadStatus.themes = true
}
const main = extension?.main
if (!loadStatus.plugin && main && main.endsWith('.js')) {
const script = window.document.createElement('script')
script.src = path.resolve('/extensions', extension.id, main)
script.async = true
window.document.body.appendChild(script)
loadStatus.plugin = true
}
loaded.set(extension.id, loadStatus)
}
}
/**
* Initialization extension system
*/
export async function init () {
logger.debug('init')
for (const extension of await getInstalledExtensions()) {
load(extension)
}
}
+8 -1
View File
@@ -8,7 +8,14 @@ export default {
id: 'status-bar-tool',
position: 'left',
title: ctx.i18n.t('status-bar.tool.tool'),
list: []
list: [
{
id: 'extension-manager',
type: 'normal',
title: ctx.i18n.t('status-bar.tool.extension-manager'),
onClick: () => ctx.showExtensionManager(),
}
]
}
})
}
-1
View File
@@ -49,7 +49,6 @@ export function setLanguage (language: LanguageName) {
*/
export function mergeLanguage (lang: Language, nls: Record<string, any>) {
_mergeLanguage(lang, nls)
triggerHook('I18N_CHANGE_LANGUAGE', { lang: getLanguage(), currentLang: getCurrentLanguage() })
}
/**
+12
View File
@@ -76,14 +76,26 @@ export function removeStyles (id: string) {
}
}
/**
* register theme style
* @param style
*/
export function registerThemeStyle (style: ThemeStyle) {
ioc.register('THEME_STYLES', style)
}
/**
* get theme styles
* @returns
*/
export function getThemeStyles (): ThemeStyle[] {
return ioc.get('THEME_STYLES')
}
/**
* remove theme styles
* @param style
*/
export function removeThemeStyle (style: ThemeStyle | ((item: ThemeStyle) => boolean)) {
if (typeof style === 'function') {
ioc.removeWhen('THEME_STYLES', style)
+2
View File
@@ -21,6 +21,8 @@ declare module 'filenamify/browser'
declare module 'katex'
declare module 'luckyexcel'
declare module 'xterm-theme'
declare module 'parse-author'
declare module 'js-untar'
declare module 'path-browserify' {
import path from 'path'
export default path
+5
View File
@@ -10,6 +10,7 @@ import { getSelectionInfo, whenEditorReady } from '@fe/services/editor'
import { getLanguage, setLanguage } from '@fe/services/i18n'
import { fetchSettings } from '@fe/services/setting'
import { getPurchased } from '@fe/others/premium'
import * as extension from '@fe/others/extension'
import { setTheme } from '@fe/services/theme'
import { toggleOutline } from '@fe/services/layout'
import * as view from '@fe/services/view'
@@ -120,3 +121,7 @@ store.watch(() => store.state.currentFile, (val) => {
}, { immediate: true })
fetchSettings()
registerHook('STARTUP', () => {
setTimeout(extension.init, 500)
}, true)
+21
View File
@@ -392,3 +392,24 @@ export async function rpc (code: string) {
return data
}
export async function fetchInstalledExtensions (): Promise<{id: string, enabled: boolean}[]> {
const { data } = await fetchHttp('/api/extensions')
return data
}
export async function installExtension (id: string, url: string): Promise<any> {
return fetchHttp(`/api/extensions/install?id=${encodeURIComponent(id)}&url=${encodeURIComponent(url)}`, { method: 'POST' })
}
export async function uninstallExtension (id: string): Promise<any> {
return fetchHttp(`/api/extensions/uninstall?id=${encodeURIComponent(id)}`, { method: 'POST' })
}
export async function enableExtension (id: string): Promise<any> {
return fetchHttp(`/api/extensions/enable?id=${encodeURIComponent(id)}`, { method: 'POST' })
}
export async function disableExtension (id: string): Promise<any> {
return fetchHttp(`/api/extensions/disable?id=${encodeURIComponent(id)}`, { method: 'POST' })
}
+3
View File
@@ -93,6 +93,7 @@ export type ThemeName = 'system' | 'dark' | 'light'
export type LanguageName = 'system' | Language
export type ExportType = 'pdf' | 'docx' | 'html' | 'rst' | 'adoc'
export type SettingGroup = 'repos' | 'appearance' | 'editor' | 'image' | 'proxy' | 'other' | 'openai'
export type RegistryHostname = 'registry.npmjs.org' | 'registry.npmmirror.com'
export type RenderEnv = {
source: string,
@@ -134,6 +135,7 @@ export type BuildInSettings = {
'proxy.server': string,
'proxy.pac-url': string,
'proxy.bypass-list': string,
'extension.registry': RegistryHostname,
'keep-running-after-closing-window': boolean,
'plantuml-api': string,
}
@@ -151,6 +153,7 @@ export type BuildInActions = {
'view.exit-presentation': () => void,
'doc.show-history': (doc?: Doc) => void
'doc.hide-history': () => void,
'extension.show-manager': (id?: string) => void,
'layout.toggle-view': (visible?: boolean) => void,
'layout.toggle-side': (visible?: boolean) => void,
'layout.toggle-xterm': (visible?: boolean) => void,
+3
View File
@@ -28,6 +28,7 @@
<Premium />
<ControlCenter />
<DocHistory />
<ExtensionManager />
</template>
<script lang="ts">
@@ -54,6 +55,7 @@ import ControlCenter from '@fe/components/ControlCenter.vue'
import DocHistory from '@fe/components/DocHistory.vue'
import ActionBar from '@fe/components/ActionBar.vue'
import Outline from '@fe/components/Outline.vue'
import ExtensionManager from '@fe/components/ExtensionManager.vue'
export default defineComponent({
name: 'x-main',
@@ -74,6 +76,7 @@ export default defineComponent({
DocHistory,
ActionBar,
Outline,
ExtensionManager,
},
setup () {
const store = useStore<AppState>()
+34
View File
@@ -224,6 +224,7 @@ const data = {
'copy-content': 'Copy Content',
'doc-history': 'Document History',
'share-preview': 'Share Preview',
'extension-manager': 'Extension Manager',
},
'document-info': {
'selected': 'Selected',
@@ -527,6 +528,39 @@ const data = {
'args-json': 'Custom Arguments',
'args-json-desc': 'Query parameters, JSON string like {"temperature": 0.3}',
},
'extension': {
'extension-manager': 'Extension Manager',
'all': 'All',
'installed': 'Installed',
'official': 'Official',
'unofficial': 'Unofficial',
'unknown': 'Unknown',
'author': 'Author',
'origin': 'Origin',
'unpacked-size': 'Unpacked Size',
'latest-version': 'Latest Version',
'installed-version': 'Installed Version',
'homepage': 'Homepage',
'download': 'Download',
'toast-loaded': 'Extension Loaded',
'upgradable': 'Upgradable',
'incompatible': 'Incompatible',
'not-installed': 'Not Installed',
'enabled': 'Enabled',
'disabled': 'Disabled',
'reload-required': 'Reload Required',
'no-extension': 'No Extension',
'reload': 'Reload',
'install': 'Install',
'uninstall': 'Uninstall',
'installing': 'Installing',
'uninstalling': 'Uninstalling',
'upgrade': 'Upgrade',
'disable': 'Disable',
'enable': 'Enable',
'uninstall-confirm': 'Are you sure want to uninstall [%s]?',
'registry': 'Registry',
},
}
export type BaseLanguage = typeof data
+34
View File
@@ -225,6 +225,7 @@ const data: BaseLanguage = {
'copy-content': '复制内容',
'doc-history': '文档历史版本',
'share-preview': '分享预览',
'extension-manager': '扩展管理',
},
'document-info': {
'selected': '已选择',
@@ -528,6 +529,39 @@ const data: BaseLanguage = {
'args-json': '自定义参数',
'args-json-desc': '请求参数,JSON 字符串如 {"temperature": 0.3}',
},
'extension': {
'extension-manager': '扩展管理',
'all': '全部',
'installed': '已安装',
'official': '官方',
'unofficial': '非官方',
'unknown': '未知',
'author': '作者',
'origin': '来源',
'unpacked-size': '解包大小',
'latest-version': '最新版本',
'installed-version': '已安装版本',
'homepage': '主页',
'download': '下载',
'toast-loaded': '扩展加载成功',
'upgradable': '可升级',
'incompatible': '不兼容',
'not-installed': '未安装',
'enabled': '已启用',
'disabled': '已禁用',
'reload-required': '需要重载',
'no-extension': '没有扩展',
'reload': '重载',
'install': '安装',
'installing': '正在安装',
'uninstalling': '正在卸载',
'uninstall': '卸载',
'upgrade': '升级',
'disable': '禁用',
'enable': '启用',
'uninstall-confirm': '你确定要卸载扩展 [%s] 吗?',
'registry': '仓库源',
},
}
export default data
+3
View File
@@ -67,6 +67,9 @@ export default defineConfig({
'/custom-css': {
target: 'http://localhost:3044'
},
'/extension': {
target: 'http://localhost:3044'
},
'/api': {
target: 'http://localhost:3044'
},
+53 -4
View File
@@ -1540,6 +1540,13 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
"@types/tar-stream@^2.2.2":
version "2.2.2"
resolved "https://registry.yarnpkg.com/@types/tar-stream/-/tar-stream-2.2.2.tgz#be9d0be9404166e4b114151f93e8442e6ab6fb1d"
integrity sha512-1AX+Yt3icFuU6kxwmPakaiGrJUwG44MpuiqPg4dSolRFk6jmvs4b3IbUol9wKDLIgU76gevn3EwE8y/DkSJCZQ==
dependencies:
"@types/node" "*"
"@types/tough-cookie@*":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d"
@@ -2180,6 +2187,11 @@ at-least-node@^1.0.0:
resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
author-regex@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/author-regex/-/author-regex-1.0.0.tgz#d08885be6b9bbf9439fe087c76287245f0a81450"
integrity sha1-0IiFvmubv5Q5/gh8dihyRfCoFFA=
autoprefixer@^10.2.6:
version "10.2.6"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.2.6.tgz#aadd9ec34e1c98d403e01950038049f0eb252949"
@@ -2314,6 +2326,15 @@ binary-split@^1.0.5:
dependencies:
through2 "^2.0.3"
bl@^4.0.3:
version "4.1.0"
resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
dependencies:
buffer "^5.5.0"
inherits "^2.0.4"
readable-stream "^3.4.0"
blob@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
@@ -2456,7 +2477,7 @@ buffer-from@^1.0.0:
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
buffer@^5.1.0:
buffer@^5.1.0, buffer@^5.5.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
@@ -4111,7 +4132,7 @@ encodeurl@^1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
end-of-stream@^1.1.0:
end-of-stream@^1.1.0, end-of-stream@^1.4.1:
version "1.4.4"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
@@ -4927,6 +4948,11 @@ front-matter@^4.0.2:
dependencies:
js-yaml "^3.13.1"
fs-constants@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fs-extra@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.0.0.tgz#9ff61b655dde53fb34a82df84bb214ce802e17c1"
@@ -5482,7 +5508,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3:
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -6292,6 +6318,11 @@ js-tokens@^4.0.0:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-untar@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/js-untar/-/js-untar-2.0.0.tgz#b452d28dedd3b0be92c2ac9a7d70f612a93c7453"
integrity sha512-7CsDLrYQMbLxDt2zl9uKaPZSdmJMvGGQ7wo9hoB3J+z/VcO2w63bXFgHVnjF1+S9wD3zAu8FBVj7EYWjTQ3Z7g==
js-yaml@^3.13.1:
version "3.14.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
@@ -7419,6 +7450,13 @@ parent-module@^1.0.0:
dependencies:
callsites "^3.0.0"
parse-author@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/parse-author/-/parse-author-2.0.0.tgz#d3460bf1ddd0dfaeed42da754242e65fb684a81f"
integrity sha1-00YL8d3Q367tQtp1QkLmX7aEqB8=
dependencies:
author-regex "^1.0.0"
parse-json@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
@@ -7900,7 +7938,7 @@ read-pkg@^5.2.0:
parse-json "^5.0.0"
type-fest "^0.6.0"
readable-stream@3, readable-stream@^3.0.0:
readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@@ -8712,6 +8750,17 @@ tapable@^0.1.8:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.1.10.tgz#29c35707c2b70e50d07482b5d202e8ed446dafd4"
integrity sha1-KcNXB8K3DlDQdIK10gLo7URtr9Q=
tar-stream@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
dependencies:
bl "^4.0.3"
end-of-stream "^1.4.1"
fs-constants "^1.0.0"
inherits "^2.0.3"
readable-stream "^3.1.1"
temp-file@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/temp-file/-/temp-file-3.4.0.tgz#766ea28911c683996c248ef1a20eea04d51652c7"