mirror of
https://github.com/zyllian/zyllian.github.io.git
synced 2025-05-09 18:16:43 -07:00
custom front matter parser for showing errors + make all extras configurable from the page side
This commit is contained in:
parent
159c8fa5b3
commit
76c75a40d9
9 changed files with 119 additions and 97 deletions
48
Cargo.lock
generated
48
Cargo.lock
generated
|
@ -99,12 +99,6 @@ version = "1.0.92"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "74f37166d7d48a0284b99dd824694c26119c700b53bf0d1540cdb147dbdaaf13"
|
checksum = "74f37166d7d48a0284b99dd824694c26119c700b53bf0d1540cdb147dbdaaf13"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arraydeque"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atom_syndication"
|
name = "atom_syndication"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
|
@ -749,18 +743,6 @@ dependencies = [
|
||||||
"phf 0.11.2",
|
"phf 0.11.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gray_matter"
|
|
||||||
version = "0.2.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "31ee6a6070bad7c953b0c8be9367e9372181fed69f3e026c4eb5160d8b3c0222"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"toml",
|
|
||||||
"yaml-rust2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.3.26"
|
version = "0.3.26"
|
||||||
|
@ -820,15 +802,6 @@ version = "0.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb"
|
checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashlink"
|
|
||||||
version = "0.8.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
|
|
||||||
dependencies = [
|
|
||||||
"hashbrown 0.14.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "headers"
|
name = "headers"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
@ -2263,15 +2236,6 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml"
|
|
||||||
version = "0.5.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-service"
|
name = "tower-service"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
|
@ -2701,17 +2665,6 @@ version = "0.5.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "yaml-rust2"
|
|
||||||
version = "0.8.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8"
|
|
||||||
dependencies = [
|
|
||||||
"arraydeque",
|
|
||||||
"encoding_rs",
|
|
||||||
"hashlink",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.7.4"
|
version = "0.7.4"
|
||||||
|
@ -2810,7 +2763,6 @@ dependencies = [
|
||||||
"fs_extra",
|
"fs_extra",
|
||||||
"futures",
|
"futures",
|
||||||
"grass",
|
"grass",
|
||||||
"gray_matter",
|
|
||||||
"handlebars",
|
"handlebars",
|
||||||
"hotwatch",
|
"hotwatch",
|
||||||
"itertools",
|
"itertools",
|
||||||
|
|
|
@ -10,7 +10,6 @@ eyre = "0.6"
|
||||||
fs_extra = "1.2"
|
fs_extra = "1.2"
|
||||||
futures = {version = "0.3", optional = true}
|
futures = {version = "0.3", optional = true}
|
||||||
grass = {version = "0.13", default-features = false}
|
grass = {version = "0.13", default-features = false}
|
||||||
gray_matter = "0.2"
|
|
||||||
handlebars = "6"
|
handlebars = "6"
|
||||||
hotwatch = {version = "0.5", optional = true}
|
hotwatch = {version = "0.5", optional = true}
|
||||||
itertools = "0.13"
|
itertools = "0.13"
|
||||||
|
|
|
@ -6,5 +6,7 @@ embed:
|
||||||
title: click
|
title: click
|
||||||
site_name: zyl.gay
|
site_name: zyl.gay
|
||||||
description: click click click
|
description: click click click
|
||||||
extra: click
|
extra:
|
||||||
|
name: basic
|
||||||
|
template: extras/click
|
||||||
---
|
---
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
---
|
---
|
||||||
extra: index
|
extra:
|
||||||
|
name: resource-list-outside
|
||||||
|
template: extras/index-injection
|
||||||
|
resource: blog
|
||||||
|
count: 3
|
||||||
---
|
---
|
||||||
|
|
||||||
# zyl is gay
|
# zyl is gay
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
use std::{collections::HashMap, path::PathBuf};
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
|
|
||||||
use eyre::{eyre, Context, OptionExt};
|
use eyre::{eyre, Context, OptionExt};
|
||||||
use gray_matter::{engine::YAML, Matter};
|
|
||||||
use handlebars::Handlebars;
|
use handlebars::Handlebars;
|
||||||
use lol_html::{element, html_content::ContentType, HtmlRewriter, Settings};
|
use lol_html::{element, html_content::ContentType, HtmlRewriter, Settings};
|
||||||
use pulldown_cmark::{Options, Parser};
|
use pulldown_cmark::{Options, Parser};
|
||||||
|
@ -32,8 +31,6 @@ struct TemplateData<'a, T> {
|
||||||
|
|
||||||
/// Struct used to build the site.
|
/// Struct used to build the site.
|
||||||
pub struct SiteBuilder<'a> {
|
pub struct SiteBuilder<'a> {
|
||||||
/// The matter instance used to extract front matter.
|
|
||||||
pub(crate) matter: Matter<YAML>,
|
|
||||||
/// The Handlebars registry used to render templates.
|
/// The Handlebars registry used to render templates.
|
||||||
pub(crate) reg: Handlebars<'a>,
|
pub(crate) reg: Handlebars<'a>,
|
||||||
/// The site info used to build the site.
|
/// The site info used to build the site.
|
||||||
|
@ -59,7 +56,6 @@ impl<'a> SiteBuilder<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
matter: Matter::new(),
|
|
||||||
reg: Handlebars::new(),
|
reg: Handlebars::new(),
|
||||||
resource_builders: HashMap::new(),
|
resource_builders: HashMap::new(),
|
||||||
site,
|
site,
|
||||||
|
@ -257,16 +253,14 @@ impl<'a> SiteBuilder<'a> {
|
||||||
/// Helper to build a page without writing it to disk.
|
/// Helper to build a page without writing it to disk.
|
||||||
pub fn build_page_raw_with_extra_data<T>(
|
pub fn build_page_raw_with_extra_data<T>(
|
||||||
&self,
|
&self,
|
||||||
page_metadata: PageMetadata,
|
mut page_metadata: PageMetadata,
|
||||||
page_html: &str,
|
page_html: &str,
|
||||||
extra_data: T,
|
extra_data: T,
|
||||||
) -> eyre::Result<String>
|
) -> eyre::Result<String>
|
||||||
where
|
where
|
||||||
T: Serialize,
|
T: Serialize,
|
||||||
{
|
{
|
||||||
let extra = page_metadata
|
let extra = page_metadata.extra.take();
|
||||||
.extra
|
|
||||||
.and_then(|extra| crate::extras::get_extra(&extra));
|
|
||||||
|
|
||||||
let title = match &page_metadata.title {
|
let title = match &page_metadata.title {
|
||||||
Some(page_title) => format!("{} / {}", self.site.config.title, page_title),
|
Some(page_title) => format!("{} / {}", self.site.config.title, page_title),
|
||||||
|
@ -293,8 +287,10 @@ impl<'a> SiteBuilder<'a> {
|
||||||
// Modify HTML output
|
// Modify HTML output
|
||||||
let mut out = self.rewrite_html(out)?;
|
let mut out = self.rewrite_html(out)?;
|
||||||
|
|
||||||
if let Some(extra) = extra {
|
if let Some(data) = extra {
|
||||||
out = extra.handle(out, self)?;
|
if let Some(extra) = crate::extras::get_extra(&data.name) {
|
||||||
|
out = extra.handle(out, self, &data)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.serving {
|
if !self.serving {
|
||||||
|
@ -310,19 +306,13 @@ impl<'a> SiteBuilder<'a> {
|
||||||
|
|
||||||
let input = std::fs::read_to_string(page_path)
|
let input = std::fs::read_to_string(page_path)
|
||||||
.with_context(|| format!("Failed to read page at {}", page_path.display()))?;
|
.with_context(|| format!("Failed to read page at {}", page_path.display()))?;
|
||||||
let page = self.matter.parse(&input);
|
let page = crate::frontmatter::FrontMatter::parse(input)?;
|
||||||
|
|
||||||
let page_metadata = if let Some(data) = page.data {
|
|
||||||
data.deserialize()?
|
|
||||||
} else {
|
|
||||||
PageMetadata::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let parser = Parser::new_ext(&page.content, Options::all());
|
let parser = Parser::new_ext(&page.content, Options::all());
|
||||||
let mut page_html = String::new();
|
let mut page_html = String::new();
|
||||||
pulldown_cmark::html::push_html(&mut page_html, parser);
|
pulldown_cmark::html::push_html(&mut page_html, parser);
|
||||||
|
|
||||||
let out = self.build_page_raw(page_metadata, &page_html)?;
|
let out = self.build_page_raw(page.data.unwrap_or_default(), &page_html)?;
|
||||||
|
|
||||||
let out_path = self.build_path.join(page_name).with_extension("html");
|
let out_path = self.build_path.join(page_name).with_extension("html");
|
||||||
std::fs::create_dir_all(out_path.parent().unwrap())
|
std::fs::create_dir_all(out_path.parent().unwrap())
|
||||||
|
|
|
@ -1,32 +1,58 @@
|
||||||
use lol_html::{element, RewriteStrSettings};
|
use lol_html::{element, RewriteStrSettings};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{builder::SiteBuilder, resource::ResourceTemplateData};
|
use crate::{builder::SiteBuilder, resource::ResourceTemplateData};
|
||||||
|
|
||||||
|
/// Types of extras.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Extra {
|
pub enum Extra {
|
||||||
Basic(&'static str),
|
/// Simply appends to the page within content.
|
||||||
HtmlModification(fn(page: String, builder: &SiteBuilder) -> eyre::Result<String>),
|
Basic,
|
||||||
|
/// May modify the HTML output in any way.
|
||||||
|
HtmlModification(
|
||||||
|
fn(page: String, builder: &SiteBuilder, data: &ExtraData) -> eyre::Result<String>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Extra {
|
impl Extra {
|
||||||
/// runs the handler for the extra
|
/// runs the handler for the extra
|
||||||
pub fn handle(&self, page: String, builder: &SiteBuilder) -> eyre::Result<String> {
|
pub fn handle(
|
||||||
|
&self,
|
||||||
|
page: String,
|
||||||
|
builder: &SiteBuilder,
|
||||||
|
data: &ExtraData,
|
||||||
|
) -> eyre::Result<String> {
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BasicData {
|
||||||
|
template: String,
|
||||||
|
}
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
Self::Basic(template) => {
|
Self::Basic => {
|
||||||
let content = builder.reg.render(template, &())?;
|
let data: BasicData = serde_yml::from_value(data.inner.clone())?;
|
||||||
|
let content = builder.reg.render(&data.template, &())?;
|
||||||
append_to(&page, &content, "main.page")
|
append_to(&page, &content, "main.page")
|
||||||
}
|
}
|
||||||
Self::HtmlModification(f) => (f)(page, builder),
|
Self::HtmlModification(f) => (f)(page, builder, data),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Data for extras.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ExtraData {
|
||||||
|
/// The name of the extra to run.
|
||||||
|
pub name: String,
|
||||||
|
/// The inner data for the extra.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub inner: serde_yml::Value,
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets the extra for the given value.
|
/// Gets the extra for the given value.
|
||||||
pub fn get_extra(extra: &str) -> Option<Extra> {
|
pub fn get_extra(extra: &str) -> Option<Extra> {
|
||||||
match extra {
|
match extra {
|
||||||
"index" => Some(Extra::HtmlModification(index)),
|
"basic" => Some(Extra::Basic),
|
||||||
"click" => Some(Extra::Basic("extras/click")),
|
"resource-list-outside" => Some(Extra::HtmlModification(resource_list_outside)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,22 +72,35 @@ fn append_to(page: &str, content: &str, selector: &str) -> eyre::Result<String>
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extra to add a sidebar to the index page with recent blog posts on it.
|
/// Extra to add a sidebar to the index page with recent blog posts on it.
|
||||||
fn index(page: String, builder: &SiteBuilder) -> eyre::Result<String> {
|
fn resource_list_outside(
|
||||||
|
page: String,
|
||||||
|
builder: &SiteBuilder,
|
||||||
|
data: &ExtraData,
|
||||||
|
) -> eyre::Result<String> {
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResourceListData {
|
||||||
|
template: String,
|
||||||
|
resource: String,
|
||||||
|
count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct SidebarTemplateData<'r> {
|
struct ResourceListTemplateData<'r> {
|
||||||
resources: Vec<ResourceTemplateData<'r>>,
|
resources: Vec<ResourceTemplateData<'r>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let sidebar = builder.reg.render(
|
let data: ResourceListData = serde_yml::from_value(data.inner.clone())?;
|
||||||
"extras/index-injection",
|
|
||||||
&SidebarTemplateData {
|
let resource_list = builder.reg.render(
|
||||||
|
&data.template,
|
||||||
|
&ResourceListTemplateData {
|
||||||
resources: builder
|
resources: builder
|
||||||
.resource_builders
|
.resource_builders
|
||||||
.get("blog")
|
.get(&data.resource)
|
||||||
.expect("missing blog builder")
|
.ok_or_else(|| eyre::eyre!("missing resource builder: {}", data.resource))?
|
||||||
.loaded_metadata
|
.loaded_metadata
|
||||||
.iter()
|
.iter()
|
||||||
.take(3)
|
.take(data.count)
|
||||||
.map(|(id, v)| ResourceTemplateData {
|
.map(|(id, v)| ResourceTemplateData {
|
||||||
resource: v,
|
resource: v,
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
@ -71,5 +110,5 @@ fn index(page: String, builder: &SiteBuilder) -> eyre::Result<String> {
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
append_to(&page, &sidebar, "#content")
|
append_to(&page, &resource_list, "#content")
|
||||||
}
|
}
|
||||||
|
|
32
src/frontmatter.rs
Normal file
32
src/frontmatter.rs
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
|
/// Very basic YAML front matter parser.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FrontMatter<T> {
|
||||||
|
/// The content past the front matter.
|
||||||
|
pub content: String,
|
||||||
|
/// The front matter found, if any.
|
||||||
|
pub data: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> FrontMatter<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
/// Parses the given input for front matter.
|
||||||
|
pub fn parse(input: String) -> eyre::Result<Self> {
|
||||||
|
if input.starts_with("---\n") {
|
||||||
|
if let Some((frontmatter, content)) = input[3..].split_once("---\n") {
|
||||||
|
let data = serde_yml::from_str(frontmatter)?;
|
||||||
|
return Ok(Self {
|
||||||
|
content: content.to_string(),
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
content: input,
|
||||||
|
data: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
mod builder;
|
mod builder;
|
||||||
mod extras;
|
mod extras;
|
||||||
|
mod frontmatter;
|
||||||
mod link_list;
|
mod link_list;
|
||||||
mod resource;
|
mod resource;
|
||||||
#[cfg(feature = "serve")]
|
#[cfg(feature = "serve")]
|
||||||
|
@ -11,6 +12,7 @@ use std::{
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use extras::ExtraData;
|
||||||
use eyre::Context;
|
use eyre::Context;
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use resource::{EmbedMetadata, ResourceBuilderConfig};
|
use resource::{EmbedMetadata, ResourceBuilderConfig};
|
||||||
|
@ -73,7 +75,8 @@ pub struct PageMetadata {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub styles: Vec<String>,
|
pub styles: Vec<String>,
|
||||||
/// The extra stuff to run for the page, if any.
|
/// The extra stuff to run for the page, if any.
|
||||||
pub extra: Option<String>,
|
#[serde(default)]
|
||||||
|
pub extra: Option<ExtraData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Struct containing information about the site.
|
/// Struct containing information about the site.
|
||||||
|
|
|
@ -10,7 +10,7 @@ use rss::{validation::Validate, ChannelBuilder, ItemBuilder};
|
||||||
use serde::{Deserialize, Serialize, Serializer};
|
use serde::{Deserialize, Serialize, Serializer};
|
||||||
use time::{format_description::well_known::Rfc2822, OffsetDateTime};
|
use time::{format_description::well_known::Rfc2822, OffsetDateTime};
|
||||||
|
|
||||||
use crate::{builder::SiteBuilder, link_list::Link, PageMetadata};
|
use crate::{builder::SiteBuilder, frontmatter::FrontMatter, link_list::Link, PageMetadata};
|
||||||
|
|
||||||
/// Source base path for resources.
|
/// Source base path for resources.
|
||||||
pub const RESOURCES_PATH: &str = "resources";
|
pub const RESOURCES_PATH: &str = "resources";
|
||||||
|
@ -194,21 +194,22 @@ impl ResourceBuilder {
|
||||||
let id = Self::get_id(path);
|
let id = Self::get_id(path);
|
||||||
|
|
||||||
let input = std::fs::read_to_string(path)?;
|
let input = std::fs::read_to_string(path)?;
|
||||||
let mut page = builder
|
let page = FrontMatter::<ResourceMetadata>::parse(input)
|
||||||
.matter
|
.wrap_err_with(|| eyre::eyre!("Failed to parse resource front matter"))?;
|
||||||
.parse_with_struct::<ResourceMetadata>(&input)
|
|
||||||
.ok_or_else(|| eyre::anyhow!("Failed to parse resource front matter"))?;
|
|
||||||
|
|
||||||
let parser = Parser::new_ext(&page.content, Options::all());
|
let parser = Parser::new_ext(&page.content, Options::all());
|
||||||
let mut html = String::new();
|
let mut html = String::new();
|
||||||
pulldown_cmark::html::push_html(&mut html, parser);
|
pulldown_cmark::html::push_html(&mut html, parser);
|
||||||
|
|
||||||
page.data.content = html;
|
let mut data = page
|
||||||
if let Some(cdn_file) = page.data.cdn_file {
|
.data
|
||||||
page.data.cdn_file = Some(builder.site.config.cdn_url(&cdn_file)?.to_string());
|
.ok_or_else(|| eyre::eyre!("missing front matter for file at {path:?}"))?;
|
||||||
|
data.content = html;
|
||||||
|
if let Some(cdn_file) = data.cdn_file {
|
||||||
|
data.cdn_file = Some(builder.site.config.cdn_url(&cdn_file)?.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((id, page.data))
|
Ok((id, data))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads all resource metadata from the given config.
|
/// Loads all resource metadata from the given config.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue