{"id":51016329,"url":"https://github.com/dnakov/hairball","last_synced_at":"2026-06-21T11:01:44.861Z","repository":{"id":350489503,"uuid":"1206537740","full_name":"dnakov/hairball","owner":"dnakov","description":"Swift markdown parsing and rendering library for iOS/macOS. Streaming, theming, LaTeX, syntax highlighting.","archived":false,"fork":false,"pushed_at":"2026-04-20T02:10:39.000Z","size":297,"stargazers_count":13,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-20T04:25:31.580Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/dnakov.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-10T02:32:25.000Z","updated_at":"2026-04-20T02:10:42.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/dnakov/hairball","commit_stats":null,"previous_names":["dnakov/hairball"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/dnakov/hairball","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dnakov%2Fhairball","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dnakov%2Fhairball/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dnakov%2Fhairball/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dnakov%2Fhairball/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/dnakov","download_url":"https://codeload.github.com/dnakov/hairball/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dnakov%2Fhairball/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34607126,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-21T02:00:05.568Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-21T11:01:43.336Z","updated_at":"2026-06-21T11:01:44.852Z","avatar_url":"https://github.com/dnakov.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Hairball\n\nA Swift markdown parsing and rendering library for iOS and macOS. Two targets:\n\n- **Hairball** — parsing, AST, processors. No UI dependencies.\n- **HairballUI** — SwiftUI rendering with theming, syntax highlighting (Highlightr), LaTeX (SwiftMath), streaming support, and per-glyph text effects via iOS 18's `TextRenderer`.\n\n```swift\n.package(url: \"https://github.com/dnakov/hairball.git\", from: \"1.0.0\")\n```\n\nPlatforms: iOS 18+, macOS 15+\n\n## Monorepo Layout\n\n- `Sources/` and `Tests/` keep the root SwiftPM package surface for Apple consumers.\n- `apps/apple-demo/` contains the Apple demo app and Xcode project assets.\n- `android/` contains the Android Gradle workspace:\n  `hairball-core`, `hairball-compose`, and `hairball-demo`.\n- `spec/fixtures/` contains Swift-generated golden fixtures used to keep Android aligned with the Swift AST and processor behavior.\n- `scripts/` contains cross-platform verification helpers:\n  `export-fixtures.sh`, `verify-fixtures.sh`, `build-apple-demo.sh`, and `android-publish-dry-run.sh`.\n\n---\n\n## Quick Start\n\n```swift\nimport HairballUI\n\n// Render a markdown string\nMarkdownView(\"# Hello\\n\\nSome **bold** and *italic* text.\")\n\n// With processors\nMarkdownView(\"Check $E=mc^2$ and https://example.com\", processors: [\n    LatexTransformer(),\n    AutoLinkTransformer(),\n    CitationProcessor(),\n])\n\n// With a theme\nMarkdownView(\"# Styled\")\n    .markdownTheme(.assistantBubble)\n\n// With syntax highlighting theme\nMarkdownView(\"```swift\\nlet x = 42\\n```\")\n    .codeSyntaxHighlighter(HighlightrCodeSyntaxHighlighter(theme: \"github-dark\"))\n```\n\n---\n\n## Rendering Layers\n\nFour layers, from highest to lowest level of control:\n\n### Layer 1: `MarkdownView` — highest level\n\nTakes a string, parses it, renders it.\n\n```swift\nMarkdownView(\"# Title\\n\\nParagraph with **bold**.\")\n```\n\n### Layer 2: `MarkdownDocumentView` — you own the document\n\nYou parse the markdown yourself. The view just renders.\n\n```swift\nlet parser = MarkdownParser()\nlet doc = parser.parse(myMarkdown)\n\nMarkdownDocumentView(document: doc)\n```\n\n### Layer 3: `MarkdownBlocksView` — you own the blocks and animation\n\nYou parse, identify blocks, and control streaming animation.\n\n```swift\nlet blocks = IdentifiedBlock.identify(document.blocks)\n\nMarkdownBlocksView(blocks: blocks, isStreaming: true)\n    .streamingTextEffect(FadeEdgeEffect(edgeWidth: 8))\n    .tokenReveal(.init(duration: 0.15, mode: .continuous))\n```\n\n### Layer 4: `BlockNodeView` — you own everything\n\nRender individual blocks with zero opinions from the library.\n\n```swift\nForEach(IdentifiedBlock.identify(doc.blocks)) { item in\n    BlockNodeView(node: item.block)\n}\n```\n\n---\n\n## Streaming\n\n### Option A: Let Hairball manage the pipeline\n\n```swift\n@StateObject var renderer = StreamingMarkdownRenderer(\n    processors: [LatexTransformer(), AutoLinkTransformer()],\n    throttleInterval: 0.016\n)\n\n// View\nStreamingMarkdownContentView(renderer: renderer)\n    .streamingTextEffect(FadeEdgeEffect())\n    .tokenReveal(.init(duration: 0.15, mode: .continuous))\n\n// Feed tokens\nTask {\n    for await token in myLLMStream {\n        renderer.append(token)\n    }\n    renderer.finish()\n}\n```\n\n### Option B: You handle streaming, Hairball renders\n\n```swift\n@State private var document = Document(blocks: [])\nlet parser = MarkdownParser()\n\nMarkdownDocumentView(document: document, isStreaming: true)\n    .streamingTextEffect(MatrixDecodeEffect())\n    .tokenReveal(.default)\n\nfunc onToken(_ token: String) {\n    accumulated += token\n    document = parser.parse(accumulated)\n}\n```\n\n### Reveal Config — timing and mode\n\n```swift\n.tokenReveal(TokenRevealConfig(\n    duration: 0.15,       // smoothing constant or speed\n    mode: .continuous     // or .linear\n))\n\n// Presets\n.tokenReveal(.fast)       // 80ms\n.tokenReveal(.slow)       // 300ms\n.tokenReveal(.disabled)   // no animation\n.tokenReveal(.default)    // 150ms continuous\n```\n\n**Two reveal modes:**\n\n- **Continuous** — a smooth cursor chases the stream at 60fps using exponential smoothing. Speeds up when behind, slows when close. `duration` is the smoothing time constant.\n- **Linear** — constant-speed reveal at 60fps. `duration` controls speed (0.1 = 1000 chars/sec, 1.0 = 100 chars/sec). Keeps going at the same rate after streaming ends.\n\n### Streaming Architecture\n\n```\ntokens -\u003e StreamingMarkdownRenderer -\u003e Document -\u003e MarkdownBlocksView\n              ^ throttleInterval          ^ tokenReveal config\n              (content buffer)            (animation timing)\n                                          ^ streamingTextEffect\n                                          (per-glyph rendering)\n                                          ^ revealGranularity\n                                          (char/block/chunk/line)\n```\n\nA single 60fps cursor sweeps across all blocks. Each block receives its local cursor position and renders via `TextRenderer` for per-glyph control. The renderer's `throttleInterval` controls how often tokens are parsed into blocks. The view's `tokenReveal` config controls how the cursor animates. Keep `throttleInterval` low (0.016) so content is available for the cursor.\n\n---\n\n## Streaming Text Effects\n\nEffects use iOS 18's `TextRenderer` API for per-glyph control over how text appears during streaming. Every effect works across paragraphs, headings, code blocks, lists, and blockquotes.\n\n### Built-in effects\n\n```swift\n// Fade edge — solid text with fading edge near cursor\n.streamingTextEffect(FadeEdgeEffect(edgeWidth: 8))\n\n// Glow cursor — colored glow follows the cursor\n.streamingTextEffect(GlowCursorEffect(glowColor: .cyan, glowRadius: 12))\n\n// Wave — characters near the cursor bounce\n.streamingTextEffect(WaveRevealEffect(amplitude: 6, wavelength: 12))\n\n// Scale pop — characters scale up as they appear\n.streamingTextEffect(ScalePopEffect(popWidth: 3))\n\n// Rainbow — hue cycling near the cursor\n.streamingTextEffect(RainbowEffect(trailLength: 16))\n\n// Sparkle — particle burst at cursor position\n.streamingTextEffect(SparkleEffect(sparkleCount: 8, color: .yellow))\n\n// Fire trail — warm gradient glow trailing behind\n.streamingTextEffect(FireTrailEffect(trailLength: 18))\n\n// Explosion — expanding particle ring per character\n.streamingTextEffect(ExplosionEffect())\n\n// Nyan Cat — pixel-art cat with rainbow trail\n.streamingTextEffect(NyanCatEffect())\n\n// Matrix decode — character rain with block-level decode\n// (use with .revealGranularity(.block) for full rain effect)\n.streamingTextEffect(MatrixDecodeEffect())\n\n// Phosphor CRT — green-screen terminal with scanlines\n.streamingTextEffect(PhosphorCRTEffect(decayLength: 20))\n\n// Shockwave — circular ripples displace nearby characters\n.streamingTextEffect(ShockwaveEffect())\n\n// Simple reveal — characters appear, nothing drawn past cursor\n.streamingTextEffect(RevealEffect())\n```\n\n### Combining effects\n\n```swift\n// Compose multiple effects — first draws text, rest add decorations\n.streamingTextEffect(CombinedEffect(\n    WaveRevealEffect(amplitude: 4, wavelength: 10),\n    GlowCursorEffect(glowColor: .orange, glowRadius: 10),\n    SparkleEffect(sparkleCount: 10, color: .yellow)\n))\n\n// Or use the shorthand\nlet effect = WaveRevealEffect().combined(with: SparkleEffect())\n```\n\n### Custom effects\n\nImplement the `StreamingTextEffect` protocol to create your own:\n\n```swift\nstruct MyEffect: StreamingTextEffect {\n    func draw(\n        layout: Text.Layout,\n        revealedCount: Int,\n        settledCount: Int,\n        time: Double,\n        in ctx: inout GraphicsContext\n    ) {\n        // revealedCount: how many characters are visible (cursor position)\n        // settledCount:  how many characters are done animating (based on granularity)\n        // time:          continuous seconds, ticks at 60fps while streaming\n        //                (use for effects that animate between token arrivals)\n        //\n        // index \u003c settledCount            -\u003e draw normally (settled)\n        // settledCount \u003c= index \u003c revealed -\u003e in the effect's active zone\n        // index \u003e= revealedCount           -\u003e not yet revealed\n\n        let trail = effectiveTrail(ownTrail: 10, revealedCount: revealedCount, settledCount: settledCount)\n\n        forEachSlice(in: layout, { index, slice, context in\n            guard index \u003c revealedCount else { return false }\n            let dist = revealedCount - index\n\n            if dist \u003c= trail {\n                // Character is in the animation zone — apply your effect\n                let t = Double(dist) / Double(trail)\n                var c = context\n                c.opacity = t  // fade in from cursor\n                c.draw(slice)\n            } else {\n                // Settled — draw normally\n                context.draw(slice)\n            }\n            return true\n        }, context: \u0026ctx)\n    }\n}\n```\n\n**Available helpers on `StreamingTextEffect`:**\n\n| Helper | Purpose |\n|--------|---------|\n| `forEachSlice(in:_:context:)` | Iterate all character slices with global index |\n| `effectiveTrail(ownTrail:revealedCount:settledCount:)` | Expand trail width to cover unsettled chars (respects granularity) |\n| `totalCharCount(in:)` | Count all character slices in the layout |\n| `drawRevealedAndGetCursorPoint(in:revealedCount:context:)` | Draw all revealed slices normally, return cursor position |\n\n### Reveal Granularity\n\nControls when characters transition from the effect's active state to settled rendering. Text always reveals character-by-character — granularity controls the *effect scope*, not visibility.\n\n```swift\n// Characters settle as the cursor passes (default)\n.revealGranularity(.character)\n\n// Entire block stays in effect zone until complete, then settles at once\n// e.g. Matrix + block = all chars show scrambled, whole block decodes when done\n.revealGranularity(.block)\n\n// Characters settle in chunks of N\n.revealGranularity(.chunk(10))\n\n// Characters settle line by line\n.revealGranularity(.line)\n```\n\nA block is considered \"complete\" when either a subsequent block appears (meaning this block's content is finalized) or the stream ends. This prevents premature settling while a block is still receiving tokens.\n\nWith non-character granularity, blocks automatically get continuous 60fps animation via `TimelineView` while streaming. Effects receive a `time` parameter (seconds) that ticks independently of token arrivals — use it for effects that need to animate between bursts (e.g. Matrix rain falling). When the block completes, the timeline stops and text renders normally.\n\n---\n\n## Theming\n\nEvery element is configurable:\n\n```swift\nlet theme = MarkdownTheme(\n    bodyFont: .system(size: 15),\n    foregroundColor: .white,\n    paragraphSpacing: 10,\n    codeBlock: CodeBlockStyle(\n        backgroundColor: Color(white: 0.1),\n        textColor: Color(white: 0.85),\n        cornerRadius: 10\n    ),\n    blockquote: BlockquoteStyle(\n        borderColor: .blue,\n        borderWidth: 3,\n        textColor: .gray\n    ),\n    table: TableStyle(\n        headerBackground: Color(white: 0.15),\n        backgroundStyle: .alternatingRows(even: Color(white: 0.08), odd: .clear)\n    ),\n    link: LinkStyle(color: .blue, underline: true)\n)\n\nMarkdownView(\"...\")\n    .markdownTheme(theme)\n```\n\nBuilt-in presets: `.default`, `.assistantBubble`, `.userBubble`, `.userBubblePending`\n\n### Syntax highlighting themes\n\n```swift\nlet highlighter = HighlightrCodeSyntaxHighlighter(theme: \"atom-one-dark\")\n\nhighlighter.setTheme(\"github\")             // change at runtime\nhighlighter.availableThemes                 // [\"atom-one-dark\", \"github\", ...]\n\nMarkdownView(\"...\")\n    .codeSyntaxHighlighter(highlighter)\n```\n\n---\n\n## Custom Rendering\n\nReplace the view for any block type:\n\n```swift\nstruct MyProvider: MarkdownViewComponentProvider {\n    func makeCodeBlock(language: String?, code: String, configuration: BlockConfiguration) -\u003e some View {\n        MyFancyCodeBlock(code: code, language: language)\n    }\n\n    func makeHeading(level: Int, content: [InlineNode], configuration: BlockConfiguration) -\u003e some View {\n        HeadingView(level: level, content: content)\n    }\n}\n\nMarkdownView(\"...\")\n    .markdownComponentProvider(MyProvider())\n```\n\nOr replace just the code block renderer:\n\n```swift\nstruct NeonCodeRenderer: CodeBlockRenderer {\n    func makeBody(configuration: CodeBlockConfiguration) -\u003e some View {\n        Text(configuration.highlightedCode)\n            .padding()\n            .background(.black)\n            .cornerRadius(12)\n    }\n}\n\nMarkdownView(\"...\")\n    .codeBlockRenderer(NeonCodeRenderer())\n```\n\n---\n\n## Processors\n\nTransform the parsed AST before rendering:\n\n| Processor | What it does |\n|-----------|-------------|\n| `LatexTransformer` | `$...$` to inline math, `$$...$$` to display math |\n| `AutoLinkTransformer` | Raw URLs in text become tappable links |\n| `CitationProcessor` | `[^1]` and `[1](url)` become citation nodes |\n| `DefaultMarkdownProcessor` | Normalize whitespace, merge text nodes |\n\n```swift\nMarkdownView(\"...\", processors: [\n    AutoLinkTransformer(),\n    LatexTransformer(),\n    CitationProcessor(),\n])\n```\n\nWrite your own:\n\n```swift\nstruct MyProcessor: MarkdownProcessor {\n    func process(_ document: Document) -\u003e Document {\n        // Walk and transform the AST\n    }\n}\n```\n\n---\n\n## AST Access\n\nParse markdown into a typed AST for programmatic use:\n\n```swift\nimport Hairball\n\nlet parser = MarkdownParser()\nlet document = parser.parse(\"# Hello\\n\\n**bold** text\")\n\nfor block in document.blocks {\n    switch block {\n    case .heading(let level, let content):\n        print(\"H\\(level): \\(content)\")\n    case .paragraph(let content):\n        for inline in content {\n            switch inline {\n            case .strong(let children): print(\"Bold: \\(children)\")\n            case .text(let str): print(\"Text: \\(str)\")\n            default: break\n            }\n        }\n    default: break\n    }\n}\n```\n\n### Block types\n\n`heading`, `paragraph`, `codeBlock`, `blockQuote`, `orderedList`, `unorderedList`, `table`, `thematicBreak`, `htmlBlock`, `latexBlock`, `blockDirective`, `customBlock`\n\n### Inline types\n\n`text`, `emphasis`, `strong`, `strikethrough`, `inlineCode`, `link`, `image`, `softBreak`, `hardBreak`, `lineBreak`, `inlineHTML`, `latex`, `citation`, `customInline`\n\n---\n\n## Building Documents Programmatically\n\n```swift\nlet doc = Document(blocks: [\n    .heading(level: 1, content: [.text(\"Title\")]),\n    .paragraph(content: [\n        .text(\"Hello \"),\n        .strong(children: [.text(\"world\")]),\n    ]),\n    .codeBlock(language: \"swift\", content: \"let x = 42\"),\n    .unorderedList(tight: true, items: [\n        ListItem(children: [.paragraph(content: [.text(\"Item 1\")])], checkbox: .checked),\n        ListItem(children: [.paragraph(content: [.text(\"Item 2\")])], checkbox: .unchecked),\n    ]),\n])\n\nMarkdownView(document: doc)\n```\n\nOr with the result builder:\n\n```swift\nMarkdownView {\n    Heading(level: 1, \"Title\")\n    Paragraph(\"Some text\")\n    CodeBlock(language: \"swift\", \"let x = 42\")\n    if showOptional {\n        Paragraph(\"Conditional content\")\n    }\n}\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdnakov%2Fhairball","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdnakov%2Fhairball","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdnakov%2Fhairball/lists"}