diff --git a/Inkwell/ContentFormats/ContentFormatRegistration.swift b/Inkwell/ContentFormats/ContentFormatRegistration.swift new file mode 100644 index 0000000..7f3699e --- /dev/null +++ b/Inkwell/ContentFormats/ContentFormatRegistration.swift @@ -0,0 +1,39 @@ +// +// ContentFormatRegistration.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// +// Registers every platform-specific `content` format Inkwell knows how to +// read/write with ATProtoKit's `ATRecordTypeRegistry`, so +// `SiteStandardLexicon.DocumentRecord.content` (an open-union +// `UnknownType`, deliberately left undefined by standard.site itself) can +// resolve to one of these concrete structs by `$type` rather than falling +// back to raw JSON. +// +// Deliberately kept separate from `SiteStandardRegistration.swift`: these +// aren't `site.standard.*` lexicons, they're each publishing platform's +// own content schema (Leaflet, Markpub, Pckt, Offprint) — see the note in +// `SiteStandardLexicon.swift`. Both are called from `InkwellApp`'s +// `.task`, before anything attempts to decode a document. + +import Foundation +import ATProtoKit + +/// Registration for the `content`-format record types ContentProvider.swift's +/// providers convert to/from markdown (see `ProviderRegistry`). +enum ContentFormatRegistration { + + /// Call this once, early in the app's lifetime — see `InkwellApp.swift`'s + /// `.task`, alongside `SiteStandardLexicon.registerRecordTypes()`. Safe + /// to call more than once; `ATRecordTypeRegistry` skips any type that's + /// already registered. + static func registerRecordTypes() async { + await ATRecordTypeRegistry.shared.register(types: [ + LeafletContent.self, + MarkpubContent.self, + PcktContent.self, + OffprintContent.self + ]) + } +} diff --git a/Inkwell/ContentFormats/LeafletContent.swift b/Inkwell/ContentFormats/LeafletContent.swift new file mode 100644 index 0000000..9d22050 --- /dev/null +++ b/Inkwell/ContentFormats/LeafletContent.swift @@ -0,0 +1,203 @@ +// +// LeafletContent.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// +// The `pub.leaflet.content` record format — what Leaflet (leaflet.pub) +// itself stores in a standard.site document's `content` open union. +// Not part of standard.site proper; registered separately by +// ContentFormatRegistration.swift so `UnknownType` can resolve it, and +// converted to/from the editor's markdown by `LeafletProvider` in +// ContentProvider.swift. +// + +import Foundation +import ATProtoKit + +// MARK: - pub.leaflet.content + +public struct LeafletContent: ATRecordProtocol { + public static private(set) var type = "pub.leaflet.content" + + public let pages: [LeafletPage]? + public let blobPages: ComAtprotoLexicon.Repository.UploadBlobOutput? + + public init(pages: [LeafletPage]?, blobPages: ComAtprotoLexicon.Repository.UploadBlobOutput?) { + self.pages = pages + self.blobPages = blobPages + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.pages = try container.decodeIfPresent([LeafletPage].self, forKey: .pages) + + if let blobContainer = try? container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .blobPages) { + self.blobPages = blobContainer.blob + } else { + self.blobPages = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .blobPages) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.type, forKey: .type) + try container.encodeIfPresent(pages, forKey: .pages) + try container.encodeIfPresent(blobPages, forKey: .blobPages) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case pages + case blobPages + } +} + +public struct LeafletPage: Codable, Equatable, Hashable, Sendable { + public let type: String + public let blocks: [LeafletBlockContainer]? + + public init(type: String, blocks: [LeafletBlockContainer]?) { + self.type = type + self.blocks = blocks + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case blocks + } +} + +public struct LeafletBlockContainer: Codable, Equatable, Hashable, Sendable { + public let block: LeafletBlock + public let alignment: String? + + public init(block: LeafletBlock, alignment: String? = nil) { + self.block = block + self.alignment = alignment + } +} + +public struct LeafletBlock: Codable, Equatable, Hashable, Sendable { + public let type: String + public let plaintext: String? + public let level: Int? + public let language: String? + public let tex: String? + public let image: ComAtprotoLexicon.Repository.UploadBlobOutput? + public let alt: String? + public let facets: [LeafletFacet]? + + // Lists + public let children: [LeafletListItem]? + public let startIndex: Int? + public let orderedListChildren: IndirectBox? + + public init( + type: String, + plaintext: String? = nil, + level: Int? = nil, + language: String? = nil, + tex: String? = nil, + image: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil, + alt: String? = nil, + facets: [LeafletFacet]? = nil, + children: [LeafletListItem]? = nil, + startIndex: Int? = nil, + orderedListChildren: IndirectBox? = nil + ) { + self.type = type + self.plaintext = plaintext + self.level = level + self.language = language + self.tex = tex + self.image = image + self.alt = alt + self.facets = facets + self.children = children + self.startIndex = startIndex + self.orderedListChildren = orderedListChildren + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(String.self, forKey: .type) + self.plaintext = try container.decodeIfPresent(String.self, forKey: .plaintext) + self.level = try container.decodeIfPresent(Int.self, forKey: .level) + self.language = try container.decodeIfPresent(String.self, forKey: .language) + self.tex = try container.decodeIfPresent(String.self, forKey: .tex) + + if let imageContainer = try? container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .image) { + self.image = imageContainer.blob + } else { + self.image = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .image) + } + + self.alt = try container.decodeIfPresent(String.self, forKey: .alt) + self.facets = try container.decodeIfPresent([LeafletFacet].self, forKey: .facets) + self.children = try container.decodeIfPresent([LeafletListItem].self, forKey: .children) + self.startIndex = try container.decodeIfPresent(Int.self, forKey: .startIndex) + + // Handle potential circular list structure + self.orderedListChildren = try container.decodeIfPresent(IndirectBox.self, forKey: .orderedListChildren) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(plaintext, forKey: .plaintext) + try container.encodeIfPresent(level, forKey: .level) + try container.encodeIfPresent(language, forKey: .language) + try container.encodeIfPresent(tex, forKey: .tex) + try container.encodeIfPresent(image, forKey: .image) + try container.encodeIfPresent(alt, forKey: .alt) + try container.encodeIfPresent(facets, forKey: .facets) + try container.encodeIfPresent(children, forKey: .children) + try container.encodeIfPresent(startIndex, forKey: .startIndex) + try container.encodeIfPresent(orderedListChildren, forKey: .orderedListChildren) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case plaintext + case level + case language + case tex + case image + case alt + case facets + case children + case startIndex + case orderedListChildren + } +} + +public struct LeafletListItem: Codable, Equatable, Hashable, Sendable { + public let type: String + public let content: LeafletBlock? + public let checked: Bool? + public let children: [LeafletListItem]? + public let orderedListChildren: IndirectBox? + + public init( + type: String, + content: LeafletBlock? = nil, + checked: Bool? = nil, + children: [LeafletListItem]? = nil, + orderedListChildren: IndirectBox? = nil + ) { + self.type = type + self.content = content + self.checked = checked + self.children = children + self.orderedListChildren = orderedListChildren + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case content + case checked + case children + case orderedListChildren + } +} diff --git a/Inkwell/ContentFormats/MarkpubContent.swift b/Inkwell/ContentFormats/MarkpubContent.swift new file mode 100644 index 0000000..65dfd69 --- /dev/null +++ b/Inkwell/ContentFormats/MarkpubContent.swift @@ -0,0 +1,79 @@ +// +// MarkpubContent.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// +// The `at.markpub.markdown` record format — markpub stores GFM markdown +// directly, so this is the simplest of Inkwell's content providers (see +// `MarkpubProvider` in ContentProvider.swift): close to a 1:1 mapping with +// the editor's own markdown, nothing lost on round-trip. +// + +import Foundation +import ATProtoKit + +// MARK: - at.markpub.markdown + +public struct MarkpubContent: ATRecordProtocol { + public static private(set) var type = "at.markpub.markdown" + + public let text: MarkpubText + + public init(text: MarkpubText) { + self.text = text + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.text = try container.decode(MarkpubText.self, forKey: .text) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.type, forKey: .type) + try container.encode(text, forKey: .text) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case text + } +} + +nonisolated public struct MarkpubText: Codable, Equatable, Hashable, Sendable { + public let type: String + public let markdown: String? + public let textBlob: ComAtprotoLexicon.Repository.UploadBlobOutput? + + public init(type: String = "at.markpub.text", markdown: String? = nil, textBlob: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil) { + self.type = type + self.markdown = markdown + self.textBlob = textBlob + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(String.self, forKey: .type) + self.markdown = try container.decodeIfPresent(String.self, forKey: .markdown) + + if let blobContainer = try? container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .textBlob) { + self.textBlob = blobContainer.blob + } else { + self.textBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .textBlob) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(markdown, forKey: .markdown) + try container.encodeIfPresent(textBlob, forKey: .textBlob) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case markdown + case textBlob + } +} diff --git a/Inkwell/ContentFormats/OffprintContent.swift b/Inkwell/ContentFormats/OffprintContent.swift new file mode 100644 index 0000000..a35ce36 --- /dev/null +++ b/Inkwell/ContentFormats/OffprintContent.swift @@ -0,0 +1,153 @@ +// +// OffprintContent.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// +// The `app.offprint.content` record format — offprint.app's block-array +// content shape. Converted to/from the editor's markdown by +// `OffprintProvider` in ContentProvider.swift; inline formatting uses +// ``LeafletFacet`` (see RichTextFacets.swift) under offprint's own +// `app.offprint.richtext.facet#*` `$type` strings. +// + +import Foundation +import ATProtoKit + +// MARK: - app.offprint.content + +public struct OffprintContent: ATRecordProtocol { + public static private(set) var type = "app.offprint.content" + + public let items: [OffprintBlock]? + + public init(items: [OffprintBlock]? = nil) { + self.items = items + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.items = try container.decodeIfPresent([OffprintBlock].self, forKey: .items) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.type, forKey: .type) + try container.encodeIfPresent(items, forKey: .items) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case items + } +} + +public struct OffprintBlock: Codable, Equatable, Hashable, Sendable { + public let type: String + public let plaintext: String? + public let level: Int? + public let language: String? + public let facets: [LeafletFacet]? + public let content: [OffprintBlock]? + public let image: ComAtprotoLexicon.Repository.UploadBlobOutput? + public let alt: String? + + // Lists + public let children: [OffprintListItem]? + public let ordered: Bool? + public let start: Int? + + public init( + type: String, + plaintext: String? = nil, + level: Int? = nil, + language: String? = nil, + facets: [LeafletFacet]? = nil, + content: [OffprintBlock]? = nil, + image: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil, + alt: String? = nil, + children: [OffprintListItem]? = nil, + ordered: Bool? = nil, + start: Int? = nil + ) { + self.type = type + self.plaintext = plaintext + self.level = level + self.language = language + self.facets = facets + self.content = content + self.image = image + self.alt = alt + self.children = children + self.ordered = ordered + self.start = start + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(String.self, forKey: .type) + self.plaintext = try container.decodeIfPresent(String.self, forKey: .plaintext) + self.level = try container.decodeIfPresent(Int.self, forKey: .level) + self.language = try container.decodeIfPresent(String.self, forKey: .language) + self.facets = try container.decodeIfPresent([LeafletFacet].self, forKey: .facets) + self.content = try container.decodeIfPresent([OffprintBlock].self, forKey: .content) + self.alt = try container.decodeIfPresent(String.self, forKey: .alt) + + if let imageContainer = try? container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .image) { + self.image = imageContainer.blob + } else { + self.image = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .image) + } + + self.children = try container.decodeIfPresent([OffprintListItem].self, forKey: .children) + self.ordered = try container.decodeIfPresent(Bool.self, forKey: .ordered) + self.start = try container.decodeIfPresent(Int.self, forKey: .start) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(plaintext, forKey: .plaintext) + try container.encodeIfPresent(level, forKey: .level) + try container.encodeIfPresent(language, forKey: .language) + try container.encodeIfPresent(facets, forKey: .facets) + try container.encodeIfPresent(content, forKey: .content) + try container.encodeIfPresent(image, forKey: .image) + try container.encodeIfPresent(alt, forKey: .alt) + try container.encodeIfPresent(children, forKey: .children) + try container.encodeIfPresent(ordered, forKey: .ordered) + try container.encodeIfPresent(start, forKey: .start) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case plaintext + case level + case language + case facets + case content + case image + case alt + case children + case ordered + case start + } +} + +public struct OffprintListItem: Codable, Equatable, Hashable, Sendable { + public let type: String + public let content: [OffprintBlock]? + public let checked: Bool? + + public init(type: String, content: [OffprintBlock]? = nil, checked: Bool? = nil) { + self.type = type + self.content = content + self.checked = checked + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case content + case checked + } +} diff --git a/Inkwell/ContentFormats/PcktContent.swift b/Inkwell/ContentFormats/PcktContent.swift new file mode 100644 index 0000000..0c0d1ab --- /dev/null +++ b/Inkwell/ContentFormats/PcktContent.swift @@ -0,0 +1,171 @@ +// +// PcktContent.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// +// The `blog.pckt.content` record format — pckt.blog's block-array content +// shape. Converted to/from the editor's markdown by `PcktProvider` in +// ContentProvider.swift; inline formatting uses ``LeafletFacet`` (see +// RichTextFacets.swift) under pckt's own `blog.pckt.richtext.facet#*` +// `$type` strings. +// + +import Foundation +import ATProtoKit + +// MARK: - blog.pckt.content + +public struct PcktContent: ATRecordProtocol { + public static private(set) var type = "blog.pckt.content" + + public let items: [PcktBlock]? + public let blob: ComAtprotoLexicon.Repository.UploadBlobOutput? + + public init(items: [PcktBlock]? = nil, blob: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil) { + self.items = items + self.blob = blob + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.items = try container.decodeIfPresent([PcktBlock].self, forKey: .items) + + if let blobContainer = try? container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .blob) { + self.blob = blobContainer.blob + } else { + self.blob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .blob) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.type, forKey: .type) + try container.encodeIfPresent(items, forKey: .items) + try container.encodeIfPresent(blob, forKey: .blob) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case items + case blob + } +} + +public struct PcktBlock: Codable, Equatable, Hashable, Sendable { + public let type: String + public let plaintext: String? + public let level: Int? + public let language: String? + public let facets: [LeafletFacet]? + public let content: [PcktBlock]? + public let children: [PcktListItem]? + public let attrs: PcktBlockAttrs? + + public init( + type: String, + plaintext: String? = nil, + level: Int? = nil, + language: String? = nil, + facets: [LeafletFacet]? = nil, + content: [PcktBlock]? = nil, + children: [PcktListItem]? = nil, + attrs: PcktBlockAttrs? = nil + ) { + self.type = type + self.plaintext = plaintext + self.level = level + self.language = language + self.facets = facets + self.content = content + self.children = children + self.attrs = attrs + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(String.self, forKey: .type) + self.plaintext = try container.decodeIfPresent(String.self, forKey: .plaintext) + self.level = try container.decodeIfPresent(Int.self, forKey: .level) + self.language = try container.decodeIfPresent(String.self, forKey: .language) + self.facets = try container.decodeIfPresent([LeafletFacet].self, forKey: .facets) + self.content = try container.decodeIfPresent([PcktBlock].self, forKey: .content) + self.children = try container.decodeIfPresent([PcktListItem].self, forKey: .children) + self.attrs = try container.decodeIfPresent(PcktBlockAttrs.self, forKey: .attrs) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(plaintext, forKey: .plaintext) + try container.encodeIfPresent(level, forKey: .level) + try container.encodeIfPresent(language, forKey: .language) + try container.encodeIfPresent(facets, forKey: .facets) + try container.encodeIfPresent(content, forKey: .content) + try container.encodeIfPresent(children, forKey: .children) + try container.encodeIfPresent(attrs, forKey: .attrs) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case plaintext + case level + case language + case facets + case content + case children + case attrs + } +} + +public struct PcktBlockAttrs: Codable, Equatable, Hashable, Sendable { + public let blob: ComAtprotoLexicon.Repository.UploadBlobOutput? + public let url: String? + public let alt: String? + + public init(blob: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil, url: String? = nil, alt: String? = nil) { + self.blob = blob + self.url = url + self.alt = alt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.url = try container.decodeIfPresent(String.self, forKey: .url) + self.alt = try container.decodeIfPresent(String.self, forKey: .alt) + + if let blobContainer = try? container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .blob) { + self.blob = blobContainer.blob + } else { + self.blob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .blob) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(url, forKey: .url) + try container.encodeIfPresent(alt, forKey: .alt) + try container.encodeIfPresent(blob, forKey: .blob) + } + + enum CodingKeys: String, CodingKey { + case blob + case url + case alt + } +} + +public struct PcktListItem: Codable, Equatable, Hashable, Sendable { + public let type: String + public let content: [PcktBlock]? + + public init(type: String, content: [PcktBlock]? = nil) { + self.type = type + self.content = content + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case content + } +} diff --git a/Inkwell/ContentFormats/RichTextFacets.swift b/Inkwell/ContentFormats/RichTextFacets.swift new file mode 100644 index 0000000..55f6bb1 --- /dev/null +++ b/Inkwell/ContentFormats/RichTextFacets.swift @@ -0,0 +1,105 @@ +// +// RichTextFacets.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// +// Shared inline-formatting primitives used by every standard.site `content` +// provider Inkwell understands (Leaflet, Pckt, Offprint — see +// ContentProvider.swift). None of this is part of standard.site itself: +// it's the byte-range "facet" shape Bluesky popularised for +// `app.bsky.richtext.facet`, which Leaflet/Pckt/Offprint each re-declare +// under their own NSID rather than sharing Bluesky's. The wire shape is +// identical across all three, so one Swift type does for all of them — +// only the `$type` strings inside `features` differ (see +// `ContentProvider.swift`'s `FacetSchema`). +// + +import Foundation +import ATProtoKit + +/// A single facet: a byte range plus the formatting features applied to it. +public struct LeafletFacet: Codable, Equatable, Hashable, Sendable { + public let type: String? + public let index: LeafletByteSlice + public let features: [LeafletFacetFeature] + + public init(type: String? = nil, index: LeafletByteSlice, features: [LeafletFacetFeature]) { + self.type = type + self.index = index + self.features = features + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case index + case features + } +} + +/// A UTF-8 byte range within a block's plaintext, used by ``LeafletFacet``. +public struct LeafletByteSlice: Codable, Equatable, Hashable, Sendable { + public let byteStart: Int + public let byteEnd: Int + + public init(byteStart: Int, byteEnd: Int) { + self.byteStart = byteStart + self.byteEnd = byteEnd + } +} + +/// A single formatting feature within a facet (bold, italic, code, strike, link). +/// +/// `uri`/`tag`/`did` are mutually exclusive depending on `type` — only `link` +/// features use `uri`, matching the same convention as Bluesky's own +/// richtext facets. +public struct LeafletFacetFeature: Codable, Equatable, Hashable, Sendable { + public let type: String + public let uri: String? + public let tag: String? + public let did: String? + + public init(type: String, uri: String? = nil, tag: String? = nil, did: String? = nil) { + self.type = type + self.uri = uri + self.tag = tag + self.did = did + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case uri + case tag + case did + } +} + +/// A `final class` box used to break the value-type recursion that a +/// directly-recursive `struct` can't express in Swift (`LeafletBlock` and +/// `LeafletBlockContainer` reference each other through ordered-list +/// children). Only Leaflet's block tree currently needs this, but the box +/// itself is generic so any future format with the same circular-list shape +/// can reuse it instead of redefining its own indirection wrapper. +public final class IndirectBox: Codable, Equatable, Hashable, Sendable where T: Equatable & Hashable { + public let value: T + + public init(_ value: T) { + self.value = value + } + + public init(from decoder: Decoder) throws { + self.value = try T(from: decoder) + } + + public func encode(to encoder: Encoder) throws { + try value.encode(to: encoder) + } + + public static func == (lhs: IndirectBox, rhs: IndirectBox) -> Bool { + lhs.value == rhs.value + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(value) + } +} diff --git a/Inkwell/ContentProvider.swift b/Inkwell/ContentProvider.swift new file mode 100644 index 0000000..b010129 --- /dev/null +++ b/Inkwell/ContentProvider.swift @@ -0,0 +1,1226 @@ +// +// ContentProvider.swift +// Inkwell +// +// Created by Letta on 20/06/2026. +// +// Multi-format content provider system, modelled on standard.horse's +// architecture: markdown is the universal editing format, and each provider +// converts to/from its AT Protocol record format. This lets Inkwell write +// posts in Leaflet, Markpub, Pckt, or Offprint format from a single editor. +// + +import Foundation +import ATProtoKit + +// MARK: - ContentProvider Protocol + +/// A content-format provider that reads and writes one standard.site `content` +/// member ($type), converting it to/from the markdown the editor speaks. +protocol ContentProvider { + /// Stable id used in the format picker and provider lookups. + var id: String { get } + /// Display name, e.g. "Leaflet". + var label: String { get } + /// The content object's `$type` this provider reads and writes. + var contentType: String { get } + /// Whether in-post image upload works. + var supportsImages: Bool { get } + /// True if this provider handles the given stored content object. + func matches(_ content: UnknownType?) -> Bool + /// Read stored content into editable markdown. + func toMarkdown(_ content: UnknownType?) -> ConvertResult + /// Build a fresh content object from edited markdown. + func fromMarkdown(_ markdown: String) -> UnknownType? +} + +/// Result of converting stored content to markdown. +struct ConvertResult { + let markdown: String + /// Human labels for blocks/features dropped converting to markdown. + let lost: [String] +} + +// MARK: - Facet Schema + +/// Maps a format's facet `$type` strings to the markdown marks we support. +/// Only the `$type` strings differ between formats; the facet structure is +/// identical across Leaflet, Pckt, and Offprint. +struct FacetSchema { + let facet: String + let byteSlice: String + let bold: String + let italic: String + let code: String + let strike: String + let link: String + + static let leaflet = FacetSchema( + facet: "pub.leaflet.richtext.facet", + byteSlice: "pub.leaflet.richtext.facet#byteSlice", + bold: "pub.leaflet.richtext.facet#bold", + italic: "pub.leaflet.richtext.facet#italic", + code: "pub.leaflet.richtext.facet#code", + strike: "pub.leaflet.richtext.facet#strikethrough", + link: "pub.leaflet.richtext.facet#link" + ) + + static let pckt = FacetSchema( + facet: "blog.pckt.richtext.facet", + byteSlice: "blog.pckt.richtext.facet#byteSlice", + bold: "blog.pckt.richtext.facet#bold", + italic: "blog.pckt.richtext.facet#italic", + code: "blog.pckt.richtext.facet#code", + strike: "blog.pckt.richtext.facet#strikethrough", + link: "blog.pckt.richtext.facet#link" + ) + + static let offprint = FacetSchema( + facet: "app.offprint.richtext.facet", + byteSlice: "app.offprint.richtext.facet#byteSlice", + bold: "app.offprint.richtext.facet#bold", + italic: "app.offprint.richtext.facet#italic", + code: "app.offprint.richtext.facet#code", + strike: "app.offprint.richtext.facet#strikethrough", + link: "app.offprint.richtext.facet#link" + ) +} + +// MARK: - Facet Converter + +/// Converts between AT Protocol facets (plaintext + byte-range features) and +/// markdown inline syntax (**bold**, *italic*, `code`, ~~strike~~, [text](url)). +enum FacetConverter { + + /// Convert facets to markdown inline text by inserting delimiters at + /// facet boundaries. Consecutive segments with the same marks are merged + /// to avoid unnecessary delimiter pairs. + static func facetsToMarkdown(_ plaintext: String, facets: [LeafletFacet]?, schema: FacetSchema) -> String { + guard let facets = facets, !facets.isEmpty else { + return plaintext + } + + // Convert to UTF-8 bytes once for efficient byte-range extraction. + let utf8Bytes = Array(plaintext.utf8) + let totalBytes = utf8Bytes.count + + // Collect all byte boundaries where the active mark-set may change. + var boundaries = Set([0, totalBytes]) + for facet in facets { + boundaries.insert(facet.index.byteStart) + boundaries.insert(facet.index.byteEnd) + } + let sortedBounds = boundaries.sorted() + + // For each segment, determine which marks are active. + struct Segment { + let text: String + let bold: Bool + let italic: Bool + let code: Bool + let strike: Bool + let link: String? + } + + var segments: [Segment] = [] + for idx in 0..<(sortedBounds.count - 1) { + let start = sortedBounds[idx] + let end = sortedBounds[idx + 1] + if start >= end || start >= totalBytes { continue } + let clampedEnd = min(end, totalBytes) + + // Extract the text for this byte range by decoding UTF-8 bytes. + let byteSlice = Array(utf8Bytes[start..= facet.index.byteStart && start < facet.index.byteEnd { + for feature in facet.features { + switch feature.type { + case schema.bold: bold = true + case schema.italic: italic = true + case schema.code: code = true + case schema.strike: strike = true + case schema.link: link = feature.uri + default: break + } + } + } + } + + let seg = Segment(text: text, bold: bold, italic: italic, code: code, strike: strike, link: link) + + // Merge with previous segment if marks are identical. + if let last = segments.last, + last.bold == seg.bold, last.italic == seg.italic, + last.code == seg.code, last.strike == seg.strike, + last.link == seg.link { + segments[segments.count - 1] = Segment( + text: last.text + seg.text, bold: seg.bold, italic: seg.italic, + code: seg.code, strike: seg.strike, link: seg.link + ) + } else { + segments.append(seg) + } + } + + // Build markdown from segments. + var result = "" + for seg in segments { + var wrapped = seg.text + if seg.code { + wrapped = "`\(wrapped)`" + } else { + if seg.strike { wrapped = "~~\(wrapped)~~" } + if seg.italic { wrapped = "*\(wrapped)*" } + if seg.bold { wrapped = "**\(wrapped)**" } + } + if let link = seg.link { + wrapped = "[\(wrapped)](\(link))" + } + result += wrapped + } + return result + } + + /// Parse markdown inline syntax into plaintext + facets. + /// Handles **bold**, *italic*, `code`, ~~strike~~, and [text](url). + static func markdownToFacets(_ markdown: String, schema: FacetSchema) -> (plaintext: String, facets: [LeafletFacet]) { + var plaintext = "" + var facets: [LeafletFacet] = [] + let chars = Array(markdown) + var i = 0 + + // Stack of active marks: (byteStart, featureType, uri?) + var markStack: [(start: Int, type: String, uri: String?)] = [] + + while i < chars.count { + // Bold: **text** + if i + 1 < chars.count && chars[i] == "*" && chars[i + 1] == "*" { + if let mark = markStack.last, mark.type == schema.bold { + let byteEnd = plaintext.utf8.count + if byteEnd > mark.start { + facets.append(LeafletFacet( + index: LeafletByteSlice(byteStart: mark.start, byteEnd: byteEnd), + features: [LeafletFacetFeature(type: schema.bold)] + )) + } + markStack.removeLast() + i += 2 + } else { + markStack.append((start: plaintext.utf8.count, type: schema.bold, uri: nil)) + i += 2 + } + continue + } + + // Italic: *text* + if chars[i] == "*" { + if let mark = markStack.last, mark.type == schema.italic { + let byteEnd = plaintext.utf8.count + if byteEnd > mark.start { + facets.append(LeafletFacet( + index: LeafletByteSlice(byteStart: mark.start, byteEnd: byteEnd), + features: [LeafletFacetFeature(type: schema.italic)] + )) + } + markStack.removeLast() + i += 1 + } else { + markStack.append((start: plaintext.utf8.count, type: schema.italic, uri: nil)) + i += 1 + } + continue + } + + // Strikethrough: ~~text~~ + if i + 1 < chars.count && chars[i] == "~" && chars[i + 1] == "~" { + if let mark = markStack.last, mark.type == schema.strike { + let byteEnd = plaintext.utf8.count + if byteEnd > mark.start { + facets.append(LeafletFacet( + index: LeafletByteSlice(byteStart: mark.start, byteEnd: byteEnd), + features: [LeafletFacetFeature(type: schema.strike)] + )) + } + markStack.removeLast() + i += 2 + } else { + markStack.append((start: plaintext.utf8.count, type: schema.strike, uri: nil)) + i += 2 + } + continue + } + + // Code: `text` + if chars[i] == "`" { + // Find closing backtick + if let closeIdx = chars[(i + 1)...].firstIndex(of: "`") { + let content = String(chars[(i + 1).. [MarkdownBlock] { + var blocks: [MarkdownBlock] = [] + let lines = markdown.components(separatedBy: "\n") + var i = 0 + + while i < lines.count { + let trimmed = lines[i].trimmingCharacters(in: .whitespaces) + + // Skip empty lines + if trimmed.isEmpty { + i += 1 + continue + } + + // Code block / math block + if trimmed.hasPrefix("```") { + let lang = String(trimmed.dropFirst(3)) + var codeLines: [String] = [] + i += 1 + while i < lines.count && !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") { + codeLines.append(lines[i]) + i += 1 + } + i += 1 // skip closing ``` + let content = codeLines.joined(separator: "\n") + if lang == "math" { + blocks.append(.math(tex: content)) + } else { + blocks.append(.code(language: lang.isEmpty ? nil : lang, content: content)) + } + continue + } + + // Heading + if trimmed.hasPrefix("#") { + let level = trimmed.prefix(while: { $0 == "#" }).count + if level >= 1 && level <= 6 { + let text = trimmed.dropFirst(level).trimmingCharacters(in: .whitespaces) + blocks.append(.heading(level: level, text: text)) + i += 1 + continue + } + } + + // Horizontal rule + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + blocks.append(.horizontalRule) + i += 1 + continue + } + + // Blockquote + if trimmed.hasPrefix(">") { + var quoteLines: [String] = [] + while i < lines.count { + let l = lines[i].trimmingCharacters(in: .whitespaces) + if l.hasPrefix(">") { + quoteLines.append(l.dropFirst().trimmingCharacters(in: .whitespaces)) + i += 1 + } else if !l.isEmpty { + break + } else { + i += 1 + break + } + } + blocks.append(.blockquote(text: quoteLines.joined(separator: "\n"))) + continue + } + + // Image (on its own line) + if trimmed.hasPrefix("![") { + if let closeBracket = trimmed.firstIndex(of: "]"), + trimmed.index(after: closeBracket) < trimmed.endIndex, + trimmed[trimmed.index(after: closeBracket)] == "(", + let closeParen = trimmed[trimmed.index(after: trimmed.index(after: closeBracket))...].firstIndex(of: ")") { + let alt = String(trimmed[trimmed.index(after: trimmed.startIndex)..") || l.hasPrefix("```") || + l.hasPrefix("- ") || l.hasPrefix("* ") || l == "---" || l == "***" || + l.hasPrefix("![") { + break + } + paraLines.append(l) + i += 1 + } + if !paraLines.isEmpty { + blocks.append(.paragraph(text: paraLines.joined(separator: " "))) + } + } + + return blocks + } + + private static func parseList(_ lines: [String], from start: Int, ordered: Bool) -> ([MarkdownListItem], Int) { + var items: [MarkdownListItem] = [] + var i = start + let baseIndent = lines[start].prefix(while: { $0 == " " }).count + + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.isEmpty { + i += 1 + continue + } + + // Check if this line is a list item at the base indent level + let indent = line.prefix(while: { $0 == " " }).count + if indent < baseIndent { + break + } + + // Check for list markers + let isUnordered = trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") + let isOrdered = ordered && { + if let firstSpace = trimmed.firstIndex(of: " "), + let _ = Int(trimmed[.. baseIndent { + let (nested, nextI) = parseList(lines, from: i + 1, ordered: false) + children = nested + i = nextI - 1 + } + } + + items.append(MarkdownListItem(text: itemText, checked: checked, children: children.isEmpty ? nil : children)) + i += 1 + } + + return (items, i) + } +} + +// MARK: - Markdown Serializer + +/// Converts MarkdownBlock array back to a markdown string. +enum MarkdownSerializer { + + static func serialize(_ blocks: [MarkdownBlock]) -> String { + blocks.map { blockToString($0) }.joined(separator: "\n\n") + } + + private static func blockToString(_ block: MarkdownBlock) -> String { + switch block { + case .heading(let level, let text): + return String(repeating: "#", count: level) + " " + text + + case .paragraph(let text): + return text + + case .code(let language, let content): + let lang = language ?? "" + return "```\(lang)\n\(content)\n```" + + case .math(let tex): + return "```math\n\(tex)\n```" + + case .blockquote(let text): + return text.components(separatedBy: "\n").map { "> " + $0 }.joined(separator: "\n") + + case .image(let alt, let url): + return "![\(alt)](\(url))" + + case .horizontalRule: + return "---" + + case .unorderedList(let items): + return items.map { listItemToString($0, prefix: "- ") }.joined(separator: "\n") + + case .orderedList(let start, let items): + return items.enumerated().map { (idx, item) in + listItemToString(item, prefix: "\(start + idx). ") + }.joined(separator: "\n") + + case .taskList(let items): + return items.map { item in + let checkbox = item.checked == true ? "[x] " : item.checked == false ? "[ ] " : "" + return "- " + checkbox + item.text + }.joined(separator: "\n") + } + } + + private static func listItemToString(_ item: MarkdownListItem, prefix: String) -> String { + var result = prefix + item.text + if let children = item.children { + let childLines = children.map { listItemToString($0, prefix: " - ") } + result += "\n" + childLines.joined(separator: "\n") + } + return result + } +} + +// MARK: - Leaflet Provider + +/// Leaflet provider (`pub.leaflet.content`). Leaflet documents are a list of +/// pages; we read and write a single `linearDocument` page whose `blocks` map +/// closely to markdown. Inline formatting uses leaflet's richtext facets. +struct LeafletProvider: ContentProvider { + let id = "leaflet" + let label = "Leaflet" + let contentType = "pub.leaflet.content" + let supportsImages = true + + private let schema = FacetSchema.leaflet + private let b: (String) -> String = { "pub.leaflet.blocks.\($0)" } + + func matches(_ content: UnknownType?) -> Bool { + content?.getRecord(ofType: LeafletContent.self) != nil + } + + func toMarkdown(_ content: UnknownType?) -> ConvertResult { + guard let leaflet = content?.getRecord(ofType: LeafletContent.self) else { + return ConvertResult(markdown: "", lost: []) + } + + var lost = Set() + var blocks: [MarkdownBlock] = [] + + let pages = leaflet.pages ?? [] + let page = pages.first(where: { $0.type == "pub.leaflet.pages.linearDocument" }) ?? pages.first + let blockContainers = page?.blocks ?? [] + + for container in blockContainers { + let inner = container.block + if let mdBlock = leafletBlockToMarkdown(inner, alignment: container.alignment, lost: &lost) { + blocks.append(mdBlock) + } + } + + return ConvertResult(markdown: MarkdownSerializer.serialize(blocks), lost: Array(lost)) + } + + private func leafletBlockToMarkdown(_ block: LeafletBlock, alignment: String?, lost: inout Set) -> MarkdownBlock? { + if let alignment = alignment, !alignment.hasSuffix("textAlignLeft") { + lost.insert("text alignment") + } + + switch block.type { + case b("text"): + let text = FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + return text.isEmpty ? nil : .paragraph(text: text) + + case b("header"): + let level = max(1, min(6, block.level ?? 1)) + let text = FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + return .heading(level: level, text: text) + + case b("blockquote"): + let text = FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + return .blockquote(text: text) + + case b("code"): + return .code(language: block.language, content: block.plaintext ?? "") + + case b("math"): + return .math(tex: block.tex ?? "") + + case b("horizontalRule"): + return .horizontalRule + + case b("image"): + // Image blobs are stored as PDS blobs; we reference by CID. + let cid = block.image?.reference.link ?? "" + return .image(alt: block.alt ?? "", url: cid) + + case b("unorderedList"): + let items = (block.children ?? []).map { leafletListItemToMarkdown($0) } + return .unorderedList(items: items) + + case b("orderedList"): + let start = block.startIndex ?? 1 + let items = (block.children ?? []).map { leafletListItemToMarkdown($0) } + return .orderedList(start: start, items: items) + + default: + lost.insert("an unsupported block") + return nil + } + } + + private func leafletListItemToMarkdown(_ item: LeafletListItem) -> MarkdownListItem { + var text = "" + if let content = item.content { + switch content.type { + case b("text"): + text = FacetConverter.facetsToMarkdown( + content.plaintext ?? "", facets: content.facets, schema: schema + ) + case b("image"): + let cid = content.image?.reference.link ?? "" + text = "![\(content.alt ?? "")](\(cid))" + default: + text = content.plaintext ?? "" + } + } + + var children: [MarkdownListItem]? = nil + if let kids = item.children, !kids.isEmpty { + children = kids.map { leafletListItemToMarkdown($0) } + } + + return MarkdownListItem(text: text, checked: item.checked, children: children) + } + + func fromMarkdown(_ markdown: String) -> UnknownType? { + let blocks = MarkdownParser.parse(markdown) + var leafletBlocks: [LeafletBlockContainer] = [] + + for block in blocks { + if let lb = markdownToLeafletBlock(block) { + leafletBlocks.append(LeafletBlockContainer(block: lb)) + } + } + + let page = LeafletPage(type: "pub.leaflet.pages.linearDocument", blocks: leafletBlocks) + let content = LeafletContent(pages: [page], blobPages: nil) + return UnknownType.record(content) + } + + private func markdownToLeafletBlock(_ block: MarkdownBlock) -> LeafletBlock? { + switch block { + case .heading(let level, let text): + let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) + return LeafletBlock( + type: b("header"), plaintext: plaintext, level: level, + facets: facets.isEmpty ? nil : facets + ) + + case .paragraph(let text): + let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) + return LeafletBlock( + type: b("text"), plaintext: plaintext, + facets: facets.isEmpty ? nil : facets + ) + + case .blockquote(let text): + let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) + return LeafletBlock( + type: b("blockquote"), plaintext: plaintext, + facets: facets.isEmpty ? nil : facets + ) + + case .code(let language, let content): + return LeafletBlock( + type: b("code"), plaintext: content, language: language + ) + + case .math(let tex): + return LeafletBlock(type: b("math"), tex: tex) + + case .horizontalRule: + return LeafletBlock(type: b("horizontalRule")) + + case .image: + // Can't create blob refs from markdown; skip + return nil + + case .unorderedList(let items): + let listItems = items.map { markdownToLeafletListItem($0, ordered: false) } + return LeafletBlock(type: b("unorderedList"), children: listItems) + + case .orderedList(let start, let items): + let listItems = items.map { markdownToLeafletListItem($0, ordered: true) } + return LeafletBlock(type: b("orderedList"), children: listItems, startIndex: start) + + case .taskList(let items): + let listItems = items.map { markdownToLeafletListItem($0, ordered: false) } + return LeafletBlock(type: b("unorderedList"), children: listItems) + } + } + + private func markdownToLeafletListItem(_ item: MarkdownListItem, ordered: Bool) -> LeafletListItem { + let itemType = ordered ? "pub.leaflet.blocks.orderedList#listItem" : "pub.leaflet.blocks.unorderedList#listItem" + let (plaintext, facets) = FacetConverter.markdownToFacets(item.text, schema: schema) + let content = LeafletBlock( + type: b("text"), plaintext: plaintext, + facets: facets.isEmpty ? nil : facets + ) + let children = item.children?.map { markdownToLeafletListItem($0, ordered: ordered) } + return LeafletListItem( + type: itemType, content: content, checked: item.checked, + children: children + ) + } +} + +// MARK: - Markpub Provider + +/// Markpub provider (`at.markpub.markdown`). Markpub stores GFM markdown +/// directly, so conversion is near-identity: read the inline `text.markdown` +/// and write it straight back. Nothing is ever lost. +struct MarkpubProvider: ContentProvider { + let id = "markpub" + let label = "Markdown (markpub)" + let contentType = "at.markpub.markdown" + let supportsImages = false + + func matches(_ content: UnknownType?) -> Bool { + content?.getRecord(ofType: MarkpubContent.self) != nil + } + + func toMarkdown(_ content: UnknownType?) -> ConvertResult { + guard let markpub = content?.getRecord(ofType: MarkpubContent.self) else { + return ConvertResult(markdown: "", lost: []) + } + return ConvertResult(markdown: markpub.text.markdown ?? "", lost: []) + } + + func fromMarkdown(_ markdown: String) -> UnknownType? { + let text = MarkpubText(type: "at.markpub.text", markdown: markdown) + let content = MarkpubContent(text: text) + return UnknownType.record(content) + } +} + +// MARK: - Pckt Provider + +/// Pckt provider (`blog.pckt.content`). Pckt stores an `items` array of blocks. +/// Blocks map closely to markdown; inline formatting uses pckt's richtext facets. +struct PcktProvider: ContentProvider { + let id = "pckt" + let label = "pckt" + let contentType = "blog.pckt.content" + let supportsImages = true + + private let schema = FacetSchema.pckt + private let b: (String) -> String = { "blog.pckt.block.\($0)" } + + func matches(_ content: UnknownType?) -> Bool { + content?.getRecord(ofType: PcktContent.self) != nil + } + + func toMarkdown(_ content: UnknownType?) -> ConvertResult { + guard let pckt = content?.getRecord(ofType: PcktContent.self) else { + return ConvertResult(markdown: "", lost: []) + } + + var lost = Set() + var blocks: [MarkdownBlock] = [] + + for block in pckt.items ?? [] { + if let mdBlock = pcktBlockToMarkdown(block, lost: &lost) { + blocks.append(mdBlock) + } + } + + return ConvertResult(markdown: MarkdownSerializer.serialize(blocks), lost: Array(lost)) + } + + private func pcktBlockToMarkdown(_ block: PcktBlock, lost: inout Set) -> MarkdownBlock? { + switch block.type { + case b("text"): + let text = FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + return text.isEmpty ? nil : .paragraph(text: text) + + case b("heading"): + let level = max(1, min(6, block.level ?? 1)) + let text = FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + return .heading(level: level, text: text) + + case b("blockquote"): + let inner = block.content ?? [] + let text = inner.map { block in + FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + }.joined(separator: "\n") + return .blockquote(text: text) + + case b("codeBlock"): + return .code(language: block.language, content: block.plaintext ?? "") + + case b("horizontalRule"): + return .horizontalRule + + case b("hardBreak"): + return nil // paragraph breaks already separate blocks + + case b("image"): + let attrs = block.attrs + let url = attrs?.blob?.reference.link ?? attrs?.url ?? "" + return .image(alt: attrs?.alt ?? "", url: url) + + case b("bulletList"): + let items = (block.children ?? []).map { pcktListItemToMarkdown($0) } + return .unorderedList(items: items) + + case b("orderedList"): + let start = block.attrs?.url.flatMap { Int($0) } ?? 1 + let items = (block.children ?? []).map { pcktListItemToMarkdown($0) } + return .orderedList(start: start, items: items) + + case b("taskList"): + let items = (block.children ?? []).map { pcktListItemToMarkdown($0) } + return .taskList(items: items) + + default: + lost.insert("an unsupported block") + return nil + } + } + + private func pcktListItemToMarkdown(_ item: PcktListItem) -> MarkdownListItem { + var text = "" + for block in item.content ?? [] { + if block.type == b("text") { + text += FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + } + } + + return MarkdownListItem(text: text, checked: nil, children: nil) + } + + func fromMarkdown(_ markdown: String) -> UnknownType? { + let blocks = MarkdownParser.parse(markdown) + var items: [PcktBlock] = [] + + for block in blocks { + if let pb = markdownToPcktBlock(block) { + items.append(pb) + } + } + + let content = PcktContent(items: items) + return UnknownType.record(content) + } + + private func markdownToPcktBlock(_ block: MarkdownBlock) -> PcktBlock? { + switch block { + case .heading(let level, let text): + let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) + return PcktBlock( + type: b("heading"), plaintext: plaintext, level: level, + facets: facets.isEmpty ? nil : facets + ) + + case .paragraph(let text): + let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) + return PcktBlock( + type: b("text"), plaintext: plaintext, + facets: facets.isEmpty ? nil : facets + ) + + case .blockquote(let text): + let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) + return PcktBlock( + type: b("blockquote"), + content: [PcktBlock(type: b("text"), plaintext: plaintext, + facets: facets.isEmpty ? nil : facets)] + ) + + case .code(let language, let content): + return PcktBlock( + type: b("codeBlock"), plaintext: content, language: language + ) + + case .math: + // Pckt doesn't have a math block; store as code + return nil + + case .horizontalRule: + return PcktBlock(type: b("horizontalRule")) + + case .image(let alt, let url): + // Pckt allows a plain URL src (unlike leaflet) + return PcktBlock( + type: b("image"), + attrs: PcktBlockAttrs(url: url, alt: alt) + ) + + case .unorderedList(let items): + let listItems = items.map { markdownToPcktListItem($0) } + return PcktBlock(type: b("bulletList"), children: listItems) + + case .orderedList(_, let items): + let listItems = items.map { markdownToPcktListItem($0) } + return PcktBlock(type: b("orderedList"), children: listItems) + + case .taskList(let items): + let listItems = items.map { markdownToPcktListItem($0) } + return PcktBlock(type: b("taskList"), children: listItems) + } + } + + private func markdownToPcktListItem(_ item: MarkdownListItem) -> PcktListItem { + let (plaintext, facets) = FacetConverter.markdownToFacets(item.text, schema: schema) + let textBlock = PcktBlock( + type: b("text"), plaintext: plaintext, + facets: facets.isEmpty ? nil : facets + ) + return PcktListItem(type: b("listItem"), content: [textBlock]) + } +} + +// MARK: - Offprint Provider + +/// Offprint provider (`app.offprint.content`). Offprint stores an `items` +/// array of blocks. Blocks map closely to markdown; inline formatting uses +/// offprint's richtext facets. +struct OffprintProvider: ContentProvider { + let id = "offprint" + let label = "Offprint" + let contentType = "app.offprint.content" + let supportsImages = true + + private let schema = FacetSchema.offprint + private let b: (String) -> String = { "app.offprint.block.\($0)" } + + func matches(_ content: UnknownType?) -> Bool { + content?.getRecord(ofType: OffprintContent.self) != nil + } + + func toMarkdown(_ content: UnknownType?) -> ConvertResult { + guard let offprint = content?.getRecord(ofType: OffprintContent.self) else { + return ConvertResult(markdown: "", lost: []) + } + + var lost = Set() + var blocks: [MarkdownBlock] = [] + + for block in offprint.items ?? [] { + if let mdBlock = offprintBlockToMarkdown(block, lost: &lost) { + blocks.append(mdBlock) + } + } + + return ConvertResult(markdown: MarkdownSerializer.serialize(blocks), lost: Array(lost)) + } + + private func offprintBlockToMarkdown(_ block: OffprintBlock, lost: inout Set) -> MarkdownBlock? { + switch block.type { + case b("text"): + let text = FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + return text.isEmpty ? nil : .paragraph(text: text) + + case b("heading"): + let level = max(1, min(3, block.level ?? 1)) + let text = FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + return .heading(level: level, text: text) + + case b("blockquote"): + let inner = block.content ?? [] + let text = inner.map { block in + FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + }.joined(separator: "\n") + return .blockquote(text: text) + + case b("codeBlock"): + return .code(language: block.language, content: block.plaintext ?? "") + + case b("mathBlock"): + return .math(tex: block.plaintext ?? "") + + case b("horizontalRule"): + return .horizontalRule + + case b("image"): + let cid = block.image?.reference.link ?? "" + return .image(alt: block.alt ?? "", url: cid) + + case b("bulletList"): + let items = (block.children ?? []).map { offprintListItemToMarkdown($0) } + return .unorderedList(items: items) + + case b("orderedList"): + let start = block.start ?? 1 + let items = (block.children ?? []).map { offprintListItemToMarkdown($0) } + return .orderedList(start: start, items: items) + + case b("taskList"): + let items = (block.children ?? []).map { offprintListItemToMarkdown($0) } + return .taskList(items: items) + + default: + lost.insert("an unsupported block") + return nil + } + } + + private func offprintListItemToMarkdown(_ item: OffprintListItem) -> MarkdownListItem { + var text = "" + for block in item.content ?? [] { + if block.type == b("text") { + text += FacetConverter.facetsToMarkdown( + block.plaintext ?? "", facets: block.facets, schema: schema + ) + } + } + + return MarkdownListItem(text: text, checked: item.checked, children: nil) + } + + func fromMarkdown(_ markdown: String) -> UnknownType? { + let blocks = MarkdownParser.parse(markdown) + var items: [OffprintBlock] = [] + + for block in blocks { + if let ob = markdownToOffprintBlock(block) { + items.append(ob) + } + } + + let content = OffprintContent(items: items) + return UnknownType.record(content) + } + + private func markdownToOffprintBlock(_ block: MarkdownBlock) -> OffprintBlock? { + switch block { + case .heading(let level, let text): + let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) + // Offprint headings are levels 1-3 + return OffprintBlock( + type: b("heading"), plaintext: plaintext, level: min(level, 3), + facets: facets.isEmpty ? nil : facets + ) + + case .paragraph(let text): + let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) + return OffprintBlock( + type: b("text"), plaintext: plaintext, + facets: facets.isEmpty ? nil : facets + ) + + case .blockquote(let text): + let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) + return OffprintBlock( + type: b("blockquote"), + content: [OffprintBlock(type: b("text"), plaintext: plaintext, + facets: facets.isEmpty ? nil : facets)] + ) + + case .code(let language, let content): + return OffprintBlock( + type: b("codeBlock"), plaintext: content, language: language + ) + + case .math(let tex): + return OffprintBlock(type: b("mathBlock"), plaintext: tex) + + case .horizontalRule: + return OffprintBlock(type: b("horizontalRule")) + + case .image: + // Offprint images are blob-only; external URLs can't be stored + return nil + + case .unorderedList(let items): + let listItems = items.map { markdownToOffprintListItem($0) } + return OffprintBlock(type: b("bulletList"), children: listItems) + + case .orderedList(let start, let items): + let listItems = items.map { markdownToOffprintListItem($0) } + return OffprintBlock(type: b("orderedList"), children: listItems, start: start) + + case .taskList(let items): + let listItems = items.map { markdownToOffprintListItem($0) } + return OffprintBlock(type: b("taskList"), children: listItems) + } + } + + private func markdownToOffprintListItem(_ item: MarkdownListItem) -> OffprintListItem { + let (plaintext, facets) = FacetConverter.markdownToFacets(item.text, schema: schema) + let textBlock = OffprintBlock( + type: b("text"), plaintext: plaintext, + facets: facets.isEmpty ? nil : facets + ) + let itemType = item.checked != nil ? "app.offprint.block.taskList#taskItem" : "app.offprint.block.bulletList#listItem" + return OffprintListItem( + type: itemType, content: [textBlock], checked: item.checked + ) + } +} + +// MARK: - Provider Registry + +/// All providers, markpub first (the default for new posts, matching standard.horse). +enum ProviderRegistry { + static let providers: [ContentProvider] = [ + MarkpubProvider(), + LeafletProvider(), + PcktProvider(), + OffprintProvider(), + ] + + static let defaultProvider = providers[0] + + /// The provider that handles a stored content object, if any. + static func detectProvider(_ content: UnknownType?) -> ContentProvider? { + providers.first(where: { $0.matches(content) }) + } + + /// Find a provider by its id. + static func providerById(_ id: String) -> ContentProvider? { + providers.first(where: { $0.id == id }) + } + + /// The provider whose `$type` matches the given content type string. + static func providerByContentType(_ type: String?) -> ContentProvider? { + guard let type = type else { return nil } + return providers.first(where: { $0.contentType == type }) + } +} diff --git a/Inkwell/MarkdownRendererView.swift b/Inkwell/MarkdownRendererView.swift new file mode 100644 index 0000000..c492254 --- /dev/null +++ b/Inkwell/MarkdownRendererView.swift @@ -0,0 +1,346 @@ +// +// MarkdownRendererView.swift +// Inkwell +// +// Created by Letta on 20/06/2026. +// +// A SwiftUI view that renders markdown text using the same MarkdownParser +// the editor uses (see ContentProvider.swift). This is the read-side +// counterpart to the write-side markdown editor: when a document's content +// is in a format other than Leaflet (or when we want a unified rendering +// path), the ContentProvider system converts it to markdown, and this view +// turns that markdown back into SwiftUI views. +// +// Not a full CommonMark renderer — it handles the same block types the +// MarkdownParser supports (headings, paragraphs, code, math, blockquotes, +// images, lists, task lists, horizontal rules) with inline formatting +// via AttributedString. +// + +import SwiftUI + +struct MarkdownRendererView: View { + let markdown: String + let foregroundColor: Color + let accentColor: Color + + private var blocks: [MarkdownBlock] { + MarkdownParser.parse(markdown) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(blocks.indices, id: \.self) { idx in + renderBlock(blocks[idx]) + } + } + } + + @ViewBuilder + private func renderBlock(_ block: MarkdownBlock) -> some View { + switch block { + case .heading(let level, let text): + let fontSize: CGFloat = switch level { + case 1: 28 + case 2: 24 + case 3: 20 + case 4: 18 + case 5: 16 + default: 16 + } + Text(renderInline(text)) + .font(.system(size: fontSize, design: .serif)) + .fontWeight(.bold) + .foregroundStyle(foregroundColor) + .padding(.top, level == 1 ? 8 : 4) + + case .paragraph(let text): + Text(renderInline(text)) + .font(.system(.body, design: .serif)) + .foregroundStyle(foregroundColor) + .lineSpacing(6) + + case .code(let language, let content): + VStack(alignment: .leading, spacing: 6) { + if let lang = language, !lang.isEmpty { + Text(lang.uppercased()) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.primary.opacity(0.05)) + .cornerRadius(4) + } + ScrollView(.horizontal, showsIndicators: false) { + Text(content) + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(.primary) + .padding(12) + } + .background(Color.primary.opacity(0.03)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + .padding(.vertical, 6) + + case .math(let tex): + HStack { + Spacer() + Text("$$ \(tex) $$") + .font(.system(.body, design: .serif)) + .italic() + .padding(12) + .background(Color.primary.opacity(0.03)) + .cornerRadius(8) + Spacer() + } + .padding(.vertical, 8) + + case .blockquote(let text): + HStack(spacing: 0) { + Rectangle() + .fill(accentColor) + .frame(width: 4) + .padding(.trailing, 16) + Text(renderInline(text)) + .font(.system(.body, design: .serif)) + .italic() + .foregroundStyle(.secondary) + .lineSpacing(6) + } + .padding(.vertical, 8) + + case .image(let alt, let url): + // Images referenced by URL (Pckt allows external URLs). + // Blob-referenced images are handled by the Leaflet block renderer. + if let imgURL = URL(string: url) { + VStack(alignment: .center, spacing: 8) { + AsyncImage(url: imgURL) { phase in + switch phase { + case .empty: + ProgressView().frame(minHeight: 180) + case .success(let image): + image + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + case .failure: + Image(systemName: "photo") + .font(.largeTitle) + .foregroundStyle(.secondary) + .frame(minHeight: 180) + @unknown default: + EmptyView() + } + } + if !alt.isEmpty { + Text(alt) + .font(.caption) + .italic() + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + } + + case .horizontalRule: + Divider() + .background(accentColor.opacity(0.2)) + .padding(.vertical, 12) + + case .unorderedList(let items): + renderListItems(items, ordered: false, startIndex: nil) + + case .orderedList(let start, let items): + renderListItems(items, ordered: true, startIndex: start) + + case .taskList(let items): + renderTaskList(items) + } + } + + private func renderListItems(_ items: [MarkdownListItem], ordered: Bool, startIndex: Int?) -> AnyView { + AnyView(VStack(alignment: .leading, spacing: 8) { + ForEach(items.indices, id: \.self) { index in + let item = items[index] + HStack(alignment: .top, spacing: 8) { + if ordered { + let itemNumber = (startIndex ?? 1) + index + Text("\(itemNumber).") + .font(.system(.body, design: .serif)) + .foregroundStyle(accentColor) + .frame(width: 20, alignment: .trailing) + } else { + Text("\u{2022}") + .font(.title3) + .foregroundStyle(accentColor) + .frame(width: 20, alignment: .center) + } + + VStack(alignment: .leading, spacing: 8) { + Text(renderInline(item.text)) + .font(.system(.body, design: .serif)) + .foregroundStyle(foregroundColor) + + if let children = item.children, !children.isEmpty { + renderListItems(children, ordered: false, startIndex: nil) + .padding(.leading, 12) + } + } + } + } + } + .padding(.leading, 4)) + } + + private func renderTaskList(_ items: [MarkdownListItem]) -> some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(items.indices, id: \.self) { index in + let item = items[index] + HStack(alignment: .top, spacing: 8) { + Image(systemName: item.checked == true ? "checkmark.square.fill" : "square") + .foregroundStyle(item.checked == true ? accentColor : .secondary) + .frame(width: 20, alignment: .center) + + Text(renderInline(item.text)) + .font(.system(.body, design: .serif)) + .foregroundStyle(foregroundColor) + .strikethrough(item.checked == true) + } + } + } + .padding(.leading, 4) + } + + // MARK: - Inline Formatting + + /// Converts markdown inline syntax (**bold**, *italic*, `code`, + /// ~~strike~~, [text](url)) to an AttributedString. + private func renderInline(_ text: String) -> AttributedString { + (try? AttributedString( + markdown: text, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + )) ?? AttributedString(text) + } + + /// Applies bold, italic, and strikethrough formatting by scanning + /// the markdown text for delimiter pairs and setting attributes on + /// the corresponding AttributedString ranges. + private func applyInlineFormatting(_ attrString: inout AttributedString, text: String) { + let chars = Array(text) + var i = 0 + var plaintextIndex = 0 + + var boldStart: Int? = nil + var italicStart: Int? = nil + var strikeStart: Int? = nil + + while i < chars.count { + // Bold: **text** + if i + 1 < chars.count && chars[i] == "*" && chars[i + 1] == "*" { + if let start = boldStart { + if plaintextIndex > start { + if let range = byteRangeToAttrRange(start, plaintextIndex, in: attrString) { + attrString[range].inlinePresentationIntent = .stronglyEmphasized + } + } + boldStart = nil + } else { + boldStart = plaintextIndex + } + i += 2 + continue + } + + // Italic: *text* + if chars[i] == "*" { + if let start = italicStart { + if plaintextIndex > start { + if let range = byteRangeToAttrRange(start, plaintextIndex, in: attrString) { + attrString[range].inlinePresentationIntent = .emphasized + } + } + italicStart = nil + } else { + italicStart = plaintextIndex + } + i += 1 + continue + } + + // Strikethrough: ~~text~~ + if i + 1 < chars.count && chars[i] == "~" && chars[i + 1] == "~" { + if let start = strikeStart { + if plaintextIndex > start { + if let range = byteRangeToAttrRange(start, plaintextIndex, in: attrString) { + attrString[range].strikethroughStyle = .single + } + } + strikeStart = nil + } else { + strikeStart = plaintextIndex + } + i += 2 + continue + } + + // Code: `text` + if chars[i] == "`" { + if let closeIdx = chars[(i + 1)...].firstIndex(of: "`") { + let content = String(chars[(i + 1).. Range? { + let utf8 = String(attrString.characters).utf8 + guard byteStart >= 0, byteEnd >= byteStart, byteEnd <= utf8.count else { return nil } + + guard let startIdx = utf8.index(utf8.startIndex, offsetBy: byteStart, limitedBy: utf8.endIndex), + let endIdx = utf8.index(utf8.startIndex, offsetBy: byteEnd, limitedBy: utf8.endIndex) else { + return nil + } + + let stringRange = startIdx..`. + /// + /// - SeeAlso: This is based on the [`site.standard.document`][docs] lexicon. + /// + /// [docs]: https://standard.site/docs/lexicons/document/ + public struct DocumentRecord: ATRecordProtocol, Sendable { + + /// The identifier of the lexicon. + /// + /// - Warning: The value must not change. + public static let type: String = "site.standard.document" + + /// Points to a publication record (`at://...`) or, for loose/standalone documents, a + /// publication URL (`https://...`). + /// + /// Avoid a trailing slash. + public let site: String + + /// The title of the document. + /// + /// - Important: Current maximum length is 500 graphemes (5,000 bytes). + public let title: String + + /// The timestamp of the document's publish time. + public let publishedAt: Date + + /// Combined with ``site`` (or the publication's `url`) to construct a canonical URL to + /// the document. Optional. Should be prefixed with a leading slash. + public let path: String? + + /// A brief description or excerpt from the document. Optional. + /// + /// - Important: Current maximum length is 3,000 graphemes (30,000 bytes). + public let description: String? + + /// An image used as a thumbnail or cover image. Optional. Maximum size is 1MB. + public let coverImage: ComAtprotoLexicon.Repository.UploadBlobOutput? + + /// The record's content. Optional. + /// + /// This is an open union: Standard.site only defines the metadata layer and + /// deliberately leaves content format up to each platform (Markdown, blocks, rich + /// text, etc.). Represented here as `UnknownType` so any registered `$type` decodes + /// into its concrete model via `ATRecordTypeRegistry`, while anything unregistered + /// still safely round-trips as raw JSON instead of failing to decode. + public let content: UnknownType? + + /// A plaintext representation of the document's contents. Optional. + /// + /// Should not contain Markdown or other formatting — useful for search/indexing + /// without needing to understand ``content``'s format. + public let textContent: String? + + /// A strong reference to a Bluesky post. Optional. + /// + /// Useful for keeping track of comments/discussion happening off-platform, on Bluesky. + public let bskyPostRef: ComAtprotoLexicon.Repository.StrongReference? + + /// Tags used to categorize the document. Optional. + /// + /// Avoid prepending tags with hashtags. + /// + /// - Important: Current maximum length for each tag is 128 graphemes (1,280 bytes). + public let tags: [String]? + + /// Describes relationships between this document and external resources. Optional. + /// + /// Another open union, same rationale as ``content``. Standard.site's published docs + /// don't detail a concrete schema for this beyond "open union," so it's left as + /// `UnknownType` here too — extend with a registered type if/when a concrete shape + /// is needed. + public let links: UnknownType? + + /// Self-label values for this document — effectively content warnings. Optional. + public let labels: ComAtprotoLexicon.Label.SelfLabelsDefinition? + + /// Additional contributors to this document, beyond the record's author. Optional. + public let contributors: [Contributor]? + + /// The timestamp of the document's last edit. Optional. + public let updatedAt: Date? + + public init( + site: String, + title: String, + publishedAt: Date, + path: String? = nil, + description: String? = nil, + coverImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil, + content: UnknownType? = nil, + textContent: String? = nil, + bskyPostRef: ComAtprotoLexicon.Repository.StrongReference? = nil, + tags: [String]? = nil, + links: UnknownType? = nil, + labels: ComAtprotoLexicon.Label.SelfLabelsDefinition? = nil, + contributors: [Contributor]? = nil, + updatedAt: Date? = nil + ) { + self.site = site + self.title = title + self.publishedAt = publishedAt + self.path = path + self.description = description + self.coverImage = coverImage + self.content = content + self.textContent = textContent + self.bskyPostRef = bskyPostRef + self.tags = tags + self.links = links + self.labels = labels + self.contributors = contributors + self.updatedAt = updatedAt + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.site = try container.decode(String.self, forKey: .site) + self.title = try container.decode(String.self, forKey: .title) + self.publishedAt = try container.decodeDate(forKey: .publishedAt) + self.path = try container.decodeIfPresent(String.self, forKey: .path) + self.description = try container.decodeIfPresent(String.self, forKey: .description) + self.coverImage = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .coverImage) + self.content = try container.decodeIfPresent(UnknownType.self, forKey: .content) + self.textContent = try container.decodeIfPresent(String.self, forKey: .textContent) + self.bskyPostRef = try container.decodeIfPresent(ComAtprotoLexicon.Repository.StrongReference.self, forKey: .bskyPostRef) + self.tags = try container.decodeIfPresent([String].self, forKey: .tags) + self.links = try container.decodeIfPresent(UnknownType.self, forKey: .links) + self.labels = try container.decodeIfPresent(ComAtprotoLexicon.Label.SelfLabelsDefinition.self, forKey: .labels) + self.contributors = try container.decodeIfPresent([Contributor].self, forKey: .contributors) + self.updatedAt = try container.decodeDateIfPresent(forKey: .updatedAt) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(SiteStandardLexicon.DocumentRecord.type, forKey: .type) + try container.encode(self.site, forKey: .site) + try container.truncatedEncode(self.title, forKey: .title, upToCharacterLength: 500) + try container.encodeDate(self.publishedAt, forKey: .publishedAt) + try container.encodeIfPresent(self.path, forKey: .path) + try container.truncatedEncodeIfPresent(self.description, forKey: .description, upToCharacterLength: 3_000) + try container.encodeIfPresent(self.coverImage, forKey: .coverImage) + try container.encodeIfPresent(self.content, forKey: .content) + try container.encodeIfPresent(self.textContent, forKey: .textContent) + try container.encodeIfPresent(self.bskyPostRef, forKey: .bskyPostRef) + try container.truncatedEncodeIfPresent(self.tags, forKey: .tags, upToCharacterLength: 128) + try container.encodeIfPresent(self.links, forKey: .links) + try container.encodeIfPresent(self.labels, forKey: .labels) + try container.encodeIfPresent(self.contributors, forKey: .contributors) + try container.encodeDateIfPresent(self.updatedAt, forKey: .updatedAt) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case site + case title + case publishedAt + case path + case description + case coverImage + case content + case textContent + case bskyPostRef + case tags + case links + case labels + case contributors + case updatedAt + } + } +} + +extension SiteStandardLexicon.DocumentRecord { + + /// Describes a participant on a document beyond the record's author. + /// + /// - SeeAlso: This is based on the [`site.standard.document#contributor`][docs] definition. + /// + /// [docs]: https://standard.site/docs/lexicons/document/ + public struct Contributor: Sendable, Codable, Equatable, Hashable { + + /// The identifier of the lexicon. + /// + /// - Warning: The value must not change. + public static let type: String = "site.standard.document#contributor" + + /// The decentralized identifier (DID) of the contributor. + public let did: String + + /// The contributor's role (ex: "editor", "translator"). Optional. + /// + /// - Important: Current maximum length is 100 graphemes (1,000 bytes). + public let role: String? + + /// An optional display name override for the contributor. Optional. + /// + /// - Important: Current maximum length is 100 graphemes (1,000 bytes). + public let displayName: String? + + public init(did: String, role: String? = nil, displayName: String? = nil) { + self.did = did + self.role = role + self.displayName = displayName + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.did = try container.decode(String.self, forKey: .did) + self.role = try container.decodeIfPresent(String.self, forKey: .role) + self.displayName = try container.decodeIfPresent(String.self, forKey: .displayName) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(Contributor.type, forKey: .type) + try container.encode(self.did, forKey: .did) + try container.truncatedEncodeIfPresent(self.role, forKey: .role, upToCharacterLength: 100) + try container.truncatedEncodeIfPresent(self.displayName, forKey: .displayName, upToCharacterLength: 100) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case did + case role + case displayName + } + } +} diff --git a/Inkwell/StandardSite/SiteStandardGraphRecommend.swift b/Inkwell/StandardSite/SiteStandardGraphRecommend.swift new file mode 100644 index 0000000..c3dd0ce --- /dev/null +++ b/Inkwell/StandardSite/SiteStandardGraphRecommend.swift @@ -0,0 +1,63 @@ +// +// SiteStandardGraphRecommend.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// + +import Foundation +import ATProtoKit + +extension SiteStandardLexicon.Graph { + + /// A record model for a Standard.site recommend. + /// + /// A lightweight social signal: a user creates one of these pointing at a + /// ``SiteStandardLexicon/DocumentRecord`` they endorse. Aggregators and readers can use + /// these to surface popular or trusted documents — unlike ``SubscriptionRecord``, which + /// follows a whole publication, this endorses a single document. + /// + /// - SeeAlso: This is based on the [`site.standard.graph.recommend`][docs] lexicon. + /// + /// [docs]: https://standard.site/docs/lexicons/recommend/ + public struct RecommendRecord: ATRecordProtocol, Sendable { + + /// The identifier of the lexicon. + /// + /// - Warning: The value must not change. + public static let type: String = "site.standard.graph.recommend" + + /// An AT-URI reference to the document record being recommended + /// (ex: `at://did:plc:abc123/site.standard.document/xyz789`). + public let document: String + + /// The timestamp marking when the recommend was created. + public let createdAt: Date + + public init(document: String, createdAt: Date) { + self.document = document + self.createdAt = createdAt + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.document = try container.decode(String.self, forKey: .document) + self.createdAt = try container.decodeDate(forKey: .createdAt) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(SiteStandardLexicon.Graph.RecommendRecord.type, forKey: .type) + try container.encode(self.document, forKey: .document) + try container.encodeDate(self.createdAt, forKey: .createdAt) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case document + case createdAt + } + } +} diff --git a/Inkwell/StandardSite/SiteStandardGraphSubscription.swift b/Inkwell/StandardSite/SiteStandardGraphSubscription.swift new file mode 100644 index 0000000..6a478a0 --- /dev/null +++ b/Inkwell/StandardSite/SiteStandardGraphSubscription.swift @@ -0,0 +1,63 @@ +// +// SiteStandardGraphSubscription.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// + +import Foundation +import ATProtoKit + +extension SiteStandardLexicon.Graph { + + /// A record model for a Standard.site subscription. + /// + /// Tracks a relationship between a user and a publication, enabling follow-style + /// functionality and personalised content feeds across the network. The subscriber is + /// whoever's repo the record lives in — there's no separate "subscriber" field, same as + /// `app.bsky.graph.follow`. + /// + /// - SeeAlso: This is based on the [`site.standard.graph.subscription`][docs] lexicon. + /// + /// [docs]: https://standard.site/docs/lexicons/subscription/ + public struct SubscriptionRecord: ATRecordProtocol, Sendable { + + /// The identifier of the lexicon. + /// + /// - Warning: The value must not change. + public static let type: String = "site.standard.graph.subscription" + + /// An AT-URI reference to the publication record being subscribed to + /// (ex: `at://did:plc:abc123/site.standard.publication/xyz789`). + public let publication: String + + /// The timestamp marking when the subscription was created. Optional. + public let createdAt: Date? + + public init(publication: String, createdAt: Date? = nil) { + self.publication = publication + self.createdAt = createdAt + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.publication = try container.decode(String.self, forKey: .publication) + self.createdAt = try container.decodeDateIfPresent(forKey: .createdAt) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(SiteStandardLexicon.Graph.SubscriptionRecord.type, forKey: .type) + try container.encode(self.publication, forKey: .publication) + try container.encodeDateIfPresent(self.createdAt, forKey: .createdAt) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case publication + case createdAt + } + } +} diff --git a/Inkwell/StandardSite/SiteStandardLexicon.swift b/Inkwell/StandardSite/SiteStandardLexicon.swift new file mode 100644 index 0000000..511543f --- /dev/null +++ b/Inkwell/StandardSite/SiteStandardLexicon.swift @@ -0,0 +1,55 @@ +// +// SiteStandardLexicon.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// + +import Foundation + +/// The root namespace for Standard.site (`site.standard.*`) lexicons. +/// +/// [Standard.site](https://standard.site) is a community-maintained set of AT Protocol +/// lexicons for long-form publishing — "one schema, every platform." It's what Leaflet (and +/// Inkwell's eventual leaflet.pub support) publishes documents and publications as, alongside +/// other tools like pckt.blog, offprint.app, and self-hosted sites via sequoia.pub. +/// +/// These types deliberately follow the same modelling conventions ATProtoKit itself uses for +/// Bluesky's `app.bsky.*`/`com.atproto.*` lexicons (see `AppBskyLexicon`/`ComAtprotoLexicon`), +/// so they slot into the same `ATRecordProtocol`/`UnknownType` machinery rather than being a +/// parallel, bespoke decoding path: +/// - Top-level collection records (the ones actually stored at a repo path) conform to +/// `ATRecordProtocol`, exactly like `AppBskyLexicon.Feed.PostRecord` etc. +/// - Shared AT Protocol primitives (blobs, strong refs, self-labels) reuse ATProtoKit's own +/// `ComAtprotoLexicon` types instead of redefining them. +/// - Open-union fields (content formats Standard.site deliberately leaves up to each platform) +/// use ATProtoKit's `UnknownType`. +/// +/// Call ``SiteStandardLexicon/registerRecordTypes()`` once at launch (see +/// `SiteStandardRegistration.swift`) so those open unions can resolve `site.standard.*` +/// content by its `$type` instead of falling back to raw JSON. +/// +/// Four lexicons make up the standard: +/// - ``SiteStandardLexicon/PublicationRecord`` — `site.standard.publication` +/// - ``SiteStandardLexicon/DocumentRecord`` — `site.standard.document` +/// - ``SiteStandardLexicon/Graph/SubscriptionRecord`` — `site.standard.graph.subscription` +/// - ``SiteStandardLexicon/Graph/RecommendRecord`` — `site.standard.graph.recommend` +/// +/// Plus two supporting pieces: ``SiteStandardLexicon/Theme`` for publication theming, and +/// ``SiteStandardLexicon/Verification`` for proving a record actually belongs to the domain +/// it claims to. +/// +/// - Note: Standard.site is explicitly *not* a standard for site content — only metadata, +/// discovery, and social features. Don't expect (or add) a Leaflet-specific block-content +/// model here; that belongs in its own lexicon, registered separately if/when Inkwell needs +/// to decode it. +/// +/// - SeeAlso: [standard.site/docs/introduction](https://standard.site/docs/introduction/) +public enum SiteStandardLexicon { + + /// Namespace for `site.standard.graph.*` lexicons (subscriptions, recommends). + public enum Graph {} + + /// Namespace for `site.standard.theme.*` lexicons (publication theming). + public enum Theme {} +} diff --git a/Inkwell/StandardSite/SiteStandardPermissions.swift b/Inkwell/StandardSite/SiteStandardPermissions.swift new file mode 100644 index 0000000..40694ba --- /dev/null +++ b/Inkwell/StandardSite/SiteStandardPermissions.swift @@ -0,0 +1,46 @@ +// +// SiteStandardPermissions.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// + +import Foundation + +extension SiteStandardLexicon { + + /// Standard.site's OAuth permission sets. + /// + /// These aren't lexicon records — they're scope identifiers a client includes when + /// requesting authorization, so the user's PDS knows which `site.standard.*` collections + /// the app is allowed to create/update/delete records in. + /// + /// Inkwell currently authenticates with an app password via `ATProtocolConfiguration` + /// (see `LoginStateManager`) rather than OAuth, so these aren't wired into the sign-in + /// flow yet — app-password sessions get whatever access the account itself has, with no + /// scoping. They're here for whenever OAuth (and therefore properly scoped, user-consented + /// permissions) lands. + /// + /// - SeeAlso: [standard.site/docs/permissions](https://standard.site/docs/permissions/) + public enum Permissions { + + /// Full access to publications, documents, subscriptions, and recommends. + /// + /// Grants create/update/delete on: + /// `site.standard.publication`, `site.standard.document`, + /// `site.standard.graph.subscription`, `site.standard.graph.recommend`. + /// + /// Requested via OAuth scope as `include:site.standard.authFull`. + public static let authFull = "site.standard.authFull" + + /// A narrower scope for managing subscriptions and document recommends only. + /// + /// Grants create/update/delete on: + /// `site.standard.graph.subscription`, `site.standard.graph.recommend`. + /// + /// Requested via OAuth scope as `include:site.standard.authSocial`. Appropriate for + /// reader-only features (subscribing to publications, recommending documents) that + /// shouldn't also be able to touch someone's actual publication/document records. + public static let authSocial = "site.standard.authSocial" + } +} diff --git a/Inkwell/StandardSite/SiteStandardPublication.swift b/Inkwell/StandardSite/SiteStandardPublication.swift new file mode 100644 index 0000000..c1c8a2c --- /dev/null +++ b/Inkwell/StandardSite/SiteStandardPublication.swift @@ -0,0 +1,141 @@ +// +// SiteStandardPublication.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// + +import Foundation +import ATProtoKit + +extension SiteStandardLexicon { + + /// A record model for a Standard.site publication. + /// + /// A publication represents a collection of documents published to the web — the "site" + /// itself, roughly analogous to an RSS ``. It's optional (a document can stand + /// alone via its `site` URL), but recommended whenever documents belong to a + /// larger collection. + /// + /// - SeeAlso: This is based on the [`site.standard.publication`][docs] lexicon. + /// + /// [docs]: https://standard.site/docs/lexicons/publication/ + public struct PublicationRecord: ATRecordProtocol, Sendable { + + /// The identifier of the lexicon. + /// + /// - Warning: The value must not change. + public static let type: String = "site.standard.publication" + + /// The base URL for the publication (ex: `https://standard.site`). + /// + /// Combined with a document's `path` to construct that document's canonical URL. + /// Avoid a trailing slash. + public let url: String + + /// The name of the publication. + /// + /// - Important: Current maximum length is 500 graphemes (5,000 bytes). + public let name: String + + /// A square image identifying the publication. Optional. + /// + /// Should be at least 256x256. Maximum size is 1MB. + public let icon: ComAtprotoLexicon.Repository.UploadBlobOutput? + + /// A brief description of the publication. Optional. + /// + /// - Important: Current maximum length is 3,000 graphemes (30,000 bytes). + public let description: String? + + /// A simplified theme for tools and apps to use when displaying this publication's + /// content. Optional. + public let basicTheme: SiteStandardLexicon.Theme.BasicDefinition? + + /// Self-label values for this publication — effectively content warnings. Optional. + public let labels: ComAtprotoLexicon.Label.SelfLabelsDefinition? + + /// Platform-specific preferences for the publication. Optional. + public let preferences: Preferences? + + public init( + url: String, + name: String, + icon: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil, + description: String? = nil, + basicTheme: SiteStandardLexicon.Theme.BasicDefinition? = nil, + labels: ComAtprotoLexicon.Label.SelfLabelsDefinition? = nil, + preferences: Preferences? = nil + ) { + self.url = url + self.name = name + self.icon = icon + self.description = description + self.basicTheme = basicTheme + self.labels = labels + self.preferences = preferences + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.url = try container.decode(String.self, forKey: .url) + self.name = try container.decode(String.self, forKey: .name) + self.icon = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .icon) + self.description = try container.decodeIfPresent(String.self, forKey: .description) + self.basicTheme = try container.decodeIfPresent(SiteStandardLexicon.Theme.BasicDefinition.self, forKey: .basicTheme) + self.labels = try container.decodeIfPresent(ComAtprotoLexicon.Label.SelfLabelsDefinition.self, forKey: .labels) + self.preferences = try container.decodeIfPresent(Preferences.self, forKey: .preferences) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + // Standard.site's own examples always include `$type` on the wire, so this is + // written explicitly here rather than leaning on whatever's calling encode(to:) + // to add it separately. + try container.encode(SiteStandardLexicon.PublicationRecord.type, forKey: .type) + try container.encode(self.url, forKey: .url) + try container.truncatedEncode(self.name, forKey: .name, upToCharacterLength: 500) + try container.encodeIfPresent(self.icon, forKey: .icon) + try container.truncatedEncodeIfPresent(self.description, forKey: .description, upToCharacterLength: 3_000) + try container.encodeIfPresent(self.basicTheme, forKey: .basicTheme) + try container.encodeIfPresent(self.labels, forKey: .labels) + try container.encodeIfPresent(self.preferences, forKey: .preferences) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case url + case name + case icon + case description + case basicTheme + case labels + case preferences + } + } +} + +extension SiteStandardLexicon.PublicationRecord { + + /// Platform-specific preferences for a publication. + /// + /// This is an inline `object` type (not a separate `ref`), so unlike + /// ``PublicationRecord`` itself, instances of this type don't carry a `$type` + /// discriminator when encoded — matching every example in the Standard.site docs, which + /// show `"preferences": { "showInDiscover": true }` with no `$type` inside it. + /// + /// - SeeAlso: This is based on the [`site.standard.publication#preferences`][docs] definition. + /// + /// [docs]: https://standard.site/docs/lexicons/publication/ + nonisolated public struct Preferences: Sendable, Codable, Equatable, Hashable { + + /// Whether the publication should appear in discovery feeds. Optional. + public let showInDiscover: Bool? + + public init(showInDiscover: Bool? = nil) { + self.showInDiscover = showInDiscover + } + } +} diff --git a/Inkwell/StandardSite/SiteStandardRegistration.swift b/Inkwell/StandardSite/SiteStandardRegistration.swift new file mode 100644 index 0000000..edcc474 --- /dev/null +++ b/Inkwell/StandardSite/SiteStandardRegistration.swift @@ -0,0 +1,30 @@ +// +// SiteStandardRegistration.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// + +import Foundation +import ATProtoKit + +extension SiteStandardLexicon { + + /// Registers every `site.standard.*` record type with ATProtoKit's + /// `ATRecordTypeRegistry`, so `UnknownType` elsewhere in the SDK — including these + /// lexicons' own open-union fields, like ``DocumentRecord/content`` — can resolve a + /// `$type` it encounters back to one of these concrete structs instead of falling back to + /// raw, untyped JSON. + /// + /// Call this once, early in the app's lifetime — see `InkwellApp.swift`'s `.task`, where + /// it runs alongside `LoginStateManager.restoreSessionIfPossible()`. Safe to call more + /// than once; `ATRecordTypeRegistry` skips any type that's already registered. + public static func registerRecordTypes() async { + await ATRecordTypeRegistry.shared.register(types: [ + SiteStandardLexicon.PublicationRecord.self, + SiteStandardLexicon.DocumentRecord.self, + SiteStandardLexicon.Graph.SubscriptionRecord.self, + SiteStandardLexicon.Graph.RecommendRecord.self + ]) + } +} diff --git a/Inkwell/StandardSite/SiteStandardTheme.swift b/Inkwell/StandardSite/SiteStandardTheme.swift new file mode 100644 index 0000000..219059b --- /dev/null +++ b/Inkwell/StandardSite/SiteStandardTheme.swift @@ -0,0 +1,212 @@ +// +// SiteStandardTheme.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// + +import Foundation +import ATProtoKit + +extension SiteStandardLexicon.Theme { + + /// A simplified theme definition for a publication. + /// + /// Lets a publication keep a consistent visual identity (background/foreground/accent + /// colours) across different reading apps, rather than each one falling back to its own + /// default styling. + /// + /// - SeeAlso: This is based on the [`site.standard.theme.basic`][docs] lexicon. + /// + /// [docs]: https://standard.site/docs/lexicons/theme/ + nonisolated public struct BasicDefinition: Sendable, Codable, Equatable, Hashable { + + /// The identifier of the lexicon. + /// + /// - Warning: The value must not change. + public static let type: String = "site.standard.theme.basic" + + /// The colour used for content backgrounds. + public let background: RGBColor + + /// The colour used for content text. + public let foreground: RGBColor + + /// The colour used for links and button backgrounds. + public let accent: RGBColor + + /// The colour used for button text. + public let accentForeground: RGBColor + + public init(background: RGBColor, foreground: RGBColor, accent: RGBColor, accentForeground: RGBColor) { + self.background = background + self.foreground = foreground + self.accent = accent + self.accentForeground = accentForeground + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.background = try container.decode(RGBColor.self, forKey: .background) + self.foreground = try container.decode(RGBColor.self, forKey: .foreground) + self.accent = try container.decode(RGBColor.self, forKey: .accent) + self.accentForeground = try container.decode(RGBColor.self, forKey: .accentForeground) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(BasicDefinition.type, forKey: .type) + try container.encode(self.background, forKey: .background) + try container.encode(self.foreground, forKey: .foreground) + try container.encode(self.accent, forKey: .accent) + try container.encode(self.accentForeground, forKey: .accentForeground) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case background + case foreground + case accent + case accentForeground + } + } + + /// An opaque RGB colour, used by ``BasicDefinition``. + /// + /// - SeeAlso: This is based on the [`site.standard.theme.color#rgb`][docs] definition. + /// + /// [docs]: https://standard.site/docs/lexicons/theme/ + nonisolated public struct RGBColor: Sendable, Codable, Equatable, Hashable { + + /// The identifier of the lexicon. + /// + /// - Warning: The value must not change. + public static let type: String = "site.standard.theme.color#rgb" + + /// The red channel value. Range: 0-255. + public let r: Int + + /// The green channel value. Range: 0-255. + public let g: Int + + /// The blue channel value. Range: 0-255. + public let b: Int + + public init(r: Int, g: Int, b: Int) { + self.r = r + self.g = g + self.b = b + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.r = try container.decode(Int.self, forKey: .r) + self.g = try container.decode(Int.self, forKey: .g) + self.b = try container.decode(Int.self, forKey: .b) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(RGBColor.type, forKey: .type) + try container.encode(self.r, forKey: .r) + try container.encode(self.g, forKey: .g) + try container.encode(self.b, forKey: .b) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case r + case g + case b + } + } + + /// A translucent RGBA colour. + /// + /// Defined by the `site.standard.theme.color` lexicon alongside ``RGBColor``, though + /// ``BasicDefinition``'s four colour properties are all currently typed as ``RGBColor`` + /// rather than this per the docs — included here for completeness/forward compatibility. + /// + /// - SeeAlso: This is based on the [`site.standard.theme.color#rgba`][docs] definition. + /// + /// [docs]: https://standard.site/docs/lexicons/theme/ + nonisolated public struct RGBAColor: Sendable, Codable, Equatable, Hashable { + + /// The identifier of the lexicon. + /// + /// - Warning: The value must not change. + public static let type: String = "site.standard.theme.color#rgba" + + /// The red channel value. Range: 0-255. + public let r: Int + + /// The green channel value. Range: 0-255. + public let g: Int + + /// The blue channel value. Range: 0-255. + public let b: Int + + /// The alpha (opacity) value, where 0 is transparent and 100 is opaque. Range: 0-100. + public let a: Int + + public init(r: Int, g: Int, b: Int, a: Int) { + self.r = r + self.g = g + self.b = b + self.a = a + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.r = try container.decode(Int.self, forKey: .r) + self.g = try container.decode(Int.self, forKey: .g) + self.b = try container.decode(Int.self, forKey: .b) + self.a = try container.decode(Int.self, forKey: .a) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(RGBAColor.type, forKey: .type) + try container.encode(self.r, forKey: .r) + try container.encode(self.g, forKey: .g) + try container.encode(self.b, forKey: .b) + try container.encode(self.a, forKey: .a) + } + + enum CodingKeys: String, CodingKey { + case type = "$type" + case r + case g + case b + case a + } + } +} + +#if canImport(SwiftUI) +import SwiftUI + +extension SiteStandardLexicon.Theme.RGBColor { + + /// A SwiftUI `Color` representation of this value, for direct use when rendering a + /// publication's theme. + public var color: Color { + Color(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255) + } +} + +extension SiteStandardLexicon.Theme.RGBAColor { + + /// A SwiftUI `Color` representation of this value, for direct use when rendering a + /// publication's theme. + public var color: Color { + Color(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255, opacity: Double(a) / 100) + } +} +#endif diff --git a/Inkwell/StandardSite/SiteStandardVerification.swift b/Inkwell/StandardSite/SiteStandardVerification.swift new file mode 100644 index 0000000..5b2ce6f --- /dev/null +++ b/Inkwell/StandardSite/SiteStandardVerification.swift @@ -0,0 +1,172 @@ +// +// SiteStandardVerification.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// + +import Foundation + +extension SiteStandardLexicon { + + /// Verifies that a Standard.site record actually belongs to the domain it claims to. + /// + /// Since publication/document records reference web pages, anyone could in principle + /// publish a record claiming someone else's domain. Standard.site's answer is a + /// `.well-known` endpoint (publications) and an HTML `` tag (documents) that the + /// domain itself serves, pointing back at the AT-URI. A record should only be treated as + /// trustworthy once that round-trip checks out. + /// + /// - SeeAlso: [standard.site/docs/verification](https://standard.site/docs/verification/) + public enum Verification { + + /// The reason a publication failed verification. + public enum Failure: Error, LocalizedError { + + /// The publication's `url` couldn't be turned into a request URL. + case invalidPublicationURL(String) + + /// The `.well-known` endpoint didn't return a successful response. + case endpointUnreachable(statusCode: Int?) + + /// The endpoint responded, but the body wasn't a usable AT-URI. + case malformedResponse + + /// The endpoint's AT-URI didn't match the publication record being verified. + case mismatchedURI(expected: String, found: String) + + /// The canonical document page did not contain the required link relation. + case documentLinkMissing(expected: String) + + public var errorDescription: String? { + switch self { + case .invalidPublicationURL(let url): + return "\"\(url)\" isn't a valid publication URL." + case .endpointUnreachable(let statusCode): + if let statusCode { + return "The .well-known endpoint returned status \(statusCode)." + } + return "The .well-known endpoint couldn't be reached." + case .malformedResponse: + return "The .well-known endpoint's response wasn't a usable AT-URI." + case .mismatchedURI(let expected, let found): + return "Expected \(expected) but the domain's .well-known endpoint points to \(found)." + case .documentLinkMissing(let expected): + return "The document page doesn't link back to \(expected)." + } + } + } + + /// Verifies a publication by fetching its `.well-known` endpoint and confirming it + /// points back at the given AT-URI. + /// + /// - Parameters: + /// - publicationURI: The publication record's own AT-URI + /// (ex: `at://did:plc:abc123/site.standard.publication/rkey`), as claimed by + /// whoever's surfacing it (a firehose consumer, a reader feature, etc.) — *not* + /// something read out of the publication record itself, since that would just be + /// trusting the thing being verified. + /// - publication: The publication record being verified, used for its `url`. + /// + /// - Throws: ``Failure`` if the domain doesn't confirm ownership of `publicationURI`, + /// or a networking error if the endpoint can't be reached at all. + public static func verify( + publicationURI: String, + publication: SiteStandardLexicon.PublicationRecord + ) async throws { + guard let endpoint = publicationVerificationURL(for: publication.url) else { + throw Failure.invalidPublicationURL(publication.url) + } + + let (data, response) = try await URLSession.shared.data(from: endpoint) + + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + throw Failure.endpointUnreachable(statusCode: (response as? HTTPURLResponse)?.statusCode) + } + + guard let foundURI = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + foundURI.hasPrefix("at://") else { + throw Failure.malformedResponse + } + + guard foundURI == publicationURI else { + throw Failure.mismatchedURI(expected: publicationURI, found: foundURI) + } + } + + /// Builds the verification endpoint, including the publication path for + /// non-root publications as required by the Standard.site specification. + static func publicationVerificationURL(for publicationURL: String) -> URL? { + guard var components = URLComponents(string: publicationURL), + components.scheme?.lowercased() == "https", + components.host != nil else { + return nil + } + + let publicationPath = components.path + .split(separator: "/") + .map(String.init) + .joined(separator: "/") + components.path = "/.well-known/site.standard.publication" + if !publicationPath.isEmpty { + components.path += "/\(publicationPath)" + } + components.query = nil + components.fragment = nil + return components.url + } + + /// Verifies a document page by checking for its required + /// `` element. + public static func verify( + documentURI: String, + document: SiteStandardLexicon.DocumentRecord, + publication: SiteStandardLexicon.PublicationRecord? = nil + ) async throws { + guard let url = document.canonicalURL(publication: publication) else { + throw Failure.invalidPublicationURL(document.site) + } + + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw Failure.endpointUnreachable(statusCode: (response as? HTTPURLResponse)?.statusCode) + } + guard let html = String(data: data, encoding: .utf8) else { + throw Failure.malformedResponse + } + + let escapedURI = NSRegularExpression.escapedPattern(for: documentURI) + let patterns = [ + #"]*\brel\s*=\s*[\"']site\.standard\.document[\"'][^>]*\bhref\s*=\s*[\"']"# + escapedURI + #"[\"'][^>]*>"#, + #"]*\bhref\s*=\s*[\"']"# + escapedURI + #"[\"'][^>]*\brel\s*=\s*[\"']site\.standard\.document[\"'][^>]*>"# + ] + let range = NSRange(html.startIndex..` tag Inkwell would emit if it ever serves a + /// document's content as a web page (ex: a "view in browser" export). + /// + /// This is a *hint* only, per the spec — readers should still confirm via + /// ``verify(publicationURI:publication:)`` rather than trusting this tag alone. + /// + /// - Parameters: + /// - recordURI: The AT-URI of the `site.standard.publication` or + /// `site.standard.document` record. + /// - relation: Either `"site.standard.publication"` or `"site.standard.document"`, + /// matching the kind of record `recordURI` points to. + /// - Returns: A `` tag suitable for a document's ``. + public static func discoveryLinkTag(forRecordURI recordURI: String, relation: String) -> String { + "" + } + } +} diff --git a/Inkwell/StandardSite/StandardSiteTypes.swift b/Inkwell/StandardSite/StandardSiteTypes.swift new file mode 100644 index 0000000..2d30725 --- /dev/null +++ b/Inkwell/StandardSite/StandardSiteTypes.swift @@ -0,0 +1,154 @@ +// +// StandardSiteTypes.swift +// Inkwell +// +// Created by Letta on 20/06/2026. +// +// Wrapper types that carry the AT-URI and author DID alongside decoded +// standard.site records. The lexicon structs (PublicationRecord, +// DocumentRecord, etc.) only model the record *value* — they don't know +// their own AT-URI or which repo they live in. These wrappers fill that +// gap so the UI and subscription/notification code can reference records +// by URI and fetch cross-repo content. +// + +import Foundation + +// MARK: - AT-URI Parser + +/// Parses AT-URIs (`at://did:plc:abc123/collection/rkey`) into their +/// components. Used for extracting the author DID and record key from +/// subscription and publication AT-URIs. +struct ATURI: Equatable, Hashable { + let did: String + let collection: String + let recordKey: String + + /// The full AT-URI string. + var uri: String { "at://\(did)/\(collection)/\(recordKey)" } + + /// Parses an AT-URI string. Returns `nil` if the format is invalid. + static func parse(_ uri: String) -> ATURI? { + // Strip the "at://" prefix + guard uri.hasPrefix("at://") else { return nil } + let stripped = String(uri.dropFirst(5)) + + // Split into segments: did/collection/rkey + let parts = stripped.split(separator: "/", maxSplits: 2, omittingEmptySubsequences: false) + guard parts.count == 3 else { return nil } + + let did = String(parts[0]) + let collection = String(parts[1]) + let recordKey = String(parts[2]) + + guard !did.isEmpty, !collection.isEmpty, !recordKey.isEmpty else { return nil } + return ATURI(did: did, collection: collection, recordKey: recordKey) + } +} + +// MARK: - Entry Wrappers + +/// A publication record enriched with its AT-URI and author DID. +struct PublicationEntry: Identifiable, Equatable, Hashable { + /// The AT-URI of the publication record. + let uri: String + /// The DID of the repo that contains this publication. + let authorDID: String + /// The decoded publication record. + let record: SiteStandardLexicon.PublicationRecord + + var id: String { uri } +} + +extension PublicationEntry { + /// Matches both the preferred publication AT-URI and the URL form used by + /// older and standalone Standard.site producers. + func contains(_ document: SiteStandardLexicon.DocumentRecord) -> Bool { + document.site == uri || normalizedSite(document.site) == normalizedSite(record.url) + } +} + +/// A document record enriched with its AT-URI and author DID. +struct DocumentEntry: Identifiable, Equatable, Hashable { + /// The AT-URI of the document record. + let uri: String + /// The DID of the repo that contains this document. + let authorDID: String + /// The decoded document record. + let record: SiteStandardLexicon.DocumentRecord + + var id: String { uri } +} + +extension SiteStandardLexicon.DocumentRecord { + /// Builds the canonical web URL described by Standard.site's `site` + `path` + /// rules. A publication is required when `site` is an AT-URI. + func canonicalURL(publication: SiteStandardLexicon.PublicationRecord? = nil) -> URL? { + let baseString: String + if site.hasPrefix("at://") { + guard let publication else { return nil } + baseString = publication.url + } else { + baseString = site + } + + guard var components = URLComponents(string: baseString), + components.scheme?.lowercased() == "https", + components.host != nil else { + return nil + } + + if let path, !path.isEmpty { + let documentPath = path.hasPrefix("/") ? path : "/\(path)" + components.path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + components.path = "/" + [components.path, documentPath] + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } + .filter { !$0.isEmpty } + .joined(separator: "/") + } + + return components.url + } +} + +private func normalizedSite(_ value: String) -> String { + guard var components = URLComponents(string: value), components.host != nil else { + return value.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + components.scheme = components.scheme?.lowercased() + components.host = components.host?.lowercased() + while components.path.count > 1 && components.path.hasSuffix("/") { + components.path.removeLast() + } + return components.string ?? value +} + +/// A subscription record enriched with its AT-URI and record key. +struct SubscriptionEntry: Identifiable, Equatable, Hashable { + /// The AT-URI of the subscription record itself. + let uri: String + /// The record key (needed to delete the subscription). + let recordKey: String + /// The decoded subscription record (contains the publication AT-URI). + let record: SiteStandardLexicon.Graph.SubscriptionRecord + + var id: String { uri } + + /// The parsed AT-URI of the publication being subscribed to. + var publicationURI: ATURI? { ATURI.parse(record.publication) } +} + +/// A recommend record enriched with its AT-URI and record key. +struct RecommendEntry: Identifiable, Equatable, Hashable { + /// The AT-URI of the recommend record itself. + let uri: String + /// The record key. + let recordKey: String + /// The decoded recommend record (contains the document AT-URI). + let record: SiteStandardLexicon.Graph.RecommendRecord + + var id: String { uri } + + /// The parsed AT-URI of the document being recommended. + var documentURI: ATURI? { ATURI.parse(record.document) } +}