diff options
author | Jean-Nicolas Veigel <art.jnveigel@gmail.com> | 2024-03-15 16:56:48 +0100 |
---|---|---|
committer | Louis Pilfold <louis@lpil.uk> | 2024-03-26 10:31:25 +0000 |
commit | bfe16ae8838987fa5e7d5147423c4547883eccc2 (patch) | |
tree | f49649cbd1fb7a2f570c4fb82f6469a1e4197df1 | |
parent | 1e3b77d4681cf4098694f67d89f652f04336e0fc (diff) | |
download | tour-bfe16ae8838987fa5e7d5147423c4547883eccc2.tar.gz tour-bfe16ae8838987fa5e7d5147423c4547883eccc2.zip |
feat(wip): modular page rendering
-rw-r--r-- | src/tour.gleam | 334 | ||||
-rw-r--r-- | src/tour/document.gleam | 163 | ||||
-rw-r--r-- | src/tour/page.gleam | 105 | ||||
-rw-r--r-- | src/tour/widgets.gleam | 27 |
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)') |