Metadata and contacts (#38)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
- Rework metadata, now each type of file based on markdown have his own metadata struct (blog/portfolio/contacts) - Contact is now generated by markdown files Reviewed-on: #38 Co-authored-by: Mylloon <kennel.anri@tutanota.com> Co-committed-by: Mylloon <kennel.anri@tutanota.com>
This commit is contained in:
parent
34c1720cdc
commit
f84a37829c
10 changed files with 200 additions and 252 deletions
|
@ -6,7 +6,7 @@ use serde::{Deserialize, Deserializer};
|
|||
use std::fs;
|
||||
|
||||
#[derive(Default, Deserialize, Content, Debug)]
|
||||
pub struct FileMetadata {
|
||||
pub struct FileMetadataBlog {
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
pub date: Option<Date>,
|
||||
|
@ -17,6 +17,37 @@ pub struct FileMetadata {
|
|||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Content, Debug)]
|
||||
pub struct FileMetadataContact {
|
||||
pub title: String,
|
||||
pub custom: Option<bool>,
|
||||
pub user: Option<String>,
|
||||
pub link: Option<String>,
|
||||
pub newtab: Option<bool>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Content, Debug)]
|
||||
pub struct FileMetadataPortfolio {
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
pub enum TypeFileMetadata {
|
||||
Blog,
|
||||
Contact,
|
||||
Portfolio,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Content, Debug)]
|
||||
pub struct FileMetadata {
|
||||
pub blog: Option<FileMetadataBlog>,
|
||||
pub contact: Option<FileMetadataContact>,
|
||||
pub portfolio: Option<FileMetadataPortfolio>,
|
||||
}
|
||||
|
||||
#[derive(Content, Debug, Clone)]
|
||||
pub struct Tag {
|
||||
pub name: String,
|
||||
|
@ -87,14 +118,14 @@ pub fn get_options() -> ComrakOptions {
|
|||
}
|
||||
|
||||
/// Transform markdown string to File structure
|
||||
fn read(raw_text: &str) -> File {
|
||||
fn read(raw_text: &str, metadata_type: TypeFileMetadata) -> File {
|
||||
let arena = Arena::new();
|
||||
|
||||
let options = get_options();
|
||||
let root = parse_document(&arena, raw_text, &options);
|
||||
|
||||
// Find metadata
|
||||
let metadata = get_metadata(root);
|
||||
let metadata = get_metadata(root, metadata_type);
|
||||
|
||||
let mermaid_name = "mermaid";
|
||||
hljs_replace(root, mermaid_name);
|
||||
|
@ -117,26 +148,54 @@ fn read(raw_text: &str) -> File {
|
|||
}
|
||||
|
||||
/// Read markdown file
|
||||
pub fn read_file(filename: &str) -> Option<File> {
|
||||
pub fn read_file(filename: &str, expected_file: TypeFileMetadata) -> Option<File> {
|
||||
match fs::read_to_string(filename) {
|
||||
Ok(text) => Some(read(&text)),
|
||||
Ok(text) => Some(read(&text, expected_file)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize metadata based on a type
|
||||
fn deserialize_metadata<T: Default + serde::de::DeserializeOwned>(text: &str) -> T {
|
||||
serde_yaml::from_str(text.trim_matches(&['-', '\n'] as &[_])).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fetch metadata from AST
|
||||
pub fn get_metadata<'a>(root: &'a AstNode<'a>) -> FileMetadata {
|
||||
pub fn get_metadata<'a>(root: &'a AstNode<'a>, mtype: TypeFileMetadata) -> FileMetadata {
|
||||
match root
|
||||
.children()
|
||||
.find_map(|node| match &node.data.borrow().value {
|
||||
NodeValue::FrontMatter(text) => {
|
||||
// '-' correspond to `front_matter_delimiter`
|
||||
serde_yaml::from_str(text.trim_matches(&['-', '\n'] as &[_])).unwrap_or_default()
|
||||
}
|
||||
NodeValue::FrontMatter(text) => Some(match mtype {
|
||||
TypeFileMetadata::Blog => FileMetadata {
|
||||
blog: Some(deserialize_metadata(text)),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
TypeFileMetadata::Contact => FileMetadata {
|
||||
contact: Some(deserialize_metadata(text)),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
TypeFileMetadata::Portfolio => FileMetadata {
|
||||
portfolio: Some(deserialize_metadata(text)),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
}),
|
||||
_ => None,
|
||||
}) {
|
||||
Some(data) => data,
|
||||
None => FileMetadata::default(),
|
||||
None => match mtype {
|
||||
TypeFileMetadata::Blog => FileMetadata {
|
||||
blog: Some(FileMetadataBlog::default()),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
TypeFileMetadata::Contact => FileMetadata {
|
||||
contact: Some(FileMetadataContact::default()),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
TypeFileMetadata::Portfolio => FileMetadata {
|
||||
portfolio: Some(FileMetadataPortfolio::default()),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,9 @@ use crate::{
|
|||
config::Config,
|
||||
misc::{
|
||||
date::Date,
|
||||
markdown::{get_metadata, get_options, read_file, File, FileMetadata},
|
||||
markdown::{
|
||||
get_metadata, get_options, read_file, File, FileMetadataBlog, TypeFileMetadata,
|
||||
},
|
||||
utils::get_url,
|
||||
},
|
||||
template::{Infos, NavBar},
|
||||
|
@ -87,7 +89,10 @@ impl Post {
|
|||
let blog_dir = "data/blog";
|
||||
let ext = ".md";
|
||||
|
||||
if let Some(file) = read_file(&format!("{blog_dir}/{}{ext}", self.url)) {
|
||||
if let Some(file) = read_file(
|
||||
&format!("{blog_dir}/{}{ext}", self.url),
|
||||
TypeFileMetadata::Blog,
|
||||
) {
|
||||
self.content = Some(file.content);
|
||||
}
|
||||
}
|
||||
|
@ -126,7 +131,7 @@ fn get_posts(location: &str) -> Vec<Post> {
|
|||
|
||||
let options = get_options();
|
||||
let root = parse_document(&arena, &text, &options);
|
||||
let mut metadata = get_metadata(root);
|
||||
let mut metadata = get_metadata(root, TypeFileMetadata::Blog).blog.unwrap();
|
||||
|
||||
// Always have a title
|
||||
metadata.title = match metadata.title {
|
||||
|
@ -136,9 +141,9 @@ fn get_posts(location: &str) -> Vec<Post> {
|
|||
|
||||
metadata
|
||||
}
|
||||
Err(_) => FileMetadata {
|
||||
Err(_) => FileMetadataBlog {
|
||||
title: Some(file_without_ext.into()),
|
||||
..FileMetadata::default()
|
||||
..FileMetadataBlog::default()
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -222,20 +227,23 @@ fn get_post(
|
|||
let blog_dir = "data/blog";
|
||||
let ext = ".md";
|
||||
|
||||
*post = read_file(&format!("{blog_dir}/{filename}{ext}"));
|
||||
*post = read_file(
|
||||
&format!("{blog_dir}/{filename}{ext}"),
|
||||
TypeFileMetadata::Blog,
|
||||
);
|
||||
|
||||
let default = (&filename, Vec::new(), String::new());
|
||||
let (title, tags, toc) = match post {
|
||||
Some(data) => (
|
||||
match &data.metadata.info.title {
|
||||
match &data.metadata.info.blog.as_ref().unwrap().title {
|
||||
Some(text) => text,
|
||||
None => default.0,
|
||||
},
|
||||
match &data.metadata.info.tags {
|
||||
match &data.metadata.info.blog.as_ref().unwrap().tags {
|
||||
Some(tags) => tags.clone(),
|
||||
None => default.1,
|
||||
},
|
||||
match &data.metadata.info.toc {
|
||||
match &data.metadata.info.blog.as_ref().unwrap().toc {
|
||||
// TODO: Generate TOC
|
||||
Some(true) => String::new(),
|
||||
_ => default.2,
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
use actix_web::{get, routes, web, HttpRequest, HttpResponse, Responder};
|
||||
use cached::proc_macro::once;
|
||||
use glob::glob;
|
||||
use ramhorns::Content;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
misc::utils::get_url,
|
||||
misc::{
|
||||
markdown::{read_file, File, TypeFileMetadata},
|
||||
utils::get_url,
|
||||
},
|
||||
template::{Infos, NavBar},
|
||||
};
|
||||
|
||||
|
@ -63,17 +67,65 @@ async fn service_redirection(req: HttpRequest) -> impl Responder {
|
|||
#[derive(Content, Debug)]
|
||||
struct NetworksTemplate {
|
||||
navbar: NavBar,
|
||||
|
||||
socials_exists: bool,
|
||||
socials: Vec<File>,
|
||||
|
||||
forges_exists: bool,
|
||||
forges: Vec<File>,
|
||||
|
||||
others_exists: bool,
|
||||
others: Vec<File>,
|
||||
}
|
||||
|
||||
fn remove_paragraphs(list: &mut [File]) {
|
||||
list.iter_mut()
|
||||
.for_each(|file| file.content = file.content.replace("<p>", "").replace("</p>", ""));
|
||||
}
|
||||
|
||||
#[once(time = 60)]
|
||||
fn build_page(config: Config, url: String) -> String {
|
||||
let contacts_dir = "data/contacts";
|
||||
let ext = ".md";
|
||||
|
||||
let socials_dir = "socials";
|
||||
let mut socials = glob(&format!("{contacts_dir}/{socials_dir}/*{ext}"))
|
||||
.unwrap()
|
||||
.map(|e| read_file(&e.unwrap().to_string_lossy(), TypeFileMetadata::Contact).unwrap())
|
||||
.collect::<Vec<File>>();
|
||||
|
||||
let forges_dir = "forges";
|
||||
let mut forges = glob(&format!("{contacts_dir}/{forges_dir}/*{ext}"))
|
||||
.unwrap()
|
||||
.map(|e| read_file(&e.unwrap().to_string_lossy(), TypeFileMetadata::Contact).unwrap())
|
||||
.collect::<Vec<File>>();
|
||||
|
||||
let others_dir = "others";
|
||||
let mut others = glob(&format!("{contacts_dir}/{others_dir}/*{ext}"))
|
||||
.unwrap()
|
||||
.map(|e| read_file(&e.unwrap().to_string_lossy(), TypeFileMetadata::Contact).unwrap())
|
||||
.collect::<Vec<File>>();
|
||||
|
||||
// Remove paragraphs in custom statements
|
||||
[&mut socials, &mut forges, &mut others]
|
||||
.iter_mut()
|
||||
.for_each(|it| remove_paragraphs(it));
|
||||
|
||||
config.tmpl.render(
|
||||
"contact.html",
|
||||
"contact/index.html",
|
||||
NetworksTemplate {
|
||||
navbar: NavBar {
|
||||
contact: true,
|
||||
..NavBar::default()
|
||||
},
|
||||
socials_exists: !socials.is_empty(),
|
||||
socials,
|
||||
|
||||
forges_exists: !forges.is_empty(),
|
||||
forges,
|
||||
|
||||
others_exists: !others.is_empty(),
|
||||
others,
|
||||
},
|
||||
Infos {
|
||||
page_title: Some("Contacts".into()),
|
||||
|
|
|
@ -6,7 +6,7 @@ use ramhorns::Content;
|
|||
use crate::{
|
||||
config::Config,
|
||||
misc::{
|
||||
markdown::{read_file, File},
|
||||
markdown::{read_file, File, TypeFileMetadata},
|
||||
utils::get_url,
|
||||
},
|
||||
template::{Infos, NavBar},
|
||||
|
@ -38,7 +38,7 @@ fn build_page(config: Config, url: String) -> String {
|
|||
// Get apps
|
||||
let apps = glob(&format!("{projects_dir}/*{ext}"))
|
||||
.unwrap()
|
||||
.map(|e| read_file(&e.unwrap().to_string_lossy()).unwrap())
|
||||
.map(|e| read_file(&e.unwrap().to_string_lossy(), TypeFileMetadata::Portfolio).unwrap())
|
||||
.collect::<Vec<File>>();
|
||||
|
||||
let appdata = if apps.is_empty() {
|
||||
|
@ -50,7 +50,7 @@ fn build_page(config: Config, url: String) -> String {
|
|||
// Get archived apps
|
||||
let archived_apps = glob(&format!("{projects_dir}/archive/*{ext}"))
|
||||
.unwrap()
|
||||
.map(|e| read_file(&e.unwrap().to_string_lossy()).unwrap())
|
||||
.map(|e| read_file(&e.unwrap().to_string_lossy(), TypeFileMetadata::Portfolio).unwrap())
|
||||
.collect::<Vec<File>>();
|
||||
|
||||
let archived_appdata = if archived_apps.is_empty() {
|
||||
|
|
|
@ -15,6 +15,10 @@ main ul {
|
|||
column-gap: 5em;
|
||||
}
|
||||
|
||||
main li {
|
||||
break-inside: avoid-column;
|
||||
}
|
||||
|
||||
main li > p {
|
||||
margin: 0;
|
||||
padding: 3%;
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{>navbar.html}} {{#info}}
|
||||
{{>navbar.html}} {{#info}} {{#blog}}
|
||||
<h1>{{title}}</h1>
|
||||
{{#date}} {{>blog/date.html}} {{/date}}
|
||||
<ul>
|
||||
|
@ -23,7 +23,7 @@
|
|||
<li>{{name}}</li>
|
||||
{{/tags}}
|
||||
</ul>
|
||||
{{/info}} {{/metadata}} {{/post}}
|
||||
{{/blog}} {{/info}} {{/metadata}} {{/post}}
|
||||
</header>
|
||||
<main>
|
||||
{{^post}}
|
||||
|
|
|
@ -1,223 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head dir="ltr">
|
||||
{{>head.html}}
|
||||
<link rel="stylesheet" href="/css/contact.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>{{>navbar.html}}</header>
|
||||
<main>
|
||||
<h1>Contact</h1>
|
||||
<p>Je suis présent relativement partout sur internet 😸</p>
|
||||
<h2>Réseaux sociaux</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
Twitter :
|
||||
<a
|
||||
href="/contact/twitter"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Twitter, pour le shitposting"
|
||||
>@Mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Mastodon :
|
||||
<a
|
||||
href="/contact/mastodon"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Mastodon, alternative à Twitter, principalement pour l'IT"
|
||||
>Mylloon@piaille.fr</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Bluesky :
|
||||
<a
|
||||
href="/contact/bluesky"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Bluesky, alternative à Twitter, quand Elon aura rendu Twitter payant je serais principalement sur Bluesky"
|
||||
>mylloon.fr</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Discord :
|
||||
<a
|
||||
href="/contact/discord/user"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Discord perso"
|
||||
>mylloon</a
|
||||
>
|
||||
et
|
||||
<a
|
||||
href="/contact/discord/guild"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Serveur Discord accessible à tous, venez !"
|
||||
>mon serveur</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Reddit :
|
||||
<a
|
||||
href="/contact/reddit"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Reddit, sert à rien"
|
||||
>mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Instagram :
|
||||
<a
|
||||
href="/contact/instagram"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Instagram, sert à rien"
|
||||
>mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Kitsu :
|
||||
<a
|
||||
href="/contact/kitsu"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Kitsu, pour suivre les anime/manga/webtoon que je lis/regarde"
|
||||
>Mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Steam :
|
||||
<a
|
||||
href="/contact/steam"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Steam pour les jeux-vidéos"
|
||||
>Mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Youtube :
|
||||
<a
|
||||
href="/contact/youtube"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte YouTube, parfois je poste des vidéos JV ou IT"
|
||||
>Mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Twitch :
|
||||
<a
|
||||
href="/contact/twitch"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Twitch, parfois je stream soit des JV soit du dev"
|
||||
>mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Forges</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
Github :
|
||||
<a
|
||||
href="/contact/github"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte GitHub, principalement pour les contributions"
|
||||
>Mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Gitlab :
|
||||
<a
|
||||
href="/contact/gitlab"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Gitlab, sert à rien"
|
||||
>Mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Codeberg :
|
||||
<a
|
||||
href="/contact/codeberg"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Codeberg, pas utilisé mais j'adore Codeberg !"
|
||||
>Mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Forgejo (mon instance) :
|
||||
<a
|
||||
href="/contact/forgejo"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Compte Forgejo, là où il y a tout mes projets"
|
||||
>Anri</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Autre</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
Mail :
|
||||
<a
|
||||
href="mailto:kennel.anri%20at%20tutanota.com"
|
||||
title="kennel.anri at tutanota.com"
|
||||
>kennel.anri at tutanota.com</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Keyoxide :
|
||||
<a
|
||||
href="/contact/keyoxide"
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="Page Keyoxide, vérifie l'appartenance de la majorité des comptes mentionné ici via GPG"
|
||||
>Mylloon</a
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
17
templates/contact/element.html
Normal file
17
templates/contact/element.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
{{#metadata}} {{#info}} {{#contact}}
|
||||
<li>
|
||||
<p>
|
||||
{{title}} : {{^custom}} {{#newtab}}
|
||||
<a
|
||||
href="{{link}} "
|
||||
target="_blank"
|
||||
rel="noreferrer me"
|
||||
title="{{description}} "
|
||||
>{{user}}</a
|
||||
>
|
||||
{{/newtab}} {{^newtab}}
|
||||
<a href="{{link}} " title="{{description}} ">{{user}}</a>
|
||||
{{/newtab}} {{/custom}} {{#custom}} {{&content}} {{/custom}}
|
||||
</p>
|
||||
</li>
|
||||
{{/contact}} {{/info}} {{/metadata}}
|
31
templates/contact/index.html
Normal file
31
templates/contact/index.html
Normal file
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head dir="ltr">
|
||||
{{>head.html}}
|
||||
<link rel="stylesheet" href="/css/contact.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>{{>navbar.html}}</header>
|
||||
<main>
|
||||
<h1>Contact</h1>
|
||||
<p>Je suis présent relativement partout sur internet 😸</p>
|
||||
|
||||
{{#data}} {{#socials_exists}}
|
||||
<h2>Réseaux sociaux</h2>
|
||||
<ul>
|
||||
{{#socials}} {{>contact/element.html}} {{/socials}}
|
||||
</ul>
|
||||
{{/socials_exists}} {{#forges_exists}}
|
||||
<h2>Forges</h2>
|
||||
<ul>
|
||||
{{#forges}} {{>contact/element.html}} {{/forges}}
|
||||
</ul>
|
||||
{{/forges_exists}} {{#others_exists}}
|
||||
<h2>Autre</h2>
|
||||
<ul>
|
||||
{{#others}} {{>contact/element.html}} {{/others}}
|
||||
</ul>
|
||||
{{/others_exists}} {{/data}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -1,7 +1,7 @@
|
|||
{{#metadata}} {{#info}} {{#link}}
|
||||
{{#metadata}} {{#info}} {{#portfolio}} {{#link}}
|
||||
<li role="button" onclick="window.open('{{link}}', '_blank', 'noreferrer');">
|
||||
{{>portfolio/project.html}}
|
||||
</li>
|
||||
{{/link}} {{^link}}
|
||||
<li>{{>portfolio/project.html}}</li>
|
||||
{{/link}} {{/info}} {{/metadata}}
|
||||
{{/link}} {{/portfolio}} {{/info}} {{/metadata}}
|
||||
|
|
Loading…
Reference in a new issue