Skip to content

Commit c6b6188

Browse files
committed
feat(postrenderer): add theme manifests
1 parent 4f2cf00 commit c6b6188

File tree

16 files changed

+231
-224
lines changed

16 files changed

+231
-224
lines changed

quarkdown-html/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ src/main/resources/render/script/quarkdown.js.map
55
# Sass
66
src/main/resources/render/theme/**/*.css
77
src/main/resources/render/theme/**/*.css.map
8+
src/main/resources/render/theme/**/*.json
89

910
# e2e
1011
src/test/e2e/**/output/

quarkdown-html/build.gradle.kts

Lines changed: 49 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,21 @@ dependencies {
2222
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
2323
}
2424

25-
// Third-party library bundling
26-
//
27-
// Libraries from node_modules are copied into src/main/resources/render/lib/
28-
// at Gradle build time, so they are bundled in the JAR and copied to the
29-
// Quarkdown output directory at compilation time.
30-
//
31-
// A nested third-party-manifest.json is generated at the end so that the
32-
// Kotlin runtime can enumerate JAR resources (JAR directories are not listable).
33-
//
34-
// To add a new library:
35-
// 1. Add the npm package to package.json
36-
// 2. Add a LibrarySpec entry to `librariesToBundle`
37-
// 3. Add a ThirdPartyLibrary subclass in ThirdPartyLibrary.kt
25+
/*
26+
Third-party library bundling
27+
28+
Libraries from node_modules are copied into src/main/resources/render/lib/
29+
at Gradle build time, so they are bundled in the JAR and copied to the
30+
Quarkdown output directory at compilation time.
31+
32+
A nested third-party-manifest.json is generated at the end so that the
33+
Kotlin runtime can enumerate JAR resources (JAR directories are not listable).
34+
35+
To add a new library:
36+
1. Add the npm package to package.json
37+
2. Add a LibrarySpec entry to `librariesToBundle` (or to `fontsourcePackages` for fonts)
38+
3. For non-font libraries, add a ThirdPartyLibrary subclass in ThirdPartyLibrary.kt
39+
*/
3840

3941
val libDir = projectDir.resolve("src/main/resources/render/lib")
4042
val nodeModules = projectDir.resolve("node_modules")
@@ -70,16 +72,12 @@ val librariesToBundle =
7072
)
7173

7274
/**
73-
* @fontsource font sets, keyed by layout theme name.
74-
* Each entry lists the @fontsource package names to include.
75-
* All latin-subset woff2 variants are auto-discovered from each package's `files/` directory,
76-
* and `@font-face` declarations are derived from the `{font}-latin-{weight}-{style}.woff2` naming convention.
75+
* @fontsource packages to bundle. Each is placed in its own `lib/fonts/{name}/` directory
76+
* with auto-discovered latin woff2 variants and a generated `fonts.css`.
77+
* SCSS layout themes select which fonts they need via `@import url(...)`.
7778
*/
78-
val fontsourceSets =
79-
mapOf(
80-
"fonts/minimal" to listOf("lato", "inter", "noto-sans-mono"),
81-
"fonts/beamer" to listOf("source-sans-pro", "fira-sans", "noto-sans-mono"),
82-
)
79+
val fontsourcePackages =
80+
listOf("lato", "inter", "noto-sans-mono", "source-sans-pro", "fira-sans")
8381

8482
/**
8583
* Highlight.js theme CSS files to copy as SCSS partials for compile-time inlining into color themes.
@@ -137,8 +135,8 @@ val bundleThirdParty =
137135
}
138136
}
139137

140-
fontsourceSets.forEach { (targetSubdir, fontNames) ->
141-
bundleFontsource(targetSubdir, fontNames)
138+
fontsourcePackages.forEach { fontName ->
139+
bundleFontsource("fonts/$fontName", listOf(fontName))
142140
}
143141

144142
scssThirdParty.mkdirs()
@@ -174,7 +172,8 @@ fun bundleFontsource(
174172
val filesDir = nodeModules.resolve("@fontsource/$fontName/files")
175173
val family = fontName.split("-").joinToString(" ") { it.replaceFirstChar(Char::uppercaseChar) }
176174

177-
filesDir.listFiles()
175+
filesDir
176+
.listFiles()
178177
?.filter { it.name.startsWith("$fontName-latin-") && it.extension == "woff2" }
179178
?.sortedBy { it.name }
180179
?.forEach { sourceFile ->
@@ -203,74 +202,61 @@ fun bundleFontsource(
203202
}
204203

205204
/**
206-
* Generates `third-party-manifest.json` with one top-level key per library.
207-
*
208-
* Each key maps to a nested JSON tree of that library's internal directory structure,
209-
* with files at each level listed under `_files`:
205+
* Generates `third-party-manifest.json` as a nested JSON tree mirroring the `lib/` directory structure.
206+
* Files at each level are listed under `_files`; subdirectories become nested objects.
210207
*
211208
* ```json
212209
* {
213210
* "katex": {
214211
* "_files": ["katex.min.css", "katex.min.js"],
215212
* "fonts": { "_files": ["KaTeX_Main-Regular.woff2"] }
216213
* },
217-
* "fonts/latex": {
218-
* "_files": ["fonts.css", "ComputerModern-Serif-Regular.woff"]
214+
* "fonts": {
215+
* "latex": { "_files": ["fonts.css", "ComputerModern-Serif-Regular.woff"] }
219216
* }
220217
* }
221218
* ```
222-
*
223-
* A directory that contains files is a library (e.g. `katex`). A directory that only contains
224-
* subdirectories is not a library itself; its children are explored recursively until a library
225-
* is found (e.g. `fonts/` has no files, so `fonts/latex` becomes a library entry).
226-
* This allows the Kotlin runtime to look up libraries by name without path traversal.
227219
*/
228220
fun generateNestedManifest() {
229221
fun dirToJson(dir: File): Map<String, Any> =
230222
buildMap {
231-
dir.listFiles()
232-
?.filter { it.isFile }
223+
dir
224+
.listFiles()
225+
?.filter { it.isFile && it.name != "third-party-manifest.json" }
233226
?.map { it.name }
234227
?.sorted()
235228
?.takeIf { it.isNotEmpty() }
236229
?.let { put("_files", it) }
237230

238-
dir.listFiles()
231+
dir
232+
.listFiles()
239233
?.filter { it.isDirectory }
240234
?.sortedBy { it.name }
241235
?.forEach { put(it.name, dirToJson(it)) }
242236
}
243237

244-
val manifest = linkedMapOf<String, Any>()
245-
246-
fun collectLibraries(dir: File, prefix: String) {
247-
dir.listFiles()
248-
?.filter { it.isDirectory }
249-
?.sortedBy { it.name }
250-
?.forEach { child ->
251-
val key = if (prefix.isEmpty()) child.name else "$prefix/${child.name}"
252-
val hasFiles = child.listFiles()?.any { it.isFile } ?: false
253-
254-
if (hasFiles) {
255-
manifest[key] = dirToJson(child)
256-
} else {
257-
collectLibraries(child, key)
258-
}
259-
}
260-
}
261-
262-
collectLibraries(libDir, "")
263-
264-
libDir.resolve("third-party-manifest.json")
265-
.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(manifest)))
238+
libDir
239+
.resolve("third-party-manifest.json")
240+
.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(dirToJson(libDir))))
266241
}
267242

268243
// SCSS and TypeScript bundling
269244

245+
val scssDir = projectDir.resolve("src/main/scss")
246+
val themeOutputDir = projectDir.resolve("src/main/resources/render/theme")
247+
270248
tasks.compileSass {
271-
sourceDir = projectDir.resolve("src/main/scss")
272-
outputDir = projectDir.resolve("src/main/resources/render/theme")
249+
sourceDir = scssDir
250+
outputDir = themeOutputDir
273251
dependsOn(bundleThirdParty) // hljs SCSS partials must be copied before SASS compilation
252+
253+
// Copy layout theme JSON manifests (font dependencies) alongside the compiled CSS.
254+
doLast {
255+
copy {
256+
from(scssDir.resolve("layout")) { include("*.json") }
257+
into(themeOutputDir.resolve("layout"))
258+
}
259+
}
274260
}
275261

276262
val bundleTypeScript =

quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/HtmlOnlyPostRenderer.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.quarkdown.rendering.html.post
22

33
import com.quarkdown.core.context.Context
4-
import com.quarkdown.core.document.DocumentTheme
54
import com.quarkdown.core.media.storage.options.MediaStorageOptions
65
import com.quarkdown.core.media.storage.options.ReadOnlyMediaStorageOptions
76
import com.quarkdown.core.pipeline.output.ArtifactType
@@ -24,13 +23,11 @@ import com.quarkdown.rendering.html.post.document.HtmlDocumentBuilder
2423
* @param name the name of the HTML output resource, without extension
2524
* @param relativePathToRoot the relative path to follow to get from the HTML resources generated by this post-renderer
2625
* to the root (the location where the main HTML file is located, alongside `script`, `theme`, etc.)
27-
* @param theme the active document theme, forwarded to [HtmlDocumentBuilder] for third-party library resolution
2826
*/
2927
class HtmlOnlyPostRenderer(
3028
private val context: Context,
3129
private val name: String = "index",
3230
private val relativePathToRoot: String,
33-
private val theme: DocumentTheme,
3431
) : PostRenderer {
3532
// HTML requires local media to be resolved from the file system.
3633
override val preferredMediaStorageOptions: MediaStorageOptions =
@@ -40,7 +37,6 @@ class HtmlOnlyPostRenderer(
4037
HtmlDocumentBuilder(
4138
context,
4239
relativePathToRoot,
43-
theme = theme,
4440
sidebarContent = SidebarRenderer.render(context),
4541
).build(content)
4642

quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/HtmlPostRenderer.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ class HtmlPostRenderer(
4242
HtmlOnlyPostRenderer(
4343
context,
4444
relativePathToRoot = relativePathToRoot,
45-
theme = theme,
4645
),
4746
private val resourcesProvider: () -> Set<PostRendererResource> =
4847
{
@@ -52,10 +51,7 @@ class HtmlPostRenderer(
5251
locale = context.documentInfo.locale,
5352
),
5453
ScriptPostRendererResource(),
55-
ThirdPartyPostRendererResource(
56-
context = context,
57-
theme = theme,
58-
),
54+
ThirdPartyPostRendererResource(context, layoutTheme = theme.layout),
5955
MediaPostRendererResource(context.mediaStorage),
6056
if (context.documentInfo.type == DocumentType.DOCS) {
6157
SearchIndexPostRendererResource(SearchIndexGenerator.generate(context.sharedSubdocumentsData))

quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/document/HtmlDocumentBuilder.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.quarkdown.rendering.html.post.document
22

33
import com.quarkdown.core.context.Context
4-
import com.quarkdown.core.document.DocumentTheme
54
import com.quarkdown.core.document.DocumentType
65
import com.quarkdown.rendering.html.post.thirdparty.HeadContribution
76
import com.quarkdown.rendering.html.post.thirdparty.ThirdPartyLibrary
@@ -38,13 +37,11 @@ import kotlinx.html.unsafe
3837
* @param context the rendering context containing document metadata, attributes, and configuration
3938
* @param relativePathToRoot the relative path from the current document to the root directory,
4039
* used to correctly reference shared resources (scripts, themes, etc.)
41-
* @param theme the active document theme, used to resolve which third-party libraries to include
4240
* @param sidebarContent the pre-rendered sidebar HTML content (table of contents)
4341
*/
4442
class HtmlDocumentBuilder(
4543
private val context: Context,
4644
private val relativePathToRoot: String,
47-
private val theme: DocumentTheme,
4845
private val sidebarContent: CharSequence,
4946
) {
5047
private val document = context.documentInfo
@@ -132,7 +129,7 @@ class HtmlDocumentBuilder(
132129
*/
133130
private fun HEAD.thirdPartyLibraries() {
134131
ThirdPartyLibrary
135-
.all(theme)
132+
.all()
136133
.filter { it.isRequired(context) }
137134
.forEach { library ->
138135
library.headContributions.forEach { contribution ->
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.quarkdown.rendering.html.post.resources
2+
3+
import kotlinx.serialization.Serializable
4+
import kotlinx.serialization.json.Json
5+
6+
/**
7+
* Resolves resource requirements declared by a layout theme's JSON manifest.
8+
*
9+
* Each layout theme may declare its dependencies in a JSON manifest file
10+
* at `/render/theme/layout/{layoutName}.json`, for example:
11+
* ```json
12+
* {"fonts": ["fonts/lato", "fonts/inter"]}
13+
* ```
14+
*
15+
* Font names correspond to keys in `third-party-manifest.json` and to directories
16+
* under `lib/` in the output. SCSS layout themes reference these fonts via `@import url()`,
17+
* and this class ensures the corresponding files are bundled in the output.
18+
*
19+
* @param fonts third-party font library names required by this layout theme
20+
*/
21+
@Serializable
22+
data class LayoutThemeManifest(
23+
val fonts: List<String> = emptyList(),
24+
) {
25+
companion object {
26+
private val cache = mutableMapOf<String, LayoutThemeManifest?>()
27+
28+
/**
29+
* Loads the manifest for the given [layoutName],
30+
* or `null` if the layout has no manifest. Results are cached by name.
31+
*/
32+
fun load(layoutName: String?): LayoutThemeManifest? {
33+
if (layoutName == null) return null
34+
return cache.getOrPut(layoutName) { parse(layoutName) }
35+
}
36+
37+
private fun parse(layoutName: String): LayoutThemeManifest? {
38+
val json =
39+
LayoutThemeManifest::class.java
40+
.getResource("/render/theme/layout/$layoutName.json")
41+
?.readText()
42+
?: return null
43+
44+
return Json.decodeFromString<LayoutThemeManifest>(json)
45+
}
46+
}
47+
}
Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,39 @@
11
package com.quarkdown.rendering.html.post.resources
22

33
import com.quarkdown.core.context.Context
4-
import com.quarkdown.core.document.DocumentTheme
54
import com.quarkdown.core.pipeline.output.OutputResource
65
import com.quarkdown.rendering.html.post.thirdparty.ThirdPartyLibrary
76

87
/**
98
* A [PostRendererResource] that bundles third-party libraries (scripts, styles, fonts) into the output,
109
* enabling fully offline HTML rendering without any CDN dependencies.
1110
*
12-
* Library inclusion is driven entirely by [ThirdPartyLibrary], the single source of truth
13-
* for each library's condition and identity. This class simply filters the required libraries
14-
* and delegates file loading to [ThirdPartyResourceLoader].
11+
* Library inclusion is driven by [ThirdPartyLibrary] for scripts and styles,
12+
* and by [LayoutThemeManifest] for font libraries declared by the active layout theme.
13+
* File loading is delegated to [ThirdPartyResourceLoader].
1514
*
1615
* @param context the rendering context, used to evaluate library inclusion conditions
17-
* @param theme the active document theme, used to determine which font libraries to include
16+
* @param layoutTheme the active layout theme name, used to resolve font dependencies
1817
*/
1918
class ThirdPartyPostRendererResource(
2019
private val context: Context,
21-
private val theme: DocumentTheme,
20+
private val layoutTheme: String?,
2221
) : PostRendererResource {
2322
override fun includeTo(
2423
resources: MutableSet<OutputResource>,
2524
rendered: CharSequence,
2625
) {
27-
val requiredNames =
26+
val libraryNames =
2827
ThirdPartyLibrary
29-
.all(theme)
28+
.all()
3029
.filter { it.isRequired(context) }
3130
.map { it.name }
3231

33-
if (requiredNames.isEmpty()) return
32+
val fontNames = LayoutThemeManifest.load(layoutTheme)?.fonts.orEmpty()
3433

35-
resources += ThirdPartyResourceLoader.loadAll("lib", requiredNames)
34+
val allNames = libraryNames + fontNames
35+
if (allNames.isEmpty()) return
36+
37+
resources += ThirdPartyResourceLoader.loadAll("lib", allNames)
3638
}
3739
}

0 commit comments

Comments
 (0)