mirror of
https://github.com/Noratrieb/blogamer.git
synced 2026-01-14 17:05:04 +01:00
stuff
This commit is contained in:
parent
dded93cc3a
commit
9488cf6e83
8 changed files with 1636 additions and 69 deletions
1250
Cargo.lock
generated
1250
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
11
Cargo.toml
11
Cargo.toml
|
|
@ -4,7 +4,18 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
askama = "0.14.0"
|
||||||
|
bs58 = "0.5.1"
|
||||||
|
clap = { version = "4.5.40", features = ["derive"] }
|
||||||
color-eyre = "0.6.5"
|
color-eyre = "0.6.5"
|
||||||
|
image = "0.25.6"
|
||||||
pulldown-cmark = "0.13.0"
|
pulldown-cmark = "0.13.0"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_norway = "0.9.42"
|
serde_norway = "0.9.42"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
|
||||||
|
[profile.dev.package.image]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
[profile.dev.package.rav1e]
|
||||||
|
opt-level = 3
|
||||||
|
|
|
||||||
BIN
example/posts/image/Noratrieb.png
Normal file
BIN
example/posts/image/Noratrieb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
25
example/posts/image/index.md
Normal file
25
example/posts/image/index.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
title: I have an image
|
||||||
|
date: "2025-06-06"
|
||||||
|
---
|
||||||
|
|
||||||
|
meow.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
hello i exist
|
||||||
|
|
||||||
|
## woah?
|
||||||
|
|
||||||
|
yeah meow i am a cat.
|
||||||
|
or somethign. i don't know.
|
||||||
|
yeah meow i am a cat.
|
||||||
|
or somethign. i don't know.yeah meow i am a cat.
|
||||||
|
or somethign. i don't know.yeah meow i am a cat.
|
||||||
|
or somethign. i don't know.
|
||||||
|
|
||||||
|
## another one
|
||||||
|
|
||||||
|
### it's raining headings
|
||||||
|
|
||||||
|
so many headings. this is a paragraph.
|
||||||
277
src/lib.rs
277
src/lib.rs
|
|
@ -1,37 +1,111 @@
|
||||||
|
use askama::Template;
|
||||||
use color_eyre::{
|
use color_eyre::{
|
||||||
Result,
|
Result,
|
||||||
eyre::{OptionExt, WrapErr, bail, ensure},
|
eyre::{OptionExt, WrapErr, bail, ensure},
|
||||||
};
|
};
|
||||||
use pulldown_cmark::Options;
|
use pulldown_cmark::{Event, Options, Tag, TagEnd};
|
||||||
|
use sha2::Digest;
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
fs::DirEntry,
|
fs::DirEntry,
|
||||||
|
io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::context::Context;
|
#[derive(clap::Parser)]
|
||||||
|
pub struct Opts {
|
||||||
|
#[clap(long)]
|
||||||
|
optimize: bool,
|
||||||
|
#[clap(long, short)]
|
||||||
|
input: PathBuf,
|
||||||
|
#[clap(long, short)]
|
||||||
|
output: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
mod context {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
output: OutputDirectory,
|
opts: Opts,
|
||||||
|
static_files: HashMap<String, Vec<u8>>,
|
||||||
|
theme_css_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
struct PictureImages {
|
||||||
struct OutputDirectory {
|
sources: Vec<PictureSource>,
|
||||||
entries: BTreeMap<String, OutputFile>
|
fallback_path: String,
|
||||||
|
height: u32,
|
||||||
|
width: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OutputFile {
|
struct PictureSource {
|
||||||
Dir(OutputDirectory),
|
path: String,
|
||||||
BinaryFile(Vec<u8>),
|
media_type: String,
|
||||||
StringFile(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Context {
|
impl Context {
|
||||||
|
fn add_static_file(&mut self, name: &str, ext: &str, content: Vec<u8>) -> Result<String> {
|
||||||
|
let name = format!("{name}-{}{ext}", create_hash_string(&content));
|
||||||
|
let _ = self.static_files.insert(name.clone(), content);
|
||||||
|
Ok(format!("/static/{name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_image(&mut self, path: &Path) -> Result<PictureImages> {
|
||||||
|
let image = image::ImageReader::open(path)
|
||||||
|
.wrap_err("reading image")?
|
||||||
|
.decode()
|
||||||
|
.wrap_err("decoding image")?;
|
||||||
|
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.ok_or_eyre("image does not have name")?
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let optimize = self.opts.optimize;
|
||||||
|
|
||||||
|
let mut encode = |format, ext| -> Result<_> {
|
||||||
|
let mut bytes = vec![];
|
||||||
|
image.write_to(&mut io::Cursor::new(&mut bytes), format)?;
|
||||||
|
|
||||||
|
self.add_static_file(name, ext, bytes)
|
||||||
|
};
|
||||||
|
|
||||||
|
let fallback_path = encode(image::ImageFormat::Jpeg, ".jpg")?;
|
||||||
|
|
||||||
|
let sources = if optimize {
|
||||||
|
let avif_path = encode(image::ImageFormat::Avif, ".avif")?;
|
||||||
|
let webp_path = encode(image::ImageFormat::WebP, ".webp")?;
|
||||||
|
vec![
|
||||||
|
PictureSource {
|
||||||
|
path: avif_path,
|
||||||
|
media_type: "image/avif".to_owned(),
|
||||||
|
},
|
||||||
|
PictureSource {
|
||||||
|
path: webp_path,
|
||||||
|
media_type: "image/webp".to_owned(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(PictureImages {
|
||||||
|
sources,
|
||||||
|
fallback_path,
|
||||||
|
height: image.height(),
|
||||||
|
width: image.width(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_hash_string(bytes: &[u8]) -> String {
|
||||||
|
let digest = sha2::Sha256::digest(bytes);
|
||||||
|
bs58::encode(&digest[..16]).into_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Post {
|
||||||
|
name: String,
|
||||||
|
relative_to: PathBuf,
|
||||||
|
frontmatter: Frontmatter,
|
||||||
|
body_md: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
mod write {
|
mod write {
|
||||||
|
|
@ -39,38 +113,53 @@ mod write {
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub fn initialize(base: &Path) -> Result<()> {
|
pub fn initialize(base: &Path) -> Result<()> {
|
||||||
std::fs::remove_dir_all(base).wrap_err("deleting previous output")?;
|
let _ = std::fs::remove_dir_all(base).wrap_err("deleting previous output");
|
||||||
Ok(std::fs::create_dir_all(base).wrap_err("creating output")?)
|
Ok(std::fs::create_dir_all(base).wrap_err("creating output")?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_file(base: &Path, name: &str, content: &str) -> Result<()> {
|
|
||||||
Ok(std::fs::write(base.join(name), content)?)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate(out_base: PathBuf, root: &Path) -> Result<()> {
|
pub fn generate(opts: Opts) -> Result<()> {
|
||||||
let mut ctx = Context::default();
|
let mut ctx = Context {
|
||||||
|
opts,
|
||||||
|
static_files: Default::default(),
|
||||||
|
theme_css_path: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
collect_posts(&mut ctx, &root.join("posts"))
|
ctx.theme_css_path = ctx
|
||||||
.wrap_err_with(|| format!("reading posts from {}", root.display()))?;
|
.add_static_file(
|
||||||
|
"theme",
|
||||||
|
".css",
|
||||||
|
include_bytes!("../templates/theme.css")
|
||||||
|
.as_slice()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.wrap_err("adding theme.css")?;
|
||||||
|
|
||||||
write::initialize(&out_base).wrap_err("initializing output")?;
|
let posts = collect_posts(&ctx.opts.input.join("posts"))
|
||||||
for output in ctx.outputs {
|
.wrap_err_with(|| format!("reading posts from {}", ctx.opts.input.display()))?;
|
||||||
match output {
|
|
||||||
OutputFile::Post {
|
write::initialize(&ctx.opts.output).wrap_err("initializing output")?;
|
||||||
name,
|
|
||||||
frontmatter,
|
for post in posts {
|
||||||
html_body,
|
let dir = ctx.opts.output.join("blog").join("posts").join(&post.name);
|
||||||
} => {
|
std::fs::create_dir_all(&dir)?;
|
||||||
write::write_file(&out_base, &name, &html_body)?;
|
|
||||||
}
|
let html = render_post(&mut ctx, &post)?;
|
||||||
|
|
||||||
|
std::fs::write(dir.join("index.html"), html)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let static_dir = ctx.opts.output.join("static");
|
||||||
|
std::fs::create_dir(&static_dir).wrap_err("creating static")?;
|
||||||
|
for (name, content) in ctx.static_files {
|
||||||
|
std::fs::write(static_dir.join(name), content).wrap_err("writing static file")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_posts(ctx: &mut Context, path: &Path) -> Result<()> {
|
fn collect_posts(path: &Path) -> Result<Vec<Post>> {
|
||||||
|
let mut posts = vec![];
|
||||||
let entries = std::fs::read_dir(path)?;
|
let entries = std::fs::read_dir(path)?;
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
|
@ -78,18 +167,24 @@ fn collect_posts(ctx: &mut Context, path: &Path) -> Result<()> {
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
let name = name.to_str().ok_or_eyre("invalid UTF-8 filename")?;
|
let name = name.to_str().ok_or_eyre("invalid UTF-8 filename")?;
|
||||||
|
|
||||||
collect_post(ctx, &entry, name).wrap_err_with(|| format!("generating post {name}"))?;
|
let post =
|
||||||
|
collect_post(&entry, name).wrap_err_with(|| format!("generating post {name}"))?;
|
||||||
|
posts.push(post);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(posts)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_post(ctx: &mut Context, entry: &DirEntry, name: &str) -> Result<()> {
|
fn collect_post(entry: &DirEntry, name: &str) -> Result<Post> {
|
||||||
let meta = entry.metadata()?;
|
let meta = entry.metadata()?;
|
||||||
if meta.is_dir() {
|
|
||||||
todo!("directory post");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let (name, content, relative_to) = if meta.is_dir() {
|
||||||
|
let content_path = entry.path().join("index.md");
|
||||||
|
let content = std::fs::read_to_string(&content_path)
|
||||||
|
.wrap_err_with(|| format!("could not read {}", content_path.display()))?;
|
||||||
|
|
||||||
|
(name.to_owned(), content, entry.path())
|
||||||
|
} else {
|
||||||
let Some((name, ext)) = name.split_once('.') else {
|
let Some((name, ext)) = name.split_once('.') else {
|
||||||
bail!("invalid post filename {name}, must be *.md");
|
bail!("invalid post filename {name}, must be *.md");
|
||||||
};
|
};
|
||||||
|
|
@ -97,9 +192,15 @@ fn collect_post(ctx: &mut Context, entry: &DirEntry, name: &str) -> Result<()> {
|
||||||
ext == "md",
|
ext == "md",
|
||||||
"invalid filename {name}, only .md extensions are allowed"
|
"invalid filename {name}, only .md extensions are allowed"
|
||||||
);
|
);
|
||||||
|
|
||||||
let content = std::fs::read_to_string(entry.path()).wrap_err("reading contents")?;
|
let content = std::fs::read_to_string(entry.path()).wrap_err("reading contents")?;
|
||||||
|
|
||||||
|
(
|
||||||
|
name.to_owned(),
|
||||||
|
content,
|
||||||
|
entry.path().parent().unwrap().to_owned(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let rest = content
|
let rest = content
|
||||||
.strip_prefix("---\n")
|
.strip_prefix("---\n")
|
||||||
.ok_or_eyre("post must start with `---`")?;
|
.ok_or_eyre("post must start with `---`")?;
|
||||||
|
|
@ -110,15 +211,12 @@ fn collect_post(ctx: &mut Context, entry: &DirEntry, name: &str) -> Result<()> {
|
||||||
let frontmatter =
|
let frontmatter =
|
||||||
serde_norway::from_str::<Frontmatter>(frontmatter).wrap_err("¡nvalid frontmatter")?;
|
serde_norway::from_str::<Frontmatter>(frontmatter).wrap_err("¡nvalid frontmatter")?;
|
||||||
|
|
||||||
let html_body = parse_post_body(&body).wrap_err("parsing post")?;
|
Ok(Post {
|
||||||
|
name,
|
||||||
ctx.outputs.push(OutputFile::Post {
|
|
||||||
name: name.to_owned(),
|
|
||||||
frontmatter,
|
frontmatter,
|
||||||
html_body,
|
body_md: body.to_owned(),
|
||||||
});
|
relative_to,
|
||||||
|
})
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
|
|
@ -127,12 +225,81 @@ struct Frontmatter {
|
||||||
date: String,
|
date: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_post_body(content: &str) -> Result<String> {
|
fn render_post(ctx: &mut Context, post: &Post) -> Result<String> {
|
||||||
|
#[derive(askama::Template)]
|
||||||
|
#[template(path = "../templates/post.html")]
|
||||||
|
struct PostTemplate<'a> {
|
||||||
|
title: &'a str,
|
||||||
|
body: &'a str,
|
||||||
|
theme_css_path: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = render_body(ctx, &post.relative_to, &post.body_md)?;
|
||||||
|
|
||||||
|
PostTemplate {
|
||||||
|
title: &post.frontmatter.title,
|
||||||
|
body: &body,
|
||||||
|
theme_css_path: &ctx.theme_css_path,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.wrap_err("failed to render template")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_body(ctx: &mut Context, relative_to: &Path, md: &str) -> Result<String> {
|
||||||
let mut options = pulldown_cmark::Options::empty();
|
let mut options = pulldown_cmark::Options::empty();
|
||||||
options |= Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH;
|
options |= Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH;
|
||||||
let parser = pulldown_cmark::Parser::new_ext(content, options);
|
let mut parser = pulldown_cmark::Parser::new_ext(md, options);
|
||||||
|
|
||||||
let mut output = String::new();
|
let mut events = vec![];
|
||||||
pulldown_cmark::html::push_html(&mut output, parser);
|
|
||||||
Ok(output)
|
while let Some(ev) = parser.next() {
|
||||||
|
dbg!(&ev);
|
||||||
|
match ev {
|
||||||
|
Event::Start(Tag::Image {
|
||||||
|
link_type: _,
|
||||||
|
dest_url,
|
||||||
|
title: _,
|
||||||
|
id: _,
|
||||||
|
}) => {
|
||||||
|
let Some(Event::Text(alt)) = parser.next() else {
|
||||||
|
bail!("No alt text for image tag");
|
||||||
|
};
|
||||||
|
let Some(Event::End(TagEnd::Image)) = parser.next() else {
|
||||||
|
bail!("No end tag for image");
|
||||||
|
};
|
||||||
|
|
||||||
|
let sources = ctx.add_image(&relative_to.join(dest_url.as_ref()))?;
|
||||||
|
|
||||||
|
events.extend([
|
||||||
|
Event::Start(Tag::HtmlBlock),
|
||||||
|
Event::Html("<picture>".into()),
|
||||||
|
]);
|
||||||
|
for source in sources.sources {
|
||||||
|
events.push(Event::Html(
|
||||||
|
format!(
|
||||||
|
r#"<source srcset="{}" type="{}">"#,
|
||||||
|
source.path, source.media_type
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
events.extend([
|
||||||
|
Event::Html(
|
||||||
|
format!(
|
||||||
|
r#"<img src="{}" alt="{}" height="{}" width="{}">"#,
|
||||||
|
sources.fallback_path, alt, sources.height, sources.width
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
Event::Html("</picture>".into()),
|
||||||
|
Event::End(TagEnd::HtmlBlock),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
ev => events.push(ev),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body = String::new();
|
||||||
|
pulldown_cmark::html::push_html(&mut body, events.into_iter());
|
||||||
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use std::{path::Path};
|
use clap::Parser;
|
||||||
|
|
||||||
fn main() -> color_eyre::Result<()> {
|
fn main() -> color_eyre::Result<()> {
|
||||||
blogamer::generate("output".into(), Path::new("example"))
|
let opts = blogamer::Opts::parse();
|
||||||
|
blogamer::generate(opts)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
templates/post.html
Normal file
19
templates/post.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="stylesheet" href="{{ theme_css_path }}" />
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="main-content blog-main-content">
|
||||||
|
<div class="main-content-inner">
|
||||||
|
<nav><a href="/">Noratrieb</a></nav>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<hr />
|
||||||
|
<div>{{ body | safe }}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
96
templates/theme.css
Normal file
96
templates/theme.css
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
html {
|
||||||
|
--accent-color: #e5a5c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 700px) {
|
||||||
|
.main-content-inner {
|
||||||
|
width: 70vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1300px) {
|
||||||
|
.main-content-inner {
|
||||||
|
width: 50vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-to-action {
|
||||||
|
width: 250px;
|
||||||
|
height: 50px;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1000px) {
|
||||||
|
.call-to-action {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-to-action:hover {
|
||||||
|
background-color: #ac78b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-to-action * {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns-2 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.columns-2 {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
body {
|
||||||
|
--background-color: #e6dae9;
|
||||||
|
--foreground-color: #1b191c;
|
||||||
|
--black-or-white: black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
--background-color: #1b191c;
|
||||||
|
--foreground-color: #e6dae9;
|
||||||
|
--black-or-white: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue