Files
static-php-cli/docs/.vitepress/components/DepsMap.vue
2026-04-20 11:30:40 +08:00

590 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="deps-map">
<!-- Warning if data missing -->
<div v-if="missing" class="warning custom-block" style="margin-bottom: 16px">
<p class="custom-block-title">WARNING</p>
<p>Dependency data not generated yet. Run <code>bin/spc dev:gen-deps-data</code> to generate it.</p>
</div>
<template v-else>
<!-- Toolbar -->
<div class="deps-toolbar">
<input
class="deps-search"
v-model="searchText"
:placeholder="i18n.searchPlaceholder"
@input="selectedPkg = null"
/>
<div class="deps-filters">
<div class="filter-group">
<button
v-for="t in typeOptions"
:key="t.value"
:class="['filter-btn', { active: selectedType === t.value }]"
@click="selectedType = t.value; selectedPkg = null"
>{{ t.label }}</button>
</div>
<div class="filter-group">
<button
v-for="p in platformOptions"
:key="p.value"
:class="['filter-btn', { active: selectedPlatform === p.value }]"
@click="selectedPlatform = p.value"
>{{ p.label }}</button>
</div>
</div>
</div>
<div class="deps-layout">
<!-- Package list -->
<div class="deps-list">
<div v-if="filteredPackages.length === 0" class="no-results">{{ i18n.noResults }}</div>
<div
v-for="pkg in filteredPackages"
:key="pkg.name"
:class="['pkg-item', { selected: selectedPkg === pkg.name }]"
@click="selectPkg(pkg.name)"
>
<span class="pkg-name">{{ pkg.name }}</span>
<span :class="['pkg-badge', pkg.type === 'php-extension' ? 'badge-ext' : 'badge-lib']">
{{ typeLabel(pkg.type) }}
</span>
</div>
</div>
<!-- Detail panel -->
<div class="deps-detail" v-if="selectedPkg && selectedPkgData">
<h3 class="detail-title">{{ selectedPkg }}</h3>
<span :class="['detail-type-badge', selectedPkgData.type === 'php-extension' ? 'badge-ext' : 'badge-lib']">
{{ typeLabel(selectedPkgData.type) }}
</span>
<!-- OS support for extensions -->
<div v-if="selectedPkgData.type === 'php-extension' && selectedPkgData.os" class="detail-section">
<div class="detail-label">{{ i18n.supportedPlatforms }}</div>
<div class="detail-chips">
<span v-for="os in selectedPkgData.os" :key="os" class="chip chip-os">{{ os }}</span>
</div>
</div>
<!-- Required deps -->
<div class="detail-section">
<div class="detail-label">{{ i18n.requiredDeps }}</div>
<div class="detail-chips" v-if="currentDepends.length > 0">
<span
v-for="dep in currentDepends"
:key="dep"
:class="['chip', 'chip-required', { clickable: packages[dep] }]"
@click="packages[dep] && selectPkg(dep)"
>{{ dep }}</span>
</div>
<div v-else class="no-deps">{{ i18n.none }}</div>
</div>
<!-- Suggested deps -->
<div class="detail-section">
<div class="detail-label">{{ i18n.suggestedDeps }}</div>
<div class="detail-chips" v-if="currentSuggests.length > 0">
<span
v-for="dep in currentSuggests"
:key="dep"
:class="['chip', 'chip-suggested', { clickable: packages[dep] }]"
@click="packages[dep] && selectPkg(dep)"
>{{ dep }}</span>
</div>
<div v-else class="no-deps">{{ i18n.none }}</div>
</div>
<!-- Required by -->
<div class="detail-section">
<div class="detail-label">{{ i18n.requiredBy }}</div>
<div class="detail-chips" v-if="requiredBy.length > 0">
<span
v-for="dep in requiredBy"
:key="dep"
class="chip chip-rev clickable"
@click="selectPkg(dep)"
>{{ dep }}</span>
</div>
<div v-else class="no-deps">{{ i18n.none }}</div>
</div>
<!-- Suggested by -->
<div class="detail-section">
<div class="detail-label">{{ i18n.suggestedBy }}</div>
<div class="detail-chips" v-if="suggestedBy.length > 0">
<span
v-for="dep in suggestedBy"
:key="dep"
class="chip chip-rev-sug clickable"
@click="selectPkg(dep)"
>{{ dep }}</span>
</div>
<div v-else class="no-deps">{{ i18n.none }}</div>
</div>
<!-- Mermaid graph -->
<div class="detail-section" v-if="hasMermaid">
<div class="detail-label">{{ i18n.depGraph }}</div>
<div ref="mermaidEl" class="mermaid-wrap"></div>
</div>
</div>
<!-- Empty state -->
<div class="deps-detail deps-detail-empty" v-else>
<p>{{ i18n.selectHint }}</p>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useData } from 'vitepress'
import { data as depsData } from '../deps-map.data.js'
const { lang, isDark } = useData()
const missing = depsData.missing
const packages = depsData.packages ?? {}
// --- i18n ---
const I18N = {
zh: {
searchPlaceholder: '搜索包名...',
noResults: '未找到包。',
selectHint: '← 选择一个包以查看其依赖关系。',
supportedPlatforms: '支持的平台',
requiredDeps: '必需依赖',
suggestedDeps: '可选依赖',
requiredBy: '被哪些包依赖',
suggestedBy: '被哪些包建议',
depGraph: '依赖关系图',
none: '无',
},
en: {
searchPlaceholder: 'Search package...',
noResults: 'No packages found.',
selectHint: '← Select a package to view its dependency details.',
supportedPlatforms: 'Supported Platforms',
requiredDeps: 'Required Dependencies',
suggestedDeps: 'Suggested Dependencies',
requiredBy: 'Required By',
suggestedBy: 'Suggested By',
depGraph: 'Dependency Graph',
none: 'None',
},
}
const i18n = computed(() => I18N[lang.value] ?? I18N.en)
// --- State ---
const searchText = ref('')
const selectedType = ref('all')
const selectedPlatform = ref('linux')
const selectedPkg = ref(null)
const mermaidEl = ref(null)
// --- Options ---
const typeOptions = computed(() => [
{ value: 'all', label: lang.value === 'zh' ? '全部' : 'All' },
{ value: 'php-extension', label: lang.value === 'zh' ? '扩展' : 'Extensions' },
{ value: 'library', label: lang.value === 'zh' ? '库' : 'Libraries' },
])
const platformOptions = [
{ value: 'linux', label: 'Linux' },
{ value: 'macos', label: 'macOS' },
{ value: 'windows', label: 'Windows' },
]
function typeLabel(type) {
if (type === 'php-extension') return 'ext'
if (type === 'library') return 'lib'
return type
}
// --- Package list ---
const allPackages = computed(() =>
Object.entries(packages).map(([name, data]) => ({ name, ...data }))
)
const filteredPackages = computed(() => {
let list = allPackages.value
if (selectedType.value !== 'all') {
list = list.filter(p => p.type === selectedType.value)
}
if (searchText.value.trim()) {
const q = searchText.value.trim().toLowerCase()
list = list.filter(p => p.name.toLowerCase().includes(q))
}
return list
})
// --- Selected package ---
const selectedPkgData = computed(() =>
selectedPkg.value ? (packages[selectedPkg.value] ?? null) : null
)
const currentDepends = computed(() =>
selectedPkgData.value?.platforms?.[selectedPlatform.value]?.depends ?? []
)
const currentSuggests = computed(() =>
selectedPkgData.value?.platforms?.[selectedPlatform.value]?.suggests ?? []
)
const requiredBy = computed(() => {
if (!selectedPkg.value) return []
const name = selectedPkg.value
const plat = selectedPlatform.value
return Object.entries(packages)
.filter(([, d]) => (d.platforms?.[plat]?.depends ?? []).includes(name))
.map(([n]) => n)
})
const suggestedBy = computed(() => {
if (!selectedPkg.value) return []
const name = selectedPkg.value
const plat = selectedPlatform.value
return Object.entries(packages)
.filter(([, d]) => (d.platforms?.[plat]?.suggests ?? []).includes(name))
.map(([n]) => n)
})
// --- Mermaid ---
const hasMermaid = computed(
() => currentDepends.value.length > 0 || currentSuggests.value.length > 0
)
function buildMermaidCode() {
const deps = currentDepends.value
const sugs = currentSuggests.value
if (deps.length === 0 && sugs.length === 0) return ''
const safe = s => s.replace(/[^a-zA-Z0-9_]/g, '_')
const root = safe(selectedPkg.value)
const lines = ['graph LR', ` ${root}["${selectedPkg.value}"]`]
const MAX_DEPTH = 6 // max hops from root
const MAX_CHILDREN = 6 // per-node child limit for levels 2+
const visitedNodes = new Set([selectedPkg.value])
const visitedEdges = new Set()
const queue = []
// Level 1: direct required deps — solid arrows, no child limit
for (const dep of deps) {
const ek = `${selectedPkg.value}\0${dep}`
if (!visitedEdges.has(ek)) {
visitedEdges.add(ek)
lines.push(` ${root} --> ${safe(dep)}["${dep}"]`)
}
if (!visitedNodes.has(dep)) {
visitedNodes.add(dep)
queue.push({ name: dep, depth: 1 })
}
}
// BFS: levels 2MAX_DEPTH — dotted arrows, capped children per node
while (queue.length > 0) {
const { name, depth } = queue.shift()
if (depth >= MAX_DEPTH) continue
const children = packages[name]?.platforms?.[selectedPlatform.value]?.depends ?? []
for (const child of children.slice(0, MAX_CHILDREN)) {
const ek = `${name}\0${child}`
if (!visitedEdges.has(ek)) {
visitedEdges.add(ek)
lines.push(` ${safe(name)} -.-> ${safe(child)}["${child}"]`)
}
if (!visitedNodes.has(child)) {
visitedNodes.add(child)
queue.push({ name: child, depth: depth + 1 })
}
}
}
// Suggested deps from root (single level, optional dotted)
for (const sug of sugs) {
lines.push(` ${root} -. optional .-> ${safe(sug)}["${sug}"]`)
}
return lines.join('\n')
}
let mermaidLib = null
async function renderMermaid() {
if (!mermaidEl.value || !hasMermaid.value) return
const code = buildMermaidCode()
if (!code) return
try {
if (!mermaidLib) {
const m = await import('mermaid')
mermaidLib = m.default
}
mermaidLib.initialize({
startOnLoad: false,
theme: isDark.value ? 'dark' : 'default',
securityLevel: 'loose',
})
const id = 'deps-graph-' + Date.now()
const { svg } = await mermaidLib.render(id, code)
if (mermaidEl.value) {
mermaidEl.value.innerHTML = svg
}
} catch {
if (mermaidEl.value) {
mermaidEl.value.innerHTML = `<pre class="mermaid-fallback">${code}</pre>`
}
}
}
function selectPkg(name) {
selectedPkg.value = name
}
watch([selectedPkg, selectedPlatform, isDark], async () => {
await nextTick()
await renderMermaid()
})
onMounted(async () => {
await nextTick()
await renderMermaid()
})
</script>
<style scoped>
.deps-map {
font-size: 14px;
}
/* Toolbar */
.deps-toolbar {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.deps-search {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 14px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
outline: none;
box-sizing: border-box;
}
.deps-search:focus {
border-color: var(--vp-c-brand);
}
.deps-filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
gap: 4px;
}
.filter-btn {
padding: 4px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 20px;
background: var(--vp-c-bg);
color: var(--vp-c-text-2);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.filter-btn.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: #fff;
}
/* Layout */
.deps-layout {
display: flex;
gap: 16px;
min-height: 400px;
}
/* Package list */
.deps-list {
width: 260px;
flex-shrink: 0;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow-y: auto;
max-height: 600px;
}
.no-results {
padding: 16px;
color: var(--vp-c-text-3);
text-align: center;
}
.pkg-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--vp-c-divider);
transition: background 0.1s;
}
.pkg-item:last-child {
border-bottom: none;
}
.pkg-item:hover {
background: var(--vp-c-bg-soft);
}
.pkg-item.selected {
background: var(--vp-c-brand-soft);
}
.pkg-name {
font-family: var(--vp-font-family-mono);
font-size: 13px;
word-break: break-all;
}
.pkg-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
flex-shrink: 0;
margin-left: 6px;
}
/* Detail panel */
.deps-detail {
flex: 1;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
overflow-y: auto;
max-height: 600px;
}
.deps-detail-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--vp-c-text-3);
}
.detail-title {
margin: 0 0 8px 0;
font-size: 16px;
font-family: var(--vp-font-family-mono);
}
.detail-type-badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
display: inline-block;
margin-bottom: 16px;
}
.detail-section {
margin-bottom: 16px;
}
.detail-label {
font-size: 12px;
font-weight: 600;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.detail-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.no-deps {
color: var(--vp-c-text-3);
font-size: 13px;
}
/* Chips */
.chip {
font-family: var(--vp-font-family-mono);
font-size: 12px;
padding: 3px 10px;
border-radius: 12px;
border: 1px solid transparent;
display: inline-block;
}
.chip.clickable {
cursor: pointer;
transition: opacity 0.15s;
}
.chip.clickable:hover {
opacity: 0.75;
}
.chip-required {
background: var(--vp-c-danger-soft);
border-color: var(--vp-c-danger-1);
color: var(--vp-c-danger-1);
}
.chip-suggested {
background: var(--vp-c-warning-soft);
border-color: var(--vp-c-warning-1);
color: var(--vp-c-warning-1);
}
.chip-rev {
background: var(--vp-c-brand-soft);
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
}
.chip-rev-sug {
background: var(--vp-c-bg-soft);
border-color: var(--vp-c-divider);
color: var(--vp-c-text-2);
}
.chip-os {
background: var(--vp-c-bg-soft);
border-color: var(--vp-c-divider);
color: var(--vp-c-text-1);
}
/* Badges */
.badge-ext {
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-1);
}
.badge-lib {
background: var(--vp-c-tip-soft);
color: var(--vp-c-tip-1);
}
/* Mermaid */
.mermaid-wrap {
overflow-x: auto;
padding: 8px 0;
}
.mermaid-fallback {
font-size: 12px;
white-space: pre-wrap;
}
</style>