From d41c54d174419fe1d780a47b18d1f96aed39b45f Mon Sep 17 00:00:00 2001
From: Christoph Hagen <github@christophhagen.de>
Date: Mon, 3 Feb 2025 12:14:07 +0100
Subject: [PATCH] Process post content as markdown

---
 CHDataManagement.xcodeproj/project.pbxproj    | 12 ++-
 .../Generator/Markdown/ItemLinkResults.swift  | 18 ++++
 .../Markdown/MarkdownLinkProcessor.swift      | 96 ++++++++++++-------
 .../PageContentGenerator.swift                |  0
 .../Post Lists/PostContentGenerator.swift     | 93 ++++++++++++++++++
 .../Post Lists/PostListPageGenerator.swift    | 13 ++-
 .../Page Elements/FeedEntry.swift             |  4 +-
 .../Page Elements/FeedEntryData.swift         |  4 +-
 8 files changed, 194 insertions(+), 46 deletions(-)
 create mode 100644 CHDataManagement/Generator/Markdown/ItemLinkResults.swift
 rename CHDataManagement/Generator/{ => Page Generators}/PageContentGenerator.swift (100%)
 create mode 100644 CHDataManagement/Generator/Post Lists/PostContentGenerator.swift

diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj
index 5420807..0a62408 100644
--- a/CHDataManagement.xcodeproj/project.pbxproj
+++ b/CHDataManagement.xcodeproj/project.pbxproj
@@ -44,6 +44,8 @@
 		E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229904D2D135349009F8D77 /* SecurityBookmark.swift */; };
 		E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = E24252002C50E0A40029FF16 /* HighlightedTextEditor */; };
 		E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252092C52C9260029FF16 /* ContentLanguage.swift */; };
+		E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2521DFB2D501DAE00C56662 /* PostContentGenerator.swift */; };
+		E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2521DFF2D50BB6E00C56662 /* ItemLinkResults.swift */; };
 		E2581DED2C75202400F1F079 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEC2C75202400F1F079 /* Tag.swift */; };
 		E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5082CFD964E00AEF16D /* TagContentView.swift */; };
 		E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */; };
@@ -298,6 +300,8 @@
 		E229904B2D10BE59009F8D77 /* InitialSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialSetupView.swift; sourceTree = "<group>"; };
 		E229904D2D135349009F8D77 /* SecurityBookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityBookmark.swift; sourceTree = "<group>"; };
 		E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = "<group>"; };
+		E2521DFB2D501DAE00C56662 /* PostContentGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostContentGenerator.swift; sourceTree = "<group>"; };
+		E2521DFF2D50BB6E00C56662 /* ItemLinkResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLinkResults.swift; sourceTree = "<group>"; };
 		E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
 		E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = "<group>"; };
 		E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = "<group>"; };
@@ -575,7 +579,6 @@
 				E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
 				E22990412D107A94009F8D77 /* ImageVersion.swift */,
 				E2FE0F182D2723E3002963B7 /* ImageSet.swift */,
-				E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */,
 			);
 			path = Generator;
 			sourceTree = "<group>";
@@ -945,6 +948,7 @@
 		E2FE0F072D2689DC002963B7 /* Post Lists */ = {
 			isa = PBXGroup;
 			children = (
+				E2521DFB2D501DAE00C56662 /* PostContentGenerator.swift */,
 				E2FE0F0C2D268A09002963B7 /* PostListPageGeneratorSource.swift */,
 				E2FE0F0A2D2689FF002963B7 /* FeedGeneratorSource.swift */,
 				E2FE0F082D2689F0002963B7 /* TagPageGeneratorSource.swift */,
@@ -957,8 +961,9 @@
 			isa = PBXGroup;
 			children = (
 				E2FE0F1D2D281ACE002963B7 /* TagOverviewGenerator.swift */,
-				E25DA5982D02401A00AEF16D /* PageGenerator.swift */,
 				E2B85F3C2C4293F80047CD0C /* FeedPageGenerator.swift */,
+				E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */,
+				E25DA5982D02401A00AEF16D /* PageGenerator.swift */,
 			);
 			path = "Page Generators";
 			sourceTree = "<group>";
@@ -1002,6 +1007,7 @@
 		E2FE0F442D2BC76C002963B7 /* Markdown */ = {
 			isa = PBXGroup;
 			children = (
+				E2521DFF2D50BB6E00C56662 /* ItemLinkResults.swift */,
 				E2FE0F102D268E78002963B7 /* MarkdownCodeProcessor.swift */,
 				E2FE0F4A2D2BCCA5002963B7 /* MarkdownHeadlineProcessor.swift */,
 				E2FE0F452D2BC772002963B7 /* MarkdownImageProcessor.swift */,
@@ -1237,8 +1243,10 @@
 				E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */,
 				E2FD1D542D46577700B48627 /* HtmlProducer.swift in Sources */,
 				E2FE0F0D2D268A09002963B7 /* PostListPageGeneratorSource.swift in Sources */,
+				E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */,
 				E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */,
 				E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */,
+				E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */,
 				E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */,
 				E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */,
 				E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */,
diff --git a/CHDataManagement/Generator/Markdown/ItemLinkResults.swift b/CHDataManagement/Generator/Markdown/ItemLinkResults.swift
new file mode 100644
index 0000000..9d9daf9
--- /dev/null
+++ b/CHDataManagement/Generator/Markdown/ItemLinkResults.swift
@@ -0,0 +1,18 @@
+
+protocol ItemLinkResults {
+
+    func externalLink(to url: String)
+
+    func missing(page: String, source: String)
+
+    func linked(to page: Page)
+
+    func missing(tag: String, source: String)
+
+    func linked(to tag: Tag)
+
+    func missing(file: String, source: String)
+
+    func require(file: FileResource)
+}
+
diff --git a/CHDataManagement/Generator/Markdown/MarkdownLinkProcessor.swift b/CHDataManagement/Generator/Markdown/MarkdownLinkProcessor.swift
index 45e08f1..2567243 100644
--- a/CHDataManagement/Generator/Markdown/MarkdownLinkProcessor.swift
+++ b/CHDataManagement/Generator/Markdown/MarkdownLinkProcessor.swift
@@ -1,84 +1,110 @@
 import Ink
 
+extension PageGenerationResults: ItemLinkResults {
+
+}
+
 struct MarkdownLinkProcessor: MarkdownProcessor {
 
     static let modifier: Modifier.Target = .links
 
     private let content: Content
 
-    private let results: PageGenerationResults
+    private let results: ItemLinkResults
 
     private let language: ContentLanguage
 
+    init(content: Content, results: ItemLinkResults, language: ContentLanguage) {
+        self.content = content
+        self.results = results
+        self.language = language
+    }
+
     init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
         self.content = content
         self.results = results
         self.language = language
     }
 
+    func process(html: String, markdown: Substring) -> String {
+        let markdownUrl = markdown.between("(", and: ")")
+        guard let (textToReplace, url) = convert(markdownUrl: markdownUrl) else {
+            // Keep original code, no changes needed
+            return html
+        }
+        guard let convertedUrl = url else {
+            // Remove link since the target can't be found
+            return markdown.between("[", and: "]")
+        }
+        return html.replacingOccurrences(of: textToReplace, with: convertedUrl)
+    }
+
     private let pageLinkMarker = "page:"
 
     private let tagLinkMarker = "tag:"
 
     private let fileLinkMarker = "file:"
 
-    func process(html: String, markdown: Substring) -> String {
-        let url = markdown.between("(", and: ")")
-        if url.hasPrefix(pageLinkMarker) {
-            return handleInlinePageLink(url: url, html: html, markdown: markdown)
+    func convert(markdownUrl: String) -> (textToChange: String, url: String?)? {
+        if let result = handle(prefix: pageLinkMarker, in: markdownUrl, handler: convert(inlinePageLink:)) {
+            return result
         }
-        if url.hasPrefix(tagLinkMarker) {
-            return handleInlineTagLink(url: url, html: html, markdown: markdown)
+        if let result = handle(prefix: tagLinkMarker, in: markdownUrl, handler: convert(inlineTagLink:)) {
+            return result
         }
-        if url.hasPrefix(fileLinkMarker) {
-            return handleInlineFileLink(url: url, html: html, markdown: markdown)
+        if let result = handle(prefix: fileLinkMarker, in: markdownUrl, handler: convert(inlineFileLink:)) {
+            return result
         }
-        results.externalLink(to: url)
-        return html
+        results.externalLink(to: markdownUrl)
+        // No need to change anything
+        return nil
     }
 
-    private func handleInlinePageLink(url: String, html: String, markdown: Substring) -> String {
-        // Retain links pointing to elements within a page
-        let textToChange = url.dropAfterFirst("#")
-        let pageId = textToChange.replacingOccurrences(of: pageLinkMarker, with: "")
+    private func handle(prefix: String, in markdownUrl: String, handler: (String) -> String?)  -> (textToChange: String, url: String?)? {
+        guard markdownUrl.hasPrefix(prefix) else {
+            // Continue search
+            return nil
+        }
+        let inlineLink = markdownUrl.replacingOccurrences(of: prefix, with: "")
+        let itemId = inlineLink.dropAfterFirst("#")
+        let url = handler(itemId)
+        return (textToChange: prefix + itemId, url: url)
+    }
+
+    private func convert(inlinePageLink pageId: String) -> String? {
         guard let page = content.page(pageId) else {
             results.missing(page: pageId, source: "Inline page link")
             // Remove link since the page can't be found
-            return markdown.between("[", and: "]")
+            return nil
         }
-        guard !page.isDraft else {
-            return markdown.between("[", and: "]")
-        }
-
+        // Note link even for draft pages
         results.linked(to: page)
-        let pagePath = page.absoluteUrl(in: language)
-        return html.replacingOccurrences(of: textToChange, with: pagePath)
+
+        guard !page.isDraft else {
+            // TODO: Report links to draft pages
+            return nil
+        }
+        return page.absoluteUrl(in: language)
     }
 
-    private func handleInlineTagLink(url: String, html: String, markdown: Substring) -> String {
-        // Retain links pointing to elements within a page
-        let textToChange = url.dropAfterFirst("#")
-        let tagId = textToChange.replacingOccurrences(of: tagLinkMarker, with: "")
+    private func convert(inlineTagLink: String) -> String? {
+        let tagId = inlineTagLink.dropAfterFirst("#")
         guard let tag = content.tag(tagId) else {
             results.missing(tag: tagId, source: "Inline tag link")
             // Remove link since the tag can't be found
-            return markdown.between("[", and: "]")
+            return nil
         }
         results.linked(to: tag)
-        let tagPath = tag.absoluteUrl(in: language)
-        return html.replacingOccurrences(of: textToChange, with: tagPath)
+        return tag.absoluteUrl(in: language)
     }
 
-    private func handleInlineFileLink(url: String, html: String, markdown: Substring) -> String {
-        // Retain links pointing to elements within a page
-        let fileId = url.replacingOccurrences(of: fileLinkMarker, with: "")
+    private func convert(inlineFileLink fileId: String) -> String? {
         guard let file = content.file(fileId) else {
             results.missing(file: fileId, source: "Inline file link")
             // Remove link since the file can't be found
-            return markdown.between("[", and: "]")
+            return nil
         }
         results.require(file: file)
-        let filePath = file.absoluteUrl
-        return html.replacingOccurrences(of: url, with: filePath)
+        return file.absoluteUrl
     }
 }
diff --git a/CHDataManagement/Generator/PageContentGenerator.swift b/CHDataManagement/Generator/Page Generators/PageContentGenerator.swift
similarity index 100%
rename from CHDataManagement/Generator/PageContentGenerator.swift
rename to CHDataManagement/Generator/Page Generators/PageContentGenerator.swift
diff --git a/CHDataManagement/Generator/Post Lists/PostContentGenerator.swift b/CHDataManagement/Generator/Post Lists/PostContentGenerator.swift
new file mode 100644
index 0000000..c67b894
--- /dev/null
+++ b/CHDataManagement/Generator/Post Lists/PostContentGenerator.swift	
@@ -0,0 +1,93 @@
+import Foundation
+import Ink
+
+struct PostContentGenerator {
+
+    private let content: Content
+
+    private let results: PageGenerationResults
+
+    private let language: ContentLanguage
+
+    private let post: Post
+
+    init(content: Content, results: PageGenerationResults, language: ContentLanguage, post: Post) {
+        self.content = content
+        self.results = results
+        self.language = language
+        self.post = post
+    }
+
+    func generate() -> String {
+        let parser = MarkdownParser(modifiers: [
+            Modifier(target: .images, closure: handleImage),
+            Modifier(target: .codeBlocks, closure: handleCode),
+            Modifier(target: .links, closure: handleLink)
+        ])
+        return parser.html(from: post.localized(in: language).text)
+    }
+
+    private func handleImage(
+        html: String,
+        markdown: Substring
+    ) -> String {
+        results.warning("Image in \(postDescription)")
+        return ""
+    }
+
+    private func handleCode(
+        html: String,
+        markdown: Substring
+    ) -> String {
+        results.warning("Code in \(postDescription)")
+        return ""
+    }
+
+    private var postDescription: String {
+        "content of post \(post.id) (\(language.shortText))"
+    }
+
+    private func handleLink(
+        html: String,
+        markdown: Substring
+    ) -> String {
+        let converter = MarkdownLinkProcessor(content: content, results: self, language: language)
+        let markdownUrl = markdown.between("(", and: ")")
+        let text = markdown.between("[", and: "]")
+        guard let url = converter.convert(markdownUrl: markdownUrl)?.url else {
+            return text
+        }
+        return "<span class='link' onclick=\"location.href='\(url)'; event.stopPropagation();\">\(text)</span>"
+    }
+}
+
+extension PostContentGenerator: ItemLinkResults {
+
+    func externalLink(to url: String) {
+        results.externalLink(to: url)
+    }
+    
+    func missing(page: String, source: String) {
+        results.missing(page: page, source: postDescription)
+    }
+    
+    func linked(to page: Page) {
+        results.linked(to: page)
+    }
+    
+    func missing(tag: String, source: String) {
+        results.missing(tag: tag, source: postDescription)
+    }
+    
+    func linked(to tag: Tag) {
+        results.linked(to: tag)
+    }
+    
+    func missing(file: String, source: String) {
+        results.missing(file: file, source: postDescription)
+    }
+    
+    func require(file: FileResource) {
+        results.require(file: file)
+    }
+}
diff --git a/CHDataManagement/Generator/Post Lists/PostListPageGenerator.swift b/CHDataManagement/Generator/Post Lists/PostListPageGenerator.swift
index 32e078e..f779354 100644
--- a/CHDataManagement/Generator/Post Lists/PostListPageGenerator.swift	
+++ b/CHDataManagement/Generator/Post Lists/PostListPageGenerator.swift	
@@ -58,7 +58,7 @@ final class PostListPageGenerator {
         }
     }
 
-    private func makePostData(post: Post) -> FeedEntryData {
+    private func makePostData(post: Post, results: PageGenerationResults) -> FeedEntryData {
         let localized: LocalizedPost = post.localized(in: language)
 
         let linkUrl: FeedEntryData.Link? = post.linkedPage.map {
@@ -88,6 +88,12 @@ final class PostListPageGenerator {
             media = nil
         }
 
+        let text = PostContentGenerator(
+            content: source.content,
+            results: source.results,
+            language: language,
+            post: post).generate()
+        
         return FeedEntryData(
             entryId: post.id,
             title: localized.title,
@@ -95,13 +101,12 @@ final class PostListPageGenerator {
             link: linkUrl,
             tags: tags,
             labels: localized.labels,
-            text: localized.text.components(separatedBy: "\n\n"),
+            text: text,
             media: media)
-        #warning("Treat post text as markdown")
     }
 
     private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice<Post>) {
-        let posts: [FeedEntryData] = posts.map(makePostData)
+        let posts: [FeedEntryData] = posts.map { makePostData(post: $0, results: source.results) }
 
         let feedPageGenerator = FeedPageGenerator(content: source.content, results: source.results)
 
diff --git a/CHDataManagement/Page Elements/FeedEntry.swift b/CHDataManagement/Page Elements/FeedEntry.swift
index 3e94e09..6ff7853 100644
--- a/CHDataManagement/Page Elements/FeedEntry.swift	
+++ b/CHDataManagement/Page Elements/FeedEntry.swift	
@@ -35,9 +35,7 @@ struct FeedEntry: HtmlProducer {
         TagList(tags: data.tags).populate(&result)
         ContentLabels(labels: data.labels).populate(&result)
 
-        for paragraph in data.text {
-            result += "<p>\(paragraph)</p>"
-        }
+        result += data.text
         if let url = data.link {
             result += "<div class='link-center'><div class='link'>\(url.text)</div></div>"
         }
diff --git a/CHDataManagement/Page Elements/FeedEntryData.swift b/CHDataManagement/Page Elements/FeedEntryData.swift
index a814a6b..7a7937a 100644
--- a/CHDataManagement/Page Elements/FeedEntryData.swift	
+++ b/CHDataManagement/Page Elements/FeedEntryData.swift	
@@ -13,11 +13,11 @@ struct FeedEntryData {
 
     let labels: [ContentLabel]
 
-    let text: [String]
+    let text: String
 
     let media: Media?
 
-    init(entryId: String, title: String?, textAboveTitle: String, link: Link?, tags: [Tag], labels: [ContentLabel], text: [String], media: Media?) {
+    init(entryId: String, title: String?, textAboveTitle: String, link: Link?, tags: [Tag], labels: [ContentLabel], text: String, media: Media?) {
         self.entryId = entryId
         self.title = title
         self.textAboveTitle = textAboveTitle