feat: add parseYamlSafe utility (#7706)

* feat: add parseYamlSafe utility function and refactor YAML parsing in components

* fix

* fix

* fix: preserve malformed YAML in profile editors

---------

Co-authored-by: wonfen <eiwonfen@gmail.com>
This commit is contained in:
StarsSail
2026-08-09 06:02:47 +00:00
committed by GitHub
co-authored by wonfen
parent ed4e55a947
commit 117bac8110
4 changed files with 131 additions and 52 deletions
+40 -19
View File
@@ -65,6 +65,7 @@ import { useThemeMode } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import type { MonacoEditorInstance } from '@/types/monaco'
import getSystem from '@/utils/get-system'
import { parseYamlSafe } from '@/utils/yaml'
interface Props {
proxiesUid: string
@@ -181,6 +182,7 @@ export const GroupsEditorViewer = (props: Props) => {
const [prependSeq, setPrependSeq] = useState<IProxyGroupConfig[]>([])
const [appendSeq, setAppendSeq] = useState<IProxyGroupConfig[]>([])
const [deleteSeq, setDeleteSeq] = useState<string[]>([])
const hasLoadedSeqConfigRef = useRef(false)
const filteredPrependSeq = useMemo(
() => prependSeq.filter((group) => match(group.name)),
@@ -327,8 +329,17 @@ export const GroupsEditorViewer = (props: Props) => {
}
}
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
const obj = yaml.load(data) as ISeqProfileConfig | null
const obj = parseYamlSafe(data) as ISeqProfileConfig | null | undefined
setPrevData(data)
setCurrData(data)
if (obj === undefined) {
setVisualization(false)
return
}
setPrependSeq(obj?.prepend || [])
setAppendSeq(obj?.append || [])
@@ -342,17 +353,22 @@ export const GroupsEditorViewer = (props: Props) => {
}
return normalized
})
setPrevData(data)
setCurrData(data)
hasLoadedSeqConfigRef.current = true
}, [property])
useEffect(() => {
if (currData === '' || visualization !== true) {
const handleVisualizationToggle = () => {
if (visualization) {
setVisualization(false)
return
}
const obj = yaml.load(currData) as ISeqProfileConfig | null
const obj = parseYamlSafe(currData) as ISeqProfileConfig | null | undefined
if (obj === undefined) {
hasLoadedSeqConfigRef.current = false
return
}
hasLoadedSeqConfigRef.current = true
startTransition(() => {
setPrependSeq(obj?.prepend ?? [])
setAppendSeq(obj?.append ?? [])
@@ -367,12 +383,17 @@ export const GroupsEditorViewer = (props: Props) => {
return normalized
})
})
}, [currData, visualization])
setVisualization(true)
}
// 优化:异步处理大数据yaml.dump,避免UI卡死
useEffect(() => {
if (prependSeq && appendSeq && deleteSeq) {
if (hasLoadedSeqConfigRef.current && prependSeq && appendSeq && deleteSeq) {
const serialize = () => {
if (!hasLoadedSeqConfigRef.current) {
return
}
try {
setCurrData(buildGroupsYaml(prependSeq, appendSeq, deleteSeq))
} catch (e) {
@@ -391,13 +412,15 @@ export const GroupsEditorViewer = (props: Props) => {
const fetchProxyPolicy = useCallback(async () => {
const data = await readProfileFile(profileUid)
const proxiesData = await readProfileFile(proxiesUid)
const originGroupsObj = yaml.load(data) as {
const originGroupsObj = parseYamlSafe(data) as {
'proxy-groups': IProxyGroupConfig[]
} | null
const originProxiesObj = yaml.load(data) as { proxies: [] } | null
const originProxiesObj = parseYamlSafe(data) as { proxies: [] } | null
const originProxies = originProxiesObj?.proxies || []
const moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null
const moreProxiesObj = parseYamlSafe(
proxiesData,
) as ISeqProfileConfig | null
const morePrependProxies = moreProxiesObj?.prepend || []
const moreAppendProxies = moreProxiesObj?.append || []
const moreDeleteProxies = normalizeDeleteSeq(moreProxiesObj?.delete)
@@ -437,21 +460,21 @@ export const GroupsEditorViewer = (props: Props) => {
const mergeData = await readProfileFile(mergeUid)
const globalMergeData = await readProfileFile('Merge')
const originGroupsObj = yaml.load(data) as {
const originGroupsObj = parseYamlSafe(data) as {
'proxy-groups': IProxyGroupConfig[]
} | null
const originProviderObj = yaml.load(data) as {
const originProviderObj = parseYamlSafe(data) as {
'proxy-providers': Record<string, unknown>
} | null
const originProvider = originProviderObj?.['proxy-providers'] || {}
const moreProviderObj = yaml.load(mergeData) as {
const moreProviderObj = parseYamlSafe(mergeData) as {
'proxy-providers': Record<string, unknown>
} | null
const moreProvider = moreProviderObj?.['proxy-providers'] || {}
const globalProviderObj = yaml.load(globalMergeData) as {
const globalProviderObj = parseYamlSafe(globalMergeData) as {
'proxy-providers': Record<string, unknown>
} | null
const globalProvider = globalProviderObj?.['proxy-providers'] || {}
@@ -536,9 +559,7 @@ export const GroupsEditorViewer = (props: Props) => {
<Button
variant="contained"
size="small"
onClick={() => {
setVisualization((prev) => !prev)
}}
onClick={handleVisualizationToggle}
>
{visualization
? t('shared.editorModes.advanced')
@@ -48,6 +48,7 @@ import { useThemeMode } from '@/services/states'
import type { MonacoEditorInstance } from '@/types/monaco'
import getSystem from '@/utils/get-system'
import parseUri from '@/utils/uri-parser'
import { parseYamlSafe } from '@/utils/yaml'
interface Props {
profileUid: string
@@ -72,6 +73,7 @@ export const ProxiesEditorViewer = (props: Props) => {
const [prependSeq, setPrependSeq] = useState<IProxyConfig[]>([])
const [appendSeq, setAppendSeq] = useState<IProxyConfig[]>([])
const [deleteSeq, setDeleteSeq] = useState<string[]>([])
const hasLoadedSeqConfigRef = useRef(false)
// 节点的 name 会被用作 SortableContext 的 item id、React key 以及拖拽排序的
// 依据。当 name 为空/null(例如高级模式下粘贴了缺少 name 的节点)时,
@@ -275,7 +277,7 @@ export const ProxiesEditorViewer = (props: Props) => {
const fetchProfile = useCallback(async () => {
const data = await readProfileFile(profileUid)
const originProxiesObj = yaml.load(data) as {
const originProxiesObj = parseYamlSafe(data) as {
proxies: IProxyConfig[]
} | null
@@ -283,36 +285,58 @@ export const ProxiesEditorViewer = (props: Props) => {
}, [profileUid])
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
const obj = yaml.load(data) as ISeqProfileConfig | null
const obj = parseYamlSafe(data) as ISeqProfileConfig | null | undefined
setPrevData(data)
setCurrData(data)
if (obj === undefined) {
setVisualization(false)
return
}
setPrependSeq(obj?.prepend || [])
setAppendSeq(obj?.append || [])
setDeleteSeq(obj?.delete || [])
setPrevData(data)
setCurrData(data)
hasLoadedSeqConfigRef.current = true
}, [property])
useEffect(() => {
if (currData === '' || visualization !== true) {
const handleVisualizationToggle = () => {
if (visualization) {
setVisualization(false)
return
}
const obj = yaml.load(currData) as ISeqProfileConfig | null
const obj = parseYamlSafe(currData) as ISeqProfileConfig | null | undefined
if (obj === undefined) {
hasLoadedSeqConfigRef.current = false
return
}
hasLoadedSeqConfigRef.current = true
startTransition(() => {
setPrependSeq(obj?.prepend ?? [])
setAppendSeq(obj?.append ?? [])
setDeleteSeq(obj?.delete ?? [])
})
}, [currData, visualization])
setVisualization(true)
}
useEffect(() => {
if (!(prependSeq && appendSeq && deleteSeq)) {
if (
!hasLoadedSeqConfigRef.current ||
!(prependSeq && appendSeq && deleteSeq)
) {
return
}
const serialize = () => {
if (!hasLoadedSeqConfigRef.current) {
return
}
try {
setCurrData(
yaml.dump(
@@ -386,9 +410,7 @@ export const ProxiesEditorViewer = (props: Props) => {
<Button
variant="contained"
size="small"
onClick={() => {
setVisualization((prev) => !prev)
}}
onClick={handleVisualizationToggle}
>
{visualization
? t('shared.editorModes.advanced')
+32 -20
View File
@@ -52,6 +52,7 @@ import type { TranslationKey } from '@/types/generated/i18n-keys'
import type { MonacoEditorInstance } from '@/types/monaco'
import getSystem from '@/utils/get-system'
import { isValidIpCidr } from '@/utils/network'
import { parseYamlSafe } from '@/utils/yaml'
interface Props {
groupsUid: string
@@ -409,29 +410,42 @@ export const RulesEditorViewer = (props: Props) => {
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
const obj = yaml.load(data) as ISeqProfileConfig | null
const obj = parseYamlSafe(data) as ISeqProfileConfig | null | undefined
setPrevData(data)
setCurrData(data)
if (obj === undefined) {
setVisualization(false)
return
}
setPrependSeq(obj?.prepend || [])
setAppendSeq(obj?.append || [])
setDeleteSeq(obj?.delete || [])
setPrevData(data)
setCurrData(data)
hasLoadedSeqConfigRef.current = true
}, [property])
useEffect(() => {
if (currData === '' || visualization !== true) {
const handleVisualizationToggle = () => {
if (visualization) {
setVisualization(false)
return
}
const obj = yaml.load(currData) as ISeqProfileConfig | null
const obj = parseYamlSafe(currData) as ISeqProfileConfig | null | undefined
if (obj === undefined) {
hasLoadedSeqConfigRef.current = false
return
}
hasLoadedSeqConfigRef.current = true
startTransition(() => {
setPrependSeq(obj?.prepend ?? [])
setAppendSeq(obj?.append ?? [])
setDeleteSeq(obj?.delete ?? [])
})
}, [currData, visualization])
setVisualization(true)
}
// 优化:异步处理大数据yaml.dump,避免UI卡死
useEffect(() => {
@@ -482,13 +496,13 @@ export const RulesEditorViewer = (props: Props) => {
const mergeData = await readProfileFile(mergeUid) // merge配置文件
const globalMergeData = await readProfileFile('Merge') // global merge配置文件
const rulesObj = yaml.load(data) as { rules: [] } | null
const rulesObj = parseYamlSafe(data) as { rules: [] } | null
const originGroupsObj = yaml.load(data) as {
const originGroupsObj = parseYamlSafe(data) as {
'proxy-groups': IProxyGroupConfig[]
} | null
const originGroups = originGroupsObj?.['proxy-groups'] || []
const moreGroupsObj = yaml.load(groupsData) as ISeqProfileConfig | null
const moreGroupsObj = parseYamlSafe(groupsData) as ISeqProfileConfig | null
const rawPrependGroups = moreGroupsObj?.['prepend']
const morePrependGroups = Array.isArray(rawPrependGroups)
? (rawPrependGroups as IProxyGroupConfig[])
@@ -514,29 +528,29 @@ export const RulesEditorViewer = (props: Props) => {
moreAppendGroups,
)
const originRuleSetObj = yaml.load(data) as {
const originRuleSetObj = parseYamlSafe(data) as {
'rule-providers': Record<string, unknown>
} | null
const originRuleSet = originRuleSetObj?.['rule-providers'] || {}
const moreRuleSetObj = yaml.load(mergeData) as {
const moreRuleSetObj = parseYamlSafe(mergeData) as {
'rule-providers': Record<string, unknown>
} | null
const moreRuleSet = moreRuleSetObj?.['rule-providers'] || {}
const globalRuleSetObj = yaml.load(globalMergeData) as {
const globalRuleSetObj = parseYamlSafe(globalMergeData) as {
'rule-providers': Record<string, unknown>
} | null
const globalRuleSet = globalRuleSetObj?.['rule-providers'] || {}
const ruleSet = Object.assign({}, originRuleSet, moreRuleSet, globalRuleSet)
const originSubRuleObj = yaml.load(data) as {
const originSubRuleObj = parseYamlSafe(data) as {
'sub-rules': Record<string, unknown>
} | null
const originSubRule = originSubRuleObj?.['sub-rules'] || {}
const moreSubRuleObj = yaml.load(mergeData) as {
const moreSubRuleObj = parseYamlSafe(mergeData) as {
'sub-rules': Record<string, unknown>
} | null
const moreSubRule = moreSubRuleObj?.['sub-rules'] || {}
const globalSubRuleObj = yaml.load(globalMergeData) as {
const globalSubRuleObj = parseYamlSafe(globalMergeData) as {
'sub-rules': Record<string, unknown>
} | null
const globalSubRule = globalSubRuleObj?.['sub-rules'] || {}
@@ -609,9 +623,7 @@ export const RulesEditorViewer = (props: Props) => {
<Button
variant="contained"
size="small"
onClick={() => {
setVisualization((prev) => !prev)
}}
onClick={handleVisualizationToggle}
>
{visualization
? t('shared.editorModes.advanced')
+24
View File
@@ -0,0 +1,24 @@
import { load } from 'js-yaml'
import { debugLog } from './debug'
/**
* 空文档返回 null,解析失败返回 undefined,且不抛出错误。
*/
export function parseYamlSafe(input: string) {
if (
input
.split(/\r?\n/u)
.every((line) => line.trim() === '' || line.trimStart().startsWith('#'))
) {
return null
}
try {
const parsedYaml = load(input)
return parsedYaml ?? null
} catch (e) {
debugLog('parseYamlSafe failed', e)
return undefined
}
}