Skip to content

Commit 2ca0f18

Browse files
committed
feat(frontmatter): support nested YAML metadata with string scalars
1 parent 394a36f commit 2ca0f18

File tree

13 files changed

+171
-107
lines changed

13 files changed

+171
-107
lines changed

module.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies:
88
- com.vladsch.flexmark:flexmark-ext-anchorlink:0.64.8
99
- com.vladsch.flexmark:flexmark-ext-autolink:0.64.8
1010
- com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.64.8
11+
- org.yaml:snakeyaml:2.2
1112
- $kotlin.serialization.json
1213
- com.github.jknack:handlebars:4.3.1
1314
- com.github.ajalt.clikt:clikt:4.3.0

src/com/potomushto/statik/generators/ContentRepository.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.potomushto.statik.config.BlogConfig
44
import com.potomushto.statik.logging.LoggerFactory
55
import com.potomushto.statik.models.BlogPost
66
import com.potomushto.statik.models.SitePage
7+
import com.potomushto.statik.metadata.string
78
import com.potomushto.statik.processors.ContentProcessor
89
import java.nio.file.Files
910
import java.nio.file.Path
@@ -110,8 +111,8 @@ class ContentRepository(
110111
return fileWalker.walkMarkdownFiles(postsDirectory, excludeIndex = true)
111112
.map { file ->
112113
val parsedPost = contentProcessor.process(file)
113-
val title = parsedPost.metadata["title"] ?: file.nameWithoutExtension
114-
val date = parsedPost.metadata["published"]?.let { LocalDateTime.parse(it) }
114+
val title = parsedPost.metadata.string("title") ?: file.nameWithoutExtension
115+
val date = parsedPost.metadata.string("published")?.let { LocalDateTime.parse(it) }
115116
?: Files.getLastModifiedTime(file).let {
116117
LocalDateTime.ofInstant(it.toInstant(), ZoneId.systemDefault())
117118
}
@@ -129,7 +130,7 @@ class ContentRepository(
129130
}
130131
.filter { post ->
131132
// Filter out draft posts unless in development mode
132-
val isDraft = post.metadata["draft"]?.lowercase() in setOf("true", "yes", "1")
133+
val isDraft = post.metadata.string("draft")?.lowercase() in setOf("true", "yes", "1")
133134
if (isDraft && !isDevelopment) {
134135
logger.debug { "Skipping draft post: ${post.id}" }
135136
false
@@ -147,9 +148,9 @@ class ContentRepository(
147148
fileWalker.walkMarkdownFiles(pagesDirectory)
148149
.map { file ->
149150
val parsedPage = contentProcessor.process(file)
150-
val title = parsedPage.metadata["title"] ?: file.nameWithoutExtension
151-
val navOrder = parsedPage.metadata["nav_order"]?.toIntOrNull()
152-
?: parsedPage.metadata["navOrder"]?.toIntOrNull()
151+
val title = parsedPage.metadata.string("title") ?: file.nameWithoutExtension
152+
val navOrder = parsedPage.metadata.string("nav_order")?.toIntOrNull()
153+
?: parsedPage.metadata.string("navOrder")?.toIntOrNull()
153154

154155
val basePath = fileWalker.generatePath(file, pagesDirectory, stripIndex = true)
155156

src/com/potomushto/statik/generators/RssGenerator.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.potomushto.statik.generators
22

33
import com.potomushto.statik.config.BlogConfig
44
import com.potomushto.statik.models.BlogPost
5+
import com.potomushto.statik.metadata.string
56
import java.nio.file.Files
67
import java.nio.file.Path
78
import java.time.ZoneId
@@ -69,7 +70,7 @@ class RssGenerator(
6970
}
7071

7172
// Add description
72-
val description = post.metadata["description"]
73+
val description = post.metadata.string("description")
7374
?: post.content.take(300).replace(Regex("<[^>]*>"), "").trim()
7475
appendLine(" <description>${escapeXml(description)}</description>")
7576

src/com/potomushto/statik/generators/StaticDatasourceGenerator.kt

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.potomushto.statik.generators
22

33
import com.potomushto.statik.config.StaticDatasourceConfig
44
import com.potomushto.statik.logging.LoggerFactory
5+
import com.potomushto.statik.metadata.string
6+
import com.potomushto.statik.metadata.toStringMap
57
import com.potomushto.statik.models.BlogPost
68
import com.potomushto.statik.models.SitePage
79
import com.potomushto.statik.processors.ContentProcessor
@@ -205,16 +207,16 @@ class StaticDatasourceGenerator(
205207
.filter { it.isNotEmpty() }
206208
.joinToString("/")
207209

208-
val id = parsed.metadata["id"]?.ifBlank { null }
210+
val id = parsed.metadata.string("id")?.ifBlank { null }
209211
?: combinedSlug.replace('/', '-').ifBlank { file.nameWithoutExtension }
210-
val title = parsed.metadata["title"]?.ifBlank { null } ?: id
212+
val title = parsed.metadata.string("title")?.ifBlank { null } ?: id
211213

212214
val item = EntityDatasourceItem(
213215
dataset = dataset.name,
214216
id = id,
215217
title = title,
216218
content = parsed.content,
217-
metadata = parsed.metadata,
219+
metadata = parsed.metadata.toStringMap(),
218220
source = DatasourceItemSource(
219221
type = dataset.name,
220222
id = id,
@@ -244,15 +246,15 @@ class StaticDatasourceGenerator(
244246

245247
if (sources.contains(DatasetSource.POSTS)) {
246248
posts.forEach { post ->
247-
val metadataValue = post.metadata[key]?.trim() ?: return@forEach
249+
val metadataValue = post.metadata.string(key) ?: return@forEach
248250
if (expectedValue == null || metadataValue == expectedValue) {
249251
items.add(
250252
EntityDatasourceItem(
251253
dataset = dataset.name,
252-
id = post.metadata["id"]?.ifBlank { null } ?: post.id,
254+
id = post.metadata.string("id")?.ifBlank { null } ?: post.id,
253255
title = post.title,
254256
content = post.content,
255-
metadata = post.metadata,
257+
metadata = post.metadata.toStringMap(),
256258
source = DatasourceItemSource(
257259
type = "post",
258260
id = post.id,
@@ -267,15 +269,15 @@ class StaticDatasourceGenerator(
267269

268270
if (sources.contains(DatasetSource.PAGES)) {
269271
pages.forEach { page ->
270-
val metadataValue = page.metadata[key]?.trim() ?: return@forEach
272+
val metadataValue = page.metadata.string(key) ?: return@forEach
271273
if (expectedValue == null || metadataValue == expectedValue) {
272274
items.add(
273275
EntityDatasourceItem(
274276
dataset = dataset.name,
275-
id = page.metadata["id"]?.ifBlank { null } ?: page.id,
277+
id = page.metadata.string("id")?.ifBlank { null } ?: page.id,
276278
title = page.title,
277279
content = page.content,
278-
metadata = page.metadata,
280+
metadata = page.metadata.toStringMap(),
279281
source = DatasourceItemSource(
280282
type = "page",
281283
id = page.id,
@@ -339,7 +341,7 @@ class StaticDatasourceGenerator(
339341
val title: String,
340342
val path: String,
341343
val html: String,
342-
val metadata: Map<String, String>
344+
val metadata: Map<String, Any?>
343345
)
344346

345347
private enum class SourceType(val label: String) {

src/com/potomushto/statik/generators/TemplateRenderer.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.potomushto.statik.generators
22

33
import com.potomushto.statik.config.BlogConfig
44
import com.potomushto.statik.logging.LoggerFactory
5+
import com.potomushto.statik.metadata.string
56
import com.potomushto.statik.models.BlogPost
67
import com.potomushto.statik.models.SitePage
78
import com.potomushto.statik.template.FallbackTemplates
@@ -50,12 +51,12 @@ class TemplateRenderer(
5051
val templateSelection = getTemplateContent(
5152
templateName = "post",
5253
fallbackTemplate = FallbackTemplates.POST_TEMPLATE,
53-
overrideTemplate = post.metadata["template"]
54+
overrideTemplate = post.metadata.string("template")
5455
)
5556

5657
return if (post.isTemplate) {
5758
// For template files, use layout if specified in metadata or default
58-
val layout = post.metadata["layout"] ?: "default"
59+
val layout = post.metadata.string("layout") ?: "default"
5960
templateEngine.renderWithLayout(
6061
post.content,
6162
mapOf(
@@ -78,7 +79,7 @@ class TemplateRenderer(
7879
).withDatasource(datasourceContext)
7980
)
8081
} else {
81-
val layout = post.metadata["layout"] ?: "default"
82+
val layout = post.metadata.string("layout") ?: "default"
8283
templateEngine.renderWithLayout(
8384
templateSelection.content,
8485
mapOf(
@@ -110,12 +111,12 @@ class TemplateRenderer(
110111
val templateSelection = getTemplateContent(
111112
templateName = "page",
112113
fallbackTemplate = FallbackTemplates.PAGE_TEMPLATE,
113-
overrideTemplate = page.metadata["template"]
114+
overrideTemplate = page.metadata.string("template")
114115
)
115116

116117
return if (page.isTemplate) {
117118
// For template files, render directly with layout
118-
val layout = page.metadata["layout"] ?: "default"
119+
val layout = page.metadata.string("layout") ?: "default"
119120
val description = page.description ?: config.description
120121
templateEngine.renderWithLayout(
121122
page.content,
@@ -139,7 +140,7 @@ class TemplateRenderer(
139140
).withDatasource(datasourceContext)
140141
)
141142
} else {
142-
val layout = page.metadata["layout"] ?: "default"
143+
val layout = page.metadata.string("layout") ?: "default"
143144
val description = page.description ?: config.description
144145
templateEngine.renderWithLayout(
145146
templateSelection.content,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.potomushto.statik.metadata
2+
3+
import kotlinx.serialization.json.Json
4+
import kotlinx.serialization.json.JsonArray
5+
import kotlinx.serialization.json.JsonElement
6+
import kotlinx.serialization.json.JsonNull
7+
import kotlinx.serialization.json.JsonObject
8+
import kotlinx.serialization.json.JsonPrimitive
9+
10+
private val json = Json { prettyPrint = false }
11+
12+
fun metadataValueAsString(value: Any?): String? {
13+
return when (value) {
14+
null -> null
15+
is String -> value.trim()
16+
is Number -> value.toString()
17+
is Boolean -> value.toString()
18+
else -> value.toString()
19+
}
20+
}
21+
22+
fun Map<String, Any?>.string(key: String): String? = metadataValueAsString(this[key])
23+
24+
fun Map<String, Any?>.stringOrEmpty(key: String): String = metadataValueAsString(this[key]) ?: ""
25+
26+
fun Map<String, Any?>.stringList(key: String): List<String> {
27+
val value = this[key]
28+
return when (value) {
29+
is List<*> -> value.mapNotNull { metadataValueAsString(it) }
30+
.map { it.trim() }
31+
.filter { it.isNotEmpty() }
32+
else -> metadataValueAsString(value)
33+
?.split(",")
34+
?.map { it.trim() }
35+
?.filter { it.isNotEmpty() }
36+
?: emptyList()
37+
}
38+
}
39+
40+
fun Map<String, Any?>.toStringMap(): Map<String, String> {
41+
val result = LinkedHashMap<String, String>()
42+
for ((key, value) in this) {
43+
val stringValue = when (value) {
44+
null -> ""
45+
is String, is Number, is Boolean -> metadataValueAsString(value) ?: ""
46+
is Map<*, *>, is List<*> -> json.encodeToString(toJsonElement(value))
47+
else -> value.toString()
48+
}
49+
result[key] = stringValue
50+
}
51+
return result
52+
}
53+
54+
private fun toJsonElement(value: Any?): JsonElement {
55+
return when (value) {
56+
null -> JsonNull
57+
is JsonElement -> value
58+
is String -> JsonPrimitive(value)
59+
is Number -> JsonPrimitive(value)
60+
is Boolean -> JsonPrimitive(value)
61+
is Map<*, *> -> {
62+
val map = value.entries.associate { (k, v) ->
63+
k.toString() to toJsonElement(v)
64+
}
65+
JsonObject(map)
66+
}
67+
is List<*> -> JsonArray(value.map { item -> toJsonElement(item) })
68+
else -> JsonPrimitive(value.toString())
69+
}
70+
}
Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
package com.potomushto.statik.models
22

3-
import kotlinx.serialization.Serializable
43
import java.time.LocalDateTime
4+
import com.potomushto.statik.metadata.string
5+
import com.potomushto.statik.metadata.stringList
56

6-
@Serializable
77
data class BlogPost(
88
val id: String, // Unique identifier (can be filename without extension)
99
val title: String,
10-
@Serializable(with = LocalDateTimeSerializer::class)
1110
val date: LocalDateTime,
1211
val content: String, // Markdown content or template content
1312
val rawHtml: String? = null, // Optional custom HTML
14-
val metadata: Map<String, String> = mapOf(), // For SEO and other metadata
13+
val metadata: Map<String, Any?> = mapOf(), // For SEO and other metadata
1514
val outputPath: String, // URL path like "2024/blog-title"
1615
val isTemplate: Boolean = false // True if content is a Handlebars template
1716
) {
@@ -20,19 +19,15 @@ data class BlogPost(
2019
/**
2120
* Get tags from metadata. Tags can be specified as comma-separated values.
2221
*/
23-
val tags: List<String> get() = metadata["tags"]
24-
?.split(",")
25-
?.map { it.trim() }
26-
?.filter { it.isNotEmpty() }
27-
?: emptyList()
22+
val tags: List<String> get() = metadata.stringList("tags")
2823

2924
/**
3025
* Get summary from metadata, falling back to truncated content.
3126
*/
32-
val summary: String get() = metadata["summary"] ?: content.take(160)
27+
val summary: String get() = metadata.string("summary") ?: content.take(160)
3328

3429
/**
3530
* Get description from metadata, falling back to summary.
3631
*/
37-
val description: String get() = metadata["description"] ?: summary
38-
}
32+
val description: String get() = metadata.string("description") ?: summary
33+
}
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.potomushto.statik.models
22

3+
import com.potomushto.statik.metadata.string
4+
import com.potomushto.statik.metadata.stringList
5+
36
data class SitePage(
47
val id: String,
58
val title: String,
69
val content: String,
7-
val metadata: Map<String, String> = emptyMap(),
10+
val metadata: Map<String, Any?> = emptyMap(),
811
val outputPath: String,
912
val navOrder: Int? = null,
1013
val isTemplate: Boolean = false // True if content is a Handlebars template
@@ -14,19 +17,15 @@ data class SitePage(
1417
/**
1518
* Get tags from metadata. Tags can be specified as comma-separated values.
1619
*/
17-
val tags: List<String> get() = metadata["tags"]
18-
?.split(",")
19-
?.map { it.trim() }
20-
?.filter { it.isNotEmpty() }
21-
?: emptyList()
20+
val tags: List<String> get() = metadata.stringList("tags")
2221

2322
/**
2423
* Get summary from metadata. Returns null if not set.
2524
*/
26-
val summary: String? get() = metadata["summary"]
25+
val summary: String? get() = metadata.string("summary")
2726

2827
/**
2928
* Get description from metadata, falling back to summary. Returns null if neither is set.
3029
*/
31-
val description: String? get() = metadata["description"] ?: summary
30+
val description: String? get() = metadata.string("description") ?: summary
3231
}

src/com/potomushto/statik/processors/ContentProcessor.kt

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -52,37 +52,6 @@ class ContentProcessor(
5252
* Frontmatter is delimited by --- at the start of the file
5353
*/
5454
private fun extractFrontmatter(content: String): ParsedPost {
55-
val frontmatterRegex = Regex("^---\\s*\\n(.*?)\\n---\\s*\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
56-
val match = frontmatterRegex.find(content)
57-
58-
if (match != null) {
59-
val yamlContent = match.groupValues[1]
60-
val bodyContent = match.groupValues[2]
61-
val metadata = parseYaml(yamlContent)
62-
return ParsedPost(bodyContent, metadata)
63-
}
64-
65-
// No frontmatter, return content as-is with empty metadata
66-
return ParsedPost(content, emptyMap())
67-
}
68-
69-
/**
70-
* Simple YAML parser for frontmatter
71-
* Supports key: value pairs
72-
*/
73-
private fun parseYaml(yaml: String): Map<String, String> {
74-
val metadata = mutableMapOf<String, String>()
75-
yaml.lines().forEach { line ->
76-
val trimmed = line.trim()
77-
if (trimmed.isNotEmpty() && trimmed.contains(':')) {
78-
val parts = trimmed.split(':', limit = 2)
79-
if (parts.size == 2) {
80-
val key = parts[0].trim()
81-
val value = parts[1].trim().removeSurrounding("\"").removeSurrounding("'")
82-
metadata[key] = value
83-
}
84-
}
85-
}
86-
return metadata
55+
return FrontmatterParser.extract(content)
8756
}
8857
}

0 commit comments

Comments
 (0)