aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJean-Nicolas Veigel <art.jnveigel@gmail.com>2024-03-15 16:56:48 +0100
committerLouis Pilfold <louis@lpil.uk>2024-03-26 10:31:25 +0000
commitbfe16ae8838987fa5e7d5147423c4547883eccc2 (patch)
treef49649cbd1fb7a2f570c4fb82f6469a1e4197df1
parent1e3b77d4681cf4098694f67d89f652f04336e0fc (diff)
downloadtour-bfe16ae8838987fa5e7d5147423c4547883eccc2.tar.gz
tour-bfe16ae8838987fa5e7d5147423c4547883eccc2.zip
feat(wip): modular page rendering
-rw-r--r--src/tour.gleam334
-rw-r--r--src/tour/document.gleam163
-rw-r--r--src/tour/page.gleam105
-rw-r--r--src/tour/widgets.gleam27
4 files changed, 508 insertions, 121 deletions
diff --git a/src/tour.gleam b/src/tour.gleam
index 0b09e1c..a5e04ea 100644
--- a/src/tour.gleam
+++ b/src/tour.gleam
@@ -9,6 +9,8 @@ import htmb.{h, text}
import simplifile
import snag
import tour/widgets
+import tour/document
+import tour/page
const static = "static"
@@ -533,7 +535,7 @@ fn everything_chapter_lesson_html(lesson: Lesson, index: Int, end_index: Int) {
h("h2", [#("class", "lesson-title")], [text(lesson.name)]),
]),
htmb.dangerous_unescaped_fragment(string_builder.from_string(lesson.text)),
- h("pre", [#("class", "lesson-snippet")], [
+ h("pre", [#("class", "lesson-snippet hljs gleam language-gleam")], [
h("code", [], [text(lesson.code)]),
h(
"a",
@@ -598,13 +600,9 @@ fn everything_html(chapters: List(Chapter)) -> String {
[],
list.map(chapter.lessons, fn(lesson) {
h("li", [], [
- h(
- "a",
- [
- #("href", "#" <> slugify_path(lesson.path)),
- #("class", "link"),
- ],
- [text(lesson.name)],
+ link_html(
+ Link(label: lesson.name, to: "#" <> slugify_path(lesson.path)),
+ [#("class", "link padded")],
),
])
}),
@@ -613,17 +611,68 @@ fn everything_html(chapters: List(Chapter)) -> String {
)
})
+ let render_next = True
+
+ case render_next {
+ True ->
+ page.html(
+ page.PageConfig(
+ path: "everything",
+ title: "Everything!",
+ scripts: page.ScriptConfig(head: [], body: [
+ document.script(
+ "/highlight/highlight.core.min.js",
+ document.ScriptOptions(module: True, defer: True),
+ [],
+ ),
+ document.script(
+ "/highlight/highlight-gleam.js",
+ document.ScriptOptions(module: True, defer: True),
+ [],
+ ),
+ ]),
+ stylesheets: ["/highlight/highlight.css"],
+ content: [
+ h("main", [#("id", "everything")], [
+ h(
+ "aside",
+ [#("id", "everything-contents"), #("class", "dim-bg")],
+ table_of_contents,
+ ),
+ h("section", [#("id", "everything-lessons")], chapter_lessons),
+ ]),
+ ],
+ ),
+ )
+ |> page.render
+ _ ->
+ page_html(
+ at: "everything",
+ titled: "Everything!!!",
+ containing: [
+ h("main", [#("id", "everything")], [
+ h(
+ "aside",
+ [#("id", "everything-contents"), #("class", "dim-bg")],
+ table_of_contents,
+ ),
+ h("section", [#("id", "everything-lessons")], chapter_lessons),
+ ]),
+ h(
+ "script",
+ [#("src", "/highlight/highlight.core.min.js"), #("type", "module")],
+ [],
+ ),
+ h(
+ "script",
+ [#("src", "/highlight/highlight-gleam.js"), #("type", "module")],
+ [],
+ ),
+ ],
+ with_styles: ["/highlight/highlight.css"],
+ )
+ }
// TODO: use proper values for location and such
- page_html(at: "everything", titled: "Everything!!!", containing: [
- h("main", [#("id", "everything")], [
- h(
- "aside",
- [#("id", "everything-contents"), #("class", "dim-bg")],
- table_of_contents,
- ),
- h("section", [#("id", "everything-lessons")], chapter_lessons),
- ]),
- ])
}
fn lesson_html(page: Lesson) -> String {
@@ -634,129 +683,172 @@ fn lesson_html(page: Lesson) -> String {
}
}
- page_html(at: page.path, titled: page.name, containing: [
- h("article", [#("id", "playground")], [
- h("section", [#("id", "left")], [
- h("h2", [], [text(page.name)]),
- htmb.dangerous_unescaped_fragment(string_builder.from_string(page.text)),
- h("nav", [#("class", "prev-next")], [
- navlink("Back", page.previous),
- text(" — "),
- h("a", [#("href", path_table_of_contents)], [text("Contents")]),
- text(" — "),
- navlink("Next", page.next),
+ page_html(
+ at: page.path,
+ titled: page.name,
+ containing: [
+ h("article", [#("id", "playground")], [
+ h("section", [#("id", "left")], [
+ h("h2", [], [text(page.name)]),
+ htmb.dangerous_unescaped_fragment(string_builder.from_string(
+ page.text,
+ )),
+ h("nav", [#("class", "prev-next")], [
+ navlink("Back", page.previous),
+ text(" — "),
+ h("a", [#("href", path_table_of_contents)], [text("Contents")]),
+ text(" — "),
+ navlink("Next", page.next),
+ ]),
]),
- ]),
- h("section", [#("id", "right")], [
- h("section", [#("id", "editor")], [
- h("div", [#("id", "editor-target")], []),
+ h("section", [#("id", "right")], [
+ h("section", [#("id", "editor")], [
+ h("div", [#("id", "editor-target")], []),
+ ]),
+ h("aside", [#("id", "output")], []),
]),
- h("aside", [#("id", "output")], []),
]),
+ h("script", [#("type", "gleam"), #("id", "code")], [
+ htmb.dangerous_unescaped_fragment(string_builder.from_string(page.code)),
+ ]),
+ h("script", [#("type", "module"), #("src", "/index.js")], []),
+ ],
+ with_styles: [],
+ )
+}
+
+type Link {
+ Link(label: String, to: String)
+}
+
+fn link_html(for link: Link, attributes attributes: List(#(String, String))) {
+ let link_attributes = [#("href", link.to), ..attributes]
+
+ h("a", link_attributes, [text(link.label)])
+}
+
+/// Renders the tour's navbar as html
+fn page_navbar(
+ titled title: String,
+ links additional_links: List(Link),
+) -> htmb.Html {
+ let links = {
+ [Link(label: "gleam.run", to: "http://gleam.run"), ..additional_links]
+ |> list.map(fn(l) { link_html(l, [#("class", "link")]) })
+ }
+ let nav_right_items = list.flatten([links, [widgets.theme_picker()]])
+
+ h("nav", [#("class", "navbar")], [
+ h("a", [#("href", "/"), #("class", "logo")], [
+ h(
+ "img",
+ [
+ #("src", "https://gleam.run/images/lucy/lucy.svg"),
+ #("alt", "Lucy the star, Gleam's mascot"),
+ ],
+ [],
+ ),
+ text(title),
]),
- h("script", [#("type", "gleam"), #("id", "code")], [
- htmb.dangerous_unescaped_fragment(string_builder.from_string(page.code)),
- ]),
- h("script", [#("type", "module"), #("src", "/index.js")], []),
+ h("div", [#("class", "nav-right")], nav_right_items),
])
}
-fn page_html(
+/// Renders the page head as HTML
+fn page_head(
at path: String,
titled title: String,
- containing content: List(htmb.Html),
-) -> String {
+ with_styles styles: List(String),
+) -> htmb.Html {
let metaprop = fn(property, content) {
h("meta", [#("property", property), #("content", content)], [])
}
let link = fn(rel, href) { h("link", [#("rel", rel), #("href", href)], []) }
+ let stylesheet = fn(src: String) { link("stylesheet", src) }
let title = title <> " - The Gleam Language Tour"
let description =
"An interactive introduction and reference to the Gleam programming language. Learn Gleam in your browser!"
+ let metaprops = [
+ metaprop("og:type", "website"),
+ metaprop("og:title", title),
+ metaprop("og:description", description),
+ metaprop("og:url", "https://tour.gleam.run/" <> path),
+ metaprop("og:image", "https://gleam.run/images/og-image.png"),
+ metaprop("twitter:card", "summary_large_image"),
+ metaprop("twitter:url", "https://tour.gleam.run/" <> path),
+ metaprop("twitter:title", title),
+ metaprop("twitter:description", description),
+ metaprop("twitter:image", "https://gleam.run/images/og-image.png"),
+ ]
+
+ let metadata = [
+ h("meta", [#("charset", "utf-8")], []),
+ h(
+ "meta",
+ [
+ #("name", "viewport"),
+ #("content", "width=device-width, initial-scale=1"),
+ ],
+ [],
+ ),
+ h("title", [], [text(title)]),
+ h("meta", [#("name", "description"), #("content", description)], []),
+ ..metaprops
+ ]
+
+ let links = [
+ link("shortcut icon", "https://gleam.run/images/lucy/lucy.svg"),
+ link("stylesheet", "/common.css"),
+ link("stylesheet", "/style.css"),
+ ..list.map(styles, stylesheet)
+ ]
+
+ let scripts = [
+ h(
+ "script",
+ [
+ #("defer", ""),
+ #("data-domain", "tour.gleam.run"),
+ #("src", "https://plausible.io/js/script.js"),
+ ],
+ [],
+ ),
+ h("script", [#("type", "module")], [
+ htmb.dangerous_unescaped_fragment(string_builder.from_string(
+ widgets.theme_picker_js,
+ )),
+ ]),
+ ]
+
+ let head_content = {
+ let parts = [metadata, links, scripts]
+ parts
+ |> list.concat
+ }
+
+ h("head", [], head_content)
+}
+
+fn page_html(
+ at path: String,
+ titled title: String,
+ containing content: List(htmb.Html),
+ with_styles styles: List(String),
+) -> String {
+ let head = {
+ let static_styles = ["/common.css", "/styles.css"]
+ let head_styles =
+ [static_styles, styles]
+ |> list.concat()
+
+ page_head(at: path, titled: title, with_styles: head_styles)
+ }
h("html", [#("lang", "en-gb"), #("class", "theme-light")], [
- h("head", [], [
- h("meta", [#("charset", "utf-8")], []),
- h(
- "meta",
- [
- #("name", "viewport"),
- #("content", "width=device-width, initial-scale=1"),
- ],
- [],
- ),
- h("title", [], [text(title)]),
- h("meta", [#("name", "description"), #("content", description)], []),
- metaprop("og:type", "website"),
- metaprop("og:title", title),
- metaprop("og:description", description),
- metaprop("og:url", "https://tour.gleam.run/" <> path),
- metaprop("og:image", "https://gleam.run/images/og-image.png"),
- metaprop("twitter:card", "summary_large_image"),
- metaprop("twitter:url", "https://tour.gleam.run/" <> path),
- metaprop("twitter:title", title),
- metaprop("twitter:description", description),
- metaprop("twitter:image", "https://gleam.run/images/og-image.png"),
- link("shortcut icon", "https://gleam.run/images/lucy/lucy.svg"),
- link("stylesheet", "/common.css"),
- link("stylesheet", "/style.css"),
- h(
- "script",
- [
- #("defer", ""),
- #("data-domain", "tour.gleam.run"),
- #("src", "https://plausible.io/js/script.js"),
- ],
- [],
- ),
- h("script", [#("type", "module")], [
- htmb.dangerous_unescaped_fragment(string_builder.from_string(
- widgets.theme_picker_js,
- )),
- ]),
- ]),
+ head,
h("body", [], [
- h("nav", [#("class", "navbar")], [
- h("a", [#("href", "/"), #("class", "logo")], [
- h(
- "img",
- [
- #("src", "https://gleam.run/images/lucy/lucy.svg"),
- #("alt", "Lucy the star, Gleam's mascot"),
- ],
- [],
- ),
- text("Gleam Language Tour"),
- ]),
- h("div", [#("class", "nav-right")], [
- h("a", [#("href", "https://gleam.run")], [text("gleam.run")]),
- h("div", [#("class", "theme-picker")], [
- h(
- "button",
- [
- #("type", "button"),
- #("alt", "Switch to light mode"),
- #("title", "Switch to light mode"),
- #("class", "theme-button -light"),
- #("data-light-theme-toggle", ""),
- ],
- [widgets.icon_moon(), widgets.icon_toggle_left()],
- ),
- h(
- "button",
- [
- #("type", "button"),
- #("alt", "Switch to dark mode"),
- #("title", "Switch to dark mode"),
- #("class", "theme-button -dark"),
- #("data-dark-theme-toggle", ""),
- ],
- [widgets.icon_sun(), widgets.icon_toggle_right()],
- ),
- ]),
- ]),
- ]),
+ page_navbar(titled: "Gleam Language Tour", links: []),
..content
]),
])
diff --git a/src/tour/document.gleam b/src/tour/document.gleam
new file mode 100644
index 0000000..892148d
--- /dev/null
+++ b/src/tour/document.gleam
@@ -0,0 +1,163 @@
+import htmb.{type Html, h, text}
+import gleam/string_builder
+import gleam/list
+
+pub type HtmlAttribute =
+ #(String, String)
+
+pub type ScriptOptions {
+ ScriptOptions(module: Bool, defer: Bool)
+}
+
+/// Formats js script options into usage html attributes
+fn script_common_attributes(attributes: ScriptOptions) -> List(HtmlAttribute) {
+ let type_attr = #("type", case attributes.module {
+ True -> "module"
+ _ -> "text/javascript"
+ })
+ let defer_attr = #("defer", "")
+
+ case attributes.defer {
+ True -> [defer_attr, type_attr]
+ _ -> [type_attr]
+ }
+}
+
+/// Renders an HTML script tag
+pub fn script(
+ src source: String,
+ options attributes: ScriptOptions,
+ attributes additional_attributes: List(HtmlAttribute),
+) -> Html {
+ let attrs = {
+ let src_attr = #("src", source)
+ let base_attrs: List(HtmlAttribute) = [
+ src_attr,
+ ..script_common_attributes(attributes)
+ ]
+ list.flatten([base_attrs, additional_attributes])
+ }
+ h("script", attrs, [])
+}
+
+/// Renders an inline HTML script tag
+pub fn dangerous_inline_script(
+ script content: String,
+ options attributes: ScriptOptions,
+ attributes additional_attributes: List(HtmlAttribute),
+) -> Html {
+ let attrs = {
+ list.flatten([script_common_attributes(attributes), additional_attributes])
+ }
+ h("script", attrs, [
+ htmb.dangerous_unescaped_fragment(string_builder.from_string(content)),
+ ])
+}
+
+/// Renders an HTML meta tag
+pub fn meta(data attributes: List(HtmlAttribute)) -> Html {
+ h("meta", attributes, [])
+}
+
+/// Renders an HTML meta property tag
+pub fn meta_prop(property: String, content: String) -> Html {
+ meta([#("property", property), #("content", content)])
+}
+
+/// Renders an HTML link tag
+pub fn link(rel: String, href: String) -> Html {
+ h("link", [#("rel", rel), #("href", href)], [])
+}
+
+/// Renders a stylesheet link tag
+pub fn stylesheet(src: String) -> Html {
+ link("stylesheet", src)
+}
+
+/// Renders an HTML title tag
+pub fn title(title: String) -> Html {
+ h("title", [], [text(title)])
+}
+
+pub type HeadConfig {
+ HeadConfig(
+ path: String,
+ title: String,
+ description: String,
+ url: String,
+ image: String,
+ meta: List(Html),
+ stylesheets: List(String),
+ scripts: List(Html),
+ )
+}
+
+/// Renders the page head as HTML
+pub fn head(with config: HeadConfig) -> htmb.Html {
+ let meta_tags = [
+ meta_prop("og:type", "website"),
+ meta_prop("og:title", config.title),
+ meta_prop("og:description", config.description),
+ meta_prop("og:url", config.url),
+ meta_prop("og:image", config.image),
+ meta_prop("twitter:card", "summary_large_image"),
+ meta_prop("twitter:url", config.url),
+ meta_prop("twitter:title", config.title),
+ meta_prop("twitter:description", config.description),
+ meta_prop("twitter:image", config.image),
+ ..config.meta
+ ]
+
+ let head_meta = [
+ meta([#("charset", "utf-8")]),
+ meta([
+ #("name", "viewport"),
+ #("content", "width=device-width, initial-scale=1"),
+ ]),
+ title(config.title),
+ meta([#("name", "description"), #("content", config.description)]),
+ ..meta_tags
+ ]
+
+ let head_links = [
+ link("shortcut icon", "https://gleam.run/images/lucy/lucy.svg"),
+ ..list.map(config.stylesheets, stylesheet)
+ ]
+
+ let head_content = list.concat([head_meta, head_links, config.scripts])
+
+ h("head", [], head_content)
+}
+
+pub type BodyConfig {
+ BodyConfig(
+ content: List(Html),
+ static_content: List(Html),
+ scripts: List(Html),
+ attributes: List(HtmlAttribute),
+ )
+}
+
+/// Renders an Html body tag
+pub fn body(with config: BodyConfig) -> Html {
+ let content =
+ list.flatten([config.static_content, config.content, config.scripts])
+
+ h("body", config.attributes, content)
+}
+
+pub type HtmlConfig {
+ HtmlConfig(
+ attributes: List(HtmlAttribute),
+ lang: String,
+ head: HeadConfig,
+ body: BodyConfig,
+ )
+}
+
+/// Renders an HTML tag and its children
+pub fn html(with config: HtmlConfig) -> Html {
+ let attributes = [#("lang", config.lang), ..config.attributes]
+
+ h("html", attributes, [head(config.head), body(config.body)])
+}
diff --git a/src/tour/page.gleam b/src/tour/page.gleam
new file mode 100644
index 0000000..557668b
--- /dev/null
+++ b/src/tour/page.gleam
@@ -0,0 +1,105 @@
+import gleam/list
+import gleam/string
+import gleam/string_builder
+import htmb.{type Html, h, text}
+import tour/document.{
+ type HtmlAttribute, BodyConfig, HeadConfig, HtmlConfig, ScriptOptions,
+}
+import tour/widgets
+
+pub type Link {
+ Link(label: String, to: String)
+}
+
+/// Renders an HTML anchor tag
+pub fn link(for link: Link, attributes attributes: List(HtmlAttribute)) -> Html {
+ let link_attributes = [#("href", link.to), ..attributes]
+
+ h("a", link_attributes, [text(link.label)])
+}
+
+/// Renders the tour's navbar as html
+pub fn navbar(titled title: String, links links: List(Link)) -> Html {
+ let links = list.map(links, fn(l) { link(l, [#("class", "link")]) })
+
+ let nav_right_items = list.flatten([links, [widgets.theme_picker()]])
+
+ h("nav", [#("class", "navbar")], [
+ h("a", [#("href", "/"), #("class", "logo")], [
+ h(
+ "img",
+ [
+ #("src", "https://gleam.run/images/lucy/lucy.svg"),
+ #("alt", "Lucy the star, Gleam's mascot"),
+ ],
+ [],
+ ),
+ text(title),
+ ]),
+ h("div", [#("class", "nav-right")], nav_right_items),
+ ])
+}
+
+pub type ScriptConfig {
+ ScriptConfig(head: List(Html), body: List(Html))
+}
+
+pub type PageConfig {
+ PageConfig(
+ path: String,
+ title: String,
+ content: List(Html),
+ stylesheets: List(String),
+ scripts: ScriptConfig,
+ )
+}
+
+/// Renders a page in the language tour
+pub fn html(page config: PageConfig) -> Html {
+ // add path-specific class to body to make styling easier
+ let body_class = #("id", "page" <> string.replace(config.path, "/", "-"))
+
+ // render html
+ document.html(HtmlConfig(
+ head: HeadConfig(
+ description: "An interactive introduction and reference to the Gleam programming language. Learn Gleam in your browser!",
+ image: "https://gleam.run/images/og-image.png",
+ title: config.title <> " - The Gleam Language Tour",
+ url: "https://tour.gleam.run/" <> config.path,
+ path: config.path,
+ meta: [],
+ stylesheets: ["/common.css", "/style.css", ..config.stylesheets],
+ scripts: [
+ document.script(
+ "https://plausible.io/js/script.js",
+ ScriptOptions(defer: True, module: False),
+ [#("data-domain", "tour.gleam.run")],
+ ),
+ document.dangerous_inline_script(
+ widgets.theme_picker_js,
+ ScriptOptions(module: True, defer: False),
+ [],
+ ),
+ ..config.scripts.head
+ ],
+ ),
+ lang: "en-GB",
+ attributes: [#("class", "theme-light")],
+ body: BodyConfig(
+ attributes: [body_class],
+ scripts: config.scripts.body,
+ static_content: [
+ navbar(titled: "Gleam Language Tour", links: [
+ Link(label: "gleam.run", to: "http://gleam.run"),
+ ]),
+ ],
+ content: config.content,
+ ),
+ ))
+}
+
+pub fn render(page page_html: Html) -> String {
+ page_html
+ |> htmb.render_page("html")
+ |> string_builder.to_string
+}
diff --git a/src/tour/widgets.gleam b/src/tour/widgets.gleam
index 0031cb7..fabcbb1 100644
--- a/src/tour/widgets.gleam
+++ b/src/tour/widgets.gleam
@@ -60,6 +60,33 @@ pub fn icon_toggle_right() -> Html {
])
}
+pub fn theme_picker() -> Html {
+ h("div", [#("class", "theme-picker")], [
+ h(
+ "button",
+ [
+ #("type", "button"),
+ #("alt", "Switch to light mode"),
+ #("title", "Switch to light mode"),
+ #("class", "theme-button -light"),
+ #("data-light-theme-toggle", ""),
+ ],
+ [icon_moon(), icon_toggle_left()],
+ ),
+ h(
+ "button",
+ [
+ #("type", "button"),
+ #("alt", "Switch to dark mode"),
+ #("title", "Switch to dark mode"),
+ #("class", "theme-button -dark"),
+ #("data-dark-theme-toggle", ""),
+ ],
+ [icon_sun(), icon_toggle_right()],
+ ),
+ ])
+}
+
// This script is inlined in the response to avoid FOUC when applying the theme
pub const theme_picker_js = "
const mediaPrefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)')