commit 18d97e0c5afaf9f457a268393ff744db4113d828 Author: kawa Date: Sat Mar 7 11:50:19 2026 +0100 Commit initial diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6660a08 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 build.py)", + "WebFetch(domain:scribe-template.webflow.io)", + "Bash(python3 -c \"import markdown; md = markdown.Markdown\\(extensions=[''toc''], extension_configs={''toc'': {''permalink'': ''#'', ''permalink_class'': ''heading-anchor'', ''toc_depth'': ''1-4''}}\\); print\\(md.convert\\(''## Sous-titre\\\\n\\\\nTexte.''\\)\\)\")" + ] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7277d27 --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +# PageMark + +Générateur de site statique minimaliste alimenté par des fichiers Markdown. + +Écrivez du contenu en `.md`, lancez `python3 build.py`, déployez les fichiers HTML générés. + +## Fonctionnalités + +- Pages et articles en Markdown avec coloration syntaxique +- Page d'accueil générée automatiquement (5 derniers articles, du plus récent au plus ancien) +- Catégorisation automatique des articles avec arborescence de dossiers +- Métadonnées par article : titre, date, auteur, catégorie, image de couverture +- Dark mode : automatique selon l'heure (light 08h30–17h30) + toggle manuel mémorisé +- Ancres cliquables sur les titres `h1`–`h4` +- HTML personnalisé dans les articles +- Menu avec filtrage automatique des liens invalides et distinction liens internes/externes +- Aucune dépendance JavaScript côté client + +## Prérequis + +- Python 3.x +- `pip install markdown` + +## Installation + +```bash +git clone https://github.com/votre-repo/pagemark +cd pagemark +pip install markdown +``` + +## Structure du projet + +``` +pagemark/ +├── build.py # Générateur de site (lancer pour builder) +├── README.md +├── static/ +│ ├── style.css # Styles (light + dark mode) +│ └── theme.js # Gestion du dark mode (localStorage + heure) +└── md/ + ├── header.md # Nom du site (# Titre) + tagline (texte suivant) + ├── footer.md # Pied de page + ├── menu.md # Navigation (liste Markdown) + ├── categories.md # Mapping catégories → dossiers de sortie + ├── images/ # Images référençables via /images/fichier.jpg + └── pages/ + ├── index.md # Contenu optionnel affiché au-dessus des articles + └── *.md # Vos pages et articles +``` + +Les fichiers HTML sont générés à la racine du projet. + +## Mise en route rapide + +### 1. Configurer le site + +**`md/header.md`** — la première ligne `# Titre` devient le logo dans la nav, le reste la tagline : + +```markdown +# Mon Site +Le blog d'un passionné de technologie +``` + +**`md/footer.md`** — pied de page en Markdown : + +```markdown +© 2026 Votre Nom +``` + +**`md/menu.md`** — liste de liens. Les liens internes sont filtrés automatiquement (masqués si la page n'existe pas), les liens externes s'ouvrent dans un nouvel onglet avec le suffixe `↗` : + +```markdown +- [Accueil](/) +- [À propos](/racine/a-propos/) +- [GitHub](https://github.com/vous) +``` + +> Le chemin d'un lien interne doit correspondre au chemin de sortie généré (voir section Catégories). + +### 2. Créer une page ou un article + +Créez un fichier `.md` dans `md/pages/`. Les métadonnées se placent en tête de fichier : + +```markdown +::Titre:Mon premier article +::Date:07/03/2026 +::Auteur:Votre Nom +::Categories:Blog +::Image:/images/cover.jpg + +Contenu de l'article en Markdown... +``` + +> Une page sans `::Date:` est une page statique (elle n'apparaît pas dans le listing de la page d'accueil). + +### 3. Générer le site + +```bash +python3 build.py +``` + +### 4. Tester en local + +```bash +python3 -m http.server 3000 +``` + +Puis ouvrez [http://localhost:3000](http://localhost:3000). + +--- + +## Métadonnées + +| Métadonnée | Syntaxe | Rôle | +|---|---|---| +| `::Titre:` | `::Titre:Mon titre` | Affiché en `

` avant le contenu | +| `::Date:` | `::Date:dd/mm/YYYY` | Date de publication, utilisée pour le tri | +| `::Auteur:` | `::Auteur:Pseudo` | Nom de l'auteur | +| `::Categories:` | `::Categories:Blog-Tutos` | Catégorie et sous-catégorie | +| `::Image:` | `::Image:/images/cover.jpg` | Image de couverture dans les cards | + +Toutes les métadonnées sont optionnelles. Seules les pages avec `::Date:` sont traitées comme des **articles** et apparaissent dans le listing de la page d'accueil. + +--- + +## Catégories + +Le fichier `md/categories.md` mappe les noms de catégories vers des dossiers de sortie : + +``` +- Blog:/blog +- News:/news +- Tutoriels:/tutos +``` + +**Exemple :** `::Categories:News-Tech` + +1. `News` est résolu via `categories.md` → dossier `news` +2. `Tech` (sous-catégorie) → sous-dossier `tech` +3. Fichier `mon-article.md` → généré dans `news/tech/mon-article/index.html` +4. URL : `/news/tech/mon-article/` + +Si une catégorie n'est pas dans `categories.md`, son nom en minuscules est utilisé comme dossier. + +--- + +## Navigation + +**`md/menu.md`** contient une liste Markdown de liens : + +```markdown +- [Accueil](/) +- [Blog](/blog/) +- [GitHub](https://github.com/vous) +``` + +| Type de lien | Comportement | +|---|---| +| Chemin relatif (`/page/`) | Lien **interne** — affiché uniquement si la page a été générée | +| URL complète (`https://`) | Lien **externe** — toujours affiché, suffixe `↗`, `target="_blank"` | + +--- + +## Images + +Placez vos images dans `md/images/` et référencez-les dans le Markdown : + +```markdown +![Description de l'image](/images/photo.jpg) +``` + +Pour l'image de couverture d'un article (affichée dans les cards de la page d'accueil) : + +``` +::Image:/images/cover.jpg +``` + +--- + +## HTML personnalisé + +Du HTML peut être inséré directement dans n'importe quel fichier `.md` : + +```html +
+ Bloc personnalisé — les variables CSS du thème sont disponibles. +
+``` + +Les variables CSS `var(--bg)`, `var(--text)`, `var(--accent)`, etc. s'adaptent automatiquement au dark mode. + +--- + +## Dark mode + +| Comportement | Détail | +|---|---| +| **Par défaut** | Light de 08h30 à 17h30, dark le reste du temps | +| **Manuel** | Bouton `☾` / `☀` dans la barre de navigation | +| **Mémorisation** | Le choix est sauvegardé via `localStorage` — il prend le dessus sur le comportement automatique | + +--- + +## Ancres de titres + +Les titres `h1` à `h4` reçoivent automatiquement un `id` et un lien `#` cliquable au survol. Cela permet de créer des liens directs vers une section : + +``` +https://monsite.com/mon-article#sous-titre +``` + +L'`id` est généré à partir du texte du titre (minuscules, tirets à la place des espaces). + +--- + +## Déploiement avec nginx + +Copiez les fichiers générés vers votre serveur. Configuration nginx minimale : + +```nginx +server { + listen 80; + server_name monsite.com; + root /var/www/pagemark; + + location / { + try_files $uri $uri/index.html =404; + } +} +``` + +La directive `try_files $uri $uri/index.html` permet de résoudre `/mon-article/` → `/mon-article/index.html` sans redirection. + +--- + +## Workflow recommandé + +``` +1. Éditer md/pages/mon-article.md +2. python3 build.py +3. Copier les fichiers générés sur le serveur +``` + +Pour automatiser le déploiement, un simple script shell suffit : + +```bash +#!/bin/bash +python3 build.py && rsync -avz --delete . user@serveur:/var/www/pagemark \ + --exclude='md/' --exclude='static/' --exclude='*.py' --exclude='*.md' --exclude='.git' +``` + +--- + +## Licence + +MIT diff --git a/blog/exemple-article/index.html b/blog/exemple-article/index.html new file mode 100644 index 0000000..078cfa6 --- /dev/null +++ b/blog/exemple-article/index.html @@ -0,0 +1,128 @@ + + + + + + Exemple d'article — toutes les fonctionnalités + + + + + + + + + +

Le blog d'un SysAdmin

+

+
+
+ +

Ceci est un article d'exemple montrant toutes les fonctionnalités disponibles dans PageMark. Supprimez ce fichier quand vous n'en avez plus besoin.

+

Métadonnées#

+

Les métadonnées se placent en tête de fichier avec la syntaxe ::Clé:Valeur. Elles définissent le titre, la date, l'auteur et la catégorie de l'article.

+

Mise en forme du texte#

+

Le texte supporte toute la syntaxe Markdown :

+
    +
  • Gras avec **texte**
  • +
  • Italique avec *texte*
  • +
  • ~~Barré~~ avec ~~texte~~
  • +
  • Code inline avec des backticks
  • +
  • Lien avec [texte](url)
  • +
+

Titres et ancres#

+

Les titres h1 à h4 génèrent automatiquement une ancre cliquable # au survol, permettant de créer des liens directs vers une section :

+
https://monsite.com/exemple-article#images
+
+

Niveau 3#

+

Niveau 4#

+

Images#

+

Placez vos images dans md/images/ et insérez-les ainsi :

+
![Description de l'image](/images/photo.jpg)
+
+

L'image de couverture (affichée dans les cards de la page d'accueil) se définit avec ::Image:/images/cover.jpg.

+

Blocs de code#

+

Les blocs de code supportent la coloration syntaxique pour la plupart des langages :

+
def saluer(nom: str) -> str:
+    return f"Bonjour, {nom} !"
+
+print(saluer("PageMark"))
+
+
# Générer le site
+python3 build.py
+
+# Test local
+python3 -m http.server 3000
+
+
document.addEventListener("DOMContentLoaded", () => {
+  console.log("PageMark chargé !");
+});
+
+

Tableaux#

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FonctionnalitéSyntaxeRésultat
Titre::Titre:Mon titreh1 avant l'article
Date::Date:dd/mm/YYYYTri chronologique
Catégorie::Categories:A-BDossier a/b/
Image couverture::Image:/img/x.jpgCard sur l'accueil
+

Citations#

+
+

Les blockquotes s'écrivent avec > en début de ligne. +Elles peuvent s'étendre sur plusieurs lignes.

+
+

HTML personnalisé#

+

Vous pouvez insérer du HTML directement dans le Markdown — utile pour des mises en forme avancées, des iframes, des widgets, etc.

+
+ Astuce : utilisez les variables CSS var(--accent), var(--bg), var(--text), etc. pour que votre HTML s'adapte automatiquement au dark mode. +
+ +

Listes#

+

Liste non ordonnée :

+
    +
  • Élément A
  • +
  • Élément B
  • +
  • Sous-élément
  • +
  • Sous-élément
  • +
+

Liste ordonnée :

+
    +
  1. Première étape
  2. +
  3. Deuxième étape
  4. +
  5. Troisième étape
  6. +
+

Liste de tâches :

+
    +
  • [x] Tâche terminée
  • +
  • [ ] Tâche en cours
  • +
  • [ ] Tâche à faire
  • +
+
+
+

© 2026 Sebastien QUEROL — Propulsé par PageMark

+ + \ No newline at end of file diff --git a/build.py b/build.py new file mode 100644 index 0000000..da9e235 --- /dev/null +++ b/build.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +""" +Générateur de site statique — PageMark + +Prérequis : pip install markdown + +Utilisation : python3 build.py + +── Métadonnées ─────────────────────────────────────────────────────────────── + ::Titre:Mon article + ::Date:01/12/2026 + ::Auteur:Pseudo + ::Categories:Principale-SousCategorie + ::Image:/images/cover.jpg ← optionnel, image de couverture pour les cards + +── Fichiers de layout ──────────────────────────────────────────────────────── + md/header.md Ligne 1 : # Nom du site | Reste : tagline + md/footer.md Pied de page + md/menu.md Navigation + md/categories.md Mapping NomCategorie:dossier + +── Sortie ──────────────────────────────────────────────────────────────────── + index.html ← homepage auto (5 derniers articles) + md/pages/index.md ← contenu affiché AU-DESSUS des articles + md/pages/article.md ← article/index.html + md/pages/article.md (catégorie) ← cat/sous-cat/article/index.html +""" + +import re +import sys +from datetime import datetime +from pathlib import Path + +try: + import markdown +except ImportError: + print("Erreur : installez la dépendance avec : pip install markdown") + sys.exit(1) + +BASE_DIR = Path(__file__).parent +MD_DIR = BASE_DIR / 'md' +PAGES_DIR = MD_DIR / 'pages' + +MD_EXTENSIONS = ['fenced_code', 'tables', 'attr_list', 'toc'] + +MD_EXTENSION_CONFIGS = { + 'toc': { + 'permalink': '#', # symbole cliquable affiché après le titre + 'permalink_class': 'heading-anchor', + 'toc_depth': '1-4', # h1 à h4 + } +} + + +# ── Markdown ────────────────────────────────────────────────────────────────── + +def parse_md(text): + md = markdown.Markdown( + extensions=MD_EXTENSIONS, + extension_configs=MD_EXTENSION_CONFIGS, + output_format='html5' + ) + return md.convert(text) + + +# ── Métadonnées ─────────────────────────────────────────────────────────────── + +_META_RE = re.compile(r'^::([^:]+):(.*)$') + +def parse_metadata(text): + meta = {} + lines = text.splitlines(keepends=True) + i = 0 + for line in lines: + stripped = line.rstrip('\n') + m = _META_RE.match(stripped) + if m: + meta[m.group(1).strip()] = m.group(2).strip() + i += 1 + elif stripped.strip() == '': + i += 1 + else: + break + return meta, ''.join(lines[i:]) + + +# ── Catégories ──────────────────────────────────────────────────────────────── + +def load_categories(): + cat_file = MD_DIR / 'categories.md' + cats = {} + if not cat_file.exists(): + return cats + for line in cat_file.read_text('utf-8').splitlines(): + line = line.strip().lstrip('-*').strip() + if not line or line.startswith('#'): + continue + if ':' in line: + name, folder = line.split(':', 1) + cats[name.strip().lower()] = folder.strip().lstrip('/') + return cats + +def categories_to_path(categories_str, cat_map): + parts = [p.strip() for p in categories_str.split('-') if p.strip()] + if not parts: + return None + result = [] + for i, part in enumerate(parts): + result.append(cat_map.get(part.lower(), part.lower()) if i == 0 else part.lower()) + return '/'.join(result) + +def categories_to_display(categories_str): + parts = [p.strip() for p in categories_str.split('-') if p.strip()] + return ' > '.join(parts) + + +# ── Header du site ──────────────────────────────────────────────────────────── + +def extract_site_name(header_md): + """Extrait le premier # Heading comme nom/logo du site.""" + if not header_md: + return '' + m = re.search(r'^#\s+(.+)$', header_md, re.MULTILINE) + return m.group(1).strip() if m else '' + +def extract_tagline_html(header_md): + """Extrait tout ce qui suit le premier # Heading comme tagline.""" + if not header_md: + return '' + content = re.sub(r'^#[^\n]*\n?', '', header_md, count=1, flags=re.MULTILINE).strip() + return parse_md(content) if content else '' + + +# ── Articles ────────────────────────────────────────────────────────────────── + +def parse_date(date_str): + try: + return datetime.strptime(date_str.strip(), '%d/%m/%Y') + except Exception: + return datetime.min + +def extract_excerpt(content_md, max_chars=180): + """Extrait le premier paragraphe lisible du markdown.""" + text = re.sub(r'```[\s\S]*?```', '', content_md) + text = re.sub(r'^#{1,6}\s+.*$', '', text, flags=re.MULTILINE) + text = re.sub(r'<[^>]+>', '', text) + text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) + text = re.sub(r'[*_]{1,3}', '', text) + paragraphs = [p.strip().replace('\n', ' ') for p in text.split('\n\n') if p.strip()] + if not paragraphs: + return '' + excerpt = paragraphs[0] + if len(excerpt) > max_chars: + excerpt = excerpt[:max_chars].rsplit(' ', 1)[0] + '\u2026' + return excerpt + +def build_article_card_html(article, featured=False): + img_html = '' + if article.get('image'): + img_html = (f'
' + f'{article[' + f'
') + + meta_parts = [] + if article.get('categories_display'): + meta_parts.append(f'{article["categories_display"]}') + if article.get('date_str'): + meta_parts.append(f'{article["date_str"]}') + if article.get('author'): + meta_parts.append(f'{article["author"]}') + + sep = ' \u00b7 ' + card_meta = ('
' + sep.join(meta_parts) + '
') if meta_parts else '' + excerpt_html = f'

{article["excerpt"]}

' if article.get('excerpt') else '' + + if featured: + return ( + f'' + ) + else: + return ( + f'
' + f'{img_html}' + f'
' + f'{card_meta}' + f'

{article["title"]}

' + f'{excerpt_html}' + f'
' + f'
' + ) + +def generate_homepage_html(articles, intro_html=''): + intro = f'
{intro_html}
' if intro_html else '' + + if not articles: + return intro + '

Aucun article publié pour l\'instant.

' + + featured = articles[0] + recent = articles[1:5] + + featured_section = ( + f'' + ) + + recent_section = '' + if recent: + cards = '\n'.join(build_article_card_html(a) for a in recent) + recent_section = ( + f'
' + f'' + f'
{cards}
' + f'
' + ) + + return intro + featured_section + recent_section + + +# ── Fichiers de layout ──────────────────────────────────────────────────────── + +def read_layout(name): + for p in [MD_DIR / f'{name}.md', MD_DIR / name]: + if p.exists(): + return p.read_text('utf-8') + return None + + +# ── Menu ────────────────────────────────────────────────────────────────────── + +def is_external(url): + return bool(re.match(r'^https?://', url)) + +def url_to_slug(url): + slug = url.strip('/') + return slug if slug else 'index' + +def build_menu_html(existing_slugs): + text = read_layout('menu') + if not text: + return '' + links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', text) + items = [] + for label, url in links: + if is_external(url): + items.append( + f'
  • {label} \u2197
  • ' + ) + else: + if url_to_slug(url) in existing_slugs: + items.append(f'
  • {label}
  • ') + return '' if items else '' + + +# ── Page HTML ───────────────────────────────────────────────────────────────── + +def extract_title(md_text): + m = re.search(r'^#\s+(.+)$', md_text, re.MULTILINE) + return m.group(1).strip() if m else 'Page' + +def render_meta_block(meta): + if not meta: + return '' + parts = [] + titre = meta.get('Titre', '') + if titre: + parts.append(f'

    {titre}

    ') + info = [] + if meta.get('Date'): + info.append(f'{meta["Date"]}') + if meta.get('Auteur'): + info.append(f'{meta["Auteur"]}') + if meta.get('Categories'): + info.append( + f'' + f'{categories_to_display(meta["Categories"])}' + f'' + ) + if info: + sep = ' \u2014 ' + parts.append(f'
    {sep.join(info)}
    ') + return '
    ' + ''.join(parts) + '
    ' if parts else '' + +def render_page(meta_html, content_html, title, site_name, tagline_html, menu_html, footer_html): + logo = f'' if site_name else '' + nav_links = f'' if menu_html else '
    ' + + nav = ( + f'' + ) + + tagline = ( + f'
    ' + f'{tagline_html}' + f'
    ' + ) if tagline_html else '' + + divider = '

    ' + + footer = ( + f'' + ) if footer_html else '' + + return f""" + + + + + {title} + + + + + + + + + {nav} + {tagline} + {divider} +
    +
    + {meta_html} + {content_html} +
    +
    + {footer} + +""" + + +# ── Chemin de sortie ────────────────────────────────────────────────────────── + +def output_path(slug, meta, cat_map): + base_name = slug.split('/')[-1] + categories_str = meta.get('Categories', '') + if categories_str: + cat_path = categories_to_path(categories_str, cat_map) + if cat_path: + if base_name == 'index': + return BASE_DIR / cat_path / 'index.html' + return BASE_DIR / cat_path / base_name / 'index.html' + if slug == 'index': + return BASE_DIR / 'index.html' + return BASE_DIR / slug / 'index.html' + + +# ── Collecte des pages ──────────────────────────────────────────────────────── + +def collect_pages(): + pages = {} + if not PAGES_DIR.exists(): + return pages + for f in sorted(PAGES_DIR.rglob('*')): + if not f.is_file(): + continue + rel = f.relative_to(PAGES_DIR) + slug = str(rel.with_suffix('')) if f.suffix == '.md' else str(rel) + pages[slug.replace('\\', '/')] = f + return pages + + +# ── Build ───────────────────────────────────────────────────────────────────── + +def build(): + raw_pages = collect_pages() + if not raw_pages: + print(f"Aucune page trouvée dans {PAGES_DIR}") + return + + cat_map = load_categories() + + # Lecture et parsing de toutes les pages + all_pages = {} + for slug, md_file in raw_pages.items(): + raw = md_file.read_text('utf-8') + meta, content = parse_metadata(raw) + all_pages[slug] = {'slug': slug, 'md_file': md_file, 'meta': meta, 'content': content} + + # Collecte des articles (pages avec ::Date:), triés du plus récent au plus ancien + articles = [] + for slug, page in all_pages.items(): + if not page['meta'].get('Date'): + continue + out = output_path(slug, page['meta'], cat_map) + rel = out.relative_to(BASE_DIR) + parent = rel.parent.as_posix() + url = '/' if parent == '.' else f'/{parent}/' + articles.append({ + 'slug': slug, + 'url': url, + 'title': page['meta'].get('Titre') or extract_title(page['content']), + 'excerpt': extract_excerpt(page['content']), + 'date': parse_date(page['meta'].get('Date', '')), + 'date_str': page['meta'].get('Date', ''), + 'author': page['meta'].get('Auteur', ''), + 'categories_display': categories_to_display(page['meta'].get('Categories', '')) + if page['meta'].get('Categories') else '', + 'image': page['meta'].get('Image', ''), + }) + articles.sort(key=lambda a: a['date'], reverse=True) + + # Layout partagé + header_md = read_layout('header') + site_name = extract_site_name(header_md or '') + tagline_html = extract_tagline_html(header_md or '') + footer_html = parse_md(read_layout('footer') or '') + + # existing_slugs : slugs sources ET chemins de sortie réels + # (le menu peut pointer vers /news/technologie/test/ qui est le chemin généré, + # différent du slug source 'test') + output_url_slugs = set() + for slug, page in all_pages.items(): + out = output_path(slug, page['meta'], cat_map) + rel = out.relative_to(BASE_DIR) + parent = rel.parent.as_posix() + output_url_slugs.add('index' if parent == '.' else parent) + existing_slugs = set(raw_pages.keys()) | {'index'} | output_url_slugs + + menu_html = build_menu_html(existing_slugs) + + print(f"Construction du site ({len(all_pages)} page(s), {len(articles)} article(s))...\n") + + # Rendu de toutes les pages + for slug, page in sorted(all_pages.items()): + meta = page['meta'] + content = page['content'] + + if slug == 'index' and not meta.get('Date'): + # Page d'accueil : contenu de index.md comme intro + listing articles + intro_html = parse_md(content) if content.strip() else '' + content_html = generate_homepage_html(articles, intro_html) + meta_html = '' + title = site_name or 'Accueil' + out = BASE_DIR / 'index.html' + else: + content_html = parse_md(content) + meta_html = render_meta_block(meta) + title = meta.get('Titre') or extract_title(content) + out = output_path(slug, meta, cat_map) + + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + render_page(meta_html, content_html, title, site_name, tagline_html, menu_html, footer_html), + encoding='utf-8' + ) + print(f" {str(page['md_file'].relative_to(BASE_DIR)):<42} \u2192 {out.relative_to(BASE_DIR)}") + + # Si pas de index.md, génère quand même la homepage + if 'index' not in all_pages: + content_html = generate_homepage_html(articles) + out = BASE_DIR / 'index.html' + out.write_text( + render_page('', content_html, site_name or 'Accueil', + site_name, tagline_html, menu_html, footer_html), + encoding='utf-8' + ) + print(f" [homepage auto] \u2192 index.html") + + print(f"\n{len(all_pages)} page(s) g\u00e9n\u00e9r\u00e9e(s) ({len(articles)} article(s)).") + print("Test local : python3 -m http.server 3000") + + +if __name__ == '__main__': + print("Construction du site...\n") + build() diff --git a/index.html b/index.html new file mode 100644 index 0000000..23bb854 --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + Sébastien QUEROL + + + + + + + + + +

    Le blog d'un SysAdmin

    +

    +
    +
    + +
    +
    +
    + + + \ No newline at end of file diff --git a/md/categories.md b/md/categories.md new file mode 100644 index 0000000..e550824 --- /dev/null +++ b/md/categories.md @@ -0,0 +1,8 @@ +- News:/news +- DIY:/diy +- Technologie:/tech +- Dossier:/dossier +- Jeux:/jeux +- Films:/films +- Series:/series +- Videos:/videos \ No newline at end of file diff --git a/md/footer.md b/md/footer.md new file mode 100644 index 0000000..81c21f4 --- /dev/null +++ b/md/footer.md @@ -0,0 +1 @@ +© 2026 Sebastien QUEROL — Propulsé par [PageMark](https://github.com/) \ No newline at end of file diff --git a/md/header.md b/md/header.md new file mode 100644 index 0000000..afdb5bb --- /dev/null +++ b/md/header.md @@ -0,0 +1,3 @@ +# Sébastien QUEROL + +Le blog d'un SysAdmin diff --git a/md/menu.md b/md/menu.md new file mode 100644 index 0000000..b45b449 --- /dev/null +++ b/md/menu.md @@ -0,0 +1,3 @@ +- [Accueil](/) +- [A propos](/racine/a-propos/) +- [Test](/news/technologie/test) diff --git a/md/pages/a-propos.md b/md/pages/a-propos.md new file mode 100644 index 0000000..c740e86 --- /dev/null +++ b/md/pages/a-propos.md @@ -0,0 +1,6 @@ +::Date:07/03/2026 +::Auteur:Kawa +::Categories:Racine +::Titre:A propos + +Salut, moi c'est Kawa, j'aime les piles. \ No newline at end of file diff --git a/md/pages/exemple-article.md b/md/pages/exemple-article.md new file mode 100644 index 0000000..5866d69 --- /dev/null +++ b/md/pages/exemple-article.md @@ -0,0 +1,111 @@ +::Titre:Exemple d'article — toutes les fonctionnalités +::Date:01/01/2026 +::Auteur:Votre Nom +::Categories:Blog +::Image:/images/cover.jpg + +Ceci est un **article d'exemple** montrant toutes les fonctionnalités disponibles dans PageMark. Supprimez ce fichier quand vous n'en avez plus besoin. + +## Métadonnées + +Les métadonnées se placent en tête de fichier avec la syntaxe `::Clé:Valeur`. Elles définissent le titre, la date, l'auteur et la catégorie de l'article. + +## Mise en forme du texte + +Le texte supporte toute la syntaxe Markdown : + +- **Gras** avec `**texte**` +- *Italique* avec `*texte*` +- ~~Barré~~ avec `~~texte~~` +- `Code inline` avec des backticks +- [Lien](https://example.com) avec `[texte](url)` + +## Titres et ancres + +Les titres `h1` à `h4` génèrent automatiquement une ancre cliquable `#` au survol, permettant de créer des liens directs vers une section : + +``` +https://monsite.com/exemple-article#images +``` + +### Niveau 3 + +#### Niveau 4 + +## Images + +Placez vos images dans `md/images/` et insérez-les ainsi : + +```markdown +![Description de l'image](/images/photo.jpg) +``` + +L'image de couverture (affichée dans les cards de la page d'accueil) se définit avec `::Image:/images/cover.jpg`. + +## Blocs de code + +Les blocs de code supportent la coloration syntaxique pour la plupart des langages : + +```python +def saluer(nom: str) -> str: + return f"Bonjour, {nom} !" + +print(saluer("PageMark")) +``` + +```bash +# Générer le site +python3 build.py + +# Test local +python3 -m http.server 3000 +``` + +```javascript +document.addEventListener("DOMContentLoaded", () => { + console.log("PageMark chargé !"); +}); +``` + +## Tableaux + +| Fonctionnalité | Syntaxe | Résultat | +|-------------------|-----------------------|-----------------------| +| Titre | `::Titre:Mon titre` | h1 avant l'article | +| Date | `::Date:dd/mm/YYYY` | Tri chronologique | +| Catégorie | `::Categories:A-B` | Dossier `a/b/` | +| Image couverture | `::Image:/img/x.jpg` | Card sur l'accueil | + +## Citations + +> Les blockquotes s'écrivent avec `>` en début de ligne. +> Elles peuvent s'étendre sur plusieurs lignes. + +## HTML personnalisé + +Vous pouvez insérer du HTML directement dans le Markdown — utile pour des mises en forme avancées, des iframes, des widgets, etc. + +
    + Astuce : utilisez les variables CSS var(--accent), var(--bg), var(--text), etc. pour que votre HTML s'adapte automatiquement au dark mode. +
    + +## Listes + +Liste non ordonnée : + +- Élément A +- Élément B + - Sous-élément + - Sous-élément + +Liste ordonnée : + +1. Première étape +2. Deuxième étape +3. Troisième étape + +Liste de tâches : + +- [x] Tâche terminée +- [ ] Tâche en cours +- [ ] Tâche à faire diff --git a/md/pages/index.md b/md/pages/index.md new file mode 100644 index 0000000..e69de29 diff --git a/racine/a-propos/index.html b/racine/a-propos/index.html new file mode 100644 index 0000000..5fa121c --- /dev/null +++ b/racine/a-propos/index.html @@ -0,0 +1,27 @@ + + + + + + A propos + + + + + + + + + +

    Le blog d'un SysAdmin

    +

    +
    +
    + +

    Je suis Sebastien, un admin systeme dans une petite ESN. +J'aime bidouiller pleins de trucs, vous trouverez sur ce site pleins de projets en cours, finis ou oublies (theme recurrent, vous allez voir).

    +
    +
    + + + \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..783ce5e --- /dev/null +++ b/static/style.css @@ -0,0 +1,476 @@ +/* ── Reset ───────────────────────────────────────────── */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Variables ───────────────────────────────────────── */ + +:root { + --bg: #ffffff; + --bg-2: #f9f8f6; + --text: #111111; + --text-muted: #737373; + --border: #e8e5df; + --accent: #8000ff; + --accent-soft: #f2e8ff; + + --font-serif: Georgia, 'Times New Roman', serif; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + --font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + + --max-w: 860px; + --nav-h: 56px; + + color-scheme: light; +} + +[data-theme="dark"] { + --bg: #0e0e0e; + --bg-2: #1a1a1a; + --text: #e6e6e6; + --text-muted: #888888; + --border: #2e2e2e; + --accent: #a855f7; + --accent-soft: #1e0840; + + color-scheme: dark; +} + +/* ── Base ────────────────────────────────────────────── */ + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-serif); + font-size: 18px; + line-height: 1.8; + transition: background 0.2s, color 0.2s; +} + +.container { + max-width: var(--max-w); + margin: 0 auto; + padding: 0 1.5rem; +} + +/* ── Navigation ──────────────────────────────────────── */ + +.site-nav { + background: var(--bg); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; + height: var(--nav-h); + transition: background 0.2s, border-color 0.2s; +} + +@supports (backdrop-filter: blur(8px)) { + .site-nav { backdrop-filter: blur(8px); } + :root { --nav-bg-a: rgba(255,255,255,0.88); } + [data-theme="dark"] { --nav-bg-a: rgba(14,14,14,0.88); } + .site-nav { background: var(--nav-bg-a); } +} + +.nav-container { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; + gap: 1rem; +} + +/* Logo / nom du site */ +.site-logo { + font-family: var(--font-sans); + font-size: 1.05rem; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--text); + text-decoration: none; + white-space: nowrap; + flex-shrink: 0; +} +.site-logo:hover { color: var(--accent); } + +/* Liens de navigation */ +.nav-links ul { + list-style: none; + display: flex; + margin: 0; + padding: 0; +} +.nav-links li { margin: 0; } + +.nav-links a { + display: block; + padding: 0 0.75rem; + line-height: var(--nav-h); + font-family: var(--font-sans); + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + text-decoration: none; + transition: color 0.15s; + white-space: nowrap; +} +.nav-links a:hover { color: var(--text); } +.nav-links a.external::after { content: ' \u2197'; font-size: 0.75em; } + +/* Bouton thème */ +.theme-toggle { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + width: 30px; + height: 30px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + color: var(--text-muted); + flex-shrink: 0; + transition: border-color 0.15s, color 0.15s; +} +.theme-toggle:hover { border-color: var(--accent); color: var(--accent); } + +/* ── Tagline ─────────────────────────────────────────── */ + +.site-tagline { + padding: 1.4rem 0 0; +} +.site-tagline p { + font-family: var(--font-sans); + font-size: 0.95rem; + color: var(--text-muted); + margin: 0 0 1.3rem; + line-height: 1.5; +} + +.tagline-divider-wrap { } +.tagline-divider { + border: none; + border-top: 1px solid var(--border); + margin: 0; +} + +/* ── Contenu ─────────────────────────────────────────── */ + +.site-content { + padding: 2.5rem 0 5rem; + min-height: calc(100vh - 180px); +} + +/* ── Section labels (À la une / Récents) ─────────────── */ + +.section-label { + font-family: var(--font-sans); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + padding-bottom: 0.65rem; + margin-bottom: 1.5rem; +} + +/* ── Featured card ───────────────────────────────────── */ + +.home-featured { margin-bottom: 3.5rem; } + +.featured-card { + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + transition: border-color 0.15s; +} +.featured-card:hover { border-color: var(--accent); } + +.featured-card .card-image img { + width: 100%; + height: 300px; + object-fit: cover; + display: block; + margin: 0; + border-radius: 0; +} + +.featured-card .card-body { padding: 1.6rem 1.8rem 1.8rem; } + +.featured-title { + font-family: var(--font-sans); + font-size: 1.85rem; + font-weight: 800; + letter-spacing: -0.025em; + line-height: 1.18; + margin: 0.5rem 0 0.8rem; +} +.featured-title a { color: var(--text); text-decoration: none; } +.featured-title a:hover { color: var(--accent); } + +/* ── Articles grid (récents) ─────────────────────────── */ + +.home-recent { margin-bottom: 2rem; } + +.articles-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; +} + +.article-card { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.15s; +} +.article-card:hover { border-color: var(--accent); } + +.article-card .card-image img { + width: 100%; + height: 160px; + object-fit: cover; + display: block; + margin: 0; + border-radius: 0; +} + +.article-card .card-body { padding: 1rem 1.1rem 1.2rem; } + +.card-title { + font-family: var(--font-sans); + font-size: 1rem; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.3; + margin: 0.4rem 0 0.5rem; +} +.card-title a { color: var(--text); text-decoration: none; } +.card-title a:hover { color: var(--accent); } + +/* ── Card meta (partagé) ─────────────────────────────── */ + +.card-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; + font-family: var(--font-sans); + font-size: 0.76rem; + color: var(--text-muted); + margin-bottom: 0.1rem; +} +.card-sep { color: var(--border); } +.card-author { font-weight: 600; color: var(--text); } +.card-date { font-variant-numeric: tabular-nums; } + +.card-category { + background: var(--accent-soft); + color: var(--accent); + padding: 0.15em 0.6em; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.card-excerpt { + font-family: var(--font-sans); + font-size: 0.86rem; + color: var(--text-muted); + line-height: 1.5; + margin: 0.4rem 0 1rem; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-readmore { + display: inline-block; + font-family: var(--font-sans); + font-size: 0.84rem; + font-weight: 600; + color: var(--accent); + text-decoration: none; +} +.card-readmore:hover { text-decoration: underline; } + +.home-intro { margin-bottom: 2.5rem; } +.no-articles { font-family: var(--font-sans); color: var(--text-muted); } + +/* ── Métadonnées article ─────────────────────────────── */ + +.article-meta { + margin-bottom: 2.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.article-title { + font-family: var(--font-sans); + font-size: 2.5rem; + font-weight: 800; + letter-spacing: -0.035em; + line-height: 1.12; + margin: 0 0 0.7rem; +} + +.article-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + font-family: var(--font-sans); + font-size: 0.83rem; + color: var(--text-muted); +} +.article-sep { color: var(--border); } +.article-author { font-weight: 600; color: var(--text); } +.article-categories { + background: var(--accent-soft); + color: var(--accent); + padding: 0.2em 0.75em; + border-radius: 20px; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +/* ── Ancres de titre ─────────────────────────────────── */ + +.heading-anchor { + display: inline-block; + margin-left: 0.35em; + font-size: 0.7em; + font-weight: 400; + color: var(--accent); + text-decoration: none; + opacity: 0; + transition: opacity 0.15s; + vertical-align: middle; + user-select: none; +} + +h1:hover .heading-anchor, +h2:hover .heading-anchor, +h3:hover .heading-anchor, +h4:hover .heading-anchor { opacity: 0.6; } + +.heading-anchor:hover { opacity: 1 !important; } + +/* ── Typographie ─────────────────────────────────────── */ + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-sans); + font-weight: 700; + line-height: 1.25; + color: var(--text); + margin: 2em 0 0.55em; +} +h1 { font-size: 2.2rem; margin-top: 0; letter-spacing: -0.03em; } +h2 { font-size: 1.45rem; letter-spacing: -0.02em; border-bottom: 1px solid var(--border); padding-bottom: 0.4em; } +h3 { font-size: 1.2rem; } +h4 { font-size: 1rem; } + +p { margin: 0 0 1.2em; } + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +ul, ol { padding-left: 1.6em; margin: 0 0 1.2em; } +li { margin: 0.35em 0; } +li input[type="checkbox"] { margin-right: 0.4em; } + +blockquote { + border-left: 3px solid var(--accent); + padding: 0.8em 1.3em; + margin: 1.5em 0; + background: var(--accent-soft); + border-radius: 0 6px 6px 0; +} +blockquote p { margin: 0; } + +hr { + border: none; + border-top: 1px solid var(--border); + margin: 2.5em 0; +} + +/* ── Code ────────────────────────────────────────────── */ + +code { + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--bg-2); + padding: 0.15em 0.45em; + border-radius: 4px; + border: 1px solid var(--border); +} + +pre { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.2em 1.5em; + overflow-x: auto; + margin: 1.5em 0; + line-height: 1.55; +} +pre code { background: none; border: none; padding: 0; font-size: 0.875rem; } + +[data-theme="dark"] .hljs { background: var(--bg-2); } + +/* ── Images ──────────────────────────────────────────── */ + +img { + max-width: 100%; + height: auto; + border-radius: 6px; + display: block; + margin: 1.5em auto; +} +figure { margin: 1.5em 0; text-align: center; } +figcaption { font-family: var(--font-sans); font-size: 0.82rem; color: var(--text-muted); margin-top: 0.4em; } + +/* ── Tableaux ────────────────────────────────────────── */ + +table { width: 100%; border-collapse: collapse; margin: 1.5em 0; font-family: var(--font-sans); font-size: 0.9rem; } +th, td { border: 1px solid var(--border); padding: 0.65em 1em; text-align: left; } +th { background: var(--bg-2); font-weight: 600; } +tr:nth-child(even) td { background: var(--bg-2); } + +/* ── Pied de page ────────────────────────────────────── */ + +.site-footer { + background: var(--bg-2); + border-top: 1px solid var(--border); + padding: 2rem 0; + font-family: var(--font-sans); + font-size: 0.85rem; + color: var(--text-muted); +} +.site-footer p { margin: 0; } +.site-footer a { color: var(--text-muted); text-decoration: underline; } +.site-footer a:hover { color: var(--accent); } + +/* ── Responsive ──────────────────────────────────────── */ + +@media (max-width: 640px) { + body { font-size: 16px; } + h1 { font-size: 1.7rem; } + .article-title { font-size: 1.9rem; } + .featured-title { font-size: 1.5rem; } + .container { padding: 0 1rem; } + .articles-grid { grid-template-columns: 1fr; } + .nav-links a { padding: 0 0.5rem; font-size: 0.72rem; } +} diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..2f1d7d3 --- /dev/null +++ b/static/theme.js @@ -0,0 +1,47 @@ +(function () { + 'use strict'; + + var STORAGE_KEY = 'pagemark-theme'; + + function getDefaultTheme() { + var now = new Date(); + var minutes = now.getHours() * 60 + now.getMinutes(); + var start = 8 * 60 + 30; // 08:30 + var end = 17 * 60 + 30; // 17:30 + return (minutes >= start && minutes < end) ? 'light' : 'dark'; + } + + function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + + // Swap highlight.js stylesheets via l'attribut media (plus fiable que disabled) + var light = document.getElementById('hl-light'); + var dark = document.getElementById('hl-dark'); + if (light) light.media = (theme === 'dark') ? 'not all' : ''; + if (dark) dark.media = (theme === 'dark') ? '' : 'not all'; + + // Icône du bouton + var btn = document.getElementById('theme-toggle'); + if (btn) btn.textContent = theme === 'dark' ? '\u2600' : '\u263e'; // ☀ / ☾ + } + + // Appliquer immédiatement pour éviter le flash + var stored = localStorage.getItem(STORAGE_KEY); + var theme = stored || getDefaultTheme(); + document.documentElement.setAttribute('data-theme', theme); + + function toggle() { + var current = document.documentElement.getAttribute('data-theme'); + var next = current === 'dark' ? 'light' : 'dark'; + localStorage.setItem(STORAGE_KEY, next); + applyTheme(next); + } + + // Attacher le bouton sans onclick inline (compatible CSP) + document.addEventListener('DOMContentLoaded', function () { + applyTheme(theme); // sync icône + stylesheets hl.js + + var btn = document.getElementById('theme-toggle'); + if (btn) btn.addEventListener('click', toggle); + }); +}());