SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%
13.3 KB · 362 lines swift
Raw Blame History
1//2//  DocsView.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  In-app rendering of docs/API.md — the same source of truth as the served9//  behavior, rendered as a polished reference: hero header, real tables,10//  code blocks with a header bar and one-click copy, generous rounding.11//  The file is bundled by the Makefile; a repo-relative fallback covers12//  `swift run` during development.13//1415import SwiftUI1617struct DocsView: View {18    @State private var markdown: String?1920    var body: some View {21        Group {22            if let markdown {23                ScrollView {24                    VStack(alignment: .leading, spacing: ZyquoSpacing.md) {25                        DocsHero()26                        MarkdownText(markdown: markdown)27                    }28                    .padding(ZyquoSpacing.xl)29                    .frame(maxWidth: 820, alignment: .leading)30                }31                .frame(maxWidth: .infinity)32            } else {33                EmptyState(34                    systemImage: "book",35                    title: "API reference unavailable",36                    message: "docs/API.md was not found in the app bundle."37                )38            }39        }40        .onAppear(perform: load)41    }4243    private func load() {44        if let bundled = Bundle.main.url(forResource: "API", withExtension: "md"),45           let text = try? String(contentsOf: bundled, encoding: .utf8) {46            markdown = text47            return48        }49        // Development fallback: repo checkout next to the executable's cwd.50        let repoDocs = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)51            .appendingPathComponent("docs/API.md")52        markdown = try? String(contentsOf: repoDocs, encoding: .utf8)53    }54}5556/// Hero header card at the top of the reference.57private struct DocsHero: View {58    @EnvironmentObject private var server: ServerController5960    var body: some View {61        VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {62            HStack(spacing: ZyquoSpacing.xs) {63                RouterZGlyph(size: 22)64                Text("API Reference")65                    .font(.system(size: 24, weight: .bold))66                    .foregroundStyle(ZyquoColor.textPrimary)67            }68            Text("One OpenAI-compatible endpoint for every provider. Point any SDK at your local base URL — this reference is generated from the same source of truth the server implements.")69                .font(ZyquoFont.body(size: 13.5))70                .foregroundStyle(ZyquoColor.textSecondary)71                .lineSpacing(3)72            HStack(spacing: ZyquoSpacing.xs) {73                Text(server.isRunning ? server.endpointURL : "http://localhost:\(server.port)/v1")74                    .font(ZyquoFont.mono(size: 12.5, weight: .medium))75                    .foregroundStyle(ZyquoColor.accent)76                CopyButton(value: server.isRunning ? server.endpointURL : "http://localhost:\(server.port)/v1")77                if !server.isRunning {78                    CapabilityBadge(label: "SERVER STOPPED", tint: ZyquoColor.warning)79                }80            }81            .padding(.top, 2)82        }83        .padding(ZyquoSpacing.lg)84        .frame(maxWidth: .infinity, alignment: .leading)85        .background(86            RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)87                .fill(88                    LinearGradient(89                        colors: [ZyquoColor.accentSubtle, ZyquoColor.surface],90                        startPoint: .topLeading, endPoint: .bottomTrailing91                    )92                )93                .overlay(94                    RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)95                        .strokeBorder(ZyquoColor.accent.opacity(0.18), lineWidth: ZyquoMetrics.hairline)96                )97        )98    }99}100101// MARK: - Markdown renderer102103private struct MarkdownText: View {104    let markdown: String105106    var body: some View {107        VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {108            ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in109                render(block)110            }111        }112    }113114    private enum Block {115        case heading(level: Int, text: String)116        case code(String)117        case table(header: [String], rows: [[String]])118        case paragraph(String)119        case bullet(String)120        case rule121    }122123    private var blocks: [Block] {124        var result: [Block] = []125        var paragraph: [String] = []126        var codeBlock: [String]?127        var tableLines: [String] = []128129        func flushParagraph() {130            if !paragraph.isEmpty {131                result.append(.paragraph(paragraph.joined(separator: "\n")))132                paragraph = []133            }134        }135        func flushTable() {136            guard tableLines.count >= 2 else { tableLines = []; return }137            func cells(_ line: String) -> [String] {138                line.trimmingCharacters(in: CharacterSet(charactersIn: "| "))139                    .components(separatedBy: " | ")140                    .map { $0.trimmingCharacters(in: .whitespaces) }141            }142            let header = cells(tableLines[0])143            let rows = tableLines.dropFirst(2).map(cells)144            result.append(.table(header: header, rows: Array(rows)))145            tableLines = []146        }147148        for line in markdown.components(separatedBy: "\n") {149            if var code = codeBlock {150                if line.hasPrefix("```") {151                    result.append(.code(code.joined(separator: "\n")))152                    codeBlock = nil153                } else {154                    code.append(line)155                    codeBlock = code156                }157                continue158            }159            if line.hasPrefix("```") {160                flushParagraph(); flushTable()161                codeBlock = []162                continue163            }164            if line.hasPrefix("|") {165                flushParagraph()166                tableLines.append(line)167                continue168            }169            flushTable()170            if line.hasPrefix("#") {171                flushParagraph()172                let level = line.prefix(while: { $0 == "#" }).count173                result.append(.heading(level: level, text: line.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)))174            } else if line.hasPrefix("---") {175                flushParagraph()176                result.append(.rule)177            } else if line.hasPrefix("- ") {178                flushParagraph()179                result.append(.bullet(String(line.dropFirst(2))))180            } else if line.trimmingCharacters(in: .whitespaces).isEmpty {181                flushParagraph()182            } else if line.hasPrefix("  "), paragraph.isEmpty,183                      case .bullet(let text) = result.last {184                // Hard-wrapped bullet continuation line.185                result[result.count - 1] = .bullet(text + " " + line.trimmingCharacters(in: .whitespaces))186            } else {187                paragraph.append(line)188            }189        }190        flushParagraph(); flushTable()191        return result192    }193194    @ViewBuilder195    private func render(_ block: Block) -> some View {196        switch block {197        case .heading(let level, let text):198            heading(level: level, text: text)199        case .code(let code):200            CodeCard(code: code)201        case .table(let header, let rows):202            TableCard(header: header, rows: rows)203        case .paragraph(let text):204            Text(attributed(text))205                .font(ZyquoFont.body(size: 13.5))206                .foregroundStyle(ZyquoColor.textPrimary)207                .lineSpacing(3.5)208        case .bullet(let text):209            HStack(alignment: .top, spacing: ZyquoSpacing.xs) {210                Circle()211                    .fill(ZyquoColor.accent)212                    .frame(width: 5, height: 5)213                    .padding(.top, 7)214                Text(attributed(text))215                    .font(ZyquoFont.body(size: 13.5))216                    .foregroundStyle(ZyquoColor.textPrimary)217                    .lineSpacing(3)218            }219            .padding(.leading, ZyquoSpacing.xxs)220        case .rule:221            ZyquoHairline()222                .padding(.vertical, ZyquoSpacing.xs)223        }224    }225226    @ViewBuilder227    private func heading(level: Int, text: String) -> some View {228        if level <= 1 {229            EmptyView() // the hero replaces the H1230        } else if level == 2 {231            HStack(spacing: ZyquoSpacing.xs) {232                RoundedRectangle(cornerRadius: 2, style: .continuous)233                    .fill(ZyquoColor.accent)234                    .frame(width: 4, height: 18)235                Text(attributed(text))236                    .font(.system(size: 18, weight: .semibold))237                    .foregroundStyle(ZyquoColor.textPrimary)238            }239            .padding(.top, ZyquoSpacing.lg)240        } else {241            Text(attributed(text))242                .font(ZyquoFont.heading)243                .foregroundStyle(ZyquoColor.textPrimary)244                .padding(.top, ZyquoSpacing.xs)245        }246    }247248    private func attributed(_ text: String) -> AttributedString {249        (try? AttributedString(250            markdown: text,251            options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)252        )) ?? AttributedString(text)253    }254}255256// MARK: - Code card257258private struct CodeCard: View {259    let code: String260261    private var language: String {262        if code.contains("import OpenAI") || code.contains("await client") { return "javascript" }263        if code.contains("from openai") || code.contains("client =") { return "python" }264        if code.hasPrefix("curl") { return "bash" }265        if code.hasPrefix("{") || code.hasPrefix("[") { return "json" }266        return "code"267    }268269    var body: some View {270        VStack(spacing: 0) {271            HStack(spacing: ZyquoSpacing.xs) {272                HStack(spacing: 4) {273                    ForEach(0..<3, id: \.self) { _ in274                        Circle()275                            .fill(ZyquoColor.border)276                            .frame(width: 7, height: 7)277                    }278                }279                Text(language)280                    .font(ZyquoFont.mono(size: 10))281                    .foregroundStyle(ZyquoColor.textTertiary)282                Spacer()283                CopyButton(value: code)284            }285            .padding(.horizontal, ZyquoSpacing.sm)286            .padding(.vertical, ZyquoSpacing.xs)287            .background(ZyquoColor.surfaceSecondary)288289            ZyquoHairline()290291            ScrollView(.horizontal, showsIndicators: false) {292                Text(code)293                    .font(ZyquoFont.mono(size: 12))294                    .foregroundStyle(ZyquoColor.textPrimary)295                    .lineSpacing(2.5)296                    .textSelection(.enabled)297                    .padding(ZyquoSpacing.sm)298            }299            .background(ZyquoColor.surface)300        }301        .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous))302        .overlay(303            RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)304                .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)305        )306    }307}308309// MARK: - Table card310311private struct TableCard: View {312    let header: [String]313    let rows: [[String]]314315    var body: some View {316        VStack(spacing: 0) {317            row(cells: header, isHeader: true)318            ForEach(Array(rows.enumerated()), id: \.offset) { index, cells in319                ZyquoHairline()320                row(cells: cells, isHeader: false)321                    .background(index.isMultiple(of: 2) ? ZyquoColor.surface : ZyquoColor.surfaceSecondary.opacity(0.5))322            }323        }324        .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous))325        .overlay(326            RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)327                .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)328        )329    }330331    private func row(cells: [String], isHeader: Bool) -> some View {332        HStack(alignment: .top, spacing: ZyquoSpacing.sm) {333            ForEach(Array(cells.enumerated()), id: \.offset) { index, cell in334                Group {335                    if isHeader {336                        Text(cell.uppercased())337                            .font(.system(size: 9.5, weight: .semibold))338                            .foregroundStyle(ZyquoColor.textTertiary)339                            .kerning(0.4)340                    } else {341                        Text(inline(cell))342                            .font(ZyquoFont.body(size: 12))343                            .foregroundStyle(ZyquoColor.textPrimary)344                            .lineSpacing(2)345                    }346                }347                .frame(maxWidth: index == 0 ? 190 : .infinity, alignment: .leading)348            }349        }350        .padding(.horizontal, ZyquoSpacing.sm)351        .padding(.vertical, ZyquoSpacing.xs)352        .background(isHeader ? ZyquoColor.surfaceSecondary : .clear)353    }354355    private func inline(_ text: String) -> AttributedString {356        (try? AttributedString(357            markdown: text,358            options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)359        )) ?? AttributedString(text)360    }361}362