跳转至

scripts\gen-wiki-site.ps1

源码路径:scripts\gen-wiki-site.ps1 · 305 行

# 把源码 wiki 整理成可发布的 MkDocs 站点(用于 zeroling.nfarms.cn/docs/nfarms)。
#
# 为什么单独一个脚本,而不是直接在 wiki\ 上放 mkdocs.yml:
#   · wiki\ 是**安装包内容**(installer\nfarms.iss 把 wiki\ 打进 {app}\wiki),
#     往里加 mkdocs.yml/site/ 等于把构建产物塞进发布物;
#   · 站点要的是一棵树(content\)+ 一份导航,而 wiki\ 的 README 是给人看的索引 ——
#     两者的"目录"形态不同,但内容必须同源,所以由本脚本从 wiki\ 派生。
#
# 产物:build\wiki-site\{mkdocs.yml, content\}
# 之后由 scripts\deploy-wiki-site.ps1 上传到服务器构建并原子替换
# /opt/Cy/nfarms/zeroling/docs/nfarms。
#
# 注意(编码):本文件必须保存为 **UTF-8 with BOM**。PowerShell 5.1 读取无 BOM 脚本时
# 按系统 ANSI 代码页(GBK)解码,中文会变乱码并被写进导航标题。

[CmdletBinding()]
param([string]$OutDir = 'build\wiki-site')

$ErrorActionPreference = 'Stop'
$ws = Split-Path -Parent $PSScriptRoot
$wiki = Join-Path $ws 'wiki'
$out = Join-Path $ws $OutDir
$content = Join-Path $out 'content'

if (-not (Test-Path $wiki)) { throw "缺少 wiki 目录:$wiki(先跑 scripts\gen-wiki.ps1)" }

# **不发布到公网的文档**。
#
# 部署脚本里有本机私钥路径与服务器账号;仓库的 wiki\ 仍然收录它(装机版需要能查),
# 但公网站点不该把这些内部路径摆出来。私钥本身不在本仓库,泄露的只是路径与用户名,
# 仍属不必要的信息暴露。
$deny = @('scripts_deploy-wiki-site.ps1.md')

# 每次全量重建:站点是派生物,残留旧文件会让"删掉的文档"继续在线。
if (Test-Path $out) { Remove-Item $out -Recurse -Force }
New-Item -ItemType Directory -Force -Path $content | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $content 'code') | Out-Null

# ---------------------------------------------------------------- 解析 wiki 索引
# 从 wiki\README.md 反推分组与条目 —— 一处真相,不在两个地方各算一遍。
# 每个分组是 `## <组名>`,组下有 `| [显示名](code/xxx.md) | 行数 |`。
$groups = New-Object System.Collections.Generic.List[object]
$current = $null
$readmeText = [System.IO.File]::ReadAllText((Join-Path $wiki 'README.md'), [System.Text.Encoding]::UTF8)
foreach ($line in ($readmeText -split "`r?`n")) {
    if ($line -match '^##\s+(.+?)\s*$') {
        $name = $Matches[1]
        if ($name -eq '其他' -or $name -eq '覆盖面') { $current = $null; continue }
        $current = [pscustomobject]@{ Name = $name; Items = (New-Object System.Collections.Generic.List[object]) }
        $groups.Add($current)
        continue
    }
    if ($null -ne $current -and $line -match '^\|\s*\[(?<label>[^\]]+)\]\((?<doc>code/[^)]+)\)\s*\|\s*(?<n>\d+)\s*\|') {
        $docName = Split-Path $Matches['doc'] -Leaf
        if ($deny -contains $docName) { continue }
        $current.Items.Add([pscustomobject]@{
            Label = $Matches['label']
            Doc   = $Matches['doc']
            Lines = [int]$Matches['n']
        })
    }
}

# ---------------------------------------------------------------- 复制内容
$copied = 0
foreach ($g in $groups) {
    foreach ($it in $g.Items) {
        $leaf = Split-Path $it.Doc -Leaf
        Copy-Item (Join-Path $wiki ('code\' + $leaf)) (Join-Path $content ('code\' + $leaf))
        $copied++
    }
}
$handWritten = @(Get-ChildItem $wiki -File -Filter *.md | Where-Object { $_.Name -ne 'README.md' })
foreach ($f in $handWritten) { Copy-Item $f.FullName (Join-Path $content $f.Name) }

# ---------------------------------------------------------------- 首页
# 数字全部从**实际发布集合**重算,不再照抄 README。
# 照抄已经踩过一次:排除一份文档后首页仍写着 163 份,和导航对不上。
$allItems = @()
foreach ($g in $groups) { foreach ($it in $g.Items) { $allItems += $it } }
$handLines = 0
foreach ($f in $handWritten) {
    $handLines += ([System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8) -split "`n").Count
}
$totalFiles = $allItems.Count + $handWritten.Count
$totalLines = ($allItems | Measure-Object Lines -Sum).Sum + $handLines

function TopOf([string]$name) {
    # 组名可能是单段(scripts、installer、android)也可能是多段(crates\nfarms-core)。
    # 单段的顶层就是它自己 —— 早先只按"含反斜杠"判断,于是 scripts 与 installer
    # 全被归到根目录,覆盖面表和导航都少了两个分区。
    if ([string]::IsNullOrEmpty($name) -or $name -eq '(根目录)') { return '(根目录)' }
    if ($name -match '^([^\\]+)\\') { return $Matches[1] }
    return $name
}

function ShortLabel([string]$label) {
    # App 模块里的文档标签是"模块内相对路径",形如
    #   src/main/java/cn/nfarms/iot/transport/Transport.kt
    # 侧栏宽度有限,这段话会被截成 `src/main/java/cn/nfarms/iot/...`,
    # 于是同一模块下所有 .kt 在导航里长得一模一样 —— 文件名恰好被截掉。
    # 所以只保留"包内相对路径"(transport/Transport.kt),测试文件另加 test/ 前缀区分。
    $l = $label -replace '\\', '/'
    if ($l -notmatch '/|^src') { return $l }          # build.gradle.kts 这类保持原样
    if ($l -match 'java/cn/nfarms/[^/]+/(.+)$') {
        $rest = $Matches[1]
        if ($l -match '/src/test/') { return "test/$rest" }
        return $rest
    }
    if ($l -match '^(?:.*/)?src/test/(.+)$') { return "test/" + $Matches[1] }
    if ($l -match '^(?:.*/)?src/main/(.+)$') { return $Matches[1] }
    return $l
}
$topOrder = @('crates', 'apps', 'android', 'scripts', 'installer', '(根目录)')

$sb = New-Object System.Text.StringBuilder
[void]$sb.AppendLine('# NFarms 知识库')
[void]$sb.AppendLine()
[void]$sb.AppendLine('> **NFarms HUB** 的源码知识库:桌面端(`crates/`,Rust + egui)与移动端')
[void]$sb.AppendLine('> (`apps/`,五个 Android App)的**逐文件文档**,外加各轮的开发过程记录。')
[void]$sb.AppendLine('>')
[void]$sb.AppendLine('> 文档由 `scripts/gen-wiki.ps1` 从源码生成。改内容要改源码再重跑 ——')
[void]$sb.AppendLine('> `code/` 下的副本不要手改,下次生成就没了。')
[void]$sb.AppendLine()
[void]$sb.AppendLine('| | |')
[void]$sb.AppendLine('|---|---|')
[void]$sb.AppendLine("| 文档数 | $totalFiles |")
[void]$sb.AppendLine("| 总行数 | $totalLines |")
[void]$sb.AppendLine("| 建站时间 | $(Get-Date -Format 'yyyy-MM-dd HH:mm') |")
[void]$sb.AppendLine()
[void]$sb.AppendLine('## 为什么有这份库')
[void]$sb.AppendLine()
[void]$sb.AppendLine('菜单能点、按钮不报错,都不等于功能对。这个项目的验证方式经历过一次转变:')
[void]$sb.AppendLine('起初只做到「编译通过 + APK 能装」,直到把模拟器跑起来才发现**五个 App 里没有一个的界面被人看过**;')
[void]$sb.AppendLine('而且有 10 个 bug 属于"静默错误"—— 被裁掉的按钮、写错路径的下载、没被拉取的工单,')
[void]$sb.AppendLine('全都安安静静地"成功了"。逐轮细节见开发过程记录。')
[void]$sb.AppendLine()
[void]$sb.AppendLine('## 覆盖面')
[void]$sb.AppendLine()
[void]$sb.AppendLine('| 区域 | 文件 | 行数 |')
[void]$sb.AppendLine('|---|---:|---:|')
foreach ($t in $topOrder) {
    # 按**组名**归区,不能按文档路径归区:
    # 文档路径长这样 `code/crates_nfarms-core_src_kb.rs.md` —— 拍平过、没有反斜杠,
    # 拿它判断顶层会把所有文件都算成"根目录"(踩过)。
    $inTop = @()
    foreach ($g in $groups) {
        if ((TopOf $g.Name) -eq $t) { foreach ($it in $g.Items) { $inTop += $it } }
    }
    $n = $inTop.Count
    if ($n -eq 0) { continue }
    $ln = ($inTop | Measure-Object Lines -Sum).Sum
    $label = $t
    if ($label -eq '(根目录)') { $label = '根目录' }
    [void]$sb.AppendLine("| $label | $n | $ln |")
}
[void]$sb.AppendLine()
if ($handWritten.Count -gt 0) {
    [void]$sb.AppendLine('## 开发过程记录')
    [void]$sb.AppendLine()
    foreach ($h in ($handWritten | Sort-Object Name)) {
        $base = [System.IO.Path]::GetFileNameWithoutExtension($h.Name)
        [void]$sb.AppendLine("- [$base]($($h.Name))")
    }
    [void]$sb.AppendLine()
}
[void]$sb.AppendLine('## 按文件浏览')
[void]$sb.AppendLine()
foreach ($g in ($groups | Sort-Object Name)) {
    if ($g.Items.Count -eq 0) { continue }
    $sum = ($g.Items | Measure-Object Lines -Sum).Sum
    # 与导航同样处理:单段组名(scripts、android)的标题不该和上一级一模一样。
    $heading = $g.Name -replace '\\', ' / '
    if ($g.Name -match '^[^\\]+$') { $heading = "$heading(直接文件)" }
    [void]$sb.AppendLine("### $heading")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('| 文件 | 行数 |')
    [void]$sb.AppendLine('|---|---:|')
    foreach ($it in ($g.Items | Sort-Object Label)) {
        [void]$sb.AppendLine("| [$((ShortLabel $it.Label))]($($it.Doc)) | $($it.Lines) |")
    }
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("小计:$sum 行")
    [void]$sb.AppendLine()
}
[System.IO.File]::WriteAllText((Join-Path $content 'index.md'), $sb.ToString(), (New-Object System.Text.UTF8Encoding($false)))

# ---------------------------------------------------------------- mkdocs.yml
function Y([string]$s) {
    # YAML 单引号字符串:内部的 ' 写成两个。
    # 不用双引号:标签里有反斜杠(如 crates\nfarms-core),双引号里 \n 会被当成换行。
    return "'" + ($s -replace "'", "''") + "'"
}

$mk = New-Object System.Text.StringBuilder
[void]$mk.AppendLine('# NFarms 知识库站点 —— 由 scripts\gen-wiki-site.ps1 生成,勿手改')
[void]$mk.AppendLine('# 部署目标:https://zeroling.nfarms.cn/docs/nfarms/')
[void]$mk.AppendLine('#')
[void]$mk.AppendLine('# site_url 必须带**末尾斜杠和子路径**。填错时页面样式仍然正常(资源是相对路径),')
[void]$mk.AppendLine('# 但搜索索引和 sitemap 里的 URL 会是错的 —— 所以别靠"看起来没问题"判断。')
[void]$mk.AppendLine('site_name: NFarms 知识库')
[void]$mk.AppendLine('site_url: https://zeroling.nfarms.cn/docs/nfarms/')
[void]$mk.AppendLine('site_description: NFarms HUB 源码知识库(桌面 Rust 端 + 五个 Android App)')
[void]$mk.AppendLine('site_author: NFarms')
[void]$mk.AppendLine('copyright: Copyright &copy; NFarms')
[void]$mk.AppendLine('')
[void]$mk.AppendLine('docs_dir: content')
[void]$mk.AppendLine('site_dir: site')
[void]$mk.AppendLine('use_directory_urls: true')
[void]$mk.AppendLine('')
[void]$mk.AppendLine('theme:')
[void]$mk.AppendLine('  name: material')
[void]$mk.AppendLine('  language: zh')
[void]$mk.AppendLine('  features:')
[void]$mk.AppendLine('    - navigation.instant')
[void]$mk.AppendLine('    - navigation.tracking')
[void]$mk.AppendLine('    - navigation.sections')
[void]$mk.AppendLine('    - navigation.top')
[void]$mk.AppendLine('    - search.highlight')
[void]$mk.AppendLine('    - search.suggest')
[void]$mk.AppendLine('    - content.code.copy')
[void]$mk.AppendLine('    - toc.follow')
[void]$mk.AppendLine('  palette:')
[void]$mk.AppendLine('    - media: "(prefers-color-scheme: light)"')
[void]$mk.AppendLine('      scheme: default')
[void]$mk.AppendLine('      primary: indigo')
[void]$mk.AppendLine('      accent: indigo')
[void]$mk.AppendLine('      toggle:')
[void]$mk.AppendLine('        icon: material/weather-night')
[void]$mk.AppendLine('        name: 切换到深色模式')
[void]$mk.AppendLine('    - media: "(prefers-color-scheme: dark)"')
[void]$mk.AppendLine('      scheme: slate')
[void]$mk.AppendLine('      primary: indigo')
[void]$mk.AppendLine('      accent: indigo')
[void]$mk.AppendLine('      toggle:')
[void]$mk.AppendLine('        icon: material/weather-sunny')
[void]$mk.AppendLine('        name: 切换到浅色模式')
[void]$mk.AppendLine('')
[void]$mk.AppendLine('markdown_extensions:')
[void]$mk.AppendLine('  - tables')
[void]$mk.AppendLine('  - admonition')
[void]$mk.AppendLine('  - attr_list')
[void]$mk.AppendLine('  - def_list')
[void]$mk.AppendLine('  - toc:')
[void]$mk.AppendLine('      permalink: true')
[void]$mk.AppendLine('      toc_depth: 3')
[void]$mk.AppendLine('  - pymdownx.highlight:')
[void]$mk.AppendLine('      anchor_linenums: true')
[void]$mk.AppendLine('  - pymdownx.inlinehilite')
[void]$mk.AppendLine('  - pymdownx.superfences')
[void]$mk.AppendLine('  - pymdownx.details')
[void]$mk.AppendLine('')
[void]$mk.AppendLine('plugins:')
[void]$mk.AppendLine('  - search:')
[void]$mk.AppendLine('      lang:')
[void]$mk.AppendLine('        - zh')
[void]$mk.AppendLine('        - en')
[void]$mk.AppendLine('')
[void]$mk.AppendLine('extra:')
[void]$mk.AppendLine('  generator: false')
[void]$mk.AppendLine('')
[void]$mk.AppendLine('nav:')
[void]$mk.AppendLine('  - 知识库首页: index.md')
foreach ($h in ($handWritten | Sort-Object Name)) {
    $label = [System.IO.Path]::GetFileNameWithoutExtension($h.Name)
    # 必须写 $($h.Name):直接写 $h.Name 只会替换 $h,.Name 会原样输出成字面量。
    [void]$mk.AppendLine("  - $label`: $($h.Name)")
}

$byTop = $groups | Group-Object { TopOf $_.Name }
$sortedTops = @()
foreach ($o in $topOrder) { $hit = $byTop | Where-Object { $_.Name -eq $o }; if ($hit) { $sortedTops += $hit } }
foreach ($t in ($byTop | Where-Object { $topOrder -notcontains $_.Name })) { $sortedTops += $t }

foreach ($top in $sortedTops) {
    $topLabel = $top.Name
    if ($topLabel -eq '(根目录)') { $topLabel = '根目录' }
    $sub = @($top.Group | Where-Object { $_.Items.Count -gt 0 })
    if ($sub.Count -eq 0) { continue }
    if ($sub.Count -eq 1 -and $sub[0].Name -eq $top.Name) {
        [void]$mk.AppendLine("  - ${topLabel}:")
        foreach ($it in ($sub[0].Items | Sort-Object Label)) {
            [void]$mk.AppendLine("      - $(Y (ShortLabel $it.Label)): $($it.Doc)")
        }
    } else {
        [void]$mk.AppendLine("  - ${topLabel}:")
        foreach ($g in ($sub | Sort-Object Name)) {
            $glabel = ($g.Name -replace '\\', '/')
            # 组名与顶层同名时(如顶层 apps 下的 `## apps` 组,装的是直接放在 apps\ 下的文件),
            # 直接用组名会得到一个和父项一模一样的子项 —— 看起来像重复条目。
            if ($g.Name -eq $top.Name) { $glabel = "${topLabel}(直接文件)" }
            [void]$mk.AppendLine("      - $(Y $glabel):")
            foreach ($it in ($g.Items | Sort-Object Label)) {
                [void]$mk.AppendLine("          - $(Y (ShortLabel $it.Label)): $($it.Doc)")
            }
        }
    }
}

[System.IO.File]::WriteAllText((Join-Path $out 'mkdocs.yml'), $mk.ToString(), (New-Object System.Text.UTF8Encoding($false)))

Write-Host "已生成站点源: $out" -ForegroundColor Green
Write-Host ("  content\: {0} 份(code {1} + 手写 {2});未发布 {3} 份" -f $totalFiles, $copied, $handWritten.Count, $deny.Count) -ForegroundColor Cyan
Write-Host ("  mkdocs.yml: {0} 行,nav 顶层 {1} 个" -f (($mk.ToString() -split "`n").Count), $sortedTops.Count) -ForegroundColor Cyan