refactor(desktop): move markdown rendering to rust (#10000)
This commit is contained in:
1
bun.lock
1
bun.lock
@@ -198,6 +198,7 @@
|
|||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
"@tauri-apps/plugin-updater": "~2",
|
"@tauri-apps/plugin-updater": "~2",
|
||||||
"@tauri-apps/plugin-window-state": "~2",
|
"@tauri-apps/plugin-window-state": "~2",
|
||||||
|
"marked": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { NotificationProvider } from "@/context/notification"
|
|||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
@@ -45,6 +46,11 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MarkedProviderWithNativeParser(props: ParentProps) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
@@ -54,11 +60,11 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
<UiI18nBridge>
|
<UiI18nBridge>
|
||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<MarkedProvider>
|
<MarkedProviderWithNativeParser>
|
||||||
<DiffComponentProvider component={Diff}>
|
<DiffComponentProvider component={Diff}>
|
||||||
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
|
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
|
||||||
</DiffComponentProvider>
|
</DiffComponentProvider>
|
||||||
</MarkedProvider>
|
</MarkedProviderWithNativeParser>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</UiI18nBridge>
|
</UiI18nBridge>
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export type Platform = {
|
|||||||
|
|
||||||
/** Set the default server URL to use on app startup (desktop only) */
|
/** Set the default server URL to use on app startup (desktop only) */
|
||||||
setDefaultServerUrl?(url: string | null): Promise<void>
|
setDefaultServerUrl?(url: string | null): Promise<void>
|
||||||
|
|
||||||
|
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||||
|
parseMarkdown?(markdown: string): Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||||
|
|||||||
99
packages/desktop/src-tauri/Cargo.lock
generated
99
packages/desktop/src-tauri/Cargo.lock
generated
@@ -464,6 +464,15 @@ dependencies = [
|
|||||||
"toml 0.9.8",
|
"toml 0.9.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "caseless"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-normalization",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.47"
|
version = "1.2.47"
|
||||||
@@ -574,6 +583,23 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "comrak"
|
||||||
|
version = "0.50.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "321d20bf105b6871a49da44c5fbb93e90a7cd6178ea5a9fe6cbc1e6d4504bc5e"
|
||||||
|
dependencies = [
|
||||||
|
"caseless",
|
||||||
|
"entities",
|
||||||
|
"jetscii",
|
||||||
|
"phf 0.13.1",
|
||||||
|
"phf_codegen 0.13.1",
|
||||||
|
"rustc-hash",
|
||||||
|
"smallvec",
|
||||||
|
"typed-arena",
|
||||||
|
"unicode_categories",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1053,6 +1079,12 @@ dependencies = [
|
|||||||
"windows 0.51.1",
|
"windows 0.51.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "entities"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "enumflags2"
|
name = "enumflags2"
|
||||||
version = "0.7.12"
|
version = "0.7.12"
|
||||||
@@ -2153,6 +2185,12 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jetscii"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2986,6 +3024,7 @@ dependencies = [
|
|||||||
name = "opencode-desktop"
|
name = "opencode-desktop"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"comrak",
|
||||||
"futures",
|
"futures",
|
||||||
"gtk",
|
"gtk",
|
||||||
"listeners",
|
"listeners",
|
||||||
@@ -3187,6 +3226,16 @@ dependencies = [
|
|||||||
"phf_shared 0.11.3",
|
"phf_shared 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared 0.13.1",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_codegen"
|
name = "phf_codegen"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -3207,6 +3256,16 @@ dependencies = [
|
|||||||
"phf_shared 0.11.3",
|
"phf_shared 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_codegen"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator 0.13.1",
|
||||||
|
"phf_shared 0.13.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_generator"
|
name = "phf_generator"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -3237,6 +3296,16 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"phf_shared 0.13.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_macros"
|
name = "phf_macros"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -3291,6 +3360,15 @@ dependencies = [
|
|||||||
"siphasher 1.0.1",
|
"siphasher 1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher 1.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
@@ -5478,6 +5556,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-arena"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -5548,12 +5632,27 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-normalization"
|
||||||
|
version = "0.1.25"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode_categories"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ semver = "1.0.27"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
uuid = { version = "1.19.0", features = ["v4"] }
|
uuid = { version = "1.19.0", features = ["v4"] }
|
||||||
tauri-plugin-decorum = "1.1.1"
|
tauri-plugin-decorum = "1.1.1"
|
||||||
|
comrak = { version = "0.50", default-features = false }
|
||||||
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod job_object;
|
mod job_object;
|
||||||
|
mod markdown;
|
||||||
mod window_customizer;
|
mod window_customizer;
|
||||||
|
|
||||||
use cli::{install_cli, sync_cli};
|
use cli::{install_cli, sync_cli};
|
||||||
@@ -283,7 +284,8 @@ pub fn run() {
|
|||||||
install_cli,
|
install_cli,
|
||||||
ensure_server_ready,
|
ensure_server_ready,
|
||||||
get_default_server_url,
|
get_default_server_url,
|
||||||
set_default_server_url
|
set_default_server_url,
|
||||||
|
markdown::parse_markdown_command
|
||||||
])
|
])
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let app = app.handle().clone();
|
let app = app.handle().clone();
|
||||||
|
|||||||
17
packages/desktop/src-tauri/src/markdown.rs
Normal file
17
packages/desktop/src-tauri/src/markdown.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use comrak::{markdown_to_html, Options};
|
||||||
|
|
||||||
|
pub fn parse_markdown(input: &str) -> String {
|
||||||
|
let mut options = Options::default();
|
||||||
|
options.extension.strikethrough = true;
|
||||||
|
options.extension.table = true;
|
||||||
|
options.extension.tasklist = true;
|
||||||
|
options.extension.autolink = true;
|
||||||
|
options.render.r#unsafe = true;
|
||||||
|
|
||||||
|
markdown_to_html(input, &options)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn parse_markdown_command(markdown: String) -> Result<String, String> {
|
||||||
|
Ok(parse_markdown(&markdown))
|
||||||
|
}
|
||||||
@@ -316,6 +316,10 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
|||||||
setDefaultServerUrl: async (url: string | null) => {
|
setDefaultServerUrl: async (url: string | null) => {
|
||||||
await invoke("set_default_server_url", { url })
|
await invoke("set_default_server_url", { url })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
parseMarkdown: async (markdown: string) => {
|
||||||
|
return invoke<string>("parse_markdown_command", { markdown })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
createMenu()
|
createMenu()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
import markedKatex from "marked-katex-extension"
|
import markedKatex from "marked-katex-extension"
|
||||||
import markedShiki from "marked-shiki"
|
import markedShiki from "marked-shiki"
|
||||||
|
import katex from "katex"
|
||||||
import { bundledLanguages, type BundledLanguage } from "shiki"
|
import { bundledLanguages, type BundledLanguage } from "shiki"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs"
|
import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs"
|
||||||
@@ -375,10 +376,95 @@ registerCustomTheme("OpenCode", () => {
|
|||||||
} as unknown as ThemeRegistrationResolved)
|
} as unknown as ThemeRegistrationResolved)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function renderMathInText(text: string): string {
|
||||||
|
let result = text
|
||||||
|
|
||||||
|
// Display math: $$...$$
|
||||||
|
const displayMathRegex = /\$\$([\s\S]*?)\$\$/g
|
||||||
|
result = result.replace(displayMathRegex, (_, math) => {
|
||||||
|
try {
|
||||||
|
return katex.renderToString(math, {
|
||||||
|
displayMode: true,
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return `$$${math}$$`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inline math: $...$
|
||||||
|
const inlineMathRegex = /(?<!\$)\$(?!\$)((?:[^$\\]|\\.)+?)\$(?!\$)/g
|
||||||
|
result = result.replace(inlineMathRegex, (_, math) => {
|
||||||
|
try {
|
||||||
|
return katex.renderToString(math, {
|
||||||
|
displayMode: false,
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return `$${math}$`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMathExpressions(html: string): string {
|
||||||
|
// Split on code/pre/kbd tags to avoid processing their contents
|
||||||
|
const codeBlockPattern = /(<(?:pre|code|kbd)[^>]*>[\s\S]*?<\/(?:pre|code|kbd)>)/gi
|
||||||
|
const parts = html.split(codeBlockPattern)
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((part, i) => {
|
||||||
|
// Odd indices are the captured code blocks - leave them alone
|
||||||
|
if (i % 2 === 1) return part
|
||||||
|
// Process math only in non-code parts
|
||||||
|
return renderMathInText(part)
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function highlightCodeBlocks(html: string): Promise<string> {
|
||||||
|
const codeBlockRegex = /<pre><code(?:\s+class="language-([^"]*)")?>([\s\S]*?)<\/code><\/pre>/g
|
||||||
|
const matches = [...html.matchAll(codeBlockRegex)]
|
||||||
|
if (matches.length === 0) return html
|
||||||
|
|
||||||
|
const highlighter = await getSharedHighlighter({ themes: ["OpenCode"], langs: [] })
|
||||||
|
|
||||||
|
let result = html
|
||||||
|
for (const match of matches) {
|
||||||
|
const [fullMatch, lang, escapedCode] = match
|
||||||
|
const code = escapedCode
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
|
||||||
|
let language = lang || "text"
|
||||||
|
if (!(language in bundledLanguages)) {
|
||||||
|
language = "text"
|
||||||
|
}
|
||||||
|
if (!highlighter.getLoadedLanguages().includes(language)) {
|
||||||
|
await highlighter.loadLanguage(language as BundledLanguage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlighted = highlighter.codeToHtml(code, {
|
||||||
|
lang: language,
|
||||||
|
theme: "OpenCode",
|
||||||
|
tabindex: false,
|
||||||
|
})
|
||||||
|
result = result.replace(fullMatch, () => highlighted)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NativeMarkdownParser = (markdown: string) => Promise<string>
|
||||||
|
|
||||||
export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
|
export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
|
||||||
name: "Marked",
|
name: "Marked",
|
||||||
init: () => {
|
init: (props: { nativeParser?: NativeMarkdownParser }) => {
|
||||||
return marked.use(
|
const jsParser = marked.use(
|
||||||
{
|
{
|
||||||
renderer: {
|
renderer: {
|
||||||
link({ href, title, text }) {
|
link({ href, title, text }) {
|
||||||
@@ -407,5 +493,18 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (props.nativeParser) {
|
||||||
|
const nativeParser = props.nativeParser
|
||||||
|
return {
|
||||||
|
async parse(markdown: string): Promise<string> {
|
||||||
|
const html = await nativeParser(markdown)
|
||||||
|
const withMath = renderMathExpressions(html)
|
||||||
|
return highlightCodeBlocks(withMath)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsParser
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user