diff --git a/app/src/main/java/com/swoosh/microblog/data/PreviewHtmlBuilder.kt b/app/src/main/java/com/swoosh/microblog/data/PreviewHtmlBuilder.kt
new file mode 100644
index 0000000..3fa0702
--- /dev/null
+++ b/app/src/main/java/com/swoosh/microblog/data/PreviewHtmlBuilder.kt
@@ -0,0 +1,205 @@
+package com.swoosh.microblog.data
+
+import com.swoosh.microblog.data.model.LinkPreview
+
+/**
+ * Builds a responsive HTML preview from post content.
+ * Used to show "how it will look on the blog" before publishing.
+ */
+object PreviewHtmlBuilder {
+
+ /**
+ * Generates a complete HTML document from post content, optional image URL,
+ * and optional link preview data. The output uses a Ghost-like responsive
+ * styling with dark mode support.
+ */
+ fun build(
+ text: String,
+ imageUrl: String? = null,
+ linkPreview: LinkPreview? = null
+ ): String {
+ val contentHtml = buildContentHtml(text, imageUrl, linkPreview)
+ return wrapInTemplate(contentHtml)
+ }
+
+ /**
+ * Wraps existing HTML content (e.g. from the Ghost API html field) in
+ * the preview template. Use this for published posts that already have
+ * rendered HTML.
+ */
+ fun wrapExistingHtml(html: String): String {
+ return wrapInTemplate(html)
+ }
+
+ internal fun buildContentHtml(
+ text: String,
+ imageUrl: String?,
+ linkPreview: LinkPreview?
+ ): String {
+ val sb = StringBuilder()
+
+ // Convert text to HTML paragraphs
+ if (text.isNotBlank()) {
+ val paragraphs = text.split("\n\n")
+ for (paragraph in paragraphs) {
+ val trimmed = paragraph.trim()
+ if (trimmed.isNotEmpty()) {
+ val escaped = escapeHtml(trimmed)
+ // Convert single newlines within a paragraph to
+ val withBreaks = escaped.replace("\n", "
")
+ sb.append("
$withBreaks
\n") + } + } + } + + // Add feature image + if (!imageUrl.isNullOrBlank()) { + sb.append("Hello world
")) + } + + @Test + fun `build with multiline text creates multiple paragraphs`() { + val result = PreviewHtmlBuilder.build("First paragraph\n\nSecond paragraph") + assertTrue(result.contains("First paragraph
")) + assertTrue(result.contains("Second paragraph
")) + } + + @Test + fun `build with single newlines converts to br tags`() { + val result = PreviewHtmlBuilder.build("Line one\nLine two") + assertTrue(result.contains("Line one".toRegex().findAll(html).count() + assertEquals(3, paragraphCount) + } + + // --- Empty content --- + + @Test + fun `build with empty text produces valid HTML`() { + val result = PreviewHtmlBuilder.build("") + assertTrue(result.contains("")) + assertTrue(result.contains("
")) + } + + @Test + fun `build with blank text does not produce paragraph tags`() { + val result = PreviewHtmlBuilder.build(" ") + assertFalse(result.contains(""))
+ }
+
+ @Test
+ fun `build with only whitespace produces valid document`() {
+ val result = PreviewHtmlBuilder.build(" \n\n \n ")
+ assertTrue(result.startsWith(""))
+ }
+
+ // --- Image rendering ---
+
+ @Test
+ fun `build with image includes img tag`() {
+ val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
+ assertTrue(result.contains(""))
+ }
+
+ @Test
+ fun `build with image includes alt text`() {
+ val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
+ assertTrue(result.contains("alt=\"Post image\""))
+ }
+
+ @Test
+ fun `build with null image does not include img tag`() {
+ val result = PreviewHtmlBuilder.build("Text", imageUrl = null)
+ assertFalse(result.contains("
Some text
Some text
") + val imgIndex = result.indexOf("