Skip to content

Commit e8358a6

Browse files
strawmelonjuiceautofix-ci[bot]coderabbitai[bot]
authored
Feat/ssr for context (#43)
* Update README.md to reflect support for Djot in addition to Markdown * Change module name for accuracy * JSON-LD feature integration * Add sitemap generation and configuration options for improved SEO support * Apply Prettier format * 📝 CodeRabbit Chat: Add client and server Gleam tests for parsing, rendering, and caching * Apply build script's format * Remove faulty coderabbit test * ensure proper URL formatting * Fix parsing errors in sitemap generation function; add descriptions and titles * Fix sitemap URL formatting in CompleteData structure * Enhance sitemap handling in request processing; 404 when disabled * Remove * Remove news * remove redundant extraction * Enhance JSON-LD generation * Apply Prettier format * Case insensitive false --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 068de0b commit e8358a6

File tree

14 files changed

+718
-40
lines changed

14 files changed

+718
-40
lines changed

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
# CynthiaWebsiteEngine-mini
22

33
A lightweight website engine emphasising simplicity and ease of use. Optimised for small to medium-sized websites requiring both static and dynamic capabilities.
4-
Create entire sites out of just a few markdown/html/plaintext files and their json metadata!
4+
Create entire sites out of just a few djot/markdown/html/plaintext files and their json metadata!
55

66
## Key Features
77

88
- 🚀 Simple setup and configuration
99
- 🎨 Wide collection of pre-made themes
10-
- 📝 Markdown and HTML support
10+
- 📝 Djot and HTML support (MarkDown support with Pandoc)
1111
- 🔧 Static site generation capabilities
1212
- 🔌 Extensible plugin architecture based on the Cynthia v3 plugin system (coming in v2)
1313

@@ -59,20 +59,20 @@ For detailed information about configuration, theming, and deployment, check out
5959

6060
```directory
6161
./content/
62-
index.md
63-
index.md.meta.json
62+
index.dj
63+
index.dj.meta.json
6464
about.md
6565
about.md.meta.json
6666
projects/
6767
project1.html
6868
project1.html.meta.json
69-
project2.md
70-
project2.md.meta.json
69+
project2.dj
70+
project2.dj.meta.json
7171
articles/
72-
article1.md
73-
article1.md.meta.json
74-
article2.md
75-
article2.md.meta.json
72+
article1.dj
73+
article1.dj.meta.json
74+
article2.dj
75+
article2.dj.meta.json
7676
./cynthia-mini.toml
7777
```
7878

cynthia_websites_mini_client/src/cynthia_websites_mini_client/configtype.gleam

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ pub type CompleteData {
1616
server_host: Option(String),
1717
comment_repo: Option(String),
1818
git_integration: Bool,
19+
crawlable_context: Bool,
20+
sitemap: Option(String),
1921
other_vars: List(#(String, List(String))),
2022
content: List(Content),
2123
)
@@ -34,6 +36,8 @@ pub fn encode_complete_data_for_client(complete_data: CompleteData) -> json.Json
3436
git_integration:,
3537
other_vars:,
3638
content:,
39+
crawlable_context:,
40+
sitemap:,
3741
) = complete_data
3842
json.object([
3943
#("global_theme", json.string(global_theme)),
@@ -42,6 +46,11 @@ pub fn encode_complete_data_for_client(complete_data: CompleteData) -> json.Json
4246
#("global_site_name", json.string(global_site_name)),
4347
#("global_site_description", json.string(global_site_description)),
4448
#("git_integration", json.bool(git_integration)),
49+
#("crawlable_context", json.bool(crawlable_context)),
50+
#("sitemap", case sitemap {
51+
None -> json.null()
52+
Some(value) -> json.string(value)
53+
}),
4554
#("comment_repo", case comment_repo {
4655
None -> json.null()
4756
Some(value) -> json.string(value)
@@ -93,6 +102,17 @@ pub fn complete_data_decoder() -> decode.Decoder(CompleteData) {
93102
|> decode.map(list.fold(_, dict.new(), dict.merge))
94103
})
95104

105+
use crawlable_context <- decode.optional_field(
106+
"crawlable_context",
107+
default_shared_cynthia_config_global_only.crawlable_context,
108+
decode.bool,
109+
)
110+
use sitemap <- decode.optional_field(
111+
"sitemap",
112+
default_shared_cynthia_config_global_only.sitemap,
113+
decode.optional(decode.string),
114+
)
115+
96116
let other_vars = dict.to_list(other_vars)
97117

98118
decode.success(CompleteData(
@@ -105,6 +125,8 @@ pub fn complete_data_decoder() -> decode.Decoder(CompleteData) {
105125
server_host:,
106126
comment_repo:,
107127
git_integration:,
128+
crawlable_context:,
129+
sitemap:,
108130
other_vars:,
109131
content:,
110132
))
@@ -120,7 +142,18 @@ pub type SharedCynthiaConfigGlobalOnly {
120142
server_port: Option(Int),
121143
server_host: Option(String),
122144
comment_repo: Option(String),
145+
/// [True]
146+
/// Wether or not to enable git integration for the site.
123147
git_integration: Bool,
148+
/// [False]
149+
/// Wether or not to insert json-ld+context into the HTML
150+
/// to make the site crawlable by search engines or readable by LLMs.
151+
crawlable_context: Bool,
152+
/// [True]
153+
/// Wether or not to create a sitemap.xml file for the site.
154+
/// This is useful for search engines to index the site.
155+
/// This is separate from the crawlable_context setting, as no content needs to be rendered or served for the sitemap.xml file.
156+
sitemap: Option(String),
124157
other_vars: List(#(String, List(String))),
125158
)
126159
}
@@ -135,6 +168,8 @@ pub const default_shared_cynthia_config_global_only: SharedCynthiaConfigGlobalOn
135168
server_host: None,
136169
comment_repo: None,
137170
git_integration: True,
171+
crawlable_context: False,
172+
sitemap: Some("https://example.com"),
138173
other_vars: [],
139174
)
140175

@@ -152,6 +187,8 @@ pub fn merge(
152187
server_host: orig.server_host,
153188
comment_repo: orig.comment_repo,
154189
git_integration: orig.git_integration,
190+
crawlable_context: orig.crawlable_context,
191+
sitemap: orig.sitemap,
155192
other_vars: orig.other_vars,
156193
content:,
157194
)

cynthia_websites_mini_client/src/cynthia_websites_mini_client/dom.gleam

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,8 @@ pub fn set_hash(hash: String) -> Nil
3737
/// Get innerhtml of an element
3838
@external(javascript, "./dom.ts", "get_inner_html")
3939
pub fn get_inner_html(element: element.Element) -> String
40+
41+
/// jsonify_string
42+
/// Convert a string to a JSON safe string
43+
@external(javascript, "./dom.ts", "jsonify_string")
44+
pub fn jsonify_string(str: String) -> Result(String, Nil)

cynthia_websites_mini_client/src/cynthia_websites_mini_client/dom.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Error, Ok } from "../../prelude";
2+
13
export function get_color_scheme() {
24
// Media queries the preferred color colorscheme
35

@@ -57,3 +59,13 @@ export function destroy_comment_box() {
5759
}
5860
}
5961
}
62+
63+
export function jsonify_string(str: string) {
64+
// Convert a string to a JSON object
65+
try {
66+
return new Ok(JSON.stringify(str));
67+
} catch (e) {
68+
console.error("Failed to parse JSON string:", e);
69+
return new Error(null);
70+
}
71+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import cynthia_websites_mini_client
2+
import cynthia_websites_mini_client/configtype
3+
import cynthia_websites_mini_client/contenttypes
4+
import cynthia_websites_mini_client/dom
5+
import cynthia_websites_mini_client/pottery
6+
import gleam/list
7+
import gleam/result
8+
import gleam/string
9+
import lustre/element
10+
11+
/// Generates JSON-LD structured data for the website.
12+
pub fn generate_jsonld(cd: configtype.CompleteData) -> String {
13+
let base_jsonld = "{
14+
\"@context\": \"https://schema.org\",
15+
\"@type\": \"Website\",
16+
\"name\": " <> cd.global_site_name
17+
|> dom.jsonify_string()
18+
|> result.unwrap("Site name is invalid") <> ",
19+
\"description\": " <> cd.global_site_description
20+
|> dom.jsonify_string()
21+
|> result.unwrap("Site description is invalid") <> ",
22+
\"generator\": {
23+
\"@type\": \"SoftwareApplication\",
24+
\"name\": \"CynthiaMini Website Engine\",
25+
\"url\": \"https://github.com/CynthiaWebsiteEngine/Mini-docs\",
26+
\"version\": \"" <> cynthia_websites_mini_client.version() <> "\"
27+
},
28+
\"@graph\": ["
29+
30+
let content_jsonld =
31+
cd.content
32+
|> list.map(fn(c) {
33+
let title = {
34+
result.unwrap(dom.jsonify_string(c.title), "Title is invalid")
35+
}
36+
let description = {
37+
pottery.parse_html(c.description, "descr.dj")
38+
|> element.to_string()
39+
|> dom.jsonify_string()
40+
|> result.unwrap("Description is invalid")
41+
}
42+
let content_type = case c.data {
43+
contenttypes.PostData(..) -> "BlogPosting"
44+
contenttypes.PageData(..) -> "WebPage"
45+
}
46+
47+
let dates = case c.data {
48+
contenttypes.PostData(
49+
date_published: published,
50+
date_updated: updated,
51+
category: _,
52+
tags: _,
53+
) -> "\n\"datePublished\": \"" <> published <> "\",
54+
\"dateModified\": \"" <> updated <> "\","
55+
_ -> ""
56+
}
57+
58+
"{
59+
\"@type\": \"" <> content_type <> "\",
60+
\"@id\": " <> c.permalink |> dom.jsonify_string() |> result.unwrap("/") <> ",
61+
\"headline\": " <> title <> ",
62+
\"description\": " <> description <> "," <> dates <> "
63+
\"mainEntityOfPage\": {
64+
\"@type\": \"WebPage\",
65+
\"@id\": " <> c.permalink
66+
|> dom.jsonify_string()
67+
|> result.unwrap("/") <> "
68+
}
69+
}"
70+
})
71+
|> string.join(",\n")
72+
73+
base_jsonld <> content_jsonld <> "]}"
74+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import cynthia_websites_mini_client/configtype.{type CompleteData}
2+
import cynthia_websites_mini_client/contenttypes
3+
import cynthia_websites_mini_client/pottery
4+
import gleam/list
5+
import gleam/option
6+
import gleam/string
7+
import lustre/attribute.{attribute}
8+
import lustre/element.{type Element, element}
9+
10+
pub fn generate_sitemap(data: CompleteData) -> option.Option(String) {
11+
use base_url: String <- option.then(
12+
option.then(data.sitemap, fn(url) {
13+
{
14+
case url |> string.ends_with("/") {
15+
True -> url
16+
False -> url <> "/"
17+
}
18+
<> "#"
19+
}
20+
|> option.Some
21+
}),
22+
)
23+
24+
let all_entries =
25+
data.content
26+
|> list.map(fn(post) {
27+
// We'll get the url, dates, title and description for each post
28+
let url = base_url <> post.permalink
29+
let lastmod = case post.data {
30+
contenttypes.PostData(
31+
date_published: published,
32+
date_updated: updated,
33+
..,
34+
) ->
35+
// If post has an updated date use that, otherwise use published date
36+
case updated {
37+
"" -> published
38+
// Empty string means no update date
39+
_ -> updated
40+
// Use update date if available
41+
}
42+
contenttypes.PageData(..) -> ""
43+
// Pages don't have dates yet
44+
}
45+
#(url, lastmod, post.title, post.description)
46+
})
47+
|> list.map(fn(entry) {
48+
// If the entry is the homepage, make it / instead of the base URL
49+
let #(url, lastmod, title, desc) = entry
50+
let url = case url {
51+
"" -> {
52+
// If the URL is empty, we assume it's the homepage
53+
base_url <> "/"
54+
}
55+
_ -> url
56+
}
57+
#(url, lastmod, title, desc)
58+
})
59+
60+
// Create the XML using lustre
61+
let urlset =
62+
element(
63+
"urlset",
64+
[attribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9")],
65+
list.map(all_entries, fn(entry) {
66+
let #(url, lastmod, _title, _desc) = entry
67+
let mut_elements = [
68+
element("loc", [], [element.text(url)]),
69+
element("changefreq", [], [element.text("weekly")]),
70+
element("priority", [], [element.text("1.0")]),
71+
// Description and title are not standard in sitemaps, sadly.
72+
// cdata_into_lustre("title", element.text(title)),
73+
// cdata_into_lustre("description", pottery.parse_html(desc, "descr.dj")),
74+
]
75+
// Only add lastmod if we have a date
76+
let elements = case lastmod {
77+
"" -> mut_elements
78+
date -> [element("lastmod", [], [element.text(date)]), ..mut_elements]
79+
}
80+
element("url", [], elements)
81+
}),
82+
)
83+
84+
// Convert the XML tree to a string
85+
option.Some(element.to_readable_string(urlset))
86+
}
87+
// fn cdata_into_lustre(
88+
// element_tag: String,
89+
// inner: Element(a),
90+
// ) -> element.Element(a) {
91+
// // Create a CDATA section in Lustre
92+
// // as a string :
93+
// // <![CDATA[ ... ]]>
94+
// element.unsafe_raw_html(
95+
// "",
96+
// element_tag,
97+
// [],
98+
// "<![CDATA[" <> element.to_string(inner) <> "]]>",
99+
// )
100+
// }

0 commit comments

Comments
 (0)