@@ -23,6 +23,17 @@ class CmsService(
2323) {
2424 private val lock = Any ()
2525 private val basePath = normalizeBasePath(config.cms.basePath)
26+ private val previewConfig = config.copy(
27+ theme = config.theme.copy(output = PREVIEW_OUTPUT_DIRECTORY )
28+ )
29+ private val previewGenerator = SiteGenerator (
30+ rootPath = rootPath.toString(),
31+ config = previewConfig,
32+ baseUrlOverride = " $basePath /preview/" ,
33+ isDevelopment = true
34+ )
35+ private val previewAssetManager = AssetManager (rootPath.toString(), previewConfig, FileWalker (rootPath.toString()))
36+ private var previewInitialized = false
2637
2738 init {
2839 repository.initialize()
@@ -34,6 +45,13 @@ class CmsService(
3445 }
3546 }
3647
48+ fun resolvePreviewFile (requestedPath : String ): Path ? {
49+ synchronized(lock) {
50+ ensurePreviewInitializedLocked()
51+ return resolveGeneratedFile(previewOutputRoot(), requestedPath)
52+ }
53+ }
54+
3755 fun list (type : CmsContentType ? = null): CmsContentListResponse {
3856 val items = repository.list(type)
3957 .sortedWith(contentOrdering())
@@ -94,11 +112,20 @@ class CmsService(
94112 markContentDeleted(previousEntry.sourcePath, previousEntry.lastSyncedAt, previousEntry.dirty)
95113 if (previousEntry.outputPath != saved.outputPath) {
96114 deleteGeneratedContent(previousEntry.outputPath)
115+ if (previewInitialized) {
116+ deleteGeneratedPreviewContent(previousEntry.outputPath)
117+ }
97118 }
98119 }
99120
100121 repository.upsert(dirtyEntry)
101122 generator.regenerate(listOf (rootPath.resolve(saved.sourcePath)))
123+ if (saved.type == CmsContentType .POST && isDraft(saved.metadata)) {
124+ deleteGeneratedContent(saved.outputPath)
125+ }
126+ if (previewInitialized) {
127+ previewGenerator.regenerate(listOf (rootPath.resolve(saved.sourcePath)))
128+ }
102129
103130 val syncResponse = if (request.sync || config.cms.autoSyncOnSave) {
104131 syncLocked(request.commitMessage, null , accessToken)
@@ -126,6 +153,9 @@ class CmsService(
126153 val existing = repository.findMedia(composeMediaPath(targetDirectory, request.fileName))
127154 val uploaded = mediaFileService.upload(normalizedRequest)
128155 assetManager.copySingleAsset(rootPath.resolve(uploaded.sourcePath).normalize())
156+ if (previewInitialized) {
157+ previewAssetManager.copySingleAsset(rootPath.resolve(uploaded.sourcePath).normalize())
158+ }
129159 repository.upsertMedia(
130160 uploaded.copy(
131161 dirty = true ,
@@ -151,6 +181,9 @@ class CmsService(
151181 mutation.deletedPaths.forEach { sourcePath ->
152182 markMediaDeleted(sourcePath)
153183 assetManager.deleteSingleAsset(sourcePath)
184+ if (previewInitialized) {
185+ previewAssetManager.deleteSingleAsset(sourcePath)
186+ }
154187 }
155188
156189 mutation.updatedEntries.forEach { entry ->
@@ -164,6 +197,9 @@ class CmsService(
164197 )
165198 )
166199 assetManager.copySingleAsset(rootPath.resolve(entry.sourcePath).normalize())
200+ if (previewInitialized) {
201+ previewAssetManager.copySingleAsset(rootPath.resolve(entry.sourcePath).normalize())
202+ }
167203 }
168204
169205 return CmsMediaMutationResponse (
@@ -184,6 +220,9 @@ class CmsService(
184220 mutation.deletedPaths.forEach { sourcePath ->
185221 markMediaDeleted(sourcePath)
186222 assetManager.deleteSingleAsset(sourcePath)
223+ if (previewInitialized) {
224+ previewAssetManager.deleteSingleAsset(sourcePath)
225+ }
187226 }
188227
189228 return CmsMediaMutationResponse (
@@ -224,6 +263,7 @@ class CmsService(
224263 repository.replaceFromScan(scannedEntries)
225264 val scannedMedia = mediaFileService.scanAll()
226265 repository.replaceMediaFromScan(scannedMedia)
266+ previewInitialized = false
227267 return CmsRefreshResponse (
228268 items = repository.count() + repository.mediaCount(),
229269 dirty = repository.dirtyCount() + repository.mediaDirtyCount()
@@ -286,7 +326,14 @@ class CmsService(
286326 }
287327
288328 private fun deleteGeneratedContent (outputPath : String ) {
289- val outputRoot = rootPath.resolve(config.theme.output).normalize()
329+ deleteGeneratedPath(rootPath.resolve(config.theme.output).normalize(), outputPath)
330+ }
331+
332+ private fun deleteGeneratedPreviewContent (outputPath : String ) {
333+ deleteGeneratedPath(previewOutputRoot(), outputPath)
334+ }
335+
336+ private fun deleteGeneratedPath (outputRoot : Path , outputPath : String ) {
290337 val target = if (outputPath.isBlank()) {
291338 outputRoot.resolve(" index.html" )
292339 } else {
@@ -310,6 +357,28 @@ class CmsService(
310357 }
311358 }
312359
360+ private fun ensurePreviewInitializedLocked () {
361+ if (previewInitialized) {
362+ return
363+ }
364+
365+ val outputRoot = previewOutputRoot()
366+ if (Files .exists(outputRoot)) {
367+ Files .walk(outputRoot).use { stream ->
368+ stream.sorted(Comparator .reverseOrder()).forEach { candidate ->
369+ Files .deleteIfExists(candidate)
370+ }
371+ }
372+ }
373+
374+ previewGenerator.generate()
375+ previewInitialized = true
376+ }
377+
378+ private fun previewOutputRoot (): Path {
379+ return rootPath.resolve(previewConfig.theme.output).normalize()
380+ }
381+
313382 private fun pruneEmptyGeneratedDirectories (start : Path ? , stopAt : Path ) {
314383 var current = start
315384 while (current != null && current.startsWith(stopAt) && current != stopAt) {
@@ -389,6 +458,10 @@ class CmsService(
389458 return left.sourcePath.compareTo(right.sourcePath)
390459 }
391460
461+ private fun isDraft (metadata : Map <String , Any ?>): Boolean {
462+ return metadata[" draft" ]?.toString()?.lowercase() in setOf (" true" , " yes" , " 1" )
463+ }
464+
392465 private fun String?.parseCmsDateOrNull (): LocalDateTime ? {
393466 return runCatching { this ?.let (LocalDateTime ::parse) }.getOrNull()
394467 }
@@ -398,6 +471,30 @@ class CmsService(
398471 return if (path.isAbsolute) path else rootPath.resolve(path).normalize()
399472 }
400473
474+ private fun resolveGeneratedFile (outputRoot : Path , requestedPath : String ): Path ? {
475+ val normalizedRoot = outputRoot.toAbsolutePath().normalize()
476+ if (! Files .exists(normalizedRoot)) {
477+ return null
478+ }
479+
480+ val relativePath = requestedPath.trim().removePrefix(" /" ).removeSuffix(" /" )
481+ val candidates = if (relativePath.isBlank()) {
482+ listOf (normalizedRoot.resolve(" index.html" ))
483+ } else {
484+ buildList {
485+ val direct = normalizedRoot.resolve(relativePath).normalize()
486+ add(direct)
487+ if (! relativePath.substringAfterLast(' /' , " " ).contains(' .' )) {
488+ add(direct.resolve(" index.html" ).normalize())
489+ }
490+ }
491+ }
492+
493+ return candidates.firstOrNull { candidate ->
494+ candidate.startsWith(normalizedRoot) && Files .isRegularFile(candidate)
495+ }
496+ }
497+
401498 private fun maxOfNullable (left : Long? , right : Long? ): Long? {
402499 return when {
403500 left == null -> right
@@ -414,5 +511,7 @@ class CmsService(
414511 private fun normalizeSourcePath (sourcePath : String ): String {
415512 return sourcePath.trim().removePrefix(" /" ).replace(' \\ ' , ' /' )
416513 }
514+
515+ private const val PREVIEW_OUTPUT_DIRECTORY = " .statik/cms-preview"
417516 }
418517}
0 commit comments