Skip to content

Commit ce307d8

Browse files
committed
First commit
0 parents  commit ce307d8

File tree

14 files changed

+680
-0
lines changed

14 files changed

+680
-0
lines changed

.github/FUNDING.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github: loopwerk
2+
buy_me_a_coffee: loopwerk

.github/workflows/release.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Create Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "*"
7+
8+
jobs:
9+
create-release:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Create changelog text
17+
id: changelog
18+
uses: loopwerk/tag-changelog@v1
19+
with:
20+
token: ${{ secrets.GITHUB_TOKEN }}
21+
exclude_types: other,doc,chore,build
22+
23+
- name: Create release
24+
uses: softprops/action-gh-release@v2
25+
with:
26+
body: ${{ steps.changelog.outputs.changes }}
27+
token: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/test.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- "*"
10+
11+
jobs:
12+
test-xcode:
13+
runs-on: macos-latest
14+
15+
steps:
16+
- uses: actions/checkout@v2
17+
- uses: maxim-lobanov/setup-xcode@v1
18+
with:
19+
xcode-version: latest-stable
20+
- name: Build
21+
run: swift build -v
22+
- name: Run tests
23+
run: swift test -v
24+
25+
test-linux:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v2
29+
- name: Build
30+
run: swift build -v
31+
- name: Run tests
32+
run: swift test -v

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
/*.xcodeproj
5+
.swiftpm
6+
Package.resolved
7+
.claude

.swiftformat

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
--indent 2
2+
--indentcase true
3+
--patternlet inline
4+
--disable unusedArguments
5+
--disable redundantReturn

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2026 Loopwerk
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Package.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// swift-tools-version:5.5
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "SagaUtils",
7+
platforms: [
8+
.macOS(.v12),
9+
],
10+
products: [
11+
.library(
12+
name: "SagaUtils",
13+
targets: ["SagaUtils"]
14+
),
15+
],
16+
dependencies: [
17+
.package(name: "Saga", url: "https://github.com/loopwerk/Saga.git", from: "2.0.3"),
18+
.package(name: "SwiftSoup", url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"),
19+
],
20+
targets: [
21+
.target(
22+
name: "SagaUtils",
23+
dependencies: [
24+
"Saga",
25+
"SwiftSoup",
26+
]
27+
),
28+
.testTarget(
29+
name: "SagaUtilsTests",
30+
dependencies: [
31+
"SagaUtils",
32+
"SwiftSoup",
33+
"Saga",
34+
]
35+
),
36+
]
37+
)

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# SagaUtils
2+
3+
A collection of utilities for [Saga](https://github.com/loopwerk/Saga): HTML transformations and useful String extensions.
4+
5+
## Usage
6+
Include `SagaUtils` in your Package.swift:
7+
8+
```swift
9+
let package = Package(
10+
dependencies: [
11+
.package(url: "https://github.com/loopwerk/Saga", from: "2.0.3"),
12+
.package(url: "https://github.com/loopwerk/SagaUtils", from: "0.1.0"),
13+
],
14+
targets: [
15+
.target(
16+
name: "MyWebsite",
17+
dependencies: ["Saga", "SagaUtils"]),
18+
]
19+
)
20+
```
21+
22+
## HTML Transformations
23+
24+
SagaUtils provides composable HTML transformations powered by [SwiftSoup](https://github.com/scinfu/SwiftSoup). Each transformation operates on a SwiftSoup `Document` and can be combined using `swiftSoupProcessor`:
25+
26+
```swift
27+
import SagaUtils
28+
29+
try await Saga(input: "content", output: "deploy")
30+
.register(
31+
folder: "articles",
32+
metadata: ArticleMetadata.self,
33+
readers: [.parsleyMarkdownReader],
34+
itemProcessor: swiftSoupProcessor(generateTOC, convertAsides, processExternalLinks, addCodeBlockTitles),
35+
writers: [.itemWriter(swim(renderArticle))]
36+
)
37+
.run()
38+
```
39+
40+
### Available Transformations
41+
42+
- **`addHeadingAnchors`** — Adds `<a name="slug"></a>` anchors to h1, h2, h3 headings.
43+
- **`generateTOC`** — Replaces a `%TOC%` placeholder with a `<nav class="toc">` generated from headings. Also adds heading anchors, so there's no need to also use `addHeadingAnchors`. Use `generateTOC(placeholder: "@TOC")` for a custom placeholder.
44+
- **`convertAsides`** — Converts blockquotes with `[!TYPE]` syntax to `<aside class="type">` elements. For example, `[!WARNING]` becomes `<aside class="warning">`.
45+
- **`processExternalLinks`** — Adds `target="_blank"` and `rel="nofollow"` to external links.
46+
47+
You can also write your own transformations with the signature `(Document) throws -> Void` and pass them to `swiftSoupProcessor`.
48+
49+
## String Extensions
50+
51+
Useful extensions on `String`:
52+
53+
```swift
54+
// Strip HTML tags, keeping code block content
55+
"<p>Hello <strong>world</strong></p>".plainText // "Hello world"
56+
57+
// Strip HTML tags and code blocks (useful for word counting)
58+
body.textOnly
59+
60+
// Count words
61+
body.textOnly.wordCount
62+
63+
// Truncate with word boundary awareness (inspired by Jinja2)
64+
text.truncate(length: 200)
65+
text.truncate(length: 200, killWords: true, end: "")
66+
```
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Foundation
2+
3+
public extension String {
4+
/// Strip all HTML tags, returning plain text. Keeps code block content.
5+
var plainText: String {
6+
replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
7+
.trimmingCharacters(in: .whitespacesAndNewlines)
8+
}
9+
10+
/// Strip all HTML tags and code blocks, returning only prose text.
11+
var textOnly: String {
12+
replacingOccurrences(of: "(?m)<pre><span></span><code>[\\s\\S]+?</code></pre>", with: " ", options: .regularExpression)
13+
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
14+
.trimmingCharacters(in: .whitespacesAndNewlines)
15+
}
16+
17+
/// The number of words in the string.
18+
var wordCount: Int {
19+
split { $0.isWhitespace }.count
20+
}
21+
22+
/// Truncate the string to a given length.
23+
///
24+
/// See https://jinja2docs.readthedocs.io/en/stable/templates.html#truncate
25+
func truncate(length: Int = 255, killWords: Bool = false, end: String = "...", leeway: Int = 5) -> String {
26+
if count <= length + leeway {
27+
return self
28+
}
29+
30+
if killWords {
31+
return prefix(length - end.count) + end
32+
}
33+
34+
return prefix(length - end.count).split(separator: " ").dropLast().joined(separator: " ") + end
35+
}
36+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Saga
2+
import SwiftSoup
3+
4+
/// Combine SwiftSoup transformations into a Saga item processor.
5+
///
6+
/// Parses `item.body` with SwiftSoup once, applies all transformations in order,
7+
/// then serializes back. On error, `item.body` is left unchanged.
8+
///
9+
/// ```swift
10+
/// itemProcessor: swiftSoupProcessor(generateTOC, convertAsides, processExternalLinks)
11+
/// ```
12+
///
13+
/// Can be combined with other item processors using Saga's `sequence()`:
14+
/// ```swift
15+
/// itemProcessor: sequence(
16+
/// swiftSoupProcessor(generateTOC, convertAsides, processExternalLinks),
17+
/// myOtherProcessor
18+
/// )
19+
/// ```
20+
public func swiftSoupProcessor<M>(
21+
_ transformations: ((Document) throws -> Void)...
22+
) -> (Item<M>) async -> Void {
23+
return { item in
24+
do {
25+
let doc = try SwiftSoup.parseBodyFragment(item.body)
26+
for transformation in transformations {
27+
try transformation(doc)
28+
}
29+
item.body = try doc.body()?.html() ?? item.body
30+
} catch {
31+
// On error, leave item.body unchanged
32+
}
33+
}
34+
}

0 commit comments

Comments
 (0)