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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.5"),
.package(url: "https://github.com/SVGKit/SVGKit", from: "3.0.0"),
],
targets: [
.target(
name: "GutenbergKit",
dependencies: ["SwiftSoup"],
dependencies: ["SwiftSoup", "SVGKit"],
path: "ios/Sources/GutenbergKit",
exclude: [],
resources: [.copy("Gutenberg")]
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions ios/Sources/GutenbergKit/Sources/EditorTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct EditorBlock: Decodable, Identifiable {
let description: String?
let category: String?
let keywords: [String]?
var icon: String?
}

public struct EditorTitleAndContent: Decodable {
Expand All @@ -16,3 +17,29 @@ public struct EditorTitleAndContent: Decodable {
public let changed: Bool
}

extension EditorBlock: Searchable {
/// Sets the searchable fields in the order of priority
func searchableFields() -> [SearchableField] {
var fields: [SearchableField] = []

if let title, !title.isEmpty {
fields.append(SearchableField(content: title, weight: 10.0, allowFuzzyMatch: true))
}

fields.append(SearchableField(content: name, weight: 8.0, allowFuzzyMatch: false))

(keywords ?? []).forEach { keyword in
fields.append(SearchableField( content: keyword, weight: 5.0, allowFuzzyMatch: true))
}

if let description, !description.isEmpty {
fields.append(SearchableField(content: description, weight: 2.0, allowFuzzyMatch: false))
}

if let category, !category.isEmpty {
fields.append(SearchableField(content: category, weight: 2.0, allowFuzzyMatch: true))
}

return fields
}
}
4 changes: 2 additions & 2 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro

private func showBlockInserter(blocks: [EditorBlock]) {
present(UIHostingController(rootView: NavigationView {
List(blocks) {
Text($0.name)
BlockInserterView(blocks: blocks) {
print("did select:", $0)
}
}), animated: true)
}
Expand Down
37 changes: 37 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Helpers/BlockIconCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import SVGKit

@MainActor
final class BlockIconCache: ObservableObject {
var icons: [String: Result<SVGKImage, Error>] = [:]

func getIcon(for block: EditorBlock) -> SVGKImage? {
if let result = icons[block.id] {
return try? result.get()
}
let result = Result { try _getIcon(for: block) }
icons[block.id] = result
return try? result.get()
}

private func _getIcon(for block: EditorBlock) throws -> SVGKImage {
guard let svg = block.icon,
!svg.isEmpty,
let source = SVGKSourceString.source(fromContentsOf: svg),
let image = SVGKImage(source: source) else {
throw BlockIconCacheError.unknown
}
if let result = image.parseErrorsAndWarnings,
let error = result.errorsFatal.firstObject {
#if DEBUG
debugPrint("failed to parse SVG for block: \(block.name) with errors: \(String(describing: result.errorsFatal))\n\n\(svg)")
#endif
throw (error as? Error) ?? BlockIconCacheError.unknown
}
return image
}
}

private enum BlockIconCacheError: Error {
case unknown
}
190 changes: 190 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import Foundation

/// A protocol for items that can be searched
protocol Searchable {
Copy link
Contributor Author

@kean kean Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disclaimer: other code in PR is written by hand, but this file is entirely generated. It uses Levenshtein distance for fuzzy matching, and does scoring depending on weight of searchable fields. It seems to work pretty well, and we do need it in the inserter.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Searching worked relatively well from my testing. I noted one thing while searching: we are not honoring the inserter supports attribute for blocks. Blocks setting this to false should not appear in the block inserter or its search results.

/// Extract searchable fields with their weights
func searchableFields() -> [SearchableField]
}

/// A field that can be searched with an associated weight
struct SearchableField {
let content: String
let weight: Double
let allowFuzzyMatch: Bool

init(content: String, weight: Double, allowFuzzyMatch: Bool = true) {
self.content = content
self.weight = weight
self.allowFuzzyMatch = allowFuzzyMatch
}
}

/// Configuration for the search engine
struct SearchConfiguration {
/// Maximum allowed edit distance for fuzzy matching
let maxEditDistance: Int

/// Minimum similarity threshold (0-1) for fuzzy matches
let minSimilarityThreshold: Double

/// Multiplier for exact matches
let exactMatchMultiplier: Double

/// Multiplier for prefix matches
let prefixMatchMultiplier: Double

/// Multiplier for word prefix matches
let wordPrefixMatchMultiplier: Double

/// Multiplier for fuzzy matches (applied to similarity score)
let fuzzyMatchMultiplier: Double

static let `default` = SearchConfiguration(
maxEditDistance: 2,
minSimilarityThreshold: 0.7,
exactMatchMultiplier: 2.0,
prefixMatchMultiplier: 1.5,
wordPrefixMatchMultiplier: 0.8,
fuzzyMatchMultiplier: 0.6
)
}

/// A generic search engine that performs weighted fuzzy search
struct SearchEngine<Item: Searchable> {

/// Search result with relevance score
struct SearchResult {
let item: Item
let score: Double
}

let configuration: SearchConfiguration

init(configuration: SearchConfiguration = .default) {
self.configuration = configuration
}

/// Search items with weighted fuzzy matching
func search(query: String, in items: [Item]) -> [Item] {
let normalizedQuery = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)

// Empty query returns all items
guard !normalizedQuery.isEmpty else {
return items
}

// Calculate scores for all items
let results: [SearchResult] = items.compactMap { item in
let score = calculateScore(for: item, query: normalizedQuery)
return score > 0 ? SearchResult(item: item, score: score) : nil
}

// Sort by score (highest first) and return items
return results
.sorted { $0.score > $1.score }
.map { $0.item }
}

/// Calculate weighted score for an item based on query match
private func calculateScore(for item: Item, query: String) -> Double {
let fields = item.searchableFields()

return fields.reduce(0.0) { totalScore, field in
totalScore + calculateFieldScore(
field: field.content.lowercased(),
query: query,
weight: field.weight,
allowFuzzy: field.allowFuzzyMatch
)
}
}

/// Calculate score for a single field
private func calculateFieldScore(field: String, query: String, weight: Double, allowFuzzy: Bool) -> Double {
// Exact match
if field == query {
return weight * configuration.exactMatchMultiplier
}

// Contains match
if field.contains(query) {
// Higher score if it starts with the query
if field.hasPrefix(query) {
return weight * configuration.prefixMatchMultiplier
}
return weight
}

// Fuzzy match if allowed
if allowFuzzy {
// Check each word in the field
let fieldWords = field.split(separator: " ").map(String.init)
for word in fieldWords {
// Word starts with query
if word.hasPrefix(query) {
return weight * configuration.wordPrefixMatchMultiplier
}

// Calculate similarity
let similarity = calculateSimilarity(word, query)
if similarity >= configuration.minSimilarityThreshold {
return weight * similarity * configuration.fuzzyMatchMultiplier
}
}

// Try full field fuzzy match for short queries
if query.count <= 10 {
let similarity = calculateSimilarity(field, query)
if similarity >= configuration.minSimilarityThreshold {
return weight * similarity * configuration.fuzzyMatchMultiplier * 0.7
}
}
}

return 0
}

/// Calculate similarity between two strings using normalized edit distance
private func calculateSimilarity(_ str1: String, _ str2: String) -> Double {
let distance = levenshteinDistance(str1, str2)
let maxLength = max(str1.count, str2.count)

// Don't allow too many edits relative to string length
if distance > min(configuration.maxEditDistance, maxLength / 3) {
return 0
}

return 1.0 - (Double(distance) / Double(maxLength))
}

/// Calculate Levenshtein edit distance between two strings
private func levenshteinDistance(_ str1: String, _ str2: String) -> Int {
let str1Array = Array(str1)
let str2Array = Array(str2)

// Create matrix
var matrix = Array(repeating: Array(repeating: 0, count: str2Array.count + 1), count: str1Array.count + 1)

// Initialize first row and column
for i in 0...str1Array.count {
matrix[i][0] = i
}
for j in 0...str2Array.count {
matrix[0][j] = j
}

// Fill matrix
for i in 1...str1Array.count {
for j in 1...str2Array.count {
let cost = str1Array[i-1] == str2Array[j-1] ? 0 : 1
matrix[i][j] = min(
matrix[i-1][j] + 1, // deletion
matrix[i][j-1] + 1, // insertion
matrix[i-1][j-1] + cost // substitution
)
}
}

return matrix[str1Array.count][str2Array.count]
}
}
19 changes: 19 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import SwiftUI

struct CardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.background(Color(.systemBackground))
.overlay(
RoundedRectangle(cornerRadius: 26)
.stroke(Color(.opaqueSeparator), lineWidth: 0.5)
)
.clipShape(RoundedRectangle(cornerRadius: 26))
}
}

extension View {
func cardStyle() -> some View {
modifier(CardModifier())
}
}
Loading