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
This commit is contained in:
Ilya Bogdanov 2023-01-18 17:18:26 +04:00 committed by GitHub
parent 503c680eb9
commit 341b235fb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 424 additions and 94 deletions

8
Cargo.lock generated
View File

@ -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",

View File

@ -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', "<br/>")
}
fn write_module_docs(result: &mut String, docs: &Rc<ModuleDocumentation>) {
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<TypeDocumentation>) {
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" => "<ul><li>Tom</li><li>Garfield</li><li>Mr. Bigglesworth</li></ul>"))]
#[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."))]

View File

@ -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"

View File

@ -0,0 +1,9 @@
<path d="M21.1981 14.1473L27.3799 22.6473C29.3027 25.2912 27.4141 29 24.1449 29H7.85509C4.58595 29 2.69733 25.2912 4.62015 22.6473L10.802 14.1473C11.1273 13.7 11.5367 13.3316 12 13.0575V6H11.5C10.6716 6 10 5.32843 10 4.5C10 3.67157 10.6716 3 11.5 3H20.5C21.3284 3 22 3.67157 22 4.5C22 5.32843 21.3284 6 20.5 6H20V13.0575C20.4633 13.3316 20.8727 13.7 21.1981 14.1473Z" fill="#6DA85E" fill-opacity="0.7"/>
<g clip-path="url(#clip0_7717_245736)">
<path d="M21.1981 14.1473L27.3799 22.6473C29.3027 25.2912 27.4141 29 24.1449 29H7.85509C4.58595 29 2.69733 25.2912 4.62015 22.6473L10.802 14.1473C11.1273 13.7 11.5367 13.3316 12 13.0575V6H11.5C10.6716 6 10 5.32843 10 4.5C10 3.67157 10.6716 3 11.5 3H20.5C21.3284 3 22 3.67157 22 4.5C22 5.32843 21.3284 6 20.5 6H20V13.0575C20.4633 13.3316 20.8727 13.7 21.1981 14.1473Z" fill="#6DA85E"/>
</g>
<defs>
<clipPath id="clip0_7717_245736">
<rect width="32" height="12" fill="white" transform="translate(0 20)"/>
</clipPath>
</defs>

View File

@ -0,0 +1,17 @@
<rect width="32" height="32" fill="white"/>
<mask id="path-1-inside-1_7717_245722" fill="white">
<rect x="2" y="2" width="13" height="13" rx="2"/>
</mask>
<rect x="2" y="2" width="13" height="13" rx="2" stroke="#1F71D3" stroke-width="13" mask="url(#path-1-inside-1_7717_245722)"/>
<mask id="path-2-inside-2_7717_245722" fill="white">
<rect x="17" y="2" width="13" height="13" rx="2"/>
</mask>
<rect x="17" y="2" width="13" height="13" rx="2" stroke="#1F71D3" stroke-opacity="0.4" stroke-width="13" mask="url(#path-2-inside-2_7717_245722)"/>
<mask id="path-3-inside-3_7717_245722" fill="white">
<rect x="2" y="17" width="13" height="13" rx="2"/>
</mask>
<rect x="2" y="17" width="13" height="13" rx="2" stroke="#1F71D3" stroke-opacity="0.7" stroke-width="13" mask="url(#path-3-inside-3_7717_245722)"/>
<mask id="path-4-inside-4_7717_245722" fill="white">
<rect x="17" y="17" width="13" height="13" rx="2"/>
</mask>
<rect x="17" y="17" width="13" height="13" rx="2" stroke="#1F71D3" stroke-opacity="0.6" stroke-width="13" mask="url(#path-4-inside-4_7717_245722)"/>

View File

@ -0,0 +1,5 @@
<path d="M8 16C3.58172 16 1.93129e-07 12.4183 0 8C-1.93129e-07 3.58172 3.58172 1.93129e-07 8 0H24C28.4183 -1.93129e-07 32 3.58172 32 8V24C32 28.4183 28.4183 32 24 32C19.5817 32 16 28.4183 16 24C16 19.5817 12.4183 16 8 16Z" fill="#9640DA" fill-opacity="0.3"/>
<ellipse cx="24" cy="24" rx="4" ry="4" transform="rotate(-90 24 24)" fill="#9640DA"/>
<ellipse cx="24" cy="8" rx="4" ry="4" transform="rotate(-90 24 8)" fill="#9640DA"/>
<ellipse cx="8" cy="8" rx="4" ry="4" transform="rotate(-90 8 8)" fill="#9640DA"/>
<circle cx="8" cy="24" r="5" transform="rotate(-90 8 24)" fill="#9640DA" fill-opacity="0.5"/>

View File

@ -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;
}

View File

@ -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(())

View File

@ -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 `<svg>` 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<dyn Render + 'a> {
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<dyn Render + 'a> {
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<dyn Render + 'a> {
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<dyn Render + 'a> {
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<dyn Render + 'a> {
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<dyn Render + 'a> {
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<dyn Render + 'a> {
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." }
},
}
}

View File

@ -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));
}
}

View File

@ -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: [],
}