Compare commits
20 commits
main
...
math-rewor
Author | SHA1 | Date | |
---|---|---|---|
ecbbe85844 | |||
2cb1e664fe | |||
5520952d07 | |||
6eef32b6d9 | |||
7fc3f95dd5 | |||
61015a2536 | |||
2a44f1240f | |||
a738d492d2 | |||
fe9a0c750b | |||
fcc146842c | |||
23079f4418 | |||
a9f48a79a4 | |||
b3cdcff067 | |||
1c19d23f36 | |||
bbd86393eb | |||
9bf1bc807c | |||
d025981f0e | |||
fc44816a04 | |||
4ccd2c8709 | |||
21cb50e5fd |
21 changed files with 385 additions and 47 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -927,9 +927,11 @@ dependencies = [
|
|||
"minify-html",
|
||||
"minify-js 0.5.6",
|
||||
"ramhorns",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rss",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"toml",
|
||||
]
|
||||
|
|
|
@ -17,6 +17,7 @@ ramhorns = "0.14"
|
|||
toml = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1.0"
|
||||
minify-html = "0.11"
|
||||
minify-js = "0.5"
|
||||
glob = "0.3"
|
||||
|
@ -26,3 +27,4 @@ chrono = { version = "0.4.30", default-features = false, features = ["clock"]}
|
|||
chrono-tz = "0.8"
|
||||
rss = { version = "2.0", features = ["atom"] }
|
||||
lol_html = "1.2"
|
||||
regex = "1.10"
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
- [Blog](#blog)
|
||||
- [Projects](#projects)
|
||||
- [Contacts](#contacts)
|
||||
- [Courses](#courses)
|
||||
|
||||
# Installation
|
||||
|
||||
|
@ -204,3 +205,7 @@ Custom project description
|
|||
- `user` is the username used in the platform
|
||||
- `description` will be rendered as HTML "title" (text will appear when cursor
|
||||
is hover the link)
|
||||
|
||||
## Courses
|
||||
|
||||
Markdown files are stored in `/app/data/cours/`
|
||||
|
|
|
@ -7,7 +7,7 @@ use std::{fs::File, io::Write, path::Path};
|
|||
use crate::template::Template;
|
||||
|
||||
/// Store the configuration of config/config.toml
|
||||
#[derive(Deserialize, Clone, Default, Debug)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct FileConfig {
|
||||
/// http/https
|
||||
pub scheme: Option<String>,
|
||||
|
|
|
@ -5,12 +5,12 @@ use serde::Deserialize;
|
|||
|
||||
use crate::misc::utils::get_reqwest_client;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubResponse {
|
||||
items: Vec<GithubProject>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubProject {
|
||||
repository_url: String,
|
||||
number: u32,
|
||||
|
@ -19,7 +19,7 @@ struct GithubProject {
|
|||
pull_request: GithubPullRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubPullRequest {
|
||||
html_url: String,
|
||||
merged_at: Option<String>,
|
||||
|
|
|
@ -3,10 +3,15 @@ use comrak::nodes::{AstNode, NodeValue};
|
|||
use comrak::{format_html, parse_document, Arena, ComrakOptions, ListStyleType};
|
||||
use lol_html::{element, rewrite_str, RewriteStrSettings};
|
||||
use ramhorns::Content;
|
||||
use regex::{Captures, Regex};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::fs;
|
||||
|
||||
#[derive(Default, Deserialize, Content, Debug)]
|
||||
/// Regular markdown files, no metadata
|
||||
#[derive(Content, Debug, Default, Deserialize)]
|
||||
pub struct FileNoMetadata {}
|
||||
|
||||
#[derive(Content, Debug, Default, Deserialize)]
|
||||
pub struct FileMetadataBlog {
|
||||
pub title: Option<String>,
|
||||
pub date: Option<Date>,
|
||||
|
@ -16,7 +21,7 @@ pub struct FileMetadataBlog {
|
|||
pub toc: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Content, Debug)]
|
||||
#[derive(Content, Debug, Default, Deserialize)]
|
||||
pub struct FileMetadataContact {
|
||||
pub title: String,
|
||||
pub custom: Option<bool>,
|
||||
|
@ -26,7 +31,7 @@ pub struct FileMetadataContact {
|
|||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Content, Debug)]
|
||||
#[derive(Content, Debug, Default, Deserialize)]
|
||||
pub struct FileMetadataPortfolio {
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
|
@ -38,13 +43,15 @@ pub enum TypeFileMetadata {
|
|||
Blog,
|
||||
Contact,
|
||||
Portfolio,
|
||||
Cours,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Content, Debug)]
|
||||
#[derive(Content, Debug, Default, Deserialize)]
|
||||
pub struct FileMetadata {
|
||||
pub blog: Option<FileMetadataBlog>,
|
||||
pub contact: Option<FileMetadataContact>,
|
||||
pub portfolio: Option<FileMetadataPortfolio>,
|
||||
pub cours: Option<FileNoMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Content, Debug, Clone)]
|
||||
|
@ -164,12 +171,35 @@ fn custom_img_size(html: String) -> String {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
fn math_processing(regex: &str, source: &str) -> String {
|
||||
Regex::new(regex)
|
||||
.unwrap()
|
||||
.replace_all(source, |captures: &Captures| {
|
||||
captures
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|capture| {
|
||||
capture.map(|m| format!("<span data-katex=\"{}\"></span>", m.as_str()))
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("")
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Transform markdown string to File structure
|
||||
fn read(raw_text: &str, metadata_type: TypeFileMetadata) -> File {
|
||||
let arena = Arena::new();
|
||||
|
||||
// Transform LaTeX formulas
|
||||
let text = math_processing(
|
||||
r"(?U)(\$[^\$|\n]+\$)[^\$]",
|
||||
&math_processing(r"(?U)(\$\$[^\$|\n]+\$\$)", raw_text),
|
||||
);
|
||||
println!("{}", text);
|
||||
|
||||
let options = get_options();
|
||||
let root = parse_document(&arena, raw_text, &options);
|
||||
let root = parse_document(&arena, &text, &options);
|
||||
|
||||
// Find metadata
|
||||
let metadata = get_metadata(root, metadata_type);
|
||||
|
@ -182,6 +212,7 @@ fn read(raw_text: &str, metadata_type: TypeFileMetadata) -> File {
|
|||
format_html(root, &options, &mut html).unwrap();
|
||||
|
||||
let mut html_content = String::from_utf8(html).unwrap();
|
||||
/* println!("{}", html_content); */
|
||||
|
||||
html_content = custom_img_size(html_content);
|
||||
|
||||
|
@ -227,6 +258,10 @@ pub fn get_metadata<'a>(root: &'a AstNode<'a>, mtype: TypeFileMetadata) -> FileM
|
|||
portfolio: Some(deserialize_metadata(text)),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
TypeFileMetadata::Cours => FileMetadata {
|
||||
cours: Some(deserialize_metadata(text)),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
}),
|
||||
_ => None,
|
||||
}) {
|
||||
|
@ -244,6 +279,10 @@ pub fn get_metadata<'a>(root: &'a AstNode<'a>, mtype: TypeFileMetadata) -> FileM
|
|||
portfolio: Some(FileMetadataPortfolio::default()),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
TypeFileMetadata::Cours => FileMetadata {
|
||||
cours: Some(FileNoMetadata::default()),
|
||||
..FileMetadata::default()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ struct PortfolioTemplate {
|
|||
closed: Option<Vec<Project>>,
|
||||
}
|
||||
|
||||
#[derive(Content, Clone, Debug)]
|
||||
#[derive(Clone, Content, Debug)]
|
||||
struct Project {
|
||||
name: String,
|
||||
url: String,
|
||||
|
@ -35,7 +35,7 @@ struct Project {
|
|||
pulls_closed: Vec<Pull>,
|
||||
}
|
||||
|
||||
#[derive(Content, Clone, Debug)]
|
||||
#[derive(Clone, Content, Debug)]
|
||||
struct Pull {
|
||||
url: String,
|
||||
id: u32,
|
||||
|
|
|
@ -1,9 +1,128 @@
|
|||
use actix_web::{get, Responder};
|
||||
use std::path::Path;
|
||||
|
||||
use actix_web::{get, web, Responder};
|
||||
use ramhorns::Content;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
misc::{
|
||||
markdown::{read_file, File, TypeFileMetadata},
|
||||
utils::{make_kw, Html},
|
||||
},
|
||||
template::{Infos, NavBar},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PathRequest {
|
||||
q: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/cours")]
|
||||
async fn page() -> impl Responder {
|
||||
// Page de notes de cours
|
||||
// Cf. https://univ.mylloon.fr/
|
||||
// Cf. https://github.com/xy2z/PineDocs
|
||||
actix_web::web::Redirect::to("/")
|
||||
async fn page(info: web::Query<PathRequest>, config: web::Data<Config>) -> impl Responder {
|
||||
Html(build_page(info, config.get_ref().to_owned()))
|
||||
}
|
||||
|
||||
#[derive(Content, Debug)]
|
||||
struct CoursTemplate {
|
||||
navbar: NavBar,
|
||||
filetree: String,
|
||||
content: Option<File>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FileNode {
|
||||
name: String,
|
||||
is_dir: bool,
|
||||
children: Vec<FileNode>,
|
||||
}
|
||||
|
||||
/// Build the filetree
|
||||
fn get_filetree(dir_path: &str, exclusion_list: &[&str]) -> FileNode {
|
||||
let entries = std::fs::read_dir(dir_path).unwrap();
|
||||
|
||||
let mut children = Vec::new();
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let entry_path = entry.path();
|
||||
let entry_name = entry_path.file_name().and_then(|n| n.to_str()).unwrap();
|
||||
|
||||
// We should support regex?
|
||||
if !exclusion_list.contains(&entry_name) {
|
||||
let filename = entry_name.to_string();
|
||||
if entry_path.is_file() {
|
||||
children.push(FileNode {
|
||||
name: filename,
|
||||
is_dir: false,
|
||||
children: vec![],
|
||||
});
|
||||
} else {
|
||||
children.push(get_filetree(entry_path.to_str().unwrap(), exclusion_list));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileNode {
|
||||
name: Path::new(dir_path)
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
is_dir: true,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a page content
|
||||
fn get_content(
|
||||
cours_dir: &str,
|
||||
path: &web::Query<PathRequest>,
|
||||
exclusion_list: &[&str],
|
||||
) -> Option<File> {
|
||||
let filename = match &path.q {
|
||||
Some(q) => q,
|
||||
None => "index.md",
|
||||
};
|
||||
|
||||
// We should support regex?
|
||||
if exclusion_list
|
||||
.iter()
|
||||
.any(|&excluded_term| filename.contains(excluded_term))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
read_file(&format!("{cours_dir}/{filename}"), TypeFileMetadata::Cours)
|
||||
}
|
||||
|
||||
// #[once(time = 60)]
|
||||
// TODO: Uncomment before release
|
||||
fn build_page(info: web::Query<PathRequest>, config: Config) -> String {
|
||||
let cours_dir = "data/cours";
|
||||
let exclusion_list = [];
|
||||
let filetree = get_filetree(cours_dir, &exclusion_list);
|
||||
|
||||
config.tmpl.render(
|
||||
"cours.html",
|
||||
CoursTemplate {
|
||||
navbar: NavBar {
|
||||
cours: true,
|
||||
..NavBar::default()
|
||||
},
|
||||
filetree: serde_json::to_string(&filetree).unwrap(),
|
||||
content: get_content(cours_dir, &info, &exclusion_list),
|
||||
},
|
||||
Infos {
|
||||
page_title: Some("Cours".into()),
|
||||
page_desc: Some("Cours à l'univ".into()),
|
||||
page_kw: make_kw(&[
|
||||
"cours",
|
||||
"études",
|
||||
"université",
|
||||
"licence",
|
||||
"master",
|
||||
"notes",
|
||||
"digital garden",
|
||||
]),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ pub struct Template {
|
|||
}
|
||||
|
||||
/// Structure used by /routes/*.rs
|
||||
#[derive(Default, Debug)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Infos {
|
||||
/// Title
|
||||
pub page_title: Option<String>,
|
||||
|
|
47
static/css/cours.css
Normal file
47
static/css/cours.css
Normal file
|
@ -0,0 +1,47 @@
|
|||
/* Filetree */
|
||||
aside {
|
||||
float: left;
|
||||
margin-left: 20px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
aside ul {
|
||||
list-style: none;
|
||||
padding-left: 0.6em;
|
||||
}
|
||||
|
||||
aside li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Element */
|
||||
aside li:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -0.2em;
|
||||
left: -1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
aside li.collapsed > ul {
|
||||
display: none;
|
||||
}
|
||||
|
||||
aside li.directory::before {
|
||||
content: "+";
|
||||
}
|
||||
|
||||
aside li:not(.collapsed).directory::before {
|
||||
content: "-";
|
||||
}
|
||||
|
||||
aside li.directory {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media print {
|
||||
aside {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,14 +0,0 @@
|
|||
|
||||
|
||||
|
||||
Ces fontes sont distribuées gratuitement sous Licence publique Creative Commons Attribution 4.0 International :
|
||||
https://creativecommons.org/licenses/by/4.0/legalcode.fr
|
||||
|
||||
|
||||
|
||||
These fonts are freely available under Creative Commons Attribution 4.0 International Public License:
|
||||
https://creativecommons.org/licenses/by/4.0/legalcode
|
||||
|
||||
|
||||
|
||||
Luciole © Laurent Bourcellier & Jonathan Perez
|
|
@ -65,3 +65,9 @@ header nav a:hover {
|
|||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media print {
|
||||
header nav {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
|
104
static/js/cours.js
Normal file
104
static/js/cours.js
Normal file
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Build the filetree
|
||||
* @param {HTMLElement} parent Root element of the filetree
|
||||
* @param {{name: string, is_dir: boolean, children: any[]}} data FileNode
|
||||
* @param {string} location Current location, used for links creation
|
||||
*/
|
||||
const buildFileTree = (parent, data, location) => {
|
||||
const ul = document.createElement("ul");
|
||||
data.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.classList.add(item.is_dir ? "directory" : "file");
|
||||
|
||||
if (item.is_dir) {
|
||||
// Directory
|
||||
li.textContent = item.name;
|
||||
li.classList.add("collapsed");
|
||||
|
||||
// Toggle collapsing on click
|
||||
li.addEventListener("click", function (e) {
|
||||
if (e.target === li) {
|
||||
li.classList.toggle("collapsed");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// File
|
||||
const url = window.location.href.split("?")[0];
|
||||
const a = document.createElement("a");
|
||||
a.text = item.name;
|
||||
a.href = `${url}?q=${location}${item.name}`;
|
||||
li.appendChild(a);
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
buildFileTree(
|
||||
li,
|
||||
item.children,
|
||||
item.is_dir ? location + `${item.name}/` : location
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
parent.appendChild(ul);
|
||||
};
|
||||
|
||||
/**
|
||||
* Uncollapse elements from the deepest element
|
||||
* @param {HTMLLIElement} element Element to uncollapse
|
||||
*/
|
||||
const uncollapse = (element) => {
|
||||
if (element) {
|
||||
element.classList.remove("collapsed");
|
||||
uncollapse(element.parentElement.closest("li"));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the deepest opened directory
|
||||
* @param {string[]} path Current path we are looking at, init with fullpath
|
||||
* @param {NodeListOf<ChildNode>} options Options we have, init with list root
|
||||
* @returns
|
||||
*/
|
||||
const deepestNodeOpened = (path, options) => {
|
||||
// Iterate over possible options
|
||||
for (let i = 0; i < options.length; ++i) {
|
||||
// If the directory and the current path match
|
||||
if (decodeURI(path[0]) === options[i].firstChild.nodeValue) {
|
||||
if (path.length === 1) {
|
||||
// We found it
|
||||
return options[i];
|
||||
}
|
||||
|
||||
// Continue the search
|
||||
return deepestNodeOpened(
|
||||
path.slice(1),
|
||||
options[i].querySelector("ul").childNodes
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
// Build the filetree
|
||||
const fileTreeElement = document.getElementsByTagName("aside")[0];
|
||||
const dataElement = fileTreeElement.getElementsByTagName("span")[0];
|
||||
|
||||
buildFileTree(
|
||||
fileTreeElement,
|
||||
JSON.parse(dataElement.getAttribute("data-json")).children,
|
||||
""
|
||||
);
|
||||
dataElement.remove();
|
||||
|
||||
// Open nested openeded directories
|
||||
const fullpath = window.location.href.split("?")[1].substring(2);
|
||||
const path = fullpath.substring(0, fullpath.lastIndexOf("/"));
|
||||
const last_openeded = deepestNodeOpened(
|
||||
path.split("/"),
|
||||
fileTreeElement.querySelector("ul").childNodes
|
||||
);
|
||||
|
||||
uncollapse(last_openeded);
|
||||
});
|
|
@ -10,17 +10,26 @@ window.addEventListener("load", () => {
|
|||
la: "leftarrow",
|
||||
RA: "Rightarrow",
|
||||
LA: "Leftarrow",
|
||||
u: "mu",
|
||||
})
|
||||
)[Symbol.iterator]()) {
|
||||
macros[`\\${item[0]}`] = `\\${item[1]}`;
|
||||
}
|
||||
|
||||
renderMathInElement(document.body, {
|
||||
delimiters: [
|
||||
{ left: "$$", right: "$$", display: true },
|
||||
{ left: "$", right: "$", display: false },
|
||||
],
|
||||
document.querySelectorAll("span[data-katex]").forEach((element) => {
|
||||
const rawLaTeXFormula = element.getAttribute("data-katex");
|
||||
const displayMode = rawLaTeXFormula.startsWith("$$");
|
||||
const strip = displayMode ? 2 : 1;
|
||||
|
||||
katex.render(
|
||||
rawLaTeXFormula.slice(strip, rawLaTeXFormula.length - strip),
|
||||
element,
|
||||
{
|
||||
throwOnError: false,
|
||||
macros,
|
||||
displayMode,
|
||||
output: "mathml",
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
26
templates/cours.html
Normal file
26
templates/cours.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head dir="ltr">
|
||||
{{>head.html}}
|
||||
<link rel="stylesheet" href="/css/cours.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>{{>navbar.html}}</header>
|
||||
|
||||
<aside>
|
||||
<span data-json="{{#data}}{{filetree}}{{/data}} "></span>
|
||||
</aside>
|
||||
<main>
|
||||
{{#data}} {{^content}}
|
||||
<p>Fichier introuvable</p>
|
||||
{{/content}} {{#content}}
|
||||
<article>{{&content}}</article>
|
||||
</main>
|
||||
|
||||
{{#metadata}} {{#mermaid}}{{>libs/mermaid_footer.html}}{{/mermaid}}
|
||||
{{#math}}{{>libs/katex_footer.html}}{{/math}}
|
||||
{{#syntax_highlight}}{{>libs/hljs_footer.html}}{{/syntax_highlight}}
|
||||
{{/metadata}} {{/content}} {{/data}}
|
||||
<script src="/js/cours.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -4,10 +4,4 @@
|
|||
integrity="sha384-j/ZricySXBnNMJy9meJCtyXTKMhIJ42heyr7oAdxTDBy/CYA9hzpMo+YTNV5C+1X"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script
|
||||
defer
|
||||
src="//cdn.jsdelivr.net/npm/katex@0.16.6/dist/contrib/auto-render.min.js"
|
||||
integrity="sha384-+VBxd3r6XgURycqtZ117nYw44OOcIax56Z4dCRWbxyPt0Koah1uHoK0o4+/RRE05"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script src="/js/libs/katex.js"></script>
|
||||
|
|
|
@ -45,9 +45,8 @@
|
|||
|
||||
<p><a
|
||||
class="_ {{#cours}}bold{{/cours}}"
|
||||
href="https://univ.mylloon.fr"
|
||||
href="/cours"
|
||||
title="Page des notes de cours"
|
||||
target="_blank"
|
||||
>Cours</a
|
||||
></p>
|
||||
|
||||
|
|
Loading…
Reference in a new issue