mirror of
https://github.com/FuelLabs/sway.git
synced 2025-08-22 11:25:11 +00:00
Represent module level doc comments in forc doc
(#4096)
## Description Adds module level docs & previews to `forc doc`. [Screencast from 2023-02-20 20-30-24.webm](https://user-images.githubusercontent.com/57543709/220232486-7ec0742a-59ef-41c9-a459-3c8c9b7d42c7.webm) ## Checklist - [x] I have linked to any relevant issues. Closes #4095 - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have updated the documentation where relevant (API docs, the reference, and the Sway book). - [x] I have added tests that prove my fix is effective or that my feature works. - [x] I have added (or requested a maintainer to add) the necessary `Breaking*` or `New Feature` labels where relevant. - [x] I have done my best to ensure that my PR adheres to [the Fuel Labs Code Review Standards](https://github.com/FuelLabs/rfcs/blob/master/text/code-standards/external-contributors.md). - [x] I have requested a review from the relevant team or maintainers.
This commit is contained in:
parent
5d2b10bd83
commit
d9d5cb15a2
7 changed files with 131 additions and 72 deletions
|
@ -1,6 +1,6 @@
|
|||
use crate::{
|
||||
descriptor::Descriptor,
|
||||
render::{split_at_markdown_header, DocLink, ItemBody, ItemHeader, Renderable},
|
||||
render::{split_at_markdown_header, DocLink, DocStrings, ItemBody, ItemHeader, Renderable},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use horrorshow::{box_html, RenderBox};
|
||||
|
@ -50,35 +50,7 @@ impl Document {
|
|||
}
|
||||
}
|
||||
fn preview_opt(&self) -> Option<String> {
|
||||
const MAX_PREVIEW_CHARS: usize = 100;
|
||||
const CLOSING_PARAGRAPH_TAG: &str = "</p>";
|
||||
|
||||
self.raw_attributes.as_ref().map(|description| {
|
||||
let preview = split_at_markdown_header(description);
|
||||
if preview.chars().count() > MAX_PREVIEW_CHARS
|
||||
&& preview.contains(CLOSING_PARAGRAPH_TAG)
|
||||
{
|
||||
match preview.find(CLOSING_PARAGRAPH_TAG) {
|
||||
Some(index) => {
|
||||
// We add 1 here to get the index of the char after the closing tag.
|
||||
// This ensures we retain the closing tag and don't break the html.
|
||||
let (preview, _) =
|
||||
preview.split_at(index + CLOSING_PARAGRAPH_TAG.len() + 1);
|
||||
if preview.chars().count() > MAX_PREVIEW_CHARS && preview.contains('\n') {
|
||||
match preview.find('\n') {
|
||||
Some(index) => preview.split_at(index).0.to_string(),
|
||||
None => unreachable!("Previous logic prevents this panic"),
|
||||
}
|
||||
} else {
|
||||
preview.to_string()
|
||||
}
|
||||
}
|
||||
None => unreachable!("Previous logic prevents this panic"),
|
||||
}
|
||||
} else {
|
||||
preview.to_string()
|
||||
}
|
||||
})
|
||||
create_preview(self.raw_attributes.clone())
|
||||
}
|
||||
/// Gather [Documentation] from the [TyProgram].
|
||||
pub(crate) fn from_ty_program(
|
||||
|
@ -95,7 +67,7 @@ impl Document {
|
|||
let desc = Descriptor::from_typed_decl(
|
||||
decl_engine,
|
||||
decl,
|
||||
ModuleInfo::from_vec(vec![project_name.to_owned()]),
|
||||
ModuleInfo::from_ty_module(vec![project_name.to_owned()], None),
|
||||
document_private_items,
|
||||
)?;
|
||||
|
||||
|
@ -108,7 +80,10 @@ impl Document {
|
|||
if !no_deps && !typed_program.root.submodules.is_empty() {
|
||||
// this is the same process as before but for dependencies
|
||||
for (_, ref typed_submodule) in &typed_program.root.submodules {
|
||||
let module_prefix = ModuleInfo::from_vec(vec![project_name.to_owned()]);
|
||||
let attributes = (!typed_submodule.module.attributes.is_empty())
|
||||
.then(|| typed_submodule.module.attributes.to_html_string());
|
||||
let module_prefix =
|
||||
ModuleInfo::from_ty_module(vec![project_name.to_owned()], attributes);
|
||||
Document::from_ty_submodule(
|
||||
decl_engine,
|
||||
typed_submodule,
|
||||
|
@ -130,7 +105,7 @@ impl Document {
|
|||
) -> Result<()> {
|
||||
let mut new_submodule_prefix = module_prefix.to_owned();
|
||||
new_submodule_prefix
|
||||
.0
|
||||
.module_prefixes
|
||||
.push(typed_submodule.library_name.as_str().to_owned());
|
||||
for ast_node in &typed_submodule.module.all_nodes {
|
||||
if let TyAstNodeContent::Declaration(ref decl) = ast_node.content {
|
||||
|
@ -173,13 +148,16 @@ impl Renderable for Document {
|
|||
|
||||
pub(crate) type ModulePrefix = String;
|
||||
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
||||
pub(crate) struct ModuleInfo(pub(crate) Vec<ModulePrefix>);
|
||||
pub(crate) struct ModuleInfo {
|
||||
pub(crate) module_prefixes: Vec<ModulePrefix>,
|
||||
pub(crate) attributes: Option<String>,
|
||||
}
|
||||
impl ModuleInfo {
|
||||
/// The current module.
|
||||
///
|
||||
/// Panics if there are no modules.
|
||||
pub(crate) fn location(&self) -> &str {
|
||||
self.0
|
||||
self.module_prefixes
|
||||
.last()
|
||||
.expect("Expected Some module location, found None")
|
||||
}
|
||||
|
@ -187,7 +165,7 @@ impl ModuleInfo {
|
|||
///
|
||||
/// Panics if the project root is missing.
|
||||
pub(crate) fn project_name(&self) -> &str {
|
||||
self.0
|
||||
self.module_prefixes
|
||||
.first()
|
||||
.expect("Expected root module, project root missing")
|
||||
}
|
||||
|
@ -197,7 +175,7 @@ impl ModuleInfo {
|
|||
pub(crate) fn parent(&self) -> Option<&String> {
|
||||
match self.has_parent() {
|
||||
true => {
|
||||
let mut iter = self.0.iter();
|
||||
let mut iter = self.module_prefixes.iter();
|
||||
iter.next_back();
|
||||
iter.next_back()
|
||||
}
|
||||
|
@ -226,7 +204,7 @@ impl ModuleInfo {
|
|||
///
|
||||
/// Example: `module::submodule`
|
||||
fn to_path_literal_prefix(&self, location: &str) -> String {
|
||||
let mut iter = self.0.iter();
|
||||
let mut iter = self.module_prefixes.iter();
|
||||
for prefix in iter.by_ref() {
|
||||
if prefix == location {
|
||||
break;
|
||||
|
@ -239,7 +217,7 @@ impl ModuleInfo {
|
|||
///
|
||||
/// This is only used for full path syntax, e.g `module/submodule/file_name.html`.
|
||||
pub(crate) fn to_file_path_string(&self, file_name: &str, location: &str) -> Result<String> {
|
||||
let mut iter = self.0.iter();
|
||||
let mut iter = self.module_prefixes.iter();
|
||||
for prefix in iter.by_ref() {
|
||||
if prefix == location {
|
||||
break;
|
||||
|
@ -265,12 +243,47 @@ impl ModuleInfo {
|
|||
}
|
||||
/// The depth of a module as `usize`.
|
||||
pub(crate) fn depth(&self) -> usize {
|
||||
self.0.len()
|
||||
self.module_prefixes.len()
|
||||
}
|
||||
/// Create a new [ModuleInfo] from a vec.
|
||||
pub(crate) fn from_vec(vec: Vec<String>) -> Self {
|
||||
Self(vec)
|
||||
pub(crate) fn from_ty_module(module_prefixes: Vec<String>, attributes: Option<String>) -> Self {
|
||||
Self {
|
||||
module_prefixes,
|
||||
attributes,
|
||||
}
|
||||
}
|
||||
pub(crate) fn preview_opt(&self) -> Option<String> {
|
||||
create_preview(self.attributes.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a docstring preview from raw html attributes.
|
||||
///
|
||||
/// Returns `None` if there are no attributes.
|
||||
fn create_preview(raw_attributes: Option<String>) -> Option<String> {
|
||||
const MAX_PREVIEW_CHARS: usize = 100;
|
||||
const CLOSING_PARAGRAPH_TAG: &str = "</p>";
|
||||
|
||||
raw_attributes.as_ref().map(|description| {
|
||||
let preview = split_at_markdown_header(description);
|
||||
if preview.chars().count() > MAX_PREVIEW_CHARS && preview.contains(CLOSING_PARAGRAPH_TAG) {
|
||||
let closing_tag_index = preview
|
||||
.find(CLOSING_PARAGRAPH_TAG)
|
||||
.expect("closing tag out of range");
|
||||
// We add 1 here to get the index of the char after the closing tag.
|
||||
// This ensures we retain the closing tag and don't break the html.
|
||||
let (preview, _) =
|
||||
preview.split_at(closing_tag_index + CLOSING_PARAGRAPH_TAG.len() + 1);
|
||||
if preview.chars().count() > MAX_PREVIEW_CHARS && preview.contains('\n') {
|
||||
let newline_index = preview.find('\n').expect("new line char out of range");
|
||||
preview.split_at(newline_index).0.to_string()
|
||||
} else {
|
||||
preview.to_string()
|
||||
}
|
||||
} else {
|
||||
preview.to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -283,12 +296,12 @@ mod tests {
|
|||
let module = String::from("module_name");
|
||||
let mut module_vec = vec![project.clone(), module];
|
||||
|
||||
let module_info = ModuleInfo::from_vec(module_vec.clone());
|
||||
let module_info = ModuleInfo::from_ty_module(module_vec.clone(), None);
|
||||
let project_opt = module_info.parent();
|
||||
assert_eq!(Some(&project), project_opt);
|
||||
|
||||
module_vec.pop();
|
||||
let module_info = ModuleInfo::from_vec(module_vec);
|
||||
let module_info = ModuleInfo::from_ty_module(module_vec, None);
|
||||
let project_opt = module_info.parent();
|
||||
assert_eq!(None, project_opt);
|
||||
}
|
||||
|
|
|
@ -82,18 +82,22 @@ pub fn main() -> Result<()> {
|
|||
no_deps,
|
||||
document_private_items,
|
||||
)?;
|
||||
let root_attributes =
|
||||
(!typed_program.root.attributes.is_empty()).then_some(typed_program.root.attributes);
|
||||
let program_kind = typed_program.kind;
|
||||
// render docs to HTML
|
||||
let forc_version = pkg_manifest
|
||||
.project
|
||||
.forc_version
|
||||
.as_ref()
|
||||
.map(|ver| format!("{}.{}.{}", ver.major, ver.minor, ver.patch));
|
||||
let rendered_docs = RenderedDocumentation::from(raw_docs, forc_version)?;
|
||||
let rendered_docs =
|
||||
RenderedDocumentation::from(raw_docs, root_attributes, program_kind, forc_version)?;
|
||||
|
||||
// write contents to outfile
|
||||
for doc in rendered_docs.0 {
|
||||
let mut doc_path = doc_path.clone();
|
||||
for prefix in doc.module_info.0 {
|
||||
for prefix in doc.module_info.module_prefixes {
|
||||
if &prefix != project_name {
|
||||
doc_path.push(prefix);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ use std::collections::BTreeMap;
|
|||
use std::fmt::Write;
|
||||
use sway_core::language::ty::{
|
||||
TyDeclaration::{self, *},
|
||||
TyEnumVariant, TyStorageField, TyStructField, TyTraitFn,
|
||||
TyEnumVariant, TyProgramKind, TyStorageField, TyStructField, TyTraitFn,
|
||||
};
|
||||
use sway_core::transform::{AttributeKind, AttributesMap};
|
||||
use sway_lsp::utils::markdown::format_docs;
|
||||
|
@ -29,14 +29,22 @@ pub(crate) struct RenderedDocumentation(pub(crate) Vec<RenderedDocument>);
|
|||
|
||||
impl RenderedDocumentation {
|
||||
/// Top level HTML rendering for all [Documentation] of a program.
|
||||
pub fn from(raw: Documentation, forc_version: Option<String>) -> Result<RenderedDocumentation> {
|
||||
pub fn from(
|
||||
raw: Documentation,
|
||||
root_attributes: Option<AttributesMap>,
|
||||
program_kind: TyProgramKind,
|
||||
forc_version: Option<String>,
|
||||
) -> Result<RenderedDocumentation> {
|
||||
let mut rendered_docs: RenderedDocumentation = Default::default();
|
||||
let root_module = match raw.first() {
|
||||
Some(doc) => ModuleInfo::from_vec(vec![doc.module_info.project_name().to_owned()]),
|
||||
Some(doc) => ModuleInfo::from_ty_module(
|
||||
vec![doc.module_info.project_name().to_owned()],
|
||||
root_attributes.map(|attrs_map| attrs_map.to_html_string()),
|
||||
),
|
||||
None => panic!("Project does not contain a root module"),
|
||||
};
|
||||
let mut all_docs = DocLinks {
|
||||
style: DocStyle::AllDoc,
|
||||
style: DocStyle::AllDoc(program_kind.as_title_str().to_string()),
|
||||
links: Default::default(),
|
||||
};
|
||||
let mut module_map: BTreeMap<ModulePrefix, BTreeMap<BlockTitle, Vec<DocLink>>> =
|
||||
|
@ -138,7 +146,7 @@ impl RenderedDocumentation {
|
|||
name: location.clone(),
|
||||
module_info: doc.module_info.to_owned(),
|
||||
html_filename: INDEX_FILENAME.to_owned(),
|
||||
preview_opt: None,
|
||||
preview_opt: doc.module_info.preview_opt(),
|
||||
};
|
||||
match module_map.get_mut(parent_module) {
|
||||
Some(doc_links) => match doc_links.get_mut(&BlockTitle::Modules) {
|
||||
|
@ -227,7 +235,7 @@ impl RenderedDocumentation {
|
|||
version_opt: forc_version,
|
||||
module_info: root_module.clone(),
|
||||
module_docs: DocLinks {
|
||||
style: DocStyle::ProjectIndex,
|
||||
style: DocStyle::ProjectIndex(program_kind.as_title_str().to_string()),
|
||||
links: doc_links.to_owned(),
|
||||
},
|
||||
}
|
||||
|
@ -485,7 +493,7 @@ impl ItemContext {
|
|||
.iter()
|
||||
.map(|field| DocLink {
|
||||
name: field.name.as_str().to_string(),
|
||||
module_info: ModuleInfo::from_vec(vec![]),
|
||||
module_info: ModuleInfo::from_ty_module(vec![], None),
|
||||
html_filename: format!(
|
||||
"{}structfield.{}",
|
||||
IDENTITY,
|
||||
|
@ -501,7 +509,7 @@ impl ItemContext {
|
|||
.iter()
|
||||
.map(|field| DocLink {
|
||||
name: field.name.as_str().to_string(),
|
||||
module_info: ModuleInfo::from_vec(vec![]),
|
||||
module_info: ModuleInfo::from_ty_module(vec![], None),
|
||||
html_filename: format!(
|
||||
"{}storagefield.{}",
|
||||
IDENTITY,
|
||||
|
@ -517,7 +525,7 @@ impl ItemContext {
|
|||
.iter()
|
||||
.map(|variant| DocLink {
|
||||
name: variant.name.as_str().to_string(),
|
||||
module_info: ModuleInfo::from_vec(vec![]),
|
||||
module_info: ModuleInfo::from_ty_module(vec![], None),
|
||||
html_filename: format!("{}variant.{}", IDENTITY, variant.name.as_str()),
|
||||
preview_opt: None,
|
||||
})
|
||||
|
@ -529,7 +537,7 @@ impl ItemContext {
|
|||
.iter()
|
||||
.map(|method| DocLink {
|
||||
name: method.name.as_str().to_string(),
|
||||
module_info: ModuleInfo::from_vec(vec![]),
|
||||
module_info: ModuleInfo::from_ty_module(vec![], None),
|
||||
html_filename: format!(
|
||||
"{}structfield.{}",
|
||||
IDENTITY,
|
||||
|
@ -749,7 +757,7 @@ struct DocLinks {
|
|||
impl Renderable for DocLinks {
|
||||
fn render(self) -> Result<Box<dyn RenderBox>> {
|
||||
let doc_links = match self.style {
|
||||
DocStyle::AllDoc => box_html! {
|
||||
DocStyle::AllDoc(_) => box_html! {
|
||||
@ for (title, list_items) in self.links {
|
||||
@ if !list_items.is_empty() {
|
||||
h3(id=format!("{}", title.html_title_string())) { : title.as_str(); }
|
||||
|
@ -777,7 +785,7 @@ impl Renderable for DocLinks {
|
|||
}
|
||||
.into_string()
|
||||
.unwrap(),
|
||||
DocStyle::ProjectIndex => box_html! {
|
||||
DocStyle::ProjectIndex(_) => box_html! {
|
||||
@ for (title, list_items) in self.links {
|
||||
@ if !list_items.is_empty() {
|
||||
h3(id=format!("{}", title.html_title_string())) { : title.as_str(); }
|
||||
|
@ -914,7 +922,7 @@ impl SidebarNav for AllDocIndex {
|
|||
fn sidebar(&self) -> Sidebar {
|
||||
Sidebar {
|
||||
version_opt: None,
|
||||
style: DocStyle::AllDoc,
|
||||
style: self.all_docs.style.clone(),
|
||||
module_info: self.project_name.clone(),
|
||||
href_path: INDEX_FILENAME.to_owned(),
|
||||
nav: self.all_docs.clone(),
|
||||
|
@ -989,7 +997,7 @@ pub(crate) struct ModuleIndex {
|
|||
impl SidebarNav for ModuleIndex {
|
||||
fn sidebar(&self) -> Sidebar {
|
||||
let style = match self.module_info.is_root_module() {
|
||||
true => DocStyle::ProjectIndex,
|
||||
true => self.module_docs.style.clone(),
|
||||
false => DocStyle::ModuleIndex,
|
||||
};
|
||||
Sidebar {
|
||||
|
@ -1006,8 +1014,8 @@ impl Renderable for ModuleIndex {
|
|||
let doc_links = self.module_docs.clone().render()?;
|
||||
let sidebar = self.sidebar().render()?;
|
||||
let title_prefix = match self.module_docs.style {
|
||||
DocStyle::ProjectIndex => "Project ",
|
||||
DocStyle::ModuleIndex => "Module ",
|
||||
DocStyle::ProjectIndex(ref program_type) => format!("{program_type} "),
|
||||
DocStyle::ModuleIndex => "Module ".to_string(),
|
||||
_ => unreachable!("Module Index can only be either a project or module at this time."),
|
||||
};
|
||||
|
||||
|
@ -1079,6 +1087,16 @@ impl Renderable for ModuleIndex {
|
|||
}
|
||||
}
|
||||
}
|
||||
@ if self.module_info.attributes.is_some() {
|
||||
details(class="swaydoc-toggle top-doc", open) {
|
||||
summary(class="hideme") {
|
||||
span { : "Expand description" }
|
||||
}
|
||||
div(class="docblock") {
|
||||
: Raw(self.module_info.attributes.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
: doc_links;
|
||||
}
|
||||
}
|
||||
|
@ -1094,8 +1112,8 @@ trait SidebarNav {
|
|||
}
|
||||
#[derive(Clone, Ord, PartialOrd, Eq, PartialEq)]
|
||||
enum DocStyle {
|
||||
AllDoc,
|
||||
ProjectIndex,
|
||||
AllDoc(String),
|
||||
ProjectIndex(String),
|
||||
ModuleIndex,
|
||||
Item,
|
||||
}
|
||||
|
@ -1115,8 +1133,8 @@ impl Renderable for Sidebar {
|
|||
.module_info
|
||||
.to_html_shorthand_path_string("assets/sway-logo.svg");
|
||||
let location_with_prefix = match &self.style {
|
||||
DocStyle::AllDoc | DocStyle::ProjectIndex => {
|
||||
format!("Project {}", self.module_info.location())
|
||||
DocStyle::AllDoc(project_kind) | DocStyle::ProjectIndex(project_kind) => {
|
||||
format!("{project_kind} {}", self.module_info.location())
|
||||
}
|
||||
DocStyle::ModuleIndex | DocStyle::Item => format!(
|
||||
"{} {}",
|
||||
|
@ -1125,15 +1143,17 @@ impl Renderable for Sidebar {
|
|||
),
|
||||
};
|
||||
let (logo_path_to_parent, path_to_parent_or_self) = match &self.style {
|
||||
DocStyle::AllDoc | DocStyle::Item => (self.href_path.clone(), self.href_path.clone()),
|
||||
DocStyle::ProjectIndex => (IDENTITY.to_owned(), IDENTITY.to_owned()),
|
||||
DocStyle::AllDoc(_) | DocStyle::Item => {
|
||||
(self.href_path.clone(), self.href_path.clone())
|
||||
}
|
||||
DocStyle::ProjectIndex(_) => (IDENTITY.to_owned(), IDENTITY.to_owned()),
|
||||
DocStyle::ModuleIndex => (format!("../{INDEX_FILENAME}"), IDENTITY.to_owned()),
|
||||
};
|
||||
// Unfortunately, match arms that return a closure, even if they are the same
|
||||
// type, are incompatible. The work around is to return a String instead,
|
||||
// and render it from Raw in the final output.
|
||||
let styled_content = match &self.style {
|
||||
DocStyle::ProjectIndex => {
|
||||
DocStyle::ProjectIndex(_) => {
|
||||
let nav_links = self.nav.links;
|
||||
let version = match self.version_opt {
|
||||
Some(ref v) => v.as_str(),
|
||||
|
|
|
@ -465,6 +465,15 @@ impl TyProgramKind {
|
|||
TyProgramKind::Script { .. } => parsed::TreeType::Script,
|
||||
}
|
||||
}
|
||||
/// Used for project titles in `forc doc`.
|
||||
pub fn as_title_str(&self) -> &str {
|
||||
match self {
|
||||
TyProgramKind::Contract { .. } => "Contract",
|
||||
TyProgramKind::Library { .. } => "Library",
|
||||
TyProgramKind::Predicate { .. } => "Predicate",
|
||||
TyProgramKind::Script { .. } => "Script",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disallow_impure_functions(
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//! The official standard library for the Sway smart contract language.
|
||||
library std;
|
||||
|
||||
dep error_signals;
|
||||
|
|
|
@ -498,7 +498,7 @@ async fn go_to_definition_for_paths() {
|
|||
req_uri: &uri,
|
||||
req_line: 10,
|
||||
req_char: 13,
|
||||
def_line: 0,
|
||||
def_line: 1,
|
||||
def_start_char: 8,
|
||||
def_end_char: 11,
|
||||
def_path: "sway-lib-std/src/lib.sw",
|
||||
|
|
|
@ -25,7 +25,11 @@ pub use crate::{
|
|||
token::{lex, lex_commented},
|
||||
};
|
||||
|
||||
use sway_ast::{attribute::Annotated, Module, ModuleKind};
|
||||
use sway_ast::{
|
||||
attribute::Annotated,
|
||||
token::{DocComment, DocStyle},
|
||||
Module, ModuleKind,
|
||||
};
|
||||
use sway_error::handler::{ErrorEmitted, Handler};
|
||||
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
@ -45,5 +49,13 @@ pub fn parse_module_kind(
|
|||
path: Option<Arc<PathBuf>>,
|
||||
) -> Result<ModuleKind, ErrorEmitted> {
|
||||
let ts = lex(handler, &src, 0, src.len(), path)?;
|
||||
Parser::new(handler, &ts).parse()
|
||||
let mut parser = Parser::new(handler, &ts);
|
||||
while let Some(DocComment {
|
||||
doc_style: DocStyle::Inner,
|
||||
..
|
||||
}) = parser.peek()
|
||||
{
|
||||
parser.parse::<DocComment>()?;
|
||||
}
|
||||
parser.parse()
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue