mirror of
https://github.com/yang991178/fluent-reader.git
synced 2025-04-24 15:08:43 +02:00
enable context isolation
This commit is contained in:
parent
394643b95e
commit
59c5d663f1
19
.vscode/launch.json
vendored
Normal file
19
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Main Process",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceRoot}",
|
||||||
|
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
|
||||||
|
"windows": {
|
||||||
|
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
|
||||||
|
},
|
||||||
|
"program": "${workspaceRoot}/dist/electron.js",
|
||||||
|
"args" : ["."],
|
||||||
|
"outputCapture": "std",
|
||||||
|
"sourceMaps": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
4
dist/article/article.html
vendored
4
dist/article/article.html
vendored
@ -3,14 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'none'; script-src-elem 'sha256-XUiRvcvPhKzBU50B7nIFBLySVn2CJ3cNFgqtfeiRcTA='; img-src http://* https://*; style-src 'self' 'unsafe-inline'; frame-src http://* https://*; media-src http://* https://*">
|
content="default-src 'none'; script-src-elem 'sha256-prYLVBOTCtLoXJ5JJGBEADdvxnqlbKVTWQs/C8BrYsQ='; img-src http://* https://*; style-src 'self' 'unsafe-inline'; frame-src http://* https://*; media-src http://* https://*">
|
||||||
<title>Article</title>
|
<title>Article</title>
|
||||||
<link rel="stylesheet" href="scroll.css" />
|
<link rel="stylesheet" href="scroll.css" />
|
||||||
<link rel="stylesheet" href="article.css" />
|
<link rel="stylesheet" href="article.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="main"></div>
|
<div id="main"></div>
|
||||||
<script integrity="sha256-XUiRvcvPhKzBU50B7nIFBLySVn2CJ3cNFgqtfeiRcTA=" src="article.js"></script>
|
<script integrity="sha256-prYLVBOTCtLoXJ5JJGBEADdvxnqlbKVTWQs/C8BrYsQ=" src="article.js"></script>
|
||||||
<!-- Run "cat article.js | openssl dgst -sha256 -binary | openssl enc -base64 -A" for hash -->
|
<!-- Run "cat article.js | openssl dgst -sha256 -binary | openssl enc -base64 -A" for hash -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
24
dist/article/article.js
vendored
24
dist/article/article.js
vendored
@ -17,18 +17,20 @@ for (let s of dom.querySelectorAll("script")) {
|
|||||||
}
|
}
|
||||||
let main = document.getElementById("main")
|
let main = document.getElementById("main")
|
||||||
main.innerHTML = dom.body.innerHTML
|
main.innerHTML = dom.body.innerHTML
|
||||||
document.addEventListener("click", event => {
|
|
||||||
event.preventDefault()
|
let contextOn = false
|
||||||
let target = event.target
|
const dismissListener = () => {
|
||||||
while (target.nodeName !== "#document") {
|
if (contextOn) {
|
||||||
if (target.href) {
|
contextOn = false
|
||||||
window.renderer.requestNavigation(target.href)
|
window.renderer.dismissContextMenu()
|
||||||
break
|
|
||||||
}
|
}
|
||||||
target = target.parentNode
|
}
|
||||||
}
|
document.addEventListener("mousedown", dismissListener)
|
||||||
})
|
document.addEventListener("scroll", dismissListener)
|
||||||
document.addEventListener("contextmenu", event => {
|
document.addEventListener("contextmenu", event => {
|
||||||
let text = document.getSelection().toString()
|
let text = document.getSelection().toString()
|
||||||
if (text) window.renderer.contextMenu([event.clientX, event.clientY], text)
|
if (text) {
|
||||||
|
contextOn = true
|
||||||
|
window.renderer.contextMenu([event.clientX, event.clientY], text)
|
||||||
|
}
|
||||||
})
|
})
|
6
dist/article/preload.js
vendored
6
dist/article/preload.js
vendored
@ -1,10 +1,10 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron")
|
const { contextBridge, ipcRenderer } = require("electron")
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("renderer",{
|
contextBridge.exposeInMainWorld("renderer",{
|
||||||
requestNavigation: (href) => {
|
dismissContextMenu: () => {
|
||||||
ipcRenderer.sendToHost("request-navigation", href)
|
ipcRenderer.invoke("webview-context-menu", null, null)
|
||||||
},
|
},
|
||||||
contextMenu: (pos, text) => {
|
contextMenu: (pos, text) => {
|
||||||
ipcRenderer.sendToHost("context-menu", pos, text)
|
ipcRenderer.invoke("webview-context-menu", pos, text)
|
||||||
}
|
}
|
||||||
})
|
})
|
@ -61,6 +61,13 @@ const settingsBridge = {
|
|||||||
return ipcRenderer.sendSync("get-locale")
|
return ipcRenderer.sendSync("get-locale")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getFontSize: (): number => {
|
||||||
|
return ipcRenderer.sendSync("get-font-size")
|
||||||
|
},
|
||||||
|
setFontSize: (size: number) => {
|
||||||
|
ipcRenderer.invoke("set-font-size", size)
|
||||||
|
},
|
||||||
|
|
||||||
getAll: () => {
|
getAll: () => {
|
||||||
return ipcRenderer.sendSync("get-all-settings") as Object
|
return ipcRenderer.sendSync("get-all-settings") as Object
|
||||||
},
|
},
|
||||||
|
@ -40,6 +40,13 @@ const utilsBridge = {
|
|||||||
await ipcRenderer.invoke("clear-cache")
|
await ipcRenderer.invoke("clear-cache")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addWebviewContextListener: (callback: (pos: [number, number], text: string) => any) => {
|
||||||
|
ipcRenderer.removeAllListeners("webview-context-menu")
|
||||||
|
ipcRenderer.on("webview-context-menu", (_, pos, text) => {
|
||||||
|
callback(pos, text)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
addWebviewKeydownListener: (id: number, callback: (event: Electron.Input) => any) => {
|
addWebviewKeydownListener: (id: number, callback: (event: Electron.Input) => any) => {
|
||||||
ipcRenderer.invoke("add-webview-keydown-listener", id)
|
ipcRenderer.invoke("add-webview-keydown-listener", id)
|
||||||
ipcRenderer.removeAllListeners("webview-keydown")
|
ipcRenderer.removeAllListeners("webview-keydown")
|
||||||
|
@ -4,9 +4,7 @@ import { renderToString } from "react-dom/server"
|
|||||||
import { RSSItem } from "../scripts/models/item"
|
import { RSSItem } from "../scripts/models/item"
|
||||||
import { Stack, CommandBarButton, IContextualMenuProps, FocusZone } from "@fluentui/react"
|
import { Stack, CommandBarButton, IContextualMenuProps, FocusZone } from "@fluentui/react"
|
||||||
import { RSSSource, SourceOpenTarget } from "../scripts/models/source"
|
import { RSSSource, SourceOpenTarget } from "../scripts/models/source"
|
||||||
import { store } from "../scripts/settings"
|
|
||||||
|
|
||||||
const FONT_SIZE_STORE_KEY = "fontSize"
|
|
||||||
const FONT_SIZE_OPTIONS = [12, 13, 14, 15, 16, 17, 18, 19, 20]
|
const FONT_SIZE_OPTIONS = [12, 13, 14, 15, 16, 17, 18, 19, 20]
|
||||||
|
|
||||||
type ArticleProps = {
|
type ArticleProps = {
|
||||||
@ -20,6 +18,7 @@ type ArticleProps = {
|
|||||||
toggleStarred: (item: RSSItem) => void
|
toggleStarred: (item: RSSItem) => void
|
||||||
toggleHidden: (item: RSSItem) => void
|
toggleHidden: (item: RSSItem) => void
|
||||||
textMenu: (text: string, position: [number, number]) => void
|
textMenu: (text: string, position: [number, number]) => void
|
||||||
|
dismissContextMenu: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArticleState = {
|
type ArticleState = {
|
||||||
@ -36,13 +35,14 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
|||||||
fontSize: this.getFontSize(),
|
fontSize: this.getFontSize(),
|
||||||
loadWebpage: this.props.source.openTarget === SourceOpenTarget.Webpage
|
loadWebpage: this.props.source.openTarget === SourceOpenTarget.Webpage
|
||||||
}
|
}
|
||||||
|
window.utils.addWebviewContextListener(this.contextMenuHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
getFontSize = () => {
|
getFontSize = () => {
|
||||||
return store.get(FONT_SIZE_STORE_KEY, 16)
|
return window.settings.getFontSize()
|
||||||
}
|
}
|
||||||
setFontSize = (size: number) => {
|
setFontSize = (size: number) => {
|
||||||
store.set(FONT_SIZE_STORE_KEY, size)
|
window.settings.setFontSize(size)
|
||||||
this.setState({fontSize: size})
|
this.setState({fontSize: size})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,27 +79,16 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcHandler = event => {
|
contextMenuHandler = (pos: [number, number], text: string) => {
|
||||||
switch (event.channel) {
|
if (pos) {
|
||||||
case "request-navigation": {
|
|
||||||
window.utils.openExternal(event.args[0])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "context-menu": {
|
|
||||||
let articlePos = document.getElementById("article").getBoundingClientRect()
|
let articlePos = document.getElementById("article").getBoundingClientRect()
|
||||||
let [x, y] = event.args[0]
|
let [x, y] = pos
|
||||||
this.props.textMenu(event.args[1], [x + articlePos.x, y + articlePos.y])
|
this.props.textMenu(text, [x + articlePos.x, y + articlePos.y])
|
||||||
break
|
} else {
|
||||||
|
this.props.dismissContextMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
popUpHandler = event => {
|
|
||||||
window.utils.openExternal(event.url)
|
|
||||||
}
|
|
||||||
navigationHandler = event => {
|
|
||||||
window.utils.openExternal(event.url)
|
|
||||||
this.props.dismiss()
|
|
||||||
}
|
|
||||||
keyDownHandler = (input: Electron.Input) => {
|
keyDownHandler = (input: Electron.Input) => {
|
||||||
if (input.type === "keyDown") {
|
if (input.type === "keyDown") {
|
||||||
switch (input.key) {
|
switch (input.key) {
|
||||||
@ -134,11 +123,9 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
|||||||
componentDidMount = () => {
|
componentDidMount = () => {
|
||||||
let webview = document.getElementById("article") as Electron.WebviewTag
|
let webview = document.getElementById("article") as Electron.WebviewTag
|
||||||
if (webview != this.webview) {
|
if (webview != this.webview) {
|
||||||
webview.addEventListener("ipc-message", this.ipcHandler)
|
|
||||||
webview.addEventListener("new-window", this.popUpHandler)
|
|
||||||
webview.addEventListener("will-navigate", this.navigationHandler)
|
|
||||||
webview.addEventListener("dom-ready", () => {
|
webview.addEventListener("dom-ready", () => {
|
||||||
window.utils.addWebviewKeydownListener(webview.getWebContentsId(), this.keyDownHandler)
|
let id = webview.getWebContentsId()
|
||||||
|
window.utils.addWebviewKeydownListener(id, this.keyDownHandler)
|
||||||
})
|
})
|
||||||
this.webview = webview
|
this.webview = webview
|
||||||
webview.focus()
|
webview.focus()
|
||||||
@ -227,7 +214,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
|||||||
key={this.props.item._id + (this.state.loadWebpage ? "_" : "")}
|
key={this.props.item._id + (this.state.loadWebpage ? "_" : "")}
|
||||||
src={this.state.loadWebpage ? this.props.item.link : this.articleView()}
|
src={this.state.loadWebpage ? this.props.item.link : this.articleView()}
|
||||||
preload={this.state.loadWebpage ? null : "article/preload.js"}
|
preload={this.state.loadWebpage ? null : "article/preload.js"}
|
||||||
webpreferences="contextIsolation,sandbox,disableDialogs,autoplayPolicy=document-user-activation-required"
|
webpreferences="contextIsolation,disableDialogs,autoplayPolicy=document-user-activation-required"
|
||||||
partition="sandbox" />
|
partition="sandbox" />
|
||||||
</FocusZone>
|
</FocusZone>
|
||||||
)
|
)
|
||||||
|
@ -5,7 +5,7 @@ import { RSSItem, markUnread, markRead, toggleStarred, toggleHidden, itemShortcu
|
|||||||
import { AppDispatch } from "../scripts/utils"
|
import { AppDispatch } from "../scripts/utils"
|
||||||
import { dismissItem, showOffsetItem } from "../scripts/models/page"
|
import { dismissItem, showOffsetItem } from "../scripts/models/page"
|
||||||
import Article from "../components/article"
|
import Article from "../components/article"
|
||||||
import { openTextMenu } from "../scripts/models/app"
|
import { openTextMenu, closeContextMenu } from "../scripts/models/app"
|
||||||
|
|
||||||
type ArticleContainerProps = {
|
type ArticleContainerProps = {
|
||||||
itemId: string
|
itemId: string
|
||||||
@ -34,7 +34,8 @@ const mapDispatchToProps = (dispatch: AppDispatch) => {
|
|||||||
toggleHasRead: (item: RSSItem) => dispatch(item.hasRead ? markUnread(item) : markRead(item)),
|
toggleHasRead: (item: RSSItem) => dispatch(item.hasRead ? markUnread(item) : markRead(item)),
|
||||||
toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)),
|
toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)),
|
||||||
toggleHidden: (item: RSSItem) => dispatch(toggleHidden(item)),
|
toggleHidden: (item: RSSItem) => dispatch(toggleHidden(item)),
|
||||||
textMenu: (text: string, position: [number, number]) => dispatch(openTextMenu(text, position))
|
textMenu: (text: string, position: [number, number]) => dispatch(openTextMenu(text, position)),
|
||||||
|
dismissContextMenu: () => dispatch(closeContextMenu())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { ThemeSettings, SchemaTypes } from "./schema-types"
|
|||||||
import { store } from "./main/settings"
|
import { store } from "./main/settings"
|
||||||
import performUpdate from "./main/update-scripts"
|
import performUpdate from "./main/update-scripts"
|
||||||
import { WindowManager } from "./main/window"
|
import { WindowManager } from "./main/window"
|
||||||
|
import { openExternal } from "./main/utils"
|
||||||
|
|
||||||
if (!process.mas) {
|
if (!process.mas) {
|
||||||
const locked = app.requestSingleInstanceLock()
|
const locked = app.requestSingleInstanceLock()
|
||||||
@ -71,3 +72,14 @@ ipcMain.handle("import-all-settings", (_, configs: SchemaTypes) => {
|
|||||||
nativeTheme.themeSource = store.get("theme", ThemeSettings.Default)
|
nativeTheme.themeSource = store.get("theme", ThemeSettings.Default)
|
||||||
winManager.mainWindow.close()
|
winManager.mainWindow.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.on("web-contents-created", (_, contents) => {
|
||||||
|
contents.on("new-window", (event, url) => {
|
||||||
|
if (winManager.hasWindow()) event.preventDefault()
|
||||||
|
if (contents.getType() === "webview") openExternal(url)
|
||||||
|
})
|
||||||
|
contents.on("will-navigate", (event, url) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (contents.getType() === "webview") openExternal(url)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
@ -105,6 +105,14 @@ ipcMain.on("get-locale", (event) => {
|
|||||||
event.returnValue = locale
|
event.returnValue = locale
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FONT_SIZE_STORE_KEY = "fontSize"
|
||||||
|
ipcMain.on("get-font-size", (event) => {
|
||||||
|
event.returnValue = store.get(FONT_SIZE_STORE_KEY, 16)
|
||||||
|
})
|
||||||
|
ipcMain.handle("set-font-size", (_, size: number) => {
|
||||||
|
store.set(FONT_SIZE_STORE_KEY, size)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.on("get-all-settings", (event) => {
|
ipcMain.on("get-all-settings", (event) => {
|
||||||
let output = {}
|
let output = {}
|
||||||
for (let [key, value] of store) {
|
for (let [key, value] of store) {
|
||||||
|
@ -2,14 +2,18 @@ import { ipcMain, shell, dialog, app, session, webContents, clipboard } from "el
|
|||||||
import { WindowManager } from "./window"
|
import { WindowManager } from "./window"
|
||||||
import fs = require("fs")
|
import fs = require("fs")
|
||||||
|
|
||||||
|
export function openExternal(url: string) {
|
||||||
|
if (url.startsWith("https://") || url.startsWith("http://"))
|
||||||
|
shell.openExternal(url)
|
||||||
|
}
|
||||||
|
|
||||||
export function setUtilsListeners(manager: WindowManager) {
|
export function setUtilsListeners(manager: WindowManager) {
|
||||||
ipcMain.on("get-version", (event) => {
|
ipcMain.on("get-version", (event) => {
|
||||||
event.returnValue = app.getVersion()
|
event.returnValue = app.getVersion()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("open-external", (_, url: string) => {
|
ipcMain.handle("open-external", (_, url: string) => {
|
||||||
if (url.startsWith("https://") || url.startsWith("http://"))
|
openExternal(url)
|
||||||
shell.openExternal(url)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("show-error-box", (_, title, content) => {
|
ipcMain.handle("show-error-box", (_, title, content) => {
|
||||||
@ -76,6 +80,12 @@ export function setUtilsListeners(manager: WindowManager) {
|
|||||||
await session.defaultSession.clearCache()
|
await session.defaultSession.clearCache()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("webview-context-menu", (_, pos, text) => {
|
||||||
|
if (manager.hasWindow()) {
|
||||||
|
manager.mainWindow.webContents.send("webview-context-menu", pos, text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle("add-webview-keydown-listener", (_, id) => {
|
ipcMain.handle("add-webview-keydown-listener", (_, id) => {
|
||||||
let contents = webContents.fromId(id)
|
let contents = webContents.fromId(id)
|
||||||
contents.on("before-input-event", (_, input) => {
|
contents.on("before-input-event", (_, input) => {
|
||||||
|
@ -57,9 +57,9 @@ export class WindowManager {
|
|||||||
fullscreenable: false,
|
fullscreenable: false,
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: true,
|
|
||||||
webviewTag: true,
|
webviewTag: true,
|
||||||
enableRemoteModule: true,
|
enableRemoteModule: false,
|
||||||
|
contextIsolation: true,
|
||||||
preload: path.join(app.getAppPath(), (app.isPackaged ? "dist/" : "") + "preload.js")
|
preload: path.join(app.getAppPath(), (app.isPackaged ? "dist/" : "") + "preload.js")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -2,5 +2,5 @@ import { contextBridge } from "electron"
|
|||||||
import settingsBridge from "./bridges/settings"
|
import settingsBridge from "./bridges/settings"
|
||||||
import utilsBridge from "./bridges/utils"
|
import utilsBridge from "./bridges/utils"
|
||||||
|
|
||||||
window.settings = settingsBridge
|
contextBridge.exposeInMainWorld("settings", settingsBridge)
|
||||||
window.utils = utilsBridge
|
contextBridge.exposeInMainWorld("utils", utilsBridge)
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
import { IPartialTheme, loadTheme } from "@fluentui/react"
|
import { IPartialTheme, loadTheme } from "@fluentui/react"
|
||||||
import locales from "./i18n/_locales"
|
import locales from "./i18n/_locales"
|
||||||
import Store = require("electron-store")
|
import { ThemeSettings } from "../schema-types"
|
||||||
import { ThemeSettings, SchemaTypes } from "../schema-types"
|
|
||||||
import intl from "react-intl-universal"
|
import intl from "react-intl-universal"
|
||||||
|
|
||||||
export const store = new Store<SchemaTypes>()
|
|
||||||
|
|
||||||
const lightTheme: IPartialTheme = {
|
const lightTheme: IPartialTheme = {
|
||||||
defaultFontStyle: { fontFamily: '"Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif' }
|
defaultFontStyle: { fontFamily: '"Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif' }
|
||||||
}
|
}
|
||||||
@ -39,7 +36,6 @@ const darkTheme: IPartialTheme = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_STORE_KEY = "theme"
|
|
||||||
export function setThemeSettings(theme: ThemeSettings) {
|
export function setThemeSettings(theme: ThemeSettings) {
|
||||||
window.settings.setThemeSettings(theme)
|
window.settings.setThemeSettings(theme)
|
||||||
applyThemeSettings()
|
applyThemeSettings()
|
||||||
|
@ -16,6 +16,7 @@ module.exports = [
|
|||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
devtoolModuleFilenameTemplate: '[absolute-resource-path]',
|
||||||
path: __dirname + '/dist',
|
path: __dirname + '/dist',
|
||||||
filename: 'electron.js'
|
filename: 'electron.js'
|
||||||
}
|
}
|
||||||
@ -42,8 +43,11 @@ module.exports = [
|
|||||||
{
|
{
|
||||||
mode: 'production',
|
mode: 'production',
|
||||||
entry: './src/index.tsx',
|
entry: './src/index.tsx',
|
||||||
target: 'electron-renderer',
|
target: 'web',
|
||||||
devtool: 'source-map',
|
devtool: 'source-map',
|
||||||
|
performance: {
|
||||||
|
hints: false
|
||||||
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [{
|
rules: [{
|
||||||
test: /\.ts(x?)$/,
|
test: /\.ts(x?)$/,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user