Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test-macos:
name: macOS (swift test)
runs-on: macos-26
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

# swift-tools-version 6.2 requires Xcode 26; pick the newest installed.
- name: Select latest Xcode 26
run: |
LATEST=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)
Comment thread
ronaldmannak marked this conversation as resolved.
if [ -n "$LATEST" ]; then
sudo xcode-select -s "$LATEST/Contents/Developer"
fi
xcodebuild -version
swift --version

- name: Cache SwiftPM build
uses: actions/cache@v4
with:
path: .build
key: spm-macos-${{ hashFiles('Package.resolved') }}
restore-keys: spm-macos-

# Benchmarks (PicoMarkdownViewBenchmarks) are excluded: they measure
# throughput and are meant for local before/after comparisons, not a
# pass/fail gate on shared runners with noisy neighbors.
- name: Run tests
run: swift test --filter PicoMarkdownViewTests

test-ios:
name: iOS Simulator (xcodebuild test)
runs-on: macos-26
timeout-minutes: 40
steps:
- uses: actions/checkout@v4

- name: Select latest Xcode 26
run: |
LATEST=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)
if [ -n "$LATEST" ]; then
sudo xcode-select -s "$LATEST/Contents/Developer"
fi
xcodebuild -version

# Runner images add/remove device types over time, so resolve an
# available iPhone at runtime instead of hardcoding a name.
- name: Pick an iPhone simulator
id: sim
run: |
DEVICE=$(xcrun simctl list devices available | grep -m1 'iPhone' | sed -E 's/^[[:space:]]*//; s/[[:space:]]*\(.*$//')
if [ -z "$DEVICE" ]; then
echo "No available iPhone simulator found" >&2
xcrun simctl list devices >&2
exit 1
fi
echo "Using simulator: $DEVICE"
echo "device=$DEVICE" >> "$GITHUB_OUTPUT"

- name: Run tests
run: |
set -o pipefail
xcodebuild test \
-scheme PicoMarkdownView \
-destination "platform=iOS Simulator,name=${{ steps.sim.outputs.device }}" \
-only-testing:PicoMarkdownViewTests \
-resultBundlePath TestResults.xcresult \
CODE_SIGNING_ALLOWED=NO

- name: Upload result bundle on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: ios-test-results
path: TestResults.xcresult
if-no-files-found: ignore
19 changes: 10 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public enum BlockEvent: Sendable {
case blockAppendFencedCode(id: BlockID, textChunk: String) // verbatim, no inline parsing

// Table-specific deltas (GFM)
case tableHeaderCandidate(id: BlockID, cells: [InlineRun]) // before separator
case tableHeaderCandidate(id: BlockID, cells: [[InlineRun]]) // inline-parsed header cells
case tableHeaderConfirmed(id: BlockID, alignments: [TableAlignment]) // after separator
case tableAppendRow(id: BlockID, cells: [[InlineRun]])

Expand Down Expand Up @@ -159,8 +159,8 @@ Parser architecture
• FSM + bounded look-behind (≈256–1024 code units) to resolve ambiguous constructs (* vs literal, closing ```).
• Streaming guarantees: emit events only when constructs are unambiguous within the window.
• Tables:
• Header line → tableHeaderCandidate.
• Separator confirms → tableHeaderConfirmed(alignments:).
• Header line is buffered locally; nothing is emitted until the separator line resolves the candidate (events drain to the assembler at every chunk boundary, so announcing earlier would require retracting events when the candidate degrades).
• Separator confirms → blockStart(.table) + tableHeaderCandidate(cells:) + tableHeaderConfirmed(alignments:) emitted together. Header cells are inline-parsed like row cells.
Comment thread
ronaldmannak marked this conversation as resolved.
• Rows as tableAppendRow.
• Fallback: unsupported or malformed block → .unknown via blockStart(kind:.unknown) + blockAppendInline.

Expand Down Expand Up @@ -349,15 +349,16 @@ Input
3. feed("| a1 | b1 |\n| a2 | b2 |\n\n")

Expected
1. → BS(.table), THC(cells:[ "Col A", "Col B" ])
2. → THE(align:[ .left, .center ])
3. → TAR(cells:[[ "a1","b1" ]]),
TAR(cells:[[ "a2","b2" ]]),
1. → (no events; the header line is buffered locally as a candidate)
2. → BS(.table), THC(cells:[[ "Col A" ],[ "Col B" ]]), THE(align:[ .left, .center ])
3. → TAR(cells:[[ "a1" ],[ "b1" ]]),
TAR(cells:[[ "a2" ],[ "b2" ]]),
BE

Notes
• Before the separator line arrives, it’s a header candidate only.
• After confirmation, rows append as cells of InlineRuns with plain styles.
• Nothing is emitted for the candidate until the separator confirms it; events drain to the assembler at every chunk boundary, so an earlier announcement could not be retracted when the candidate degrades to .unknown.
• Header cells are inline-parsed like row cells: THC carries [[InlineRun]] (one run array per cell), so | **bold** | headers render styled.
• While unconfirmed, the candidate is also excluded from openBlocks.


Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# PicoMarkdownView

[![CI](https://github.com/PicoMLX/PicoMarkdownView/actions/workflows/ci.yml/badge.svg)](https://github.com/PicoMLX/PicoMarkdownView/actions/workflows/ci.yml)

SwiftUI component for rendering streaming Markdown and KaTeX in chat-style apps on iOS 18+ and macOS 15+.

## Installation
Expand All @@ -24,11 +26,10 @@ var body: some View {
}
```

For streaming, pass chunks or an async stream:
For live streaming, pass an async stream — chunks are fed to the parser
incrementally as they arrive:

```swift
PicoMarkdownView(chunks: ["Hello ", "world", "\n\n"])

PicoMarkdownView(stream: {
AsyncStream { continuation in
continuation.yield("Hello ")
Expand All @@ -38,6 +39,15 @@ PicoMarkdownView(stream: {
})
```

To replay an already collected sequence of chunks (e.g. a finished
response) in one shot, pass the array directly. Note that each delivery is
parsed as a complete document, so this is not intended for feeding a
growing array during live streaming — use `stream:` for that:

```swift
PicoMarkdownView(chunks: ["Hello ", "world", "\n\n"])
```

The view maintains continuous selection and reuses layout via a shared `NSTextStorage` / TextKit host under the hood.

### Configuration
Expand Down
4 changes: 2 additions & 2 deletions Sources/PicoMarkdownView/Assembler/MarkdownAssembler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,10 @@ private struct BlockEntry {
return bytes
}

mutating func setTableHeaderCandidate(_ cells: [InlineRun], allowCoalescing: Bool) -> Int {
mutating func setTableHeaderCandidate(_ cells: [[InlineRun]], allowCoalescing: Bool) -> Int {
ensureTableState()
let normalized = cells.map { cell -> [InlineRun] in
allowCoalescing ? BlockEntry.coalescedRuns([cell]) : [cell]
allowCoalescing ? BlockEntry.coalescedRuns(cell) : cell
}
let newBytes = BlockEntry.byteCount(forCells: normalized)
let delta = newBytes - (table?.headerByteCount ?? 0)
Expand Down
Loading
Loading