From 341b235fb1568ddd25b9fc6cc5d48a0aa166f3ac Mon Sep 17 00:00:00 2001 From: Ilya Bogdanov Date: Wed, 18 Jan 2023 17:18:26 +0400 Subject: [PATCH] Generate HTML for section headers and synopsis (#4038) Two tasks: - [Task link](https://www.pivotaltracker.com/story/show/184024127) - [Task link](https://www.pivotaltracker.com/story/show/184024148) This PR implements the generation of HTML from Documentation IR for section headers and the Synopsis section. The synopsis contains documentation of the type/module + a list of the type's constructors. https://user-images.githubusercontent.com/6566674/212684680-d999b525-56c7-4952-8ccc-192989acdf33.mp4 # Important Notes - Paddings are removed from the documentation panel because they are now implemented in the HTML generator, but it doesn't affect the looks much (documentation still looks awful). All other changes do not affect the current look of the component browser and are only shown in the demo scene. https://user-images.githubusercontent.com/6566674/212685347-addb9204-8441-44be-8d8e-3c2626d77f77.mp4 --- Cargo.lock | 8 + .../view/debug_scene/documentation/src/lib.rs | 93 +---- app/gui/view/documentation/Cargo.toml | 2 + .../documentation/assets/icon-examples.svg | 9 + .../documentation/assets/icon-methods.svg | 17 + .../view/documentation/assets/icon-type.svg | 5 + app/gui/view/documentation/assets/input.css | 9 + app/gui/view/documentation/build.rs | 1 + app/gui/view/documentation/src/html.rs | 347 ++++++++++++++++++ app/gui/view/documentation/src/lib.rs | 14 +- app/gui/view/documentation/tailwind.config.js | 13 +- 11 files changed, 424 insertions(+), 94 deletions(-) create mode 100644 app/gui/view/documentation/assets/icon-examples.svg create mode 100644 app/gui/view/documentation/assets/icon-methods.svg create mode 100644 app/gui/view/documentation/assets/icon-type.svg create mode 100644 app/gui/view/documentation/src/html.rs diff --git a/Cargo.lock b/Cargo.lock index c3e18c8f5d..fb0db75aec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3754,6 +3754,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "horrorshow" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8371fb981840150b1a54f7cb117bf6699f7466a1d4861daac33bc6fe2b5abea0" + [[package]] name = "http" version = "0.2.8" @@ -4162,9 +4168,11 @@ dependencies = [ "enso-frp", "enso-logger", "enso-prelude", + "enso-suggestion-database", "ensogl", "ensogl-component", "ensogl-hardcoded-theme", + "horrorshow", "ide-ci", "ide-view-graph-editor", "serde_json", diff --git a/app/gui/view/debug_scene/documentation/src/lib.rs b/app/gui/view/debug_scene/documentation/src/lib.rs index 565abf5bf8..6bb2b1cd7c 100644 --- a/app/gui/view/debug_scene/documentation/src/lib.rs +++ b/app/gui/view/debug_scene/documentation/src/lib.rs @@ -25,12 +25,6 @@ use ensogl_hardcoded_theme::application::component_browser as theme; use ide_view_documentation as documentation; use ide_view_graph_editor::component::visualization::Registry; use std::f32::consts::PI; -use std::fmt::Write; -use suggestion_database::documentation_ir::Documentation; -use suggestion_database::documentation_ir::EntryDocumentation; -use suggestion_database::documentation_ir::ModuleDocumentation; -use suggestion_database::documentation_ir::Placeholder; -use suggestion_database::documentation_ir::TypeDocumentation; use suggestion_database::SuggestionDatabase; @@ -73,92 +67,25 @@ impl DatabaseWrapper { let index = self.current_entry.get(); let ids = self.database.keys(); let id = ids[index]; - render_documentation(self.database.documentation_for_entry(id)) + let docs = self.database.documentation_for_entry(id); + ide_view_documentation::html::render(docs) } } -/// This is a temporary function for easier testing of the documentation panel. It will be replaced -/// by a proper HTML generation in the future. See https://www.pivotaltracker.com/story/show/180872953. -fn render_documentation(doc: EntryDocumentation) -> String { - let mut result = String::new(); - match doc { - EntryDocumentation::Placeholder(placeholder) => match placeholder { - Placeholder::NoDocumentation => result.push_str("No documentation available."), - Placeholder::Local { name } => writeln!(result, "Local variable: {}", name).unwrap(), - Placeholder::Function { name } => writeln!(result, "Function: {}", name).unwrap(), - }, - EntryDocumentation::Docs(docs) => match docs { - Documentation::Module(docs) => { - write_module_docs(&mut result, &docs); - } - Documentation::Type(docs) => { - write_type_docs(&mut result, docs); - } - Documentation::Constructor { name, type_docs } => { - let name = name.to_string_with_main_segment(); - let type_name = type_docs.name.to_string_with_main_segment(); - writeln!(result, "Constructor {} of type {}", name, type_name).unwrap(); - write_type_docs(&mut result, type_docs); - } - Documentation::Method { name, type_docs } => { - let name = name.to_string_with_main_segment(); - let type_name = type_docs.name.to_string_with_main_segment(); - writeln!(result, "Method {} of type {}", name, type_name).unwrap(); - write_type_docs(&mut result, type_docs); - } - Documentation::ModuleMethod { name, module_docs } => { - let name = name.to_string_with_main_segment(); - let module_name = module_docs.name.to_string_with_main_segment(); - writeln!(result, "Method {} of module {}", name, module_name).unwrap(); - write_module_docs(&mut result, &module_docs); - } - Documentation::Function { .. } => {} - Documentation::Local { .. } => {} - }, - } - result.replace('\n', "
") -} - -fn write_module_docs(result: &mut String, docs: &Rc) { - writeln!(result, "Module: {}", docs.name.to_string_with_main_segment()).unwrap(); - writeln!(result, "Tags: {:?}", docs.tags).unwrap(); - writeln!(result, "Summary: {:?}", docs.synopsis).unwrap(); - writeln!(result, "Types:").unwrap(); - for ty in docs.types.iter() { - writeln!(result, "{:?}", ty).unwrap(); - } - writeln!(result, "Functions:").unwrap(); - for ty in docs.methods.iter() { - writeln!(result, "{:?}", ty).unwrap(); - } - writeln!(result, "Examples:").unwrap(); - for ty in docs.examples.iter() { - writeln!(result, "{:?}", ty).unwrap(); - } -} - -fn write_type_docs(result: &mut String, docs: Rc) { - writeln!(result, "Type: {}", docs.name.to_string_with_main_segment()).unwrap(); - writeln!(result, "Tags: {:?}", docs.tags).unwrap(); - writeln!(result, "Summary: {:?}", docs.synopsis).unwrap(); - writeln!(result, "Constructors:").unwrap(); - for constructor in docs.constructors.iter() { - writeln!(result, "{:?}", constructor).unwrap(); - } - writeln!(result, "Methods:").unwrap(); - for method in docs.methods.iter() { - writeln!(result, "{:?}", method).unwrap(); - } - writeln!(result, "Examples: {:?}", docs.examples).unwrap(); -} - fn database() -> SuggestionDatabase { mock_suggestion_database! { #[with_doc_section(doc_section!("This is a test documentation."))] + #[with_doc_section(doc_section!("It contains muliple paragraphs of text."))] + #[with_doc_section(doc_section!("And describes the purpose of the module with a great attention to detail."))] + #[with_doc_section(doc_section!("It also contains the autobiography of the author of this code."))] + #[with_doc_section(doc_section!("And a long list of his cats."))] + #[with_doc_section(doc_section!("Here it is" => "
  • Tom
  • Garfield
  • Mr. Bigglesworth
"))] + #[with_doc_section(doc_section!(! "Important", "Important sections are used to warn the reader about the dangers of using the module."))] + #[with_doc_section(doc_section!(? "Info", "Info sections provide some insights."))] Standard.Base { #[with_doc_section(doc_section!("Maybe type."))] #[with_doc_section(doc_section!(@ "Annotated", ""))] - type Maybe { + type Maybe (a) { #[with_doc_section(doc_section!("Some constructor."))] #[with_doc_section(doc_section!(> "Example", "Some 1"))] #[with_doc_section(doc_section!("Documentation for the Some(a) constructor."))] diff --git a/app/gui/view/documentation/Cargo.toml b/app/gui/view/documentation/Cargo.toml index c259a9e676..51097c645f 100644 --- a/app/gui/view/documentation/Cargo.toml +++ b/app/gui/view/documentation/Cargo.toml @@ -17,6 +17,8 @@ ensogl-hardcoded-theme = { path = "../../../../lib/rust/ensogl/app/theme/hardcod ide-view-graph-editor = { path = "../graph-editor" } wasm-bindgen = { workspace = true } serde_json = { version = "1.0" } +horrorshow = "0.8.4" +enso-suggestion-database = { path = "../../suggestion-database" } [dependencies.web-sys] version = "0.3.4" diff --git a/app/gui/view/documentation/assets/icon-examples.svg b/app/gui/view/documentation/assets/icon-examples.svg new file mode 100644 index 0000000000..c03a3a4238 --- /dev/null +++ b/app/gui/view/documentation/assets/icon-examples.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/gui/view/documentation/assets/icon-methods.svg b/app/gui/view/documentation/assets/icon-methods.svg new file mode 100644 index 0000000000..1cf90644ef --- /dev/null +++ b/app/gui/view/documentation/assets/icon-methods.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/gui/view/documentation/assets/icon-type.svg b/app/gui/view/documentation/assets/icon-type.svg new file mode 100644 index 0000000000..0061df891a --- /dev/null +++ b/app/gui/view/documentation/assets/icon-type.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/gui/view/documentation/assets/input.css b/app/gui/view/documentation/assets/input.css index 80f330810f..12f8600344 100644 --- a/app/gui/view/documentation/assets/input.css +++ b/app/gui/view/documentation/assets/input.css @@ -8,3 +8,12 @@ @tailwind base; @tailwind components; @tailwind utilities; + +.enso-docs { + font-family: "M PLUS 1", DejaVuSansMonoBook, sans-serif; +} + +ul { + list-style-type: disc; + list-style-position: inside; +} diff --git a/app/gui/view/documentation/build.rs b/app/gui/view/documentation/build.rs index 4839983b41..29bb8c4d60 100644 --- a/app/gui/view/documentation/build.rs +++ b/app/gui/view/documentation/build.rs @@ -23,6 +23,7 @@ async fn main() -> Result { // We should rerun the tailwind on changes in the input CSS file. // It may contain custom CSS rules. println!("cargo:rerun-if-changed={CSS_INPUT_PATH}"); + println!("cargo:rerun-if-changed=tailwind.config.js"); install_and_run_tailwind().await?; Ok(()) diff --git a/app/gui/view/documentation/src/html.rs b/app/gui/view/documentation/src/html.rs new file mode 100644 index 0000000000..5f3dd75520 --- /dev/null +++ b/app/gui/view/documentation/src/html.rs @@ -0,0 +1,347 @@ +//! HTML generator for documentation. + +use enso_prelude::*; +use horrorshow::prelude::*; + +use enso_suggestion_database::documentation_ir::Constructors; +use enso_suggestion_database::documentation_ir::Documentation; +use enso_suggestion_database::documentation_ir::EntryDocumentation; +use enso_suggestion_database::documentation_ir::Function; +use enso_suggestion_database::documentation_ir::ModuleDocumentation; +use enso_suggestion_database::documentation_ir::Placeholder; +use enso_suggestion_database::documentation_ir::Synopsis; +use enso_suggestion_database::documentation_ir::TypeDocumentation; +use enso_suggestion_database::engine_protocol::language_server::DocSection; +use enso_suggestion_database::engine_protocol::language_server::Mark; +use enso_suggestion_database::entry::Argument; +use horrorshow::box_html; +use horrorshow::labels; +use horrorshow::owned_html; + + + +// ============= +// === Icons === +// ============= + +/// We use SVG icons imported as text. +type Icon = &'static str; +const ICON_TYPE: &str = include_str!("../assets/icon-type.svg"); +const ICON_METHODS: &str = include_str!("../assets/icon-methods.svg"); +const ICON_EXAMPLES: &str = include_str!("../assets/icon-examples.svg"); +/// A value for `viewBox` attribute of the SVG icon. Depends on the size of the icon exported from +/// Figma. +const ICON_VIEWBOX: &str = "0 0 32 32"; +const ICON_SVG_XMLNS: &str = "http://www.w3.org/2000/svg"; + +/// A single icon used in headers. `content` is an SVG code of the icon's content _without_ the +/// surrounding `` tags. +fn svg_icon(content: &'static str) -> impl Render { + let class = "w-5 h-5 fill-none flex-shrink-0 mt-0.5"; + owned_html! { + svg(class=class, viewBox=ICON_VIEWBOX, xmlns=ICON_SVG_XMLNS) { + :Raw(content) + } + } +} + + + +// ============== +// === Render === +// ============== + +/// Render entry documentation to HTML code with Tailwind CSS styles. +pub fn render(docs: EntryDocumentation) -> String { + match docs { + EntryDocumentation::Placeholder(placeholder) => match placeholder { + Placeholder::Function { name } => format!("Function {name}"), + Placeholder::Local { name } => format!("Local {name}"), + Placeholder::NoDocumentation => "No documentation found".into(), + }, + EntryDocumentation::Docs(docs) => render_documentation(docs), + } +} + +fn render_documentation(docs: Documentation) -> String { + match docs { + Documentation::Module(module_docs) => render_module_documentation(&module_docs), + Documentation::Type(type_docs) => render_type_documentation(&type_docs), + _ => String::from("Not implemented"), + } +} + +// === Types === + +/// Render documentation of a type. +/// +/// Consists of the following parts: +/// - Type name. +/// - Synopsis and a list of constructors. +/// - Methods TODO(https://www.pivotaltracker.com/story/show/184024167). +/// - Examples TODO(https://www.pivotaltracker.com/story/show/184024198). +fn render_type_documentation(type_docs: &TypeDocumentation) -> String { + let TypeDocumentation { name, arguments, constructors, methods, synopsis, .. } = type_docs; + + let content = owned_html! { + : header(ICON_TYPE, type_header(name.name(), arguments_list(arguments))); + : type_synopsis(synopsis, constructors); + : header(ICON_METHODS, methods_header()); + ul(class="list-disc list-inside") { + @ for method in methods.iter() { + li(class="text-base") { + : method.name.name() + } + } + } + : header(ICON_EXAMPLES, examples_header()); + }; + docs_content(content).into_string().unwrap() +} + +/// A header for the type documentation. +fn type_header<'a>(name: &'a str, arguments: impl Render + 'a) -> Box { + box_html! { + span(class="text-2xl font-bold") { + span(class="text-type") { : name } + span(class="text-arguments") { : &arguments } + } + } +} + +/// A header for the "Methods" section. +fn methods_header() -> impl Render { + owned_html! { + h1(class="text-xl font-semibold text-methods") { + : "Methods" + } + } +} + +/// A synopsis of the type. Contains a list of constructors, if it is not empty. +fn type_synopsis<'a>( + synopsis: &'a Synopsis, + constructors: &'a Constructors, +) -> Box { + box_html! { + div(class="pl-7") { + @ for p in synopsis.iter() { + : paragraph(p); + } + } + @ if !constructors.is_empty() { + p(class="pl-7") { + : "Constructors:" + } + } + ul(class="pl-7 list-disc list-outside marker:text-type") { + @ for method in constructors.iter() { + li { + span(class="text-type font-bold") { + : method.name.name(); + : arguments_list(&method.arguments); + } + : constructor_docs(method); + } + } + } + } +} + +/// Documentation of a single constructor. If the first [`DocSection`] is of type +/// [`DocSection::Paragraph`], it is rendered on the first line, after the list of arguments. All +/// other sections are rendered as separate paragraphs below. +fn constructor_docs<'a>(constructor: &'a Function) -> Box { + let (first, rest) = match &constructor.synopsis.as_ref()[..] { + [DocSection::Paragraph { body }, rest @ ..] => (Some(body), rest), + [_, rest @ ..] => (None, rest), + [] => (None, default()), + }; + box_html! { + @ if let Some(first) = first { + span { : ", "; : first; } + } + @ for p in rest { + : paragraph(p); + } + } +} + + +// === Modules === + +/// Render documentation of a module. +/// +/// Consists of the following parts: +/// - Module name +/// - Synopsis. +/// - Types TODO(https://www.pivotaltracker.com/story/show/184024179). +/// - Functions TODO(https://www.pivotaltracker.com/story/show/184024167). +/// - Examples TODO(https://www.pivotaltracker.com/story/show/184024198). +fn render_module_documentation(module_docs: &ModuleDocumentation) -> String { + let ModuleDocumentation { name, types, methods, synopsis, .. } = module_docs; + + let content = owned_html! { + : header(ICON_TYPE, module_header(name.name())); + : module_synopsis(synopsis); + : header(ICON_METHODS, types_header()); + ul(class="list-disc list-inside") { + @ for ty in types.iter() { + li(class="text-base") { + : ty.name.name() + } + } + } + : header(ICON_METHODS, functions_header()); + ul(class="list-disc list-inside") { + @ for method in methods.iter() { + li(class="text-base") { + : method.name.name() + } + } + } + : header(ICON_EXAMPLES, examples_header()); + }; + docs_content(content).into_string().unwrap() +} + +/// A header for the module documentation. +fn module_header<'a>(name: &'a str) -> Box { + box_html! { + h1(class="text-2xl font-bold text-module") { + : name + } + } +} + +/// A header for the "Functions" section. +fn functions_header() -> impl Render { + owned_html! { + h1(class="text-xl font-semibold text-methods") { + : "Functions" + } + } +} + +/// A header for the "Types" section. +fn types_header() -> impl Render { + owned_html! { + h1(class="text-xl font-semibold text-types") { + : "Types" + } + } +} + +/// A synopsis of the module. +fn module_synopsis<'a>(synopsis: &'a Synopsis) -> Box { + box_html! { + div(class="synopsis pl-7") { + @ for p in synopsis.iter() { + : paragraph(p); + } + } + } +} + + + +// ======================= +// === Common elements === +// ======================= + +/// A container for the whole documentation. Has a small paddings, sets the font using `enso-docs` +/// class. +fn docs_content(content: impl Render) -> impl Render { + owned_html! { + div(class="enso-docs text-base pl-4 pr-2") { + : &content; + } + } +} + +/// Generic header. Contains an icon on the left followed by an arbitrary content. +fn header(icon: Icon, content: impl Render) -> impl Render { + owned_html! { + div(class="flex flex-row items-center my-2") { + : svg_icon(icon); + div(class="ml-2") { + : &content; + } + } + } +} + +/// List of arguments of the type or function. +fn arguments_list<'a>(arguments: &'a [Argument]) -> Box { + box_html! { + span { + @ for arg in arguments { + : single_argument(arg); + } + } + } +} + +/// A single argument of the type or function. May contain default value. +fn single_argument(argument: &Argument) -> impl Render { + let Argument { name, default_value, .. } = argument; + let text = if let Some(default_value) = default_value { + format!("{} = {},", name, default_value) + } else { + name.to_string() + }; + owned_html! { + span { : " "; : &text; } + } +} + +/// A header for the "Examples" section. +fn examples_header() -> impl Render { + owned_html! { + h1(class="text-xl font-semibold text-examples") { + : "Examples" + } + } +} + +/// Render a single [`DocSection`] as a paragraph of text. Does not work for [`DocSection::Marked`] +/// with [`Mark::Example`] and for [`DocSection::Tag`]. +fn paragraph<'a>(doc_section: &'a DocSection) -> Box { + match doc_section { + DocSection::Keyed { key, body } => { + box_html! { + p { : key; : ": "; } + : Raw(body); + } + } + DocSection::Paragraph { body } => { + box_html! { + p { : Raw(body); } + } + } + DocSection::Marked { mark, header, body } => { + let background_color = match mark { + Mark::Important => "bg-important", + Mark::Info => "bg-info", + _ => "", + }; + let mark = match mark { + Mark::Important => String::from("!"), + Mark::Info => String::from("ℹ"), + _ => String::from("Unexpected mark."), + }; + box_html! { + div(class=labels!(background_color, "rounded-lg", "p-2", "my-2")) { + p(class="text-lg") { + span(class="font-bold") { : &mark; } + : " "; : header; + } + p { : Raw(body); } + } + } + } + _ => box_html! { + p { : "Unexpected doc section type." } + }, + } +} diff --git a/app/gui/view/documentation/src/lib.rs b/app/gui/view/documentation/src/lib.rs index c7fe04d6c5..658a99d6d3 100644 --- a/app/gui/view/documentation/src/lib.rs +++ b/app/gui/view/documentation/src/lib.rs @@ -63,6 +63,8 @@ use web::HtmlElement; use web::JsCast; use web::MouseEvent; +pub mod html; + // ============== // === Export === @@ -83,8 +85,6 @@ pub const VIEW_HEIGHT: f32 = 300.0; /// Content in the documentation view when there is no data available. const CORNER_RADIUS: f32 = graph_editor::component::node::CORNER_RADIUS; -const PADDING: f32 = 15.0; -const PADDING_TOP: f32 = 5.0; const CODE_BLOCK_CLASS: &str = "doc-code-container"; const COPY_BUTTON_CLASS: &str = "doc-copy-btn"; @@ -121,8 +121,7 @@ impl Model { let outer_dom = DomSymbol::new(&outer_div); let inner_div = web::document.create_div_or_panic(); let inner_dom = DomSymbol::new(&inner_div); - let size = - Rc::new(Cell::new(Vector2(VIEW_WIDTH - PADDING, VIEW_HEIGHT - PADDING - PADDING_TOP))); + let size = Rc::new(Cell::new(Vector2(VIEW_WIDTH, VIEW_HEIGHT))); let overlay = overlay::View::new(); // FIXME : StyleWatch is unsuitable here, as it was designed as an internal tool for shape @@ -144,8 +143,6 @@ impl Model { inner_dom.dom().set_style_or_warn("white-space", "normal"); inner_dom.dom().set_style_or_warn("overflow-y", "auto"); inner_dom.dom().set_style_or_warn("overflow-x", "auto"); - inner_dom.dom().set_style_or_warn("padding", format!("{}px", PADDING)); - inner_dom.dom().set_style_or_warn("padding-top", "5px"); inner_dom.dom().set_style_or_warn("pointer-events", "auto"); inner_dom.dom().set_style_or_warn("border-radius", format!("{}px", CORNER_RADIUS)); @@ -267,11 +264,8 @@ impl Model { fn reload_style(&self) { let size = self.size.get(); - let padding = (size.x.min(size.y) / 2.0).min(PADDING); self.outer_dom.set_dom_size(Vector2(size.x, size.y)); - self.inner_dom.set_dom_size(Vector2(size.x - padding, size.y - padding - PADDING_TOP)); - self.inner_dom.dom().set_style_or_warn("padding", format!("{}px", padding)); - self.inner_dom.dom().set_style_or_warn("padding-top", format!("{}px", PADDING_TOP)); + self.inner_dom.set_dom_size(Vector2(size.x, size.y)); } } diff --git a/app/gui/view/documentation/tailwind.config.js b/app/gui/view/documentation/tailwind.config.js index 19b1b87e7a..7a21b57638 100644 --- a/app/gui/view/documentation/tailwind.config.js +++ b/app/gui/view/documentation/tailwind.config.js @@ -2,7 +2,18 @@ module.exports = { content: ['src/**/*.rs'], theme: { - extend: {}, + extend: { + colors: { + type: '#a239e2', + module: '#a239e2', + arguments: '#e0bdf7', + methods: '#0273da', + types: '#0273da', + examples: '#59aa54', + important: '#edefe7', + info: '#e6f1f8', + }, + }, }, plugins: [], }