merge: integrate post preview feature (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 10:41:03 +01:00
commit da1f796f32
No known key found for this signature in database
7 changed files with 1120 additions and 155 deletions

View file

@ -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 <br>
val withBreaks = escaped.replace("\n", "<br>")
sb.append("<p>$withBreaks</p>\n")
}
}
}
// Add feature image
if (!imageUrl.isNullOrBlank()) {
sb.append("<figure class=\"kg-card kg-image-card\">\n")
sb.append(" <img src=\"${escapeHtml(imageUrl)}\" alt=\"Post image\" />\n")
sb.append("</figure>\n")
}
// Add bookmark card for link preview
if (linkPreview != null) {
sb.append(buildBookmarkCard(linkPreview))
}
return sb.toString()
}
internal fun buildBookmarkCard(linkPreview: LinkPreview): String {
val sb = StringBuilder()
sb.append("<div class=\"bookmark-card\">\n")
if (!linkPreview.imageUrl.isNullOrBlank()) {
sb.append(" <img class=\"bookmark-image\" src=\"${escapeHtml(linkPreview.imageUrl)}\" alt=\"\" />\n")
}
sb.append(" <div class=\"bookmark-content\">\n")
val title = linkPreview.title
if (!title.isNullOrBlank()) {
sb.append(" <div class=\"bookmark-title\">${escapeHtml(title)}</div>\n")
}
val description = linkPreview.description
if (!description.isNullOrBlank()) {
sb.append(" <div class=\"bookmark-description\">${escapeHtml(description)}</div>\n")
}
sb.append(" <div class=\"bookmark-url\">${escapeHtml(linkPreview.url)}</div>\n")
sb.append(" </div>\n")
sb.append("</div>\n")
return sb.toString()
}
internal fun escapeHtml(value: String): String {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}
internal fun wrapInTemplate(content: String): String {
return """<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
padding: 20px 16px;
line-height: 1.7;
max-width: 600px;
margin: 0 auto;
color: #1a1a1a;
background: #ffffff;
font-size: 17px;
}
p {
margin: 0 0 1.2em 0;
}
figure {
margin: 1.5em 0;
padding: 0;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
}
.bookmark-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
margin: 1.5em 0;
background: #fafafa;
}
.bookmark-card .bookmark-image {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 0;
}
.bookmark-content {
padding: 14px 16px;
}
.bookmark-title {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
margin-bottom: 4px;
color: #1a1a1a;
}
.bookmark-description {
font-size: 14px;
line-height: 1.5;
color: #666;
margin-bottom: 6px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bookmark-url {
font-size: 13px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (prefers-color-scheme: dark) {
body {
background: #1a1a2e;
color: #e0e0e0;
}
.bookmark-card {
border-color: #333;
background: #222244;
}
.bookmark-title {
color: #e0e0e0;
}
.bookmark-description {
color: #aaa;
}
.bookmark-url {
color: #888;
}
}
</style>
</head>
<body>
$content
</body>
</html>"""
}
}

View file

@ -8,10 +8,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -24,6 +21,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -33,6 +31,7 @@ import java.time.format.DateTimeFormatter
fun ComposerScreen( fun ComposerScreen(
editPost: FeedPost? = null, editPost: FeedPost? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onFullScreenPreview: ((String) -> Unit)? = null,
viewModel: ComposerViewModel = viewModel() viewModel: ComposerViewModel = viewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
@ -71,6 +70,16 @@ fun ComposerScreen(
}) { }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
} }
},
actions = {
// Full-screen preview button (only in preview mode)
if (state.isPreviewMode && state.previewHtml.isNotEmpty()) {
IconButton(onClick = {
onFullScreenPreview?.invoke(state.previewHtml)
}) {
Icon(Icons.Default.Fullscreen, "Full screen preview")
}
}
} }
) )
} }
@ -79,6 +88,76 @@ fun ComposerScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) {
// Edit / Preview segmented button row
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
SegmentedButton(
selected = !state.isPreviewMode,
onClick = { viewModel.setPreviewMode(false) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
icon = {
SegmentedButtonDefaults.Icon(active = !state.isPreviewMode) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
)
}
}
) {
Text("Edit")
}
SegmentedButton(
selected = state.isPreviewMode,
onClick = { viewModel.setPreviewMode(true) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
icon = {
SegmentedButtonDefaults.Icon(active = state.isPreviewMode) {
Icon(
Icons.Default.Visibility,
contentDescription = null,
modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
)
}
}
) {
Text("Preview")
}
}
if (state.isPreviewMode) {
// Preview mode: show rendered HTML
if (state.text.isBlank() && state.imageUri == null && state.linkPreview == null) {
Box(
modifier = Modifier
.fillMaxSize()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "Nothing to preview yet.\nSwitch to Edit and write something.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
HtmlPreviewWebView(
html = state.previewHtml,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
}
} else {
// Edit mode: original composer UI
Column(
modifier = Modifier
.fillMaxSize()
.weight(1f)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp) .padding(16.dp)
) { ) {
@ -262,6 +341,8 @@ fun ComposerScreen(
} }
} }
} }
}
}
// Link dialog // Link dialog
if (showLinkDialog) { if (showLinkDialog) {

View file

@ -5,10 +5,13 @@ import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.MobiledocBuilder import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.PreviewHtmlBuilder
import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.OpenGraphFetcher import com.swoosh.microblog.data.repository.OpenGraphFetcher
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.worker.PostUploadWorker import com.swoosh.microblog.worker.PostUploadWorker
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -27,6 +30,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
private var editingGhostId: String? = null private var editingGhostId: String? = null
private var editingUpdatedAt: String? = null private var editingUpdatedAt: String? = null
private var previewDebounceJob: Job? = null
fun loadForEdit(post: FeedPost) { fun loadForEdit(post: FeedPost) {
editingLocalId = post.localId editingLocalId = post.localId
editingGhostId = post.ghostId editingGhostId = post.ghostId
@ -48,10 +53,16 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
fun updateText(text: String) { fun updateText(text: String) {
_uiState.update { it.copy(text = text) } _uiState.update { it.copy(text = text) }
if (_uiState.value.isPreviewMode) {
debouncedPreviewUpdate()
}
} }
fun setImage(uri: Uri?) { fun setImage(uri: Uri?) {
_uiState.update { it.copy(imageUri = uri) } _uiState.update { it.copy(imageUri = uri) }
if (_uiState.value.isPreviewMode) {
debouncedPreviewUpdate()
}
} }
fun fetchLinkPreview(url: String) { fun fetchLinkPreview(url: String) {
@ -60,17 +71,63 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(isLoadingLink = true) } _uiState.update { it.copy(isLoadingLink = true) }
val preview = OpenGraphFetcher.fetch(url) val preview = OpenGraphFetcher.fetch(url)
_uiState.update { it.copy(linkPreview = preview, isLoadingLink = false) } _uiState.update { it.copy(linkPreview = preview, isLoadingLink = false) }
if (_uiState.value.isPreviewMode) {
generatePreviewHtml()
}
} }
} }
fun removeLinkPreview() { fun removeLinkPreview() {
_uiState.update { it.copy(linkPreview = null) } _uiState.update { it.copy(linkPreview = null) }
if (_uiState.value.isPreviewMode) {
debouncedPreviewUpdate()
}
} }
fun setScheduledDate(dateTimeIso: String?) { fun setScheduledDate(dateTimeIso: String?) {
_uiState.update { it.copy(scheduledAt = dateTimeIso) } _uiState.update { it.copy(scheduledAt = dateTimeIso) }
} }
/**
* Toggle between edit and preview modes.
*/
fun togglePreviewMode() {
val currentlyInPreview = _uiState.value.isPreviewMode
if (!currentlyInPreview) {
// Switching to preview: generate HTML immediately
generatePreviewHtml()
}
_uiState.update { it.copy(isPreviewMode = !currentlyInPreview) }
}
/**
* Switch to a specific mode directly.
*/
fun setPreviewMode(preview: Boolean) {
if (preview && !_uiState.value.isPreviewMode) {
generatePreviewHtml()
}
_uiState.update { it.copy(isPreviewMode = preview) }
}
private fun debouncedPreviewUpdate() {
previewDebounceJob?.cancel()
previewDebounceJob = viewModelScope.launch {
delay(PREVIEW_DEBOUNCE_MS)
generatePreviewHtml()
}
}
internal fun generatePreviewHtml() {
val state = _uiState.value
val html = PreviewHtmlBuilder.build(
text = state.text,
imageUrl = state.imageUri?.toString(),
linkPreview = state.linkPreview
)
_uiState.update { it.copy(previewHtml = html) }
}
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH) fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE) fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
@ -175,11 +232,16 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
} }
fun reset() { fun reset() {
previewDebounceJob?.cancel()
editingLocalId = null editingLocalId = null
editingGhostId = null editingGhostId = null
editingUpdatedAt = null editingUpdatedAt = null
_uiState.value = ComposerUiState() _uiState.value = ComposerUiState()
} }
companion object {
const val PREVIEW_DEBOUNCE_MS = 500L
}
} }
data class ComposerUiState( data class ComposerUiState(
@ -191,5 +253,7 @@ data class ComposerUiState(
val isSubmitting: Boolean = false, val isSubmitting: Boolean = false,
val isSuccess: Boolean = false, val isSuccess: Boolean = false,
val isEditing: Boolean = false, val isEditing: Boolean = false,
val error: String? = null val error: String? = null,
val isPreviewMode: Boolean = false,
val previewHtml: String = ""
) )

View file

@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.TextFields import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -34,8 +35,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.PreviewHtmlBuilder
import com.swoosh.microblog.data.ShareUtils import com.swoosh.microblog.data.ShareUtils
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.LinkPreview
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.ui.feed.StatusBadge import com.swoosh.microblog.ui.feed.StatusBadge
@ -48,7 +51,8 @@ fun DetailScreen(
post: FeedPost, post: FeedPost,
onBack: () -> Unit, onBack: () -> Unit,
onEdit: (FeedPost) -> Unit, onEdit: (FeedPost) -> Unit,
onDelete: (FeedPost) -> Unit onDelete: (FeedPost) -> Unit,
onPreview: ((String) -> Unit)? = null
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showOverflowMenu by remember { mutableStateOf(false) } var showOverflowMenu by remember { mutableStateOf(false) }
@ -71,6 +75,29 @@ fun DetailScreen(
} }
}, },
actions = { actions = {
// Preview button - show rendered HTML
IconButton(onClick = {
val html = if (!post.htmlContent.isNullOrBlank()) {
PreviewHtmlBuilder.wrapExistingHtml(post.htmlContent)
} else {
val linkPreview = if (post.linkUrl != null) {
LinkPreview(
url = post.linkUrl,
title = post.linkTitle,
description = post.linkDescription,
imageUrl = post.linkImageUrl
)
} else null
PreviewHtmlBuilder.build(
text = post.textContent,
imageUrl = post.imageUrl,
linkPreview = linkPreview
)
}
onPreview?.invoke(html)
}) {
Icon(Icons.Default.Visibility, "View preview")
}
// Share button - only for published posts with a URL // Share button - only for published posts with a URL
if (canShare) { if (canShare) {
IconButton(onClick = { IconButton(onClick = {

View file

@ -11,6 +11,7 @@ import com.swoosh.microblog.ui.composer.ComposerViewModel
import com.swoosh.microblog.ui.detail.DetailScreen import com.swoosh.microblog.ui.detail.DetailScreen
import com.swoosh.microblog.ui.feed.FeedScreen import com.swoosh.microblog.ui.feed.FeedScreen
import com.swoosh.microblog.ui.feed.FeedViewModel import com.swoosh.microblog.ui.feed.FeedViewModel
import com.swoosh.microblog.ui.preview.PreviewScreen
import com.swoosh.microblog.ui.settings.SettingsScreen import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen import com.swoosh.microblog.ui.setup.SetupScreen
import com.swoosh.microblog.ui.stats.StatsScreen import com.swoosh.microblog.ui.stats.StatsScreen
@ -23,6 +24,7 @@ object Routes {
const val DETAIL = "detail" const val DETAIL = "detail"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val STATS = "stats" const val STATS = "stats"
const val PREVIEW = "preview"
} }
@Composable @Composable
@ -34,6 +36,7 @@ fun SwooshNavGraph(
// Shared state for passing posts between screens // Shared state for passing posts between screens
var selectedPost by remember { mutableStateOf<FeedPost?>(null) } var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
var editPost by remember { mutableStateOf<FeedPost?>(null) } var editPost by remember { mutableStateOf<FeedPost?>(null) }
var previewHtml by remember { mutableStateOf("") }
val feedViewModel: FeedViewModel = viewModel() val feedViewModel: FeedViewModel = viewModel()
@ -73,6 +76,10 @@ fun SwooshNavGraph(
onDismiss = { onDismiss = {
feedViewModel.refresh() feedViewModel.refresh()
navController.popBackStack() navController.popBackStack()
},
onFullScreenPreview = { html ->
previewHtml = html
navController.navigate(Routes.PREVIEW)
} }
) )
} }
@ -90,6 +97,10 @@ fun SwooshNavGraph(
onDelete = { p -> onDelete = { p ->
feedViewModel.deletePost(p) feedViewModel.deletePost(p)
navController.popBackStack() navController.popBackStack()
},
onPreview = { html ->
previewHtml = html
navController.navigate(Routes.PREVIEW)
} }
) )
} }
@ -115,5 +126,12 @@ fun SwooshNavGraph(
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} }
composable(Routes.PREVIEW) {
PreviewScreen(
html = previewHtml,
onBack = { navController.popBackStack() }
)
}
} }
} }

View file

@ -0,0 +1,78 @@
package com.swoosh.microblog.ui.preview
import android.annotation.SuppressLint
import android.webkit.WebView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
/**
* Full-screen preview of rendered HTML content.
* Used from both the Composer (for draft preview) and the Detail screen
* (for viewing published post HTML).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PreviewScreen(
html: String,
title: String = "Preview",
onBack: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
HtmlPreviewWebView(
html = html,
modifier = Modifier
.fillMaxSize()
.padding(padding)
)
}
}
/**
* Composable that embeds a WebView to render HTML content.
* JavaScript is disabled for security. Viewport meta tag support is enabled.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun HtmlPreviewWebView(
html: String,
modifier: Modifier = Modifier
) {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = false
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
settings.setSupportZoom(false)
settings.builtInZoomControls = false
settings.displayZoomControls = false
// Prevent WebView from navigating away
setWebViewClient(android.webkit.WebViewClient())
loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}
},
update = { webView ->
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
},
modifier = modifier
)
}

View file

@ -0,0 +1,492 @@
package com.swoosh.microblog.data
import com.swoosh.microblog.data.model.LinkPreview
import org.junit.Assert.*
import org.junit.Test
class PreviewHtmlBuilderTest {
// --- Basic HTML structure ---
@Test
fun `build produces valid HTML document with DOCTYPE`() {
val result = PreviewHtmlBuilder.build("Hello world")
assertTrue(result.startsWith("<!DOCTYPE html>"))
}
@Test
fun `build contains viewport meta tag`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("<meta name=\"viewport\""))
assertTrue(result.contains("width=device-width"))
}
@Test
fun `build contains charset meta tag`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("<meta charset=\"UTF-8\">"))
}
@Test
fun `build wraps content in html and body tags`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("<html>"))
assertTrue(result.contains("</html>"))
assertTrue(result.contains("<body>"))
assertTrue(result.contains("</body>"))
}
@Test
fun `build includes style block`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("<style>"))
assertTrue(result.contains("</style>"))
}
// --- Dark mode CSS ---
@Test
fun `build contains dark mode media query`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(result.contains("prefers-color-scheme: dark"))
}
@Test
fun `dark mode sets dark background color`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(
"Dark mode should set a dark background",
result.contains("background: #1a1a2e")
)
}
@Test
fun `dark mode sets light text color`() {
val result = PreviewHtmlBuilder.build("Hello")
assertTrue(
"Dark mode should set light text",
result.contains("color: #e0e0e0")
)
}
// --- Plain text rendering ---
@Test
fun `build with plain text wraps in paragraph tag`() {
val result = PreviewHtmlBuilder.build("Hello world")
assertTrue(result.contains("<p>Hello world</p>"))
}
@Test
fun `build with multiline text creates multiple paragraphs`() {
val result = PreviewHtmlBuilder.build("First paragraph\n\nSecond paragraph")
assertTrue(result.contains("<p>First paragraph</p>"))
assertTrue(result.contains("<p>Second paragraph</p>"))
}
@Test
fun `build with single newlines converts to br tags`() {
val result = PreviewHtmlBuilder.build("Line one\nLine two")
assertTrue(result.contains("Line one<br>Line two"))
}
@Test
fun `build preserves paragraph separation with double newlines`() {
val html = PreviewHtmlBuilder.build("Para 1\n\nPara 2\n\nPara 3")
// Should have 3 paragraph tags
val paragraphCount = "<p>".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("<!DOCTYPE html>"))
assertTrue(result.contains("<body>"))
}
@Test
fun `build with blank text does not produce paragraph tags`() {
val result = PreviewHtmlBuilder.build(" ")
assertFalse(result.contains("<p>"))
}
@Test
fun `build with only whitespace produces valid document`() {
val result = PreviewHtmlBuilder.build(" \n\n \n ")
assertTrue(result.startsWith("<!DOCTYPE html>"))
}
// --- Image rendering ---
@Test
fun `build with image includes img tag`() {
val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
assertTrue(result.contains("<img"))
assertTrue(result.contains("https://example.com/photo.jpg"))
}
@Test
fun `build with image wraps in figure tag`() {
val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
assertTrue(result.contains("<figure"))
assertTrue(result.contains("</figure>"))
}
@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("<img"))
assertFalse(result.contains("<figure"))
}
@Test
fun `build with empty image does not include img tag`() {
val result = PreviewHtmlBuilder.build("Text", imageUrl = "")
assertFalse(result.contains("<img"))
}
@Test
fun `image appears after text paragraphs`() {
val result = PreviewHtmlBuilder.build("Some text", imageUrl = "https://example.com/img.jpg")
val textIndex = result.indexOf("<p>Some text</p>")
val imgIndex = result.indexOf("<img")
assertTrue("Image should appear after text", imgIndex > textIndex)
}
// --- Link preview / bookmark card ---
@Test
fun `build with link preview includes bookmark card`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Example Site",
description = "A great site",
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("bookmark-card"))
}
@Test
fun `build with link preview includes title`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Example Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("Example Title"))
}
@Test
fun `build with link preview includes description`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = "A description of the page",
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("A description of the page"))
}
@Test
fun `build with link preview includes URL`() {
val preview = LinkPreview(
url = "https://example.com/article",
title = "Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("https://example.com/article"))
}
@Test
fun `build with link preview and image includes bookmark image`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = null,
imageUrl = "https://example.com/og-image.jpg"
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("bookmark-image"))
assertTrue(result.contains("https://example.com/og-image.jpg"))
}
@Test
fun `build with link preview without image does not include bookmark-image in content`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = "Desc",
imageUrl = null
)
val result = PreviewHtmlBuilder.buildContentHtml("Text", null, preview)
assertFalse(result.contains("bookmark-image"))
}
@Test
fun `build with null link preview does not include bookmark card in content`() {
val result = PreviewHtmlBuilder.buildContentHtml("Text", null, null)
assertFalse(result.contains("bookmark-card"))
}
@Test
fun `bookmark card appears after text and image in content`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.buildContentHtml(
"Some text",
"https://example.com/img.jpg",
preview
)
val textIndex = result.indexOf("<p>Some text</p>")
val imgIndex = result.indexOf("<figure")
val bookmarkIndex = result.indexOf("bookmark-card")
assertTrue("Text should come first", textIndex < imgIndex)
assertTrue("Image should come before bookmark", imgIndex < bookmarkIndex)
}
// --- HTML escaping ---
@Test
fun `escapeHtml escapes ampersand`() {
assertEquals("&amp;", PreviewHtmlBuilder.escapeHtml("&"))
}
@Test
fun `escapeHtml escapes less-than`() {
assertEquals("&lt;", PreviewHtmlBuilder.escapeHtml("<"))
}
@Test
fun `escapeHtml escapes greater-than`() {
assertEquals("&gt;", PreviewHtmlBuilder.escapeHtml(">"))
}
@Test
fun `escapeHtml escapes double quotes`() {
assertEquals("&quot;", PreviewHtmlBuilder.escapeHtml("\""))
}
@Test
fun `escapeHtml escapes single quotes`() {
assertEquals("&#39;", PreviewHtmlBuilder.escapeHtml("'"))
}
@Test
fun `escapeHtml handles combined special characters`() {
assertEquals(
"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;",
PreviewHtmlBuilder.escapeHtml("<script>alert('xss')</script>")
)
}
@Test
fun `escapeHtml leaves normal text unchanged`() {
assertEquals("Hello world", PreviewHtmlBuilder.escapeHtml("Hello world"))
}
@Test
fun `escapeHtml handles empty string`() {
assertEquals("", PreviewHtmlBuilder.escapeHtml(""))
}
@Test
fun `build escapes HTML in text content`() {
val result = PreviewHtmlBuilder.build("<script>alert('xss')</script>")
assertFalse("Should not contain unescaped script tag", result.contains("<script>alert"))
assertTrue(result.contains("&lt;script&gt;"))
}
@Test
fun `build escapes HTML in link preview title`() {
val preview = LinkPreview(
url = "https://example.com",
title = "<b>Bold</b>",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
assertTrue(result.contains("&lt;b&gt;Bold&lt;/b&gt;"))
}
// --- wrapExistingHtml ---
@Test
fun `wrapExistingHtml produces valid HTML document`() {
val result = PreviewHtmlBuilder.wrapExistingHtml("<p>Hello</p>")
assertTrue(result.startsWith("<!DOCTYPE html>"))
assertTrue(result.contains("<p>Hello</p>"))
}
@Test
fun `wrapExistingHtml includes same styling as build`() {
val fromBuild = PreviewHtmlBuilder.build("Hello")
val fromWrap = PreviewHtmlBuilder.wrapExistingHtml("<p>Hello</p>")
// Both should contain the same CSS
assertTrue(fromBuild.contains("bookmark-card"))
assertTrue(fromWrap.contains("bookmark-card"))
assertTrue(fromBuild.contains("prefers-color-scheme: dark"))
assertTrue(fromWrap.contains("prefers-color-scheme: dark"))
}
@Test
fun `wrapExistingHtml preserves raw HTML content`() {
val html = "<h1>Title</h1><p>Paragraph with <strong>bold</strong> text.</p>"
val result = PreviewHtmlBuilder.wrapExistingHtml(html)
assertTrue(result.contains(html))
}
@Test
fun `wrapExistingHtml handles empty HTML`() {
val result = PreviewHtmlBuilder.wrapExistingHtml("")
assertTrue(result.startsWith("<!DOCTYPE html>"))
}
// --- buildContentHtml (internal) ---
@Test
fun `buildContentHtml with all content types produces all sections`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = "Desc",
imageUrl = "https://example.com/image.jpg"
)
val result = PreviewHtmlBuilder.buildContentHtml(
text = "Hello world",
imageUrl = "https://example.com/photo.jpg",
linkPreview = preview
)
assertTrue("Should contain paragraph", result.contains("<p>Hello world</p>"))
assertTrue("Should contain feature image", result.contains("photo.jpg"))
assertTrue("Should contain bookmark card", result.contains("bookmark-card"))
assertTrue("Should contain bookmark image", result.contains("bookmark-image"))
}
@Test
fun `buildContentHtml with no content produces empty string`() {
val result = PreviewHtmlBuilder.buildContentHtml(
text = "",
imageUrl = null,
linkPreview = null
)
assertEquals("", result)
}
// --- buildBookmarkCard (internal) ---
@Test
fun `buildBookmarkCard includes all fields when present`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Test Title",
description = "Test Description",
imageUrl = "https://example.com/thumb.jpg"
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertTrue(result.contains("bookmark-card"))
assertTrue(result.contains("bookmark-image"))
assertTrue(result.contains("bookmark-title"))
assertTrue(result.contains("Test Title"))
assertTrue(result.contains("bookmark-description"))
assertTrue(result.contains("Test Description"))
assertTrue(result.contains("bookmark-url"))
assertTrue(result.contains("https://example.com"))
}
@Test
fun `buildBookmarkCard without image omits image tag`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertFalse(result.contains("bookmark-image"))
assertFalse(result.contains("<img"))
}
@Test
fun `buildBookmarkCard without title omits title div`() {
val preview = LinkPreview(
url = "https://example.com",
title = null,
description = "Desc",
imageUrl = null
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertFalse(result.contains("bookmark-title"))
}
@Test
fun `buildBookmarkCard without description omits description div`() {
val preview = LinkPreview(
url = "https://example.com",
title = "Title",
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertFalse(result.contains("bookmark-description"))
}
@Test
fun `buildBookmarkCard always includes URL`() {
val preview = LinkPreview(
url = "https://example.com/article",
title = null,
description = null,
imageUrl = null
)
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
assertTrue(result.contains("bookmark-url"))
assertTrue(result.contains("https://example.com/article"))
}
// --- CSS styling ---
@Test
fun `template includes responsive image styling`() {
val result = PreviewHtmlBuilder.build("Test")
assertTrue(result.contains("max-width: 100%"))
}
@Test
fun `template includes responsive body max-width`() {
val result = PreviewHtmlBuilder.build("Test")
assertTrue(result.contains("max-width: 600px"))
}
@Test
fun `template includes system font stack`() {
val result = PreviewHtmlBuilder.build("Test")
assertTrue(result.contains("-apple-system"))
assertTrue(result.contains("sans-serif"))
}
@Test
fun `template includes border-radius for bookmark card`() {
val result = PreviewHtmlBuilder.build("Test")
assertTrue(result.contains("border-radius: 8px"))
}
}