Compare commits
24 Commits
0a18b57dc8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
bbace73d97
|
|||
|
e524056dc6
|
|||
|
10ced39b62
|
|||
|
875b1980e0
|
|||
|
78a632c3e5
|
|||
|
c6a269f062
|
|||
|
c977a1c805
|
|||
|
c00d4017ea
|
|||
|
f6d86343ba
|
|||
|
c377a75e06
|
|||
|
cfc7c1caab
|
|||
|
c4fa42f2b8
|
|||
|
6b670ce5c4
|
|||
|
45ab7ca578
|
|||
|
1a559e0236
|
|||
|
f0873d3b44
|
|||
|
9985b55f88
|
|||
|
bef91c5277
|
|||
|
e7bbbec7c2
|
|||
|
33356d8648
|
|||
|
b0b218e047
|
|||
|
2a9b350b26
|
|||
|
10119c3e51
|
|||
|
46c2c7ad31
|
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.build/*
|
||||||
20
.gitea/workflows/ci.yml
Normal file
20
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run tests.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: https://git.housh.dev/actions/setup-just@v1
|
||||||
|
- name: Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Setup Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Run tests.
|
||||||
|
run: just test-docker
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,3 +6,7 @@ DerivedData/
|
|||||||
.swiftpm/configuration/registries.json
|
.swiftpm/configuration/registries.json
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
.netrc
|
.netrc
|
||||||
|
.nvim/*
|
||||||
|
docs/*
|
||||||
|
Examples/*.png
|
||||||
|
./*.png
|
||||||
|
|||||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
**/*.docc
|
||||||
8
Examples/.gitignore
vendored
Normal file
8
Examples/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
||||||
24
Examples/Package.resolved
Normal file
24
Examples/Package.resolved
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "afba34e2c2164a53d5b868cb4ece6f0e542fcaed383f1b226ed9179283d0e060",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "rainbow",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/onevcat/Rainbow",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e0dada9cd44e3fa7ec3b867e49a8ddbf543e3df3",
|
||||||
|
"version" : "4.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-argument-parser",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||||
|
"version" : "1.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
27
Examples/Package.swift
Normal file
27
Examples/Package.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "CliDoc-Examples",
|
||||||
|
products: [
|
||||||
|
.executable(name: "examples", targets: ["CliDoc-Examples"])
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../"),
|
||||||
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "CliDoc-Examples",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "CliDoc", package: "swift-cli-doc"),
|
||||||
|
.product(name: "ArgumentParser", package: "swift-argument-parser")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "CliDoc-ExamplesTests",
|
||||||
|
dependencies: ["CliDoc-Examples"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
17
Examples/Sources/CliDoc-Examples/CliDoc_Examples.swift
Normal file
17
Examples/Sources/CliDoc-Examples/CliDoc_Examples.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliDoc
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct Application: ParsableCommand {
|
||||||
|
static var configuration: CommandConfiguration {
|
||||||
|
.init(
|
||||||
|
commandName: "examples",
|
||||||
|
subcommands: [
|
||||||
|
SectionCommand.self,
|
||||||
|
VStackCommand.self,
|
||||||
|
HStackCommand.self,
|
||||||
|
GroupCommand.self
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Examples/Sources/CliDoc-Examples/GroupCommand.swift
Normal file
21
Examples/Sources/CliDoc-Examples/GroupCommand.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliDocCore
|
||||||
|
|
||||||
|
struct GroupCommand: ParsableCommand {
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration(commandName: "group")
|
||||||
|
|
||||||
|
func run() throws {
|
||||||
|
let group = Group {
|
||||||
|
"My headline."
|
||||||
|
"\n"
|
||||||
|
"Some content".color(.green)
|
||||||
|
"\n"
|
||||||
|
"Foo Bar".italic()
|
||||||
|
}
|
||||||
|
.color(.blue)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("\(group.render())")
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Examples/Sources/CliDoc-Examples/HStackCommand.swift
Normal file
16
Examples/Sources/CliDoc-Examples/HStackCommand.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliDocCore
|
||||||
|
|
||||||
|
struct HStackCommand: ParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(commandName: "hstack")
|
||||||
|
|
||||||
|
func run() throws {
|
||||||
|
let note = HStack {
|
||||||
|
"NOTE:".color(.cyan).bold()
|
||||||
|
"This is my super cool note.".italic()
|
||||||
|
}
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(note.render())
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Examples/Sources/CliDoc-Examples/SectionCommand.swift
Normal file
31
Examples/Sources/CliDoc-Examples/SectionCommand.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliDocCore
|
||||||
|
|
||||||
|
struct SectionCommand: ParsableCommand {
|
||||||
|
|
||||||
|
static var configuration: CommandConfiguration {
|
||||||
|
.init(commandName: "section")
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() throws {
|
||||||
|
let section = Section {
|
||||||
|
"My super awesome section"
|
||||||
|
} header: {
|
||||||
|
"Awesome"
|
||||||
|
} footer: {
|
||||||
|
"Note: this is super awesome"
|
||||||
|
}
|
||||||
|
print(section.style(MySectionStyle()).render())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MySectionStyle: SectionStyle {
|
||||||
|
func render(content: SectionConfiguration) -> some TextNode {
|
||||||
|
VStack {
|
||||||
|
content.header.color(.green).bold().underline()
|
||||||
|
content.content
|
||||||
|
content.footer.italic()
|
||||||
|
}
|
||||||
|
.separator(.newLine(count: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Examples/Sources/CliDoc-Examples/VStackCommand.swift
Normal file
21
Examples/Sources/CliDoc-Examples/VStackCommand.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliDocCore
|
||||||
|
|
||||||
|
struct VStackCommand: ParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(commandName: "vstack")
|
||||||
|
|
||||||
|
func run() throws {
|
||||||
|
let vstack = VStack {
|
||||||
|
"Blob Esquire"
|
||||||
|
.color(.yellow)
|
||||||
|
.bold()
|
||||||
|
.underline()
|
||||||
|
|
||||||
|
"Blob is a super awesome worker.".italic()
|
||||||
|
}
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(vstack.render())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import CliDoc_Examples
|
||||||
|
|
||||||
|
@Test func example() async throws {
|
||||||
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
|
}
|
||||||
12
Examples/justfile
Normal file
12
Examples/justfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
executable_path := "./.build/release/examples"
|
||||||
|
|
||||||
|
build:
|
||||||
|
@swift build -c release
|
||||||
|
|
||||||
|
run command="section": build
|
||||||
|
@{{executable_path}} {{command}}
|
||||||
|
|
||||||
|
snapshot command="section" outputDir="${PWD}": build
|
||||||
|
@freeze --execute "{{executable_path}} {{command}}" \
|
||||||
|
--output {{outputDir}}/"{{command}}.png" \
|
||||||
|
--width 500
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "ce5e40ef3be5875abe47bbfe26413215dee9b937de7df5293343757c7476d755",
|
"originHash" : "d5e07b58f04c94c37ba09a0f1c9d09fc24e97c047c48b2c5a4a573fcda7f6c98",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "rainbow",
|
"identity" : "rainbow",
|
||||||
@@ -9,6 +9,33 @@
|
|||||||
"revision" : "e0dada9cd44e3fa7ec3b867e49a8ddbf543e3df3",
|
"revision" : "e0dada9cd44e3fa7ec3b867e49a8ddbf543e3df3",
|
||||||
"version" : "4.0.1"
|
"version" : "4.0.1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-argument-parser",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||||
|
"version" : "1.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-docc-plugin",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-docc-plugin",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64",
|
||||||
|
"version" : "1.4.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-docc-symbolkit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-docc-symbolkit",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
|
||||||
|
"version" : "1.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -4,22 +4,38 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "swift-cli-doc",
|
name: "swift-cli-doc",
|
||||||
|
platforms: [.macOS(.v10_15)],
|
||||||
products: [
|
products: [
|
||||||
|
.library(name: "CliDocCore", targets: ["CliDocCore"]),
|
||||||
.library(name: "CliDoc", targets: ["CliDoc"])
|
.library(name: "CliDoc", targets: ["CliDoc"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/onevcat/Rainbow", from: "4.0.0")
|
.package(url: "https://github.com/onevcat/Rainbow", from: "4.0.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "CliDoc",
|
name: "CliDocCore",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "Rainbow", package: "Rainbow")
|
.product(name: "Rainbow", package: "Rainbow")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "CliDocCoreTests",
|
||||||
|
dependencies: ["CliDocCore"]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "CliDoc",
|
||||||
|
dependencies: [
|
||||||
|
"CliDocCore",
|
||||||
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
|
.product(name: "Rainbow", package: "Rainbow")
|
||||||
|
]
|
||||||
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "CliDocTests",
|
name: "CliDocTests",
|
||||||
dependencies: ["CliDoc"]
|
dependencies: ["CliDocCore", "CliDoc"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
26
README.md
Normal file
26
README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# swift-cli-doc
|
||||||
|
|
||||||
|
A tool for building rich documentation for command line applications using result builders and
|
||||||
|
syntax similar to `SwiftUI`.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Add this as a package dependency to your command line application.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let package = Package(
|
||||||
|
name: "my-tool"
|
||||||
|
...
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://git.housh.dev/michael/swift-cli-doc", from: "0.1.0")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "my-tool",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "CliDoc", package: "swift-cli-doc")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
98
Sources/CliDoc/CommandConfiguration+TextNode.swift
Normal file
98
Sources/CliDoc/CommandConfiguration+TextNode.swift
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
|
||||||
|
public extension CommandConfiguration {
|
||||||
|
|
||||||
|
/// Generate a new command configuration, using ``TextNode``'s for the abstract,
|
||||||
|
/// usage, and discussion parameters.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
init<A: TextNode, U: TextNode, D: TextNode>(
|
||||||
|
commandName: String? = nil,
|
||||||
|
abstract: Abstract<A>,
|
||||||
|
usage: Usage<U>,
|
||||||
|
discussion: Discussion<D>,
|
||||||
|
version: String = "",
|
||||||
|
shouldDisplay: Bool = true,
|
||||||
|
subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
|
||||||
|
groupedSubcommands: [CommandGroup] = [],
|
||||||
|
defaultSubcommand: ParsableCommand.Type? = nil,
|
||||||
|
helpNames: NameSpecification? = nil,
|
||||||
|
aliases: [String] = []
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
commandName: commandName,
|
||||||
|
abstract: abstract.render(),
|
||||||
|
usage: usage.render(),
|
||||||
|
discussion: discussion.render(),
|
||||||
|
version: version,
|
||||||
|
shouldDisplay: shouldDisplay,
|
||||||
|
subcommands: ungroupedSubcommands,
|
||||||
|
groupedSubcommands: groupedSubcommands,
|
||||||
|
defaultSubcommand: defaultSubcommand,
|
||||||
|
helpNames: helpNames,
|
||||||
|
aliases: aliases
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new command configuration, using ``TextNode``'s for the usage, and discussion parameters.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
init<U: TextNode, D: TextNode>(
|
||||||
|
commandName: String? = nil,
|
||||||
|
abstract: String = "",
|
||||||
|
usage: Usage<U>,
|
||||||
|
discussion: Discussion<D>,
|
||||||
|
version: String = "",
|
||||||
|
shouldDisplay: Bool = true,
|
||||||
|
subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
|
||||||
|
groupedSubcommands: [CommandGroup] = [],
|
||||||
|
defaultSubcommand: ParsableCommand.Type? = nil,
|
||||||
|
helpNames: NameSpecification? = nil,
|
||||||
|
aliases: [String] = []
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
commandName: commandName,
|
||||||
|
abstract: abstract,
|
||||||
|
usage: usage.render(),
|
||||||
|
discussion: discussion.render(),
|
||||||
|
version: version,
|
||||||
|
shouldDisplay: shouldDisplay,
|
||||||
|
subcommands: ungroupedSubcommands,
|
||||||
|
groupedSubcommands: groupedSubcommands,
|
||||||
|
defaultSubcommand: defaultSubcommand,
|
||||||
|
helpNames: helpNames,
|
||||||
|
aliases: aliases
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new command configuration, using ``TextNode``'s for the discussion parameter.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
init<D: TextNode>(
|
||||||
|
commandName: String? = nil,
|
||||||
|
abstract: String = "",
|
||||||
|
usage: String? = nil,
|
||||||
|
discussion: Discussion<D>,
|
||||||
|
version: String = "",
|
||||||
|
shouldDisplay: Bool = true,
|
||||||
|
subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
|
||||||
|
groupedSubcommands: [CommandGroup] = [],
|
||||||
|
defaultSubcommand: ParsableCommand.Type? = nil,
|
||||||
|
helpNames: NameSpecification? = nil,
|
||||||
|
aliases: [String] = []
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
commandName: commandName,
|
||||||
|
abstract: abstract,
|
||||||
|
usage: usage,
|
||||||
|
discussion: discussion.render(),
|
||||||
|
version: version,
|
||||||
|
shouldDisplay: shouldDisplay,
|
||||||
|
subcommands: ungroupedSubcommands,
|
||||||
|
groupedSubcommands: groupedSubcommands,
|
||||||
|
defaultSubcommand: defaultSubcommand,
|
||||||
|
helpNames: helpNames,
|
||||||
|
aliases: aliases
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Sources/CliDoc/Exports.swift
Normal file
1
Sources/CliDoc/Exports.swift
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@_exported import CliDocCore
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import Rainbow
|
|
||||||
|
|
||||||
public extension TextNode {
|
|
||||||
@inlinable
|
|
||||||
func color(_ color: NamedColor) -> some TextNode {
|
|
||||||
modifier(ColorModifier(color: color))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
struct ColorModifier<Content: TextNode>: NodeModifier {
|
|
||||||
@usableFromInline
|
|
||||||
let color: NamedColor
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(color: NamedColor) {
|
|
||||||
self.color = color
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
func render(content: Content) -> some TextNode {
|
|
||||||
content.render().applyingColor(color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import Rainbow
|
|
||||||
|
|
||||||
public extension TextNode {
|
|
||||||
func labelStyle<C: TextNode>(color: NamedColor? = nil, styles: [Style] = []) -> some TextNode where Self == Label<C> {
|
|
||||||
modifier(LabelStyle(color: color, styles: styles))
|
|
||||||
}
|
|
||||||
|
|
||||||
func labelStyle<C: TextNode>(color: NamedColor? = nil, styles: Style...) -> some TextNode where Self == Label<C> {
|
|
||||||
labelStyle(color: color, styles: styles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct LabelStyle<C: TextNode>: NodeModifier {
|
|
||||||
@usableFromInline
|
|
||||||
let color: NamedColor?
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let styles: [Style]
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(color: NamedColor? = nil, styles: [Style] = []) {
|
|
||||||
self.color = color
|
|
||||||
self.styles = styles
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public func render(content: Label<C>) -> some TextNode {
|
|
||||||
var label: any TextNode = content.content
|
|
||||||
label = label.style(styles)
|
|
||||||
if let color {
|
|
||||||
label = label.color(color)
|
|
||||||
}
|
|
||||||
return Label { label.eraseToAnyTextNode() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import Rainbow
|
|
||||||
|
|
||||||
public extension TextNode {
|
|
||||||
@inlinable
|
|
||||||
func style(_ styles: Style...) -> some TextNode {
|
|
||||||
modifier(StyleModifier(styles: styles))
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func style(_ styles: [Style]) -> some TextNode {
|
|
||||||
modifier(StyleModifier(styles: styles))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
struct StyleModifier<Content: TextNode>: NodeModifier {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let styles: [Style]
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(styles: [Style]) {
|
|
||||||
self.styles = styles
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
func render(content: Content) -> some TextNode {
|
|
||||||
styles.reduce(content.render()) {
|
|
||||||
$0.applyingStyle($1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import Rainbow
|
|
||||||
|
|
||||||
public protocol NodeModifier {
|
|
||||||
// swiftlint:disable type_name
|
|
||||||
associatedtype _Body: TextNode
|
|
||||||
typealias Body = _Body
|
|
||||||
// swiftlint:enable type_name
|
|
||||||
|
|
||||||
associatedtype Content: TextNode
|
|
||||||
|
|
||||||
@TextBuilder
|
|
||||||
func render(content: Content) -> Body
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension NodeModifier {
|
|
||||||
|
|
||||||
func concat<T: NodeModifier>(_ modifier: T) -> ConcatModifier<Self, T> {
|
|
||||||
return .init(firstModifier: self, secondModifier: modifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ConcatModifier<M0: NodeModifier, M1: NodeModifier>: NodeModifier where M1.Content == M0.Body {
|
|
||||||
let firstModifier: M0
|
|
||||||
let secondModifier: M1
|
|
||||||
|
|
||||||
public func render(content: M0.Content) -> some TextNode {
|
|
||||||
let firstOutput = firstModifier.render(content: content)
|
|
||||||
return secondModifier.render(content: firstOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ModifiedNode<Content: TextNode, Modifier: NodeModifier> {
|
|
||||||
@usableFromInline
|
|
||||||
let content: Content
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let modifier: Modifier
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(content: Content, modifier: Modifier) {
|
|
||||||
self.content = content
|
|
||||||
self.modifier = modifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ModifiedNode: TextNode where Modifier.Content == Content {
|
|
||||||
public var body: some TextNode {
|
|
||||||
modifier.render(content: content)
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func apply<M: NodeModifier>(_ modifier: M) -> ModifiedNode<Content, ConcatModifier<Modifier, M>> {
|
|
||||||
return .init(content: content, modifier: self.modifier.concat(modifier))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ModifiedNode: NodeRepresentable where Self: TextNode {
|
|
||||||
public func render() -> String {
|
|
||||||
body.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension TextNode {
|
|
||||||
@inlinable
|
|
||||||
func modifier<M: NodeModifier>(_ modifier: M) -> ModifiedNode<Self, M> {
|
|
||||||
.init(content: self, modifier: modifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
Sources/CliDoc/Nodes/Abstract.swift
Normal file
14
Sources/CliDoc/Nodes/Abstract.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import CliDocCore
|
||||||
|
|
||||||
|
public struct Abstract<Content: TextNode>: TextNode {
|
||||||
|
@usableFromInline
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
public init(@TextBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some TextNode {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Sources/CliDoc/Nodes/Discussion.swift
Normal file
14
Sources/CliDoc/Nodes/Discussion.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import CliDocCore
|
||||||
|
|
||||||
|
public struct Discussion<Content: TextNode>: TextNode {
|
||||||
|
@usableFromInline
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
public init(@TextBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some TextNode {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
160
Sources/CliDoc/Nodes/ExampleSection.swift
Normal file
160
Sources/CliDoc/Nodes/ExampleSection.swift
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import Rainbow
|
||||||
|
|
||||||
|
public typealias Example = (label: String, example: String)
|
||||||
|
|
||||||
|
public struct ExampleSection<Header: TextNode, Label: TextNode>: TextNode {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let configuration: ExampleSectionConfiguration
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public init(
|
||||||
|
examples: [Example],
|
||||||
|
@TextBuilder title: () -> Header,
|
||||||
|
@TextBuilder label: () -> Label
|
||||||
|
) {
|
||||||
|
self.configuration = .init(
|
||||||
|
title: title(),
|
||||||
|
label: label(),
|
||||||
|
examples: examples
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public init(
|
||||||
|
_ title: @autoclosure () -> Header,
|
||||||
|
label: @autoclosure () -> Label,
|
||||||
|
examples: [Example]
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
examples: examples,
|
||||||
|
title: title,
|
||||||
|
label: label
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public var body: some TextNode {
|
||||||
|
style(.default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type-erased configuration of an ``ExampleSection``
|
||||||
|
public struct ExampleSectionConfiguration {
|
||||||
|
public let title: any TextNode
|
||||||
|
|
||||||
|
public let label: any TextNode
|
||||||
|
|
||||||
|
public let examples: [Example]
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(title: any TextNode, label: any TextNode, examples: [Example]) {
|
||||||
|
self.title = title
|
||||||
|
self.label = label
|
||||||
|
self.examples = examples
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configuration context for the `examples` of an ``ExampleSection``.
|
||||||
|
public struct ExampleConfiguration {
|
||||||
|
public let examples: [Example]
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(examples: [Example]) {
|
||||||
|
self.examples = examples
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Style
|
||||||
|
|
||||||
|
public protocol ExampleSectionStyle: TextModifier where Content == ExampleSectionConfiguration {}
|
||||||
|
public protocol ExampleStyle: TextModifier where Content == ExampleConfiguration {}
|
||||||
|
|
||||||
|
public extension ExampleSection {
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
func style<S: ExampleSectionStyle>(_ style: S) -> some TextNode {
|
||||||
|
style.render(content: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
func exampleStyle<S: ExampleStyle>(_ style: S) -> some TextNode {
|
||||||
|
DefaultExampleSectionStyle(exampleStyle: style).render(content: configuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == Example {
|
||||||
|
@inlinable
|
||||||
|
func exampleStyle<S: ExampleStyle>(_ style: S) -> some TextNode {
|
||||||
|
style.render(content: .init(examples: self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ExampleSectionStyle {
|
||||||
|
@inlinable
|
||||||
|
static func `default`<S: ExampleStyle>(exampleStyle: S) -> DefaultExampleSectionStyle<S> {
|
||||||
|
DefaultExampleSectionStyle(exampleStyle: exampleStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ExampleSectionStyle where Self == DefaultExampleSectionStyle<DefaultExampleStyle> {
|
||||||
|
@inlinable
|
||||||
|
static func `default`() -> DefaultExampleSectionStyle<DefaultExampleStyle> {
|
||||||
|
DefaultExampleSectionStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ExampleStyle where Self == DefaultExampleStyle {
|
||||||
|
static var `default`: Self {
|
||||||
|
DefaultExampleStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DefaultExampleSectionStyle<Style: ExampleStyle>: ExampleSectionStyle {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let exampleStyle: Style
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public init(exampleStyle: Style) {
|
||||||
|
self.exampleStyle = exampleStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public func render(content: ExampleSectionConfiguration) -> some TextNode {
|
||||||
|
Section {
|
||||||
|
exampleStyle.render(content: .init(examples: content.examples))
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
content.title
|
||||||
|
.color(.yellow)
|
||||||
|
.bold()
|
||||||
|
|
||||||
|
content.label.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension DefaultExampleSectionStyle where Style == DefaultExampleStyle {
|
||||||
|
@inlinable
|
||||||
|
init() {
|
||||||
|
self.init(exampleStyle: .default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DefaultExampleStyle: ExampleStyle {
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public func render(content: ExampleConfiguration) -> some TextNode {
|
||||||
|
VStack {
|
||||||
|
content.examples.map { example in
|
||||||
|
VStack {
|
||||||
|
example.label.color(.green).bold()
|
||||||
|
ShellCommand(example.example).style(.default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.separator(.newLine(count: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import Rainbow
|
|
||||||
|
|
||||||
public struct Examples<Header: TextNode, Label: TextNode>: TextNode {
|
|
||||||
public typealias Example = (label: String, example: String)
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let examples: [Example]
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let header: Header
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let label: Label
|
|
||||||
|
|
||||||
public init(
|
|
||||||
examples: [Example],
|
|
||||||
@TextBuilder header: () -> Header,
|
|
||||||
@TextBuilder label: () -> Label
|
|
||||||
) {
|
|
||||||
self.examples = examples
|
|
||||||
self.header = header()
|
|
||||||
self.label = label()
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some TextNode {
|
|
||||||
Group(separator: "") {
|
|
||||||
Group(separator: " ", content: [header.color(.yellow).style(.bold), label, "\n"])
|
|
||||||
"\n"
|
|
||||||
Group(
|
|
||||||
separator: "\n\n",
|
|
||||||
content: self.examples.map { example in
|
|
||||||
Group(separator: "\n") {
|
|
||||||
CliDoc.Label(example.label.green.bold)
|
|
||||||
ShellCommand { example.example.italic }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
public struct Group: TextNode {
|
|
||||||
@usableFromInline
|
|
||||||
var content: [any TextNode]
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
var separator: any TextNode
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
separator: any TextNode = "\n",
|
|
||||||
content: [any TextNode]
|
|
||||||
) {
|
|
||||||
self.content = content
|
|
||||||
self.separator = separator
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
separator: any TextNode = "\n",
|
|
||||||
@TextBuilder content: () -> any TextNode
|
|
||||||
) {
|
|
||||||
// Check if the content is a NodeContainer, typically is when
|
|
||||||
// using the TextBuilder with more than one text node.
|
|
||||||
//
|
|
||||||
// We need to take over the contents, so we can control the separator.
|
|
||||||
let content = content()
|
|
||||||
if let many = content as? NodeContainer {
|
|
||||||
self.content = many.nodes
|
|
||||||
} else {
|
|
||||||
// We didn't get a NodeContainer, so fallback to just storing
|
|
||||||
// the content.
|
|
||||||
self.content = [content]
|
|
||||||
}
|
|
||||||
self.separator = separator
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public var body: some TextNode {
|
|
||||||
content.map { $0.render() }.joined(separator: separator.render())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
public struct Label<Content: TextNode>: TextNode {
|
|
||||||
@usableFromInline
|
|
||||||
let content: Content
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(@TextBuilder _ content: () -> Content) {
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(_ content: Content) {
|
|
||||||
self.content = content
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public var body: some TextNode {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import Rainbow
|
|
||||||
|
|
||||||
public struct Note<Label: TextNode, Content: TextNode>: TextNode {
|
|
||||||
@usableFromInline
|
|
||||||
let label: Label
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let content: Content
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
var separator: any TextNode = " "
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
separator: any TextNode = " ",
|
|
||||||
@TextBuilder _ label: () -> Label,
|
|
||||||
@TextBuilder content: () -> Content
|
|
||||||
) {
|
|
||||||
self.separator = separator
|
|
||||||
self.label = label()
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public var body: some TextNode {
|
|
||||||
Group(separator: separator, content: [label, content])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Note where Label == String {
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
init(
|
|
||||||
separator: any TextNode = " ",
|
|
||||||
_ label: String = "NOTE:".yellow.bold,
|
|
||||||
@TextBuilder content: () -> Content
|
|
||||||
) {
|
|
||||||
self.separator = separator
|
|
||||||
self.label = label
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func important(
|
|
||||||
separator: any TextNode = " ",
|
|
||||||
_ label: String = "IMPORTANT NOTE:".red.underline,
|
|
||||||
@TextBuilder content: () -> Content
|
|
||||||
) -> Self {
|
|
||||||
self.init(separator: separator, label, content: content)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func seeAlso(
|
|
||||||
separator: any TextNode = " ",
|
|
||||||
_ label: String = "SEE ALSO:".yellow.bold,
|
|
||||||
@TextBuilder content: () -> Content
|
|
||||||
) -> Self {
|
|
||||||
self.init(separator: separator, label, content: content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,99 @@
|
|||||||
public struct ShellCommand<Content: TextNode>: TextNode {
|
import CliDocCore
|
||||||
|
import Rainbow
|
||||||
|
|
||||||
|
/// Represents a shell command text node, with a symbol and the content of
|
||||||
|
/// the command. Used for displaying example shell commands.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
public struct ShellCommand<Content: TextNode, Symbol: TextNode>: TextNode {
|
||||||
|
|
||||||
@usableFromInline
|
@usableFromInline
|
||||||
var symbol: any TextNode
|
let symbol: Symbol
|
||||||
|
|
||||||
@usableFromInline
|
@usableFromInline
|
||||||
var content: Content
|
let content: Content
|
||||||
|
|
||||||
|
/// Create a new shell command with the given content and symbol.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: The shell command to display.
|
||||||
|
/// - symbol: The symbol to use in front of the shell command.
|
||||||
@inlinable
|
@inlinable
|
||||||
public init(
|
public init(
|
||||||
symbol: any TextNode = "$",
|
@TextBuilder content: () -> Content,
|
||||||
@TextBuilder content: () -> Content
|
@TextBuilder symbol: () -> Symbol
|
||||||
) {
|
) {
|
||||||
self.symbol = symbol
|
self.symbol = symbol()
|
||||||
self.content = content()
|
self.content = content()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new shell command with the given content and symbol.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - symbol: The symbol to use in front of the shell command.
|
||||||
|
/// - content: The shell command to display.
|
||||||
|
@inlinable
|
||||||
|
public init(
|
||||||
|
symbol: @autoclosure () -> Symbol,
|
||||||
|
@TextBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.init(content: content, symbol: symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
public var body: some TextNode {
|
public var body: some TextNode {
|
||||||
Group(separator: " ", content: [symbol, content])
|
style(.default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ShellCommand where Content == String, Symbol == String {
|
||||||
|
/// Create a new shell command with the given content and symbol.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: The shell command to display.
|
||||||
|
/// - symbol: The symbol to use in front of the shell command.
|
||||||
|
@inlinable
|
||||||
|
init(
|
||||||
|
_ content: @autoclosure () -> String,
|
||||||
|
symbol: @autoclosure () -> String = "$"
|
||||||
|
) {
|
||||||
|
self.init(content: content, symbol: symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ShellCommandConfiguration {
|
||||||
|
public let symbol: any TextNode
|
||||||
|
|
||||||
|
public let content: any TextNode
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(symbol: any TextNode, content: any TextNode) {
|
||||||
|
self.symbol = symbol
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ShellCommand {
|
||||||
|
@inlinable
|
||||||
|
func style<S: ShellCommandStyle>(_ style: S) -> some TextNode {
|
||||||
|
style.render(content: .init(symbol: symbol, content: content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Style
|
||||||
|
|
||||||
|
public protocol ShellCommandStyle: TextModifier where Content == ShellCommandConfiguration {}
|
||||||
|
|
||||||
|
public extension ShellCommandStyle where Self == DefaultShellCommandStyle {
|
||||||
|
static var `default`: Self { DefaultShellCommandStyle() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DefaultShellCommandStyle: ShellCommandStyle {
|
||||||
|
|
||||||
|
public func render(content: ShellCommandConfiguration) -> some TextNode {
|
||||||
|
HStack {
|
||||||
|
content.symbol
|
||||||
|
content.content.italic()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
Sources/CliDoc/Nodes/Usage.swift
Normal file
14
Sources/CliDoc/Nodes/Usage.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import CliDocCore
|
||||||
|
|
||||||
|
public struct Usage<Content: TextNode>: TextNode {
|
||||||
|
@usableFromInline
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
public init(@TextBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some TextNode {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
public protocol NodeRepresentable {
|
|
||||||
func render() -> String
|
|
||||||
}
|
|
||||||
|
|
||||||
public protocol TextNode: NodeRepresentable {
|
|
||||||
// swiftlint:disable type_name
|
|
||||||
associatedtype _Body: TextNode
|
|
||||||
typealias Body = _Body
|
|
||||||
// swiftlint:enable type_name
|
|
||||||
|
|
||||||
var body: Body { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension TextNode {
|
|
||||||
func render() -> String {
|
|
||||||
body.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension String: NodeRepresentable {
|
|
||||||
public func render() -> String {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension String: TextNode {
|
|
||||||
public var body: some TextNode {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AnyTextNode: TextNode {
|
|
||||||
let makeString: () -> String
|
|
||||||
|
|
||||||
public init<N: TextNode>(_ node: N) {
|
|
||||||
self.makeString = node.render
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some TextNode { makeString() }
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension TextNode {
|
|
||||||
func eraseToAnyTextNode() -> AnyTextNode {
|
|
||||||
AnyTextNode(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Optional: TextNode where Wrapped: TextNode {
|
|
||||||
public var body: some TextNode {
|
|
||||||
guard let node = self else { return "".eraseToAnyTextNode() }
|
|
||||||
return node.eraseToAnyTextNode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Optional: NodeRepresentable where Wrapped: NodeRepresentable {
|
|
||||||
|
|
||||||
public func render() -> String {
|
|
||||||
guard let node = self else { return "" }
|
|
||||||
return node.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/// A result builder for creating ``TextNode`` types, similar to how
|
||||||
|
/// `ViewBuilder` works in `SwiftUI`.
|
||||||
|
///
|
||||||
@resultBuilder
|
@resultBuilder
|
||||||
public enum TextBuilder {
|
public enum TextBuilder {
|
||||||
|
|
||||||
@@ -7,27 +10,27 @@ public enum TextBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@inlinable
|
@inlinable
|
||||||
public static func buildPartialBlock<N0: TextNode, N1: TextNode>(accumulated: N0, next: N1) -> NodeContainer {
|
public static func buildPartialBlock<N0: TextNode, N1: TextNode>(accumulated: N0, next: N1) -> _NodeContainer {
|
||||||
.init(nodes: [accumulated, next])
|
.init(nodes: [accumulated, next])
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable
|
@inlinable
|
||||||
public static func buildArray<N: TextNode>(_ components: [N]) -> NodeContainer {
|
public static func buildArray<N: TextNode>(_ components: [N]) -> _NodeContainer {
|
||||||
.init(nodes: components)
|
.init(nodes: components)
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable
|
@inlinable
|
||||||
public static func buildBlock<N: TextNode>(_ components: N...) -> NodeContainer {
|
public static func buildBlock<N: TextNode>(_ components: N...) -> _NodeContainer {
|
||||||
.init(nodes: components)
|
.init(nodes: components)
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable
|
@inlinable
|
||||||
public static func buildEither<N: TextNode, N1: TextNode>(first component: N) -> EitherNode<N, N1> {
|
public static func buildEither<N: TextNode, N1: TextNode>(first component: N) -> _EitherNode<N, N1> {
|
||||||
.first(component)
|
.first(component)
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable
|
@inlinable
|
||||||
public static func buildEither<N: TextNode, N1: TextNode>(second component: N1) -> EitherNode<N, N1> {
|
public static func buildEither<N: TextNode, N1: TextNode>(second component: N1) -> _EitherNode<N, N1> {
|
||||||
.second(component)
|
.second(component)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +41,8 @@ public enum TextBuilder {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum EitherNode<N: TextNode, N1: TextNode>: TextNode {
|
// swiftlint:disable type_name
|
||||||
|
public enum _EitherNode<N: TextNode, N1: TextNode>: TextNode {
|
||||||
case first(N)
|
case first(N)
|
||||||
case second(N1)
|
case second(N1)
|
||||||
|
|
||||||
@@ -50,7 +54,7 @@ public enum EitherNode<N: TextNode, N1: TextNode>: TextNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodeContainer: TextNode {
|
public struct _NodeContainer: TextNode {
|
||||||
|
|
||||||
@usableFromInline
|
@usableFromInline
|
||||||
var nodes: [any TextNode]
|
var nodes: [any TextNode]
|
||||||
@@ -58,7 +62,7 @@ public struct NodeContainer: TextNode {
|
|||||||
@usableFromInline
|
@usableFromInline
|
||||||
init(nodes: [any TextNode]) {
|
init(nodes: [any TextNode]) {
|
||||||
self.nodes = nodes.reduce(into: [any TextNode]()) { array, next in
|
self.nodes = nodes.reduce(into: [any TextNode]()) { array, next in
|
||||||
if let many = next as? NodeContainer {
|
if let many = next as? _NodeContainer {
|
||||||
array += many.nodes
|
array += many.nodes
|
||||||
} else {
|
} else {
|
||||||
array.append(next)
|
array.append(next)
|
||||||
@@ -71,3 +75,5 @@ public struct NodeContainer: TextNode {
|
|||||||
nodes.reduce("") { $0 + $1.render() }
|
nodes.reduce("") { $0 + $1.render() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_name
|
||||||
30
Sources/CliDocCore/Documentation.docc/CliDocCore.md
Normal file
30
Sources/CliDocCore/Documentation.docc/CliDocCore.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ``CliDocCore``
|
||||||
|
|
||||||
|
A framework for writing `cli` documentation in a way similar to `SwiftUI`, where
|
||||||
|
your types conform to ``TextNode`` and implement a ``TextNode/body`` that
|
||||||
|
returns a ``TextNode``, generally using the essential types described below.
|
||||||
|
|
||||||
|
## Topics
|
||||||
|
|
||||||
|
### Essentials
|
||||||
|
|
||||||
|
- ``VStack``
|
||||||
|
- ``HStack``
|
||||||
|
- ``Section``
|
||||||
|
- ``Group``
|
||||||
|
- ``Empty``
|
||||||
|
- ``AnyTextNode``
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- ``SectionStyle``
|
||||||
|
- ``DefaultSectionStyle``
|
||||||
|
- ``SectionConfiguration``
|
||||||
|
- ``TextStyleConfiguration``
|
||||||
|
|
||||||
|
### Base Protocols
|
||||||
|
|
||||||
|
- ``TextNode``
|
||||||
|
- ``TextNodeRepresentable``
|
||||||
|
- ``TextStyle``
|
||||||
|
- ``TextModifier``
|
||||||
BIN
Sources/CliDocCore/Documentation.docc/Resources/group.png
LFS
Normal file
BIN
Sources/CliDocCore/Documentation.docc/Resources/group.png
LFS
Normal file
Binary file not shown.
BIN
Sources/CliDocCore/Documentation.docc/Resources/hstack.png
LFS
Normal file
BIN
Sources/CliDocCore/Documentation.docc/Resources/hstack.png
LFS
Normal file
Binary file not shown.
BIN
Sources/CliDocCore/Documentation.docc/Resources/section.png
LFS
Normal file
BIN
Sources/CliDocCore/Documentation.docc/Resources/section.png
LFS
Normal file
Binary file not shown.
BIN
Sources/CliDocCore/Documentation.docc/Resources/vstack.png
LFS
Normal file
BIN
Sources/CliDocCore/Documentation.docc/Resources/vstack.png
LFS
Normal file
Binary file not shown.
21
Sources/CliDocCore/Nodes/AnyTextNode.swift
Normal file
21
Sources/CliDocCore/Nodes/AnyTextNode.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/// A type-erased text node.
|
||||||
|
public struct AnyTextNode: TextNode {
|
||||||
|
@usableFromInline
|
||||||
|
let makeString: () -> String
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public init<N: TextNode>(_ node: N) {
|
||||||
|
self.makeString = node.render
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public var body: some TextNode { makeString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension TextNode {
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
func eraseToAnyTextNode() -> AnyTextNode {
|
||||||
|
AnyTextNode(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Sources/CliDocCore/Nodes/Empty.swift
Normal file
13
Sources/CliDocCore/Nodes/Empty.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/// An empty text node.
|
||||||
|
///
|
||||||
|
/// This gets removed from any output when rendering text nodes.
|
||||||
|
public struct Empty: TextNode {
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public var body: some TextNode {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Sources/CliDocCore/Nodes/Group.swift
Normal file
38
Sources/CliDocCore/Nodes/Group.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/// A group of text nodes.
|
||||||
|
///
|
||||||
|
/// This allows you to group content together, which can optionally be
|
||||||
|
/// styled.
|
||||||
|
///
|
||||||
|
/// ### Example:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// let group = Group {
|
||||||
|
/// "My headline."
|
||||||
|
/// "\n"
|
||||||
|
/// "Some content".color(.green)
|
||||||
|
/// "\n"
|
||||||
|
/// "Foo Bar".italic()
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// print(group.render())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 
|
||||||
|
///
|
||||||
|
public struct Group<Content: TextNode>: TextNode {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
var content: Content
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public init(
|
||||||
|
@TextBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public var body: some TextNode {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Sources/CliDocCore/Nodes/HStack.swift
Normal file
94
Sources/CliDocCore/Nodes/HStack.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/// A horizontal stack of text nodes.
|
||||||
|
///
|
||||||
|
/// ### Example:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// let note = HStack {
|
||||||
|
/// "NOTE:".color(.cyan).bold()
|
||||||
|
/// "This is my super cool note".italic()
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// print(note.render())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 
|
||||||
|
///
|
||||||
|
///
|
||||||
|
public struct HStack: TextNode {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let content: [any TextNode]
|
||||||
|
|
||||||
|
/// Create a new ``HStack`` with the given text nodes.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: The content of the hstack.
|
||||||
|
@inlinable
|
||||||
|
public init(
|
||||||
|
@TextBuilder content: () -> any TextNode
|
||||||
|
) {
|
||||||
|
self.content = array(from: content())
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public var body: some TextNode {
|
||||||
|
style(.separator(.space()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension HStack {
|
||||||
|
/// Apply the given style to a ``HStack``.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - style: The style to apply to the ``HStack``.
|
||||||
|
func style<S: HStackStyle>(_ style: S) -> some TextNode {
|
||||||
|
style.render(content: .init(content: content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the given separator to a ``HStack``.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - separator: The horizontal separator to use with the ``HStack``.
|
||||||
|
func separator(_ separator: Separator.Horizontal) -> some TextNode {
|
||||||
|
style(.separator(separator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Style
|
||||||
|
|
||||||
|
/// Style a ``HStack`` by creating a type that conforms to ``HStackStyle`` and use the
|
||||||
|
/// style by calling the ``HStack/style(_:)`` method on your instance.
|
||||||
|
///
|
||||||
|
public protocol HStackStyle: TextModifier where Content == _StackConfiguration {}
|
||||||
|
|
||||||
|
public extension HStackStyle where Self == HStackSeparatorStyle {
|
||||||
|
/// Apply the given separator on a ``HStack``.
|
||||||
|
///
|
||||||
|
/// - See Also: ``HStack/separator(_:)``
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - separator: The vertical separator to use with the ``HStack``.
|
||||||
|
static func separator(_ separator: Separator.Horizontal) -> Self {
|
||||||
|
HStackSeparatorStyle(separator: separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate items in a ``HStack`` with a given horizontal separator.
|
||||||
|
///
|
||||||
|
/// - See Also: ``HStack/separator(_:)``.
|
||||||
|
///
|
||||||
|
public struct HStackSeparatorStyle: HStackStyle {
|
||||||
|
@usableFromInline
|
||||||
|
let separator: Separator.Horizontal
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(separator: Separator.Horizontal) {
|
||||||
|
self.separator = separator
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public func render(content: _StackConfiguration) -> some TextNode {
|
||||||
|
AnySeparatableStackNode(content: content, separator: separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
148
Sources/CliDocCore/Nodes/LabeledContent.swift
Normal file
148
Sources/CliDocCore/Nodes/LabeledContent.swift
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/// A text node that consists of a label and content.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
public struct LabeledContent<Label: TextNode, Content: TextNode>: TextNode {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let label: Label
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
/// Create a new labeled content text node.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: The content portion of the labeled content.
|
||||||
|
/// - label: The label for the content.
|
||||||
|
@inlinable
|
||||||
|
public init(
|
||||||
|
@TextBuilder _ content: () -> Content,
|
||||||
|
@TextBuilder label: () -> Label
|
||||||
|
) {
|
||||||
|
self.label = label()
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public var body: some TextNode {
|
||||||
|
style(.default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension LabeledContent {
|
||||||
|
|
||||||
|
/// Apply the given style to the labeled content.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - style: The labeled content style to apply.
|
||||||
|
@inlinable
|
||||||
|
func style<S: LabeledContentStyle>(_ style: S) -> some TextNode {
|
||||||
|
style.render(content: .init(label: label, content: content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the type-erased label and content of a ``LabeledContent`` text node.
|
||||||
|
///
|
||||||
|
/// This is used when creating custom styles for the ``LabeledContent``.
|
||||||
|
///
|
||||||
|
public struct LabeledContentConfiguration {
|
||||||
|
|
||||||
|
/// The type-erased label text node.
|
||||||
|
public let label: any TextNode
|
||||||
|
|
||||||
|
/// The type-erased content text node.
|
||||||
|
public let content: any TextNode
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(label: any TextNode, content: any TextNode) {
|
||||||
|
self.label = label
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Style
|
||||||
|
|
||||||
|
/// Represents a style for ``LabeledContent``.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
public protocol LabeledContentStyle: TextModifier where Content == LabeledContentConfiguration {}
|
||||||
|
|
||||||
|
public extension LabeledContentStyle where Self == HorizontalLabeledContentStyle {
|
||||||
|
|
||||||
|
/// The default labeled content style, which places the label
|
||||||
|
/// and content inline with a space as a separator.
|
||||||
|
///
|
||||||
|
static var `default`: Self {
|
||||||
|
horizontal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal labeled content style, which places the label
|
||||||
|
/// and content inline with the given separator.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - separator: The horizontal separator to use.
|
||||||
|
@inlinable
|
||||||
|
static func horizontal(separator: Separator.Horizontal = .space()) -> Self {
|
||||||
|
HorizontalLabeledContentStyle(separator: separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension LabeledContentStyle where Self == VerticalLabeledContentStyle {
|
||||||
|
/// A vertical labeled content style, which places the label
|
||||||
|
/// and content with the given vertical separator.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - separator: The vertical separator to use.
|
||||||
|
@inlinable
|
||||||
|
static func vertical(separator: Separator.Vertical = .newLine()) -> Self {
|
||||||
|
VerticalLabeledContentStyle(separator: separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A labeled content style which places items inline based on a given
|
||||||
|
/// horizontal separator.
|
||||||
|
///
|
||||||
|
/// - See Also: ``LabeledContentStyle/horizontal(separator:)``
|
||||||
|
///
|
||||||
|
public struct HorizontalLabeledContentStyle: LabeledContentStyle {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let separator: Separator.Horizontal
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(separator: Separator.Horizontal) {
|
||||||
|
self.separator = separator
|
||||||
|
}
|
||||||
|
|
||||||
|
public func render(content: LabeledContentConfiguration) -> some TextNode {
|
||||||
|
HStack {
|
||||||
|
content.label
|
||||||
|
content.content
|
||||||
|
}
|
||||||
|
.separator(separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A labeled content style which places items based on a given
|
||||||
|
/// vertical separator.
|
||||||
|
///
|
||||||
|
/// - See Also: ``LabeledContentStyle/vertical(separator:)``
|
||||||
|
///
|
||||||
|
public struct VerticalLabeledContentStyle: LabeledContentStyle {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let separator: Separator.Vertical
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(separator: Separator.Vertical) {
|
||||||
|
self.separator = separator
|
||||||
|
}
|
||||||
|
|
||||||
|
public func render(content: LabeledContentConfiguration) -> some TextNode {
|
||||||
|
VStack {
|
||||||
|
content.label
|
||||||
|
content.content
|
||||||
|
}
|
||||||
|
.separator(separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
181
Sources/CliDocCore/Nodes/Section.swift
Normal file
181
Sources/CliDocCore/Nodes/Section.swift
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/// A section of text nodes, that can contain a header, content, and footer.
|
||||||
|
///
|
||||||
|
/// This allows nodes to be grouped and styled together.
|
||||||
|
///
|
||||||
|
/// **Example:**
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// let mySection = Section {
|
||||||
|
/// "My super awesome section content"
|
||||||
|
/// } header: {
|
||||||
|
/// "Awesome"
|
||||||
|
/// } footer: {
|
||||||
|
/// "Note: this is super awesome".italic()
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// **Styling Sections:**
|
||||||
|
///
|
||||||
|
/// You can style a section by creating a custom ``SectionStyle``, which gives you the
|
||||||
|
/// opportunity to arrange and style the nodes within the section.
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// struct MySectionStyle: SectionStyle {
|
||||||
|
/// func render(content: SectionConfiguration) -> some TextNode {
|
||||||
|
/// VStack(separator: .newLine(count: 2)) {
|
||||||
|
/// content.header
|
||||||
|
/// .color(.green)
|
||||||
|
/// .bold()
|
||||||
|
/// .underline()
|
||||||
|
/// content.content
|
||||||
|
/// content.footer.italic()
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// mySection.style(MySectionStyle())
|
||||||
|
///
|
||||||
|
/// print(mySection.render())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// _Below is an image of the output from the `mySection.render()` above._
|
||||||
|
///
|
||||||
|
/// 
|
||||||
|
///
|
||||||
|
public struct Section<Header: TextNode, Content: TextNode, Footer: TextNode>: TextNode {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let header: Header
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let footer: Footer
|
||||||
|
|
||||||
|
/// Create a new section with the given content, header, and footer.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: The content of the section.
|
||||||
|
/// - header: The header for the section.
|
||||||
|
/// - footer: The footer for the section.
|
||||||
|
@inlinable
|
||||||
|
public init(
|
||||||
|
@TextBuilder content: () -> Content,
|
||||||
|
@TextBuilder header: () -> Header,
|
||||||
|
@TextBuilder footer: () -> Footer
|
||||||
|
) {
|
||||||
|
self.header = header()
|
||||||
|
self.content = content()
|
||||||
|
self.footer = footer()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some TextNode {
|
||||||
|
style(.default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Section where Footer == Empty {
|
||||||
|
/// Create a new section with the given content and header, with no footer.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: The content of the section.
|
||||||
|
/// - header: The header for the section.
|
||||||
|
@inlinable
|
||||||
|
init(
|
||||||
|
@TextBuilder content: () -> Content,
|
||||||
|
@TextBuilder header: () -> Header
|
||||||
|
) {
|
||||||
|
self.init(content: content, header: header) { Empty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Section where Header == Empty {
|
||||||
|
/// Create a new section with the given content and footer, with no header.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: The content of the section.
|
||||||
|
/// - footer: The footer for the section.
|
||||||
|
@inlinable
|
||||||
|
init(
|
||||||
|
@TextBuilder content: () -> Content,
|
||||||
|
@TextBuilder footer: () -> Footer
|
||||||
|
) {
|
||||||
|
self.init(content: content, header: { Empty() }, footer: footer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Style
|
||||||
|
|
||||||
|
public extension Section {
|
||||||
|
|
||||||
|
/// Style a ``Section`` using the given ``SectionStyle``.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - style: The section style to use.
|
||||||
|
@inlinable
|
||||||
|
func style<S: SectionStyle>(_ style: S) -> some TextNode {
|
||||||
|
style.render(content: .init(header: header, content: content, footer: footer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the type-erased values of a ``Section``, that can be used to create
|
||||||
|
/// custom styling for a section.
|
||||||
|
public struct SectionConfiguration {
|
||||||
|
/// The type-erased header of a section.
|
||||||
|
public let header: any TextNode
|
||||||
|
|
||||||
|
/// The type-erased content of a section.
|
||||||
|
public let content: any TextNode
|
||||||
|
|
||||||
|
/// The type-erased footer of a section.
|
||||||
|
public let footer: any TextNode
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(header: any TextNode, content: any TextNode, footer: any TextNode) {
|
||||||
|
self.header = header
|
||||||
|
self.content = content
|
||||||
|
self.footer = footer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used to declare a custom style for a ``Section``. Your custom section style
|
||||||
|
/// must conform to this protocol.
|
||||||
|
///
|
||||||
|
public protocol SectionStyle: TextModifier where Content == SectionConfiguration {}
|
||||||
|
|
||||||
|
public extension SectionStyle where Self == DefaultSectionStyle {
|
||||||
|
|
||||||
|
/// Style a section using the default style, separating the content with
|
||||||
|
/// a new line between the elements.
|
||||||
|
static var `default`: Self { `default`(separator: .newLine(count: 2)) }
|
||||||
|
|
||||||
|
/// Style a section using the default style, separating the content with
|
||||||
|
/// given separator between the elements.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - separator: The separator to use to separate elements in a section.
|
||||||
|
static func `default`(separator: Separator.Vertical) -> Self {
|
||||||
|
DefaultSectionStyle(separator: separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the default ``SectionStyle``, which arranges the nodes in
|
||||||
|
/// a ``VStack``, using the separator passed in.
|
||||||
|
///
|
||||||
|
/// - SeeAlso: ``SectionStyle/default(separator:)``
|
||||||
|
///
|
||||||
|
public struct DefaultSectionStyle: SectionStyle {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let separator: Separator.Vertical
|
||||||
|
|
||||||
|
public func render(content: SectionConfiguration) -> some TextNode {
|
||||||
|
VStack {
|
||||||
|
content.header
|
||||||
|
content.content
|
||||||
|
content.footer
|
||||||
|
}
|
||||||
|
.style(.separator(separator))
|
||||||
|
}
|
||||||
|
}
|
||||||
116
Sources/CliDocCore/Nodes/Separator.swift
Normal file
116
Sources/CliDocCore/Nodes/Separator.swift
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/// A namespace for separator types that are used in text nodes.
|
||||||
|
///
|
||||||
|
/// These are generally used in the ``HStack``'s and ``VStack``'s to
|
||||||
|
/// specifiy how the nodes they contain are separated.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// **Note:**
|
||||||
|
///
|
||||||
|
/// > By default nodes do not contain any separators, unless they are written inline.
|
||||||
|
/// > However, both ``HStack`` and ``VStack`` allow you to specify the separator to use
|
||||||
|
/// > to control how the individual nodes they contain are separated.
|
||||||
|
///
|
||||||
|
public enum Separator {
|
||||||
|
|
||||||
|
/// Represents a horizontal separator that can be used between text nodes, typically inside
|
||||||
|
/// an ``HStack``
|
||||||
|
///
|
||||||
|
/// **Note:**
|
||||||
|
/// > By default nodes do not contain any separators, unless they are written inline.
|
||||||
|
///
|
||||||
|
public enum Horizontal: TextNode {
|
||||||
|
/// Separate nodes by spaces of the given count.
|
||||||
|
case space(count: Int = 1)
|
||||||
|
|
||||||
|
/// Separate nodes by tabs of the given count.
|
||||||
|
case tab(count: Int = 1)
|
||||||
|
|
||||||
|
/// Separate nodes by the provided string of the given count.
|
||||||
|
///
|
||||||
|
/// **Note:**
|
||||||
|
///
|
||||||
|
/// > This can allow for non-sensical separators, so should only be used
|
||||||
|
/// > if the provided horizontal separators do not work for you.
|
||||||
|
///
|
||||||
|
case custom(String, count: Int = 1)
|
||||||
|
|
||||||
|
@TextBuilder
|
||||||
|
@inlinable
|
||||||
|
public var body: some TextNode {
|
||||||
|
switch self {
|
||||||
|
case let .tab(count: count):
|
||||||
|
makeSeperator("\t", count: count)
|
||||||
|
case let .space(count: count):
|
||||||
|
makeSeperator(" ", count: count)
|
||||||
|
case let .custom(string, count: count):
|
||||||
|
makeSeperator(string, count: count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a vertical separator that can be used between text nodes, typically inside
|
||||||
|
/// a ``VStack``
|
||||||
|
///
|
||||||
|
/// **Note:**
|
||||||
|
/// > By default nodes do not contain any separators, so if you would
|
||||||
|
/// > like a blank line separating nodes, then a count of `2` is required.
|
||||||
|
/// > A count of `1` will place nodes on the next line.
|
||||||
|
///
|
||||||
|
public enum Vertical: TextNode {
|
||||||
|
/// Separate nodes by new line characters of the given count.
|
||||||
|
///
|
||||||
|
/// **Note:**
|
||||||
|
/// > By default nodes do not contain any separtors, so if you would
|
||||||
|
/// > like a blank line separating nodes, then a count of `2` is required.
|
||||||
|
/// > A count of `1` will place nodes on the next line.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - count: The count of the new lines to use.
|
||||||
|
case newLine(count: Int = 1)
|
||||||
|
|
||||||
|
/// Separate nodes by the supplied string with the given count.
|
||||||
|
///
|
||||||
|
/// **Note:**
|
||||||
|
///
|
||||||
|
/// > This can allow for non-sensical separators, so should only be used
|
||||||
|
/// > if the provided vertical separators do not work for you.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - count: The count of the new lines to use.
|
||||||
|
case custom(String, count: Int = 1)
|
||||||
|
|
||||||
|
@TextBuilder
|
||||||
|
@inlinable
|
||||||
|
public var body: some TextNode {
|
||||||
|
switch self {
|
||||||
|
case let .newLine(count: count):
|
||||||
|
makeSeperator("\n", count: count)
|
||||||
|
case let .custom(string, count: count):
|
||||||
|
makeSeperator(string, count: count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers.
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
func ensuredCount(_ count: Int) -> Int {
|
||||||
|
guard count >= 1 else { return 1 }
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
func makeSeperator(_ separator: String, count: Int) -> some TextNode {
|
||||||
|
let count = ensuredCount(count)
|
||||||
|
|
||||||
|
assert(count >= 1, "Invalid count while creating a separator")
|
||||||
|
|
||||||
|
var output = ""
|
||||||
|
for _ in 1 ... count {
|
||||||
|
output += separator
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
36
Sources/CliDocCore/Nodes/StackConfiguration.swift
Normal file
36
Sources/CliDocCore/Nodes/StackConfiguration.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// swiftlint:disable type_name
|
||||||
|
/// Represents the content of an ``HStack`` or a ``VStack``.
|
||||||
|
///
|
||||||
|
/// This is an internal convenience type, but needs to remain public
|
||||||
|
/// for protcol conformances to work properly.
|
||||||
|
public struct _StackConfiguration {
|
||||||
|
public let content: [any TextNode]
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_name
|
||||||
|
|
||||||
|
/// A helper type that removes empty text nodes, and applies a separtor between
|
||||||
|
/// the array of text nodes.
|
||||||
|
///
|
||||||
|
@usableFromInline
|
||||||
|
struct AnySeparatableStackNode<Separator: TextNode>: TextNode {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let content: [any TextNode]
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let separator: Separator
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(content: _StackConfiguration, separator: Separator) {
|
||||||
|
self.content = content.content
|
||||||
|
self.separator = separator
|
||||||
|
}
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
var body: some TextNode {
|
||||||
|
content.removingEmptys()
|
||||||
|
.map { $0.render() }
|
||||||
|
.joined(separator: separator.render())
|
||||||
|
}
|
||||||
|
}
|
||||||
98
Sources/CliDocCore/Nodes/VStack.swift
Normal file
98
Sources/CliDocCore/Nodes/VStack.swift
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/// A vertical stack of text nodes.
|
||||||
|
///
|
||||||
|
/// ### Example:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// let vStack = VStack {
|
||||||
|
/// "Blob Esquire"
|
||||||
|
/// .color(.yellow)
|
||||||
|
/// .bold()
|
||||||
|
/// .underline()
|
||||||
|
///
|
||||||
|
/// "Blob is a super awesome worker.".italic()
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// print(vStack.render())
|
||||||
|
/// ```
|
||||||
|
/// 
|
||||||
|
///
|
||||||
|
///
|
||||||
|
public struct VStack: TextNode {
|
||||||
|
@usableFromInline
|
||||||
|
let content: [any TextNode]
|
||||||
|
|
||||||
|
/// Create a new ``VStack`` with the given text nodes.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: The content of the vstack.
|
||||||
|
@inlinable
|
||||||
|
public init(
|
||||||
|
@TextBuilder content: () -> any TextNode
|
||||||
|
) {
|
||||||
|
self.content = array(from: content())
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public var body: some TextNode {
|
||||||
|
style(.separator(.newLine(count: 1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension VStack {
|
||||||
|
|
||||||
|
/// Apply the given style to a ``VStack``.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - style: The style to apply to the ``VStack``.
|
||||||
|
func style<S: VStackStyle>(_ style: S) -> some TextNode {
|
||||||
|
style.render(content: .init(content: content.removingEmptys()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the given separator to a ``VStack``.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - separator: The vertical separator to use with the ``VStack``.
|
||||||
|
func separator(_ separator: Separator.Vertical) -> some TextNode {
|
||||||
|
style(.separator(separator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Style
|
||||||
|
|
||||||
|
/// Style a ``VStack`` by creating a type that conforms to ``VStackStyle`` and use the
|
||||||
|
/// style by calling the ``VStack/style(_:)`` method on your instance.
|
||||||
|
///
|
||||||
|
public protocol VStackStyle: TextModifier where Content == _StackConfiguration {}
|
||||||
|
|
||||||
|
public extension VStackStyle where Self == VStackSeparatorStyle {
|
||||||
|
|
||||||
|
/// Apply the given separator on a ``VStack``.
|
||||||
|
///
|
||||||
|
/// - See Also: ``VStack/separator(_:)``
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - separator: The vertical separator to use with the ``VStack``.
|
||||||
|
static func separator(_ separator: Separator.Vertical) -> Self {
|
||||||
|
VStackSeparatorStyle(separator: separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate items in a ``VStack`` with a given vertical separator.
|
||||||
|
///
|
||||||
|
/// - See Also: ``VStack/separator(_:)``.
|
||||||
|
///
|
||||||
|
public struct VStackSeparatorStyle: VStackStyle {
|
||||||
|
@usableFromInline
|
||||||
|
let separator: Separator.Vertical
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(separator: Separator.Vertical) {
|
||||||
|
self.separator = separator
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public func render(content: _StackConfiguration) -> some TextNode {
|
||||||
|
AnySeparatableStackNode(content: content, separator: separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Sources/CliDocCore/TextModifier.swift
Normal file
19
Sources/CliDocCore/TextModifier.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/// A type that can modify a text node before it is rendered.
|
||||||
|
///
|
||||||
|
/// This allows you to create custom styles for your text-nodes.
|
||||||
|
///
|
||||||
|
public protocol TextModifier {
|
||||||
|
// swiftlint:disable type_name
|
||||||
|
associatedtype _Body: TextNode
|
||||||
|
typealias Body = _Body
|
||||||
|
// swiftlint:enable type_name
|
||||||
|
|
||||||
|
associatedtype Content
|
||||||
|
|
||||||
|
/// Apply custom styling to the text node.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - content: The text node to be styled.
|
||||||
|
@TextBuilder
|
||||||
|
func render(content: Content) -> Body
|
||||||
|
}
|
||||||
69
Sources/CliDocCore/TextNode.swift
Normal file
69
Sources/CliDocCore/TextNode.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/// A type that can produce a string to be used as a documentation
|
||||||
|
/// text node.
|
||||||
|
public protocol TextNodeRepresentable {
|
||||||
|
|
||||||
|
/// Produces the string output to use as the documentation string.
|
||||||
|
func render() -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A type that can produce a string to be used as a documentation
|
||||||
|
/// text node.
|
||||||
|
public protocol TextNode: TextNodeRepresentable {
|
||||||
|
// swiftlint:disable type_name
|
||||||
|
associatedtype _Body: TextNode
|
||||||
|
typealias Body = _Body
|
||||||
|
// swiftlint:enable type_name
|
||||||
|
|
||||||
|
var body: Body { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension TextNode {
|
||||||
|
func render() -> String {
|
||||||
|
body.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - String
|
||||||
|
|
||||||
|
extension String: TextNodeRepresentable {
|
||||||
|
public func render() -> String { self }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String: TextNode {
|
||||||
|
public var body: some TextNode { self }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Optional
|
||||||
|
|
||||||
|
extension Optional: TextNode where Wrapped: TextNode {
|
||||||
|
@TextBuilder
|
||||||
|
public var body: some TextNode {
|
||||||
|
switch self {
|
||||||
|
case let .some(node):
|
||||||
|
node
|
||||||
|
case .none:
|
||||||
|
Empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Optional: TextNodeRepresentable where Wrapped: TextNodeRepresentable, Wrapped: TextNode {
|
||||||
|
|
||||||
|
public func render() -> String {
|
||||||
|
body.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Array
|
||||||
|
|
||||||
|
extension Array: TextNode where Element: TextNode {
|
||||||
|
public var body: some TextNode {
|
||||||
|
_NodeContainer(nodes: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array: TextNodeRepresentable where Element: TextNodeRepresentable, Element: TextNode {
|
||||||
|
public func render() -> String {
|
||||||
|
body.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
189
Sources/CliDocCore/TextStyle.swift
Normal file
189
Sources/CliDocCore/TextStyle.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import Rainbow
|
||||||
|
|
||||||
|
public extension TextNode {
|
||||||
|
|
||||||
|
/// Apply coloring to a text node.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - color: The color to apply to the text node.
|
||||||
|
@inlinable
|
||||||
|
func color(_ color: NamedColor) -> some TextNode {
|
||||||
|
textStyle(_ColorTextStyle(.foreground(.named(color))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply coloring to a text node.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - bit8: The color to apply to the text node.
|
||||||
|
@inlinable
|
||||||
|
func color(_ bit8: UInt8) -> some TextNode {
|
||||||
|
textStyle(_ColorTextStyle(.foreground(.bit8(bit8))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply coloring to a text node using RGB.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - red: The red color to apply to the text node.
|
||||||
|
/// - green: The green color to apply to the text node.
|
||||||
|
/// - blue: The blue color to apply to the text node.
|
||||||
|
@inlinable
|
||||||
|
func color(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> some TextNode {
|
||||||
|
textStyle(_ColorTextStyle(.foreground(.bit24((red, green, blue)))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply background coloring to a text node.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - color: The color to apply to the text node.
|
||||||
|
@inlinable
|
||||||
|
func backgroundColor(_ color: NamedBackgroundColor) -> some TextNode {
|
||||||
|
textStyle(_ColorTextStyle(.background(.named(color))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply background coloring to a text node.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - bit8: The color to apply to the text node.
|
||||||
|
@inlinable
|
||||||
|
func backgroundColor(_ bit8: UInt8) -> some TextNode {
|
||||||
|
textStyle(_ColorTextStyle(.background(.bit8(bit8))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply background coloring to a text node using RGB.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - red: The red color to apply to the text node.
|
||||||
|
/// - green: The green color to apply to the text node.
|
||||||
|
/// - blue: The blue color to apply to the text node.
|
||||||
|
@inlinable
|
||||||
|
func backgroundColor(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> some TextNode {
|
||||||
|
textStyle(_ColorTextStyle(.background(.bit24((red, green, blue)))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply styles to a text node.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - styles: The styles to apply.
|
||||||
|
@inlinable
|
||||||
|
func textStyle<S: TextStyle>(_ styles: S...) -> some TextNode {
|
||||||
|
styles.reduce(render()) { string, style in
|
||||||
|
style.render(content: .init(string)).render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a bold text style to a text node.
|
||||||
|
@inlinable
|
||||||
|
func bold() -> some TextNode { textStyle(.bold) }
|
||||||
|
|
||||||
|
/// Apply a dim text style to a text node.
|
||||||
|
@inlinable
|
||||||
|
func dim() -> some TextNode { textStyle(.dim) }
|
||||||
|
|
||||||
|
/// Apply an italic text style to a text node.
|
||||||
|
@inlinable
|
||||||
|
func italic() -> some TextNode { textStyle(.italic) }
|
||||||
|
|
||||||
|
/// Apply an underline text style to a text node.
|
||||||
|
@inlinable
|
||||||
|
func underline() -> some TextNode { textStyle(.underline) }
|
||||||
|
|
||||||
|
/// Apply a blink text style to a text node.
|
||||||
|
@inlinable
|
||||||
|
func blink() -> some TextNode { textStyle(.blink) }
|
||||||
|
|
||||||
|
/// Apply a strike-through text style to a text node.
|
||||||
|
@inlinable
|
||||||
|
func strikeThrough() -> some TextNode { textStyle(.strikeThrough) }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A general purpose way of styling a text node.
|
||||||
|
///
|
||||||
|
/// This is generally used for applying styling that can work with any text node.
|
||||||
|
///
|
||||||
|
/// Most of the time you will want to customize styles for a text node's type instead
|
||||||
|
/// of using this.
|
||||||
|
public protocol TextStyle: TextModifier where Content == TextStyleConfiguration {}
|
||||||
|
|
||||||
|
/// A type-erased text node that can be used for creating
|
||||||
|
/// custom styles.
|
||||||
|
///
|
||||||
|
/// This is generally used to change the style of text nodes as
|
||||||
|
/// a whole, such as applying text colors or styling such as `bold`.
|
||||||
|
///
|
||||||
|
/// Most of the time you will want to customize styles for a text node's
|
||||||
|
/// type, instead of using this.
|
||||||
|
///
|
||||||
|
public struct TextStyleConfiguration {
|
||||||
|
public let node: any TextNode
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(_ node: any TextNode) {
|
||||||
|
self.node = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension TextStyle where Self == _StyledText {
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
static var bold: Self { .init(.bold) }
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
static var dim: Self { .init(.dim) }
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
static var italic: Self { .init(.italic) }
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
static var underline: Self { .init(.underline) }
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
static var blink: Self { .init(.blink) }
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
static var strikeThrough: Self { .init(.strikethrough) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable type_name
|
||||||
|
public struct _ColorTextStyle: TextStyle {
|
||||||
|
@usableFromInline
|
||||||
|
enum Style {
|
||||||
|
case foreground(ColorType)
|
||||||
|
case background(BackgroundColorType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let style: Style
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(_ style: Style) {
|
||||||
|
self.style = style
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public func render(content: TextStyleConfiguration) -> some TextNode {
|
||||||
|
switch style {
|
||||||
|
case let .foreground(color):
|
||||||
|
return content.node.render().applyingColor(color)
|
||||||
|
case let .background(color):
|
||||||
|
return content.node.render().applyingBackgroundColor(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct _StyledText: TextStyle {
|
||||||
|
@usableFromInline
|
||||||
|
let style: Style
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
init(_ style: Style) {
|
||||||
|
self.style = style
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public func render(content: TextStyleConfiguration) -> some TextNode {
|
||||||
|
content.node.render().applyingStyle(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_name
|
||||||
25
Sources/CliDocCore/Utils.swift
Normal file
25
Sources/CliDocCore/Utils.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
@usableFromInline
|
||||||
|
func array(from node: any TextNode) -> [any TextNode] {
|
||||||
|
if let container = node as? _NodeContainer {
|
||||||
|
return container.nodes
|
||||||
|
} else if let array = node as? [any TextNode] {
|
||||||
|
return array
|
||||||
|
} else {
|
||||||
|
return [node]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == (any TextNode) {
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
func removingEmptys() -> [String] {
|
||||||
|
compactMap { node in
|
||||||
|
let string = node.render()
|
||||||
|
if string == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
269
Tests/CliDocCoreTests/CliDocCoreTests.swift
Normal file
269
Tests/CliDocCoreTests/CliDocCoreTests.swift
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
@testable import CliDocCore
|
||||||
|
@preconcurrency import Rainbow
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite("CliDocCore Tests")
|
||||||
|
struct CliDocCoreTests {
|
||||||
|
// Ensure that rainbow is setup, for test comparisons to work properly.
|
||||||
|
let setupRainbow: Bool = {
|
||||||
|
Rainbow.enabled = true
|
||||||
|
Rainbow.outputTarget = .console
|
||||||
|
return true
|
||||||
|
}()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testGroup() {
|
||||||
|
#expect(setupRainbow)
|
||||||
|
let group = Group {
|
||||||
|
"foo"
|
||||||
|
"bar"
|
||||||
|
if setupRainbow {
|
||||||
|
"baz".color(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(group.render() == "foobar\("baz".blue)")
|
||||||
|
|
||||||
|
let group2 = Group {
|
||||||
|
VStack {
|
||||||
|
"My headline."
|
||||||
|
"Some text."
|
||||||
|
}
|
||||||
|
"\n"
|
||||||
|
HStack {
|
||||||
|
"Foo"
|
||||||
|
"Bar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.color(.green)
|
||||||
|
|
||||||
|
print(group2.render())
|
||||||
|
#expect(group2.render() == """
|
||||||
|
My headline.
|
||||||
|
Some text.
|
||||||
|
Foo Bar
|
||||||
|
""".green)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testHStack() {
|
||||||
|
#expect(setupRainbow)
|
||||||
|
let stack = HStack {
|
||||||
|
"foo"
|
||||||
|
"bar"
|
||||||
|
}
|
||||||
|
#expect(stack.render() == "foo bar")
|
||||||
|
|
||||||
|
let tabStack = HStack {
|
||||||
|
"foo"
|
||||||
|
"bar"
|
||||||
|
}
|
||||||
|
.separator(.tab())
|
||||||
|
|
||||||
|
#expect(tabStack.render() == "foo\tbar")
|
||||||
|
|
||||||
|
let customStack = HStack {
|
||||||
|
"foo"
|
||||||
|
"bar"
|
||||||
|
}
|
||||||
|
.separator(.custom(":blob:"))
|
||||||
|
|
||||||
|
#expect(customStack.render() == "foo:blob:bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testVStack() {
|
||||||
|
#expect(setupRainbow)
|
||||||
|
let stack = VStack {
|
||||||
|
"foo"
|
||||||
|
"bar"
|
||||||
|
}
|
||||||
|
#expect(stack.render() == """
|
||||||
|
foo
|
||||||
|
bar
|
||||||
|
""")
|
||||||
|
|
||||||
|
let customStack = VStack {
|
||||||
|
"foo"
|
||||||
|
"bar"
|
||||||
|
}
|
||||||
|
.separator(.custom("\n\t"))
|
||||||
|
|
||||||
|
#expect(customStack.render() == """
|
||||||
|
foo
|
||||||
|
\tbar
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testOptionalTextNode() {
|
||||||
|
let someNode = String?.some("string")
|
||||||
|
#expect(someNode.body.render() == "string")
|
||||||
|
#expect(someNode.render() == "string")
|
||||||
|
|
||||||
|
let noneNode = String?.none
|
||||||
|
#expect(noneNode.body.render() == "")
|
||||||
|
#expect(noneNode.render() == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testArrayTextNode() {
|
||||||
|
let array = ["foo", " ", "bar"]
|
||||||
|
#expect(array.body.render() == "foo bar")
|
||||||
|
#expect(array.render() == "foo bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testLabeledContent() {
|
||||||
|
let horizontal = LabeledContent {
|
||||||
|
"Content"
|
||||||
|
} label: {
|
||||||
|
"Label:".color(.yellow).bold()
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected = """
|
||||||
|
\("Label:".yellow.bold) Content
|
||||||
|
"""
|
||||||
|
#expect(horizontal.render() == expected)
|
||||||
|
|
||||||
|
#expect(horizontal.style(.vertical()).render() == """
|
||||||
|
\("Label:".yellow.bold)
|
||||||
|
Content
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
Style.bold, .italic, .dim, .underline, .blink, .strikethrough
|
||||||
|
])
|
||||||
|
func testTextStyles(style: Style) {
|
||||||
|
let node = Group { "foo" }.textStyle(_StyledText(style))
|
||||||
|
let string = "foo".applyingStyle(style)
|
||||||
|
#expect(node.render() == string)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testTextStylesDirectlyOnNode() {
|
||||||
|
let bold = Group { "foo" }.bold()
|
||||||
|
let string = "foo".bold
|
||||||
|
#expect(bold.render() == string)
|
||||||
|
|
||||||
|
let dim = Group { "foo" }.dim()
|
||||||
|
let dimString = "foo".dim
|
||||||
|
#expect(dim.render() == dimString)
|
||||||
|
|
||||||
|
let italic = Group { "foo" }.italic()
|
||||||
|
let italicString = "foo".italic
|
||||||
|
#expect(italic.render() == italicString)
|
||||||
|
|
||||||
|
let blink = Group { "foo" }.blink()
|
||||||
|
let blinkString = "foo".blink
|
||||||
|
#expect(blink.render() == blinkString)
|
||||||
|
|
||||||
|
let strikeThrough = Group { "foo" }.strikeThrough()
|
||||||
|
let strikeThroughString = "foo".applyingStyle(.strikethrough)
|
||||||
|
#expect(strikeThrough.render() == strikeThroughString)
|
||||||
|
|
||||||
|
let underline = Group { "foo" }.underline()
|
||||||
|
let underlineString = "foo".underline
|
||||||
|
#expect(underline.render() == underlineString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: NamedColor.allCases)
|
||||||
|
func testNamedColors(color: NamedColor) {
|
||||||
|
let foregroundNode = Group { "foo" }.color(color)
|
||||||
|
let string = "foo".applyingColor(color)
|
||||||
|
#expect(foregroundNode.render() == string)
|
||||||
|
|
||||||
|
let backgroundNode = Group { "foo" }.backgroundColor(color.toBackgroundColor)
|
||||||
|
let backgroundString = "foo".applyingBackgroundColor(color.toBackgroundColor)
|
||||||
|
#expect(backgroundNode.render() == backgroundString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testBit8Colors() {
|
||||||
|
let color: UInt8 = 32
|
||||||
|
let foregroundNode = Group { "foo" }.color(color)
|
||||||
|
let string = "foo".applyingColor(.bit8(color))
|
||||||
|
#expect(foregroundNode.render() == string)
|
||||||
|
|
||||||
|
let backgroundNode = Group { "foo" }.backgroundColor(color)
|
||||||
|
let backgroundString = "foo".applyingBackgroundColor(.bit8(color))
|
||||||
|
#expect(backgroundNode.render() == backgroundString)
|
||||||
|
|
||||||
|
let rgbNode = Group { "foo" }.color(color, color, color)
|
||||||
|
let rgbString = "foo".applyingColor(.bit24((color, color, color)))
|
||||||
|
#expect(rgbNode.render() == rgbString)
|
||||||
|
|
||||||
|
let rgbBackgroundNode = Group { "foo" }.backgroundColor(color, color, color)
|
||||||
|
let rgbBackgroundString = "foo".applyingBackgroundColor(.bit24((color, color, color)))
|
||||||
|
#expect(rgbBackgroundNode.render() == rgbBackgroundString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(
|
||||||
|
arguments: SectionArg.arguments
|
||||||
|
)
|
||||||
|
func testSection(arg: SectionArg) {
|
||||||
|
#expect(setupRainbow)
|
||||||
|
printIfNotEqual(arg.section.render(), arg.expected)
|
||||||
|
#expect(arg.section.render() == arg.expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SectionArg: @unchecked Sendable {
|
||||||
|
let section: any TextNode
|
||||||
|
let expected: String
|
||||||
|
|
||||||
|
static var arguments: [Self] {
|
||||||
|
[
|
||||||
|
.init(
|
||||||
|
section: Section {
|
||||||
|
"Content"
|
||||||
|
} header: {
|
||||||
|
"Header"
|
||||||
|
} footer: {
|
||||||
|
"Footer"
|
||||||
|
},
|
||||||
|
expected: """
|
||||||
|
Header
|
||||||
|
|
||||||
|
Content
|
||||||
|
|
||||||
|
Footer
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
section: Section {
|
||||||
|
"Content"
|
||||||
|
} footer: {
|
||||||
|
"Footer"
|
||||||
|
},
|
||||||
|
expected: """
|
||||||
|
Content
|
||||||
|
|
||||||
|
Footer
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
section: Section {
|
||||||
|
"Content"
|
||||||
|
} header: {
|
||||||
|
"Header"
|
||||||
|
},
|
||||||
|
expected: """
|
||||||
|
Header
|
||||||
|
|
||||||
|
Content
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func printIfNotEqual(_ lhs: String, _ rhs: String) -> Bool {
|
||||||
|
guard lhs == rhs else {
|
||||||
|
print(lhs)
|
||||||
|
print(rhs)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -1,42 +1,98 @@
|
|||||||
@testable import CliDoc
|
@testable import CliDoc
|
||||||
|
@testable import CliDocCore
|
||||||
@preconcurrency import Rainbow
|
@preconcurrency import Rainbow
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
let setupRainbow: Bool = {
|
@Suite("CliDoc tests")
|
||||||
|
struct CliDocTests {
|
||||||
|
// Ensure that rainbow is setup, for test comparisons to work properly.
|
||||||
|
let setupRainbow: Bool = {
|
||||||
Rainbow.enabled = true
|
Rainbow.enabled = true
|
||||||
Rainbow.outputTarget = .console
|
Rainbow.outputTarget = .console
|
||||||
return true
|
return true
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func testGroup() {
|
func testExamples() {
|
||||||
#expect(setupRainbow)
|
#expect(setupRainbow)
|
||||||
let group = Group {
|
let examples = ExampleSection(
|
||||||
Label { "Foo:" }
|
"Examples:",
|
||||||
"Bar"
|
label: "Some common usage examples.",
|
||||||
"Baz"
|
examples: [("First", "ls -lah"), ("Second", "find . -name foo")]
|
||||||
Note { "Bang:" } content: { "boom" }
|
)
|
||||||
if setupRainbow {
|
|
||||||
Label("Hello, rainbow").color(.blue)
|
|
||||||
} else {
|
|
||||||
Label("No color for you!").color(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.color(.green)
|
|
||||||
.style(.italic)
|
|
||||||
|
|
||||||
print(type(of: group))
|
let expected = """
|
||||||
print(group.render())
|
\("Examples:".yellow.bold)\(" ")\("Some common usage examples.".italic)
|
||||||
|
|
||||||
// let note = Note { "Bang:" } content: { "boom" }
|
\("First".green.bold)
|
||||||
// print(note.render())
|
$ \("ls -lah".italic)
|
||||||
// print(type(of: note.label))
|
|
||||||
|
\("Second".green.bold)
|
||||||
|
$ \("find . -name foo".italic)
|
||||||
|
"""
|
||||||
|
let result = printIfNotEqual(examples.render(), expected)
|
||||||
|
#expect(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testExamplesWithCustomExampleOnlyStyle() {
|
||||||
|
#expect(setupRainbow)
|
||||||
|
let examples = ExampleSection(
|
||||||
|
"Examples:",
|
||||||
|
label: "Some common usage examples.",
|
||||||
|
examples: [("First", "ls -lah"), ("Second", "find . -name foo")]
|
||||||
|
)
|
||||||
|
// .exampleStyle(CustomExampleOnlyStyle())
|
||||||
|
|
||||||
|
let expected = """
|
||||||
|
\("Examples:".applyingStyle(.bold).applyingColor(.yellow)) \("Some common usage examples.".italic)
|
||||||
|
|
||||||
|
\("First".red)
|
||||||
|
> \("ls -lah".italic)
|
||||||
|
|
||||||
|
\("Second".red)
|
||||||
|
> \("find . -name foo".italic)
|
||||||
|
"""
|
||||||
|
let result = printIfNotEqual(
|
||||||
|
examples.exampleStyle(CustomExampleOnlyStyle()).render(),
|
||||||
|
expected
|
||||||
|
)
|
||||||
|
#expect(result)
|
||||||
|
|
||||||
|
let result2 = printIfNotEqual(
|
||||||
|
examples.style(.custom).render(),
|
||||||
|
expected
|
||||||
|
)
|
||||||
|
#expect(result2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@discardableResult
|
||||||
func testExamples() {
|
func printIfNotEqual(_ lhs: String, _ rhs: String) -> Bool {
|
||||||
#expect(setupRainbow)
|
guard lhs == rhs else {
|
||||||
let examples = Examples(examples: [("First", "ls -lah"), ("Second", "find . -name foo")], header: { "Examples:" }, label: { "Common examples." })
|
print(lhs)
|
||||||
|
print(rhs)
|
||||||
print(examples.render())
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ExampleSectionStyle where Self == DefaultExampleSectionStyle<CustomExampleOnlyStyle> {
|
||||||
|
static var custom: Self {
|
||||||
|
.default(exampleStyle: CustomExampleOnlyStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CustomExampleOnlyStyle: ExampleStyle {
|
||||||
|
func render(content: ExampleConfiguration) -> some TextNode {
|
||||||
|
VStack {
|
||||||
|
content.examples.map { example in
|
||||||
|
VStack {
|
||||||
|
example.label.red
|
||||||
|
ShellCommand(symbol: ">") { example.example }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.separator(.newLine(count: 2))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
docker/Dockerfile.test
Normal file
8
docker/Dockerfile.test
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ARG SWIFT_IMAGE_VERSION="6.0"
|
||||||
|
FROM swift:${SWIFT_IMAGE_VERSION}
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ./Package.* ./
|
||||||
|
RUN swift package resolve
|
||||||
|
COPY . .
|
||||||
|
RUN swift build
|
||||||
|
CMD ["/bin/bash", "-xc", "swift", "test"]
|
||||||
41
justfile
Normal file
41
justfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
docker_image_name := "clidoc"
|
||||||
|
|
||||||
|
[private]
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@rm -rf .build
|
||||||
|
|
||||||
|
snapshot command outputDir="./Sources/CliDocCore/Documentation.docc/Resources":
|
||||||
|
@just -f Examples/justfile snapshot {{command}} ".{{outputDir}}"
|
||||||
|
|
||||||
|
test-docker: build-docker
|
||||||
|
@docker run -t --rm {{docker_image_name}}:test swift test
|
||||||
|
|
||||||
|
build-docker:
|
||||||
|
@docker build \
|
||||||
|
--file docker/Dockerfile.test \
|
||||||
|
--tag {{docker_image_name}}:test \
|
||||||
|
.
|
||||||
|
|
||||||
|
preview-documentation target="CliDoc":
|
||||||
|
# using the --enable-experimental-combined-documentation doesn't work in previews currently.
|
||||||
|
@swift package \
|
||||||
|
--disable-sandbox \
|
||||||
|
--allow-writing-to-directory "docs/" \
|
||||||
|
preview-documentation \
|
||||||
|
--target {{target}} \
|
||||||
|
--include-extended-types \
|
||||||
|
--enable-inherited-docs \
|
||||||
|
|
||||||
|
build-documentation:
|
||||||
|
swift package \
|
||||||
|
--disable-sandbox \
|
||||||
|
--allow-writing-to-directory "docs/" \
|
||||||
|
generate-documentation \
|
||||||
|
--target CliDoc \
|
||||||
|
--target CliDocCore \
|
||||||
|
--output-path "docs/" \
|
||||||
|
--transform-for-static-hosting \
|
||||||
|
--enable-experimental-combined-documentation
|
||||||
Reference in New Issue
Block a user