Compare commits

..

2 commits

49 changed files with 968 additions and 791 deletions

14
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,14 @@
name: docker
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build the Docker image
run: |
echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io
docker build . --file Dockerfile --tag docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:latest
docker push docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:latest

4
.gitignore vendored
View file

@ -15,5 +15,5 @@ blog.sublime-project
blog.sublime-workspace
ssl/
.python-version
.local
uv.lock
poetry.toml
.local

19
.travis.yml Normal file
View file

@ -0,0 +1,19 @@
language: python
python:
- "2.7"
- "3.3"
- "3.4"
- "3.5"
- "3.6"
install:
- pip install commonmark coverage coveralls
script:
- python -m unittest discover -bv
- coverage run --branch --source=. -m unittest discover -bv
- coverage report -m
after_success:
- coveralls

View file

@ -1,13 +1,39 @@
FROM nginx:1.27.4-alpine-slim
FROM nginx:1.19.0-alpine
RUN apk update && apk add --no-cache bash git python3 make tzdata curl py3-pip musl-locales
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && mv /root/.local/bin/uv /usr/local/bin
RUN apk update
RUN apk add --no-cache build-base bash git python3 make tzdata curl py3-pip libressl-dev musl-dev libffi-dev python3-dev cargo
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.4.0
RUN poetry config virtualenvs.create false
# install poetry
#ENV POETRY_HOME=/opt/poetry
#RUN python3 -m venv $POETRY_HOME
#RUN $POETRY_HOME/bin/pip install --upgrade pip
#RUN $POETRY_HOME/bin/pip install setuptools_rust poetry==1.4.0
#RUN $POETRY_HOME/bin/poetry --version
COPY docker/nginx.conf /etc/nginx/nginx.conf
# install locales
ENV MUSL_LOCALE_DEPS cmake make musl-dev gcc gettext-dev libintl
ENV MUSL_LOCPATH /usr/share/i18n/locales/musl
RUN apk add --no-cache \
$MUSL_LOCALE_DEPS \
&& wget https://gitlab.com/rilian-la-te/musl-locales/-/archive/master/musl-locales-master.zip \
&& unzip musl-locales-master.zip \
&& cd musl-locales-master \
&& cmake -DLOCALE_PROFILE=OFF -D CMAKE_INSTALL_PREFIX:PATH=/usr . && make && make install \
&& cd .. && rm -r musl-locales-master
# set timezone and locale
RUN cp /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo "Europe/Paris" > /etc/timezone
ENV TZ=Europe/Paris
RUN cp /usr/share/zoneinfo/Europe/Paris /etc/localtime
RUN echo "Europe/Paris" > /etc/timezone
ENV TZ Europe/Paris
ENV LANG fr_FR.UTF-8
ENV LANGUAGE fr_FR.UTF-8
ENV LC_ALL fr_FR.UTF-8
COPY docker/docker-init.sh /usr/local/bin/
RUN chmod +x usr/local/bin/docker-init.sh

View file

@ -1,18 +1,59 @@
# Makefile
# Makefile
#
# build target should be built even if no files depend on it
.PHONY: build
# if a file .local exists run site locally
ifeq ($(wildcard .local),)
TARGET = site_remote
else
TARGET = site_local
endif
# run locally
site:
uv run python makesite.py --params params-local.json
site: $(TARGET)
echo $(TARGET)
site_remote:
git pull
makesite
systemctl reload nginx
site_local:
rye run python makesite.py --params params-local.json
cd _site && python -m SimpleHTTPServer 2> /dev/null || python3 -m http.server
# docker build image
build:
docker build -t source.madyanne.fr/yax/blog .
# docker publish image
publish:
docker push source.madyanne.fr/yax/blog
dock: site_local
$(shell docker start --interactive bloglocal || docker run --name bloglocal -p 80:80 -p 443:443 -v `pwd`/_site:/usr/share/nginx/html:ro -v `pwd`/nginx/nginx.conf:/etc/nginx/nginx.conf:ro -v `pwd`/nginx/dhparam.pem:/etc/nginx/dhparam.pem:ro -v `pwd`/ssl:/etc/nginx/ssl:ro nginx)
undock:
docker stop bloglocal
certs:
mkdir -p ssl
cd ssl ; wget -N https://traefik.me/cert.pem
cd ssl ; wget -N https://traefik.me/chain.pem
cd ssl ; wget -N https://traefik.me/fullchain.pem
cd ssl ; wget -N https://traefik.me/privkey.pem
venv2:
virtualenv ~/.venv/makesite
echo . ~/.venv/makesite/bin/activate > venv
. ./venv && pip install commonmark coverage
venv: FORCE
python3 -m venv ~/.venv/makesite
echo . ~/.venv/makesite/bin/activate > venv
. ./venv && pip install commonmark coverage
test: FORCE
. ./venv && python -m unittest -bv
coverage:
. ./venv && coverage run --branch --source=. -m unittest discover -bv; :
. ./venv && coverage report -m
. ./venv && coverage html
clean:
find . -name "__pycache__" -exec rm -r {} +
find . -name "*.pyc" -exec rm {} +
rm -rf .coverage htmlcov
FORCE:

View file

@ -1,7 +1,7 @@
# Blog du Yax
This blog is built on top of the great work performed by fspaolo on [Makesite.py](https://github.com/fspaolo/makesite).
I cut some features and wristed the code to focus on blog posts and support my [commenting system](https://gitea.zaclys.com/yannic/stacosys). You should check fspaolo's repository to really understand Makesite.py's philosophy and find technical details.
I cut some features and wristed the code to focus on blog posts and support my [commenting system](https://github.com/kianby/stacosys). You should check fspaolo's repository to really understand Makesite.py's philosophy and find technical details.
This static blog generator code is under MIT license.

2
build.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
pyinstaller makesite.py --onefile

View file

@ -2,7 +2,7 @@
git fetch
HEADHASH=$(git rev-parse HEAD)
UPSTREAMHASH=$(git rev-parse main@{upstream})
UPSTREAMHASH=$(git rev-parse master@{upstream})
if [ "$HEADHASH" != "$UPSTREAMHASH" ]
then

View file

@ -1,19 +1,20 @@
#!/bin/bash
python -V
#export POETRY_HOME=/opt/poetry
# clone and build blog
cd /
rm -rf /blog
git clone https://gitea.zaclys.com/yannic/blog.git
git clone https://github.com/kianby/blog.git
cd /blog
uv python pin 3.12.9
uv sync
uv run python ./makesite.py
#$POETRY_HOME/bin/poetry install
#$POETRY_HOME/bin/poetry run make
poetry install
poetry run make
# nginx serve
#nginx -g 'daemon off;'
nginx
# exit on change in stacosys or Git repo
uv run python monitor.py
$POETRY_HOME/bin/poetry run python3 monitor.py

View file

@ -45,15 +45,6 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# redirections to preserve inbound links
rewrite ^/migration-du-blog-sous-pelican.html$ https://blogduyax.madyanne.fr/2013/migration-du-blog-sous-pelican/ redirect;
rewrite ^/2017/nextcloud-securite/$ https://blogduyax.madyanne.fr/2017/securite-des-donnees-focus-sur-nextcloud/ redirect;
rewrite ^/installation-de-shinken.html$ https://blogduyax.madyanne.fr/2014/installation-de-shinken/ redirect;
rewrite ^/retour-dexperience-ubuntu-touch.html$ https://blogduyax.madyanne.fr/2015/retour-dexperience-ubuntu-touch/ redirect;
rewrite ^/haute-disponibilite-avec-corosync-et-pacemaker.html$ https://blogduyax.madyanne.fr/2013/haute-disponibilite-avec-corosync-et-pacemaker/ redirect;
rewrite ^/feeds/all.atom.xml$ https://blogduyax.madyanne.fr/rss.xml redirect;
rewrite ^/feed.xml$ https://blogduyax.madyanne.fr/rss.xml redirect;
}
}
}

View file

@ -4,7 +4,9 @@
<link>{{ site_url }}/{{ post_url }}/</link>
<description>
<![CDATA[
{{ content_rss }}
<p>
{{ content }}
</p>
]]>
</description>
<pubDate>{{ rfc_2822_date }}</pubDate>

View file

@ -1,5 +1,30 @@
#!/usr/bin/env python3
# The MIT License (MIT)
#
# Copyright (c) 2018 Sunaina Pai
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# Modifed by Yax
"""Make static website/blog with Python."""
import argparse
@ -21,11 +46,11 @@ from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import html
# set user locale
locale.setlocale(locale.LC_ALL, "")
FRENCH_WEEKDAYS = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.']
FRENCH_MONTHS = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']
# initialize markdown
class HighlightRenderer(mistune.HTMLRenderer):
def block_code(self, code, info=None):
@ -55,14 +80,14 @@ def fwrite(filename, text):
f.write(text)
def log(msg, *args):
def log(msg, *log_args):
"""Log message with specified arguments."""
sys.stderr.write(msg.format(*args) + "\n")
sys.stderr.write(msg.format(*log_args) + "\n")
def truncate(text, words=25):
"""Remove tags and truncate text to the specified number of words."""
return " ".join(re.sub(r"(?s)<.*?>", " ", text).split()[:words])
return " ".join(re.sub("(?s)<.*?>", " ", text).split()[:words])
def read_headers(text):
@ -75,10 +100,12 @@ def read_headers(text):
def rfc_2822_format(date_str):
"""Convert yyyy-mm-dd date string to RFC 2822 format date string."""
# Use datetime.datetime.strptime() with the correct format string
d = datetime.datetime.strptime(date_str, "%Y-%m-%d")
return d \
.replace(tzinfo=datetime.timezone.utc) \
.strftime('%a, %d %b %Y %H:%M:%S %z')
# Convert the timetuple to a timestamp
timestamp = time.mktime(d.timetuple())
# Return the formatted timestamp
return utils.formatdate(timestamp)
def slugify(value):
@ -87,16 +114,13 @@ def slugify(value):
underscores) and converts spaces to hyphens. Also strips leading and
trailing whitespace.
"""
value = (
unicodedata.normalize("NFKD", value)
.encode("ascii", "ignore")
.decode("ascii")
)
value = re.sub(r"[^\w\s-]", "", value).strip().lower()
return re.sub(r"[-\s]+", "-", value)
value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
value = re.sub(r"[^\w\s-]", "", value) # Remove non-word characters and spaces
value = re.sub(r"\s+", "-", value) # Replace multiple spaces with a single hyphen
return value.lower() # Convert to lowercase
def read_content(filename, params):
def read_content(filename):
"""Read content and metadata from file into a dictionary."""
# Read file content.
text = fread(filename)
@ -133,7 +157,6 @@ def read_content(filename, params):
content.update(
{
"content": text,
"content_rss": fix_relative_links(params["site_url"], text),
"rfc_2822_date": rfc_2822_format(content["date"]),
"summary": summary,
}
@ -142,20 +165,12 @@ def read_content(filename, params):
return content
def fix_relative_links(site_url, text):
"""Absolute links needed in RSS feed"""
# TODO externalize links replacement configuration
return text \
.replace("src=\"/images/20", "src=\"" + site_url + "/images/20") \
.replace("href=\"/20", "href=\"" + site_url + "/20")
def clean_html_tag(text):
"""Remove HTML tags."""
while True:
original_text = text
text = re.sub(r"<\w+.*?>", "", text)
text = re.sub(r"<\/\w+>", "", text)
text = re.sub("<\w+.*?>", "", text)
text = re.sub("<\/\w+>", "", text)
if original_text == text:
break
return text
@ -171,30 +186,28 @@ def render(template, **params):
def get_header_list_value(header_name, page_params):
header_list = []
l = []
if header_name in page_params:
for s in page_params[header_name].split(" "):
if s.strip():
header_list.append(s.strip())
return header_list
l.append(s.strip())
return l
def get_friendly_date(date_str):
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d")
french_month = FRENCH_MONTHS[dt.month - 1]
return f"{dt.day:02d} {french_month} {dt.year}"
return dt.strftime("%d %b %Y")
def make_posts(
src, src_pattern, dst, layout, category_layout,
comment_layout, comment_detail_layout, **params
src, src_pattern, dst, layout, category_layout, comment_layout, comment_detail_layout, **params
):
"""Generate posts from posts directory."""
items = []
for posix_path in Path(src).glob(src_pattern):
src_path = str(posix_path)
content = read_content(src_path, params)
content = read_content(src_path)
# render text / summary for basic fields
content["content"] = render(content["content"], **params)
@ -206,18 +219,13 @@ def make_posts(
page_params["date_path"] = page_params["date"].replace("-", "/")
page_params["friendly_date"] = get_friendly_date(page_params["date"])
page_params["year"] = page_params["date"].split("-")[0]
page_params["post_url"] = \
page_params["year"] \
+ "/" \
+ page_params["slug"] + "/"
page_params["post_url"] = page_params["year"] + "/" + page_params["slug"] + "/"
# categories
categories = get_header_list_value("category", page_params)
out_cats = []
for category in categories:
out_cat = render(category_layout,
category=category,
url=slugify(category))
out_cat = render(category_layout, category=category, url=slugify(category))
out_cats.append(out_cat.strip())
page_params["categories"] = categories
page_params["category_label"] = "".join(out_cats)
@ -293,7 +301,7 @@ def make_notes(
for posix_path in Path(src).glob(src_pattern):
src_path = str(posix_path)
content = read_content(src_path, params)
content = read_content(src_path)
# render text / summary for basic fields
content["content"] = render(content["content"], **params)
@ -303,12 +311,12 @@ def make_notes(
page_params["header"] = ""
page_params["footer"] = ""
page_params["friendly_date"] = ""
page_params["category_label"] = ""
page_params["category_label"] = ""
page_params["post_url"] = "notes/" + page_params["slug"] + "/"
content["post_url"] = page_params["post_url"]
content["friendly_date"] = page_params["friendly_date"]
content["category_label"] = page_params["category_label"]
content["category_label"] = page_params["category_label"]
items.append(content)
dst_path = render(dst, **page_params)
@ -321,8 +329,7 @@ def make_notes(
def make_list(
posts, dst, list_layout, item_layout,
header_layout, footer_layout, **params
posts, dst, list_layout, item_layout, header_layout, footer_layout, **params
):
"""Generate list page for a blog."""
@ -363,17 +370,53 @@ def make_list(
fwrite(dst_path, output)
def create_blog(page_layout, list_in_page_layout, params):
def main(param_file):
# Create a new _site directory from scratch.
if os.path.isdir("_site"):
shutil.rmtree("_site")
shutil.copytree("static", "_site")
# Default parameters.
params = {
"title": "Blog",
"subtitle": "Lorem Ipsum",
"author": "Admin",
"site_url": "http://localhost:8000",
"current_year": datetime.datetime.now().year,
"stacosys_url": "",
}
log("use params from " + param_file)
if os.path.isfile(param_file):
params.update(json.loads(fread(param_file)))
# Load layouts.
banner_layout = fread("layout/banner.html")
paging_layout = fread("layout/paging.html")
archive_title_layout = fread("layout/archives.html")
page_layout = fread("layout/page.html")
post_layout = fread("layout/post.html")
post_layout = render(page_layout, content=post_layout)
list_layout = fread("layout/list.html")
item_layout = fread("layout/item.html")
item_nosummary_layout = fread("layout/item_nosummary.html")
item_note_layout = fread("layout/item_note.html")
category_title_layout = fread("layout/category_title.html")
category_layout = fread("layout/category.html")
comment_layout = fread("layout/comment.html")
comment_detail_layout = fread("layout/comment-detail.html")
category_layout = fread("layout/category.html")
item_layout = fread("layout/item.html")
rss_xml = fread("layout/rss.xml")
rss_item_xml = fread("layout/rss_item.xml")
sitemap_xml = fread("layout/sitemap.xml")
sitemap_item_xml = fread("layout/sitemap_item.xml")
note_layout = fread("layout/note.html")
posts = make_posts(
# Combine layouts to form final layouts.
post_layout = render(page_layout, content=post_layout)
list_layout = render(page_layout, content=list_layout)
note_layout = render(page_layout, content=note_layout)
# Create blogs.
blog_posts = make_posts(
"posts",
"**/*.md",
"_site/{{ post_url }}/index.html",
@ -384,11 +427,10 @@ def create_blog(page_layout, list_in_page_layout, params):
**params
)
# Create blog list pages by 10.
# Create blog list pages.
page_size = 10
chunk_posts = [
posts[i: i + page_size]
for i in range(0, len(posts), page_size)
blog_posts[i: i + page_size] for i in range(0, len(blog_posts), page_size)
]
page = 1
last_page = len(chunk_posts)
@ -403,7 +445,7 @@ def create_blog(page_layout, list_in_page_layout, params):
make_list(
chunk,
"_site/index.html",
list_in_page_layout,
list_layout,
item_layout,
banner_layout,
paging_layout,
@ -414,21 +456,17 @@ def create_blog(page_layout, list_in_page_layout, params):
make_list(
chunk,
"_site/page" + str(page) + "/index.html",
list_in_page_layout,
list_layout,
item_layout,
banner_layout,
paging_layout,
**params
)
page = page + 1
return posts
def generate_categories(list_in_page_layout, item_nosummary_layout,
posts, params):
category_title_layout = fread("layout/category_title.html")
# Create category pages
cat_post = {}
for post in posts:
for post in blog_posts:
for cat in post["categories"]:
if cat in cat_post:
cat_post[cat].append(post)
@ -439,60 +477,29 @@ def generate_categories(list_in_page_layout, item_nosummary_layout,
make_list(
cat_post[cat],
"_site/" + slugify(cat) + "/index.html",
list_in_page_layout,
list_layout,
item_nosummary_layout,
category_title_layout,
None,
**params
)
def generate_archives(blog_posts, list_in_page_layout, item_nosummary_layout,
archive_title_layout, params):
# Create archive page
make_list(
blog_posts,
"_site/archives/index.html",
list_in_page_layout,
list_layout,
item_nosummary_layout,
archive_title_layout,
None,
**params
)
def generate_notes(page_layout, archive_title_layout,
list_in_page_layout, params):
note_layout = fread("layout/note.html")
item_note_layout = fread("layout/item_note.html")
note_layout = render(page_layout, content=note_layout)
notes = make_notes(
"notes",
"**/*.md",
"_site/{{ post_url }}/index.html",
note_layout,
**params
)
make_list(
notes,
"_site/notes/index.html",
list_in_page_layout,
item_note_layout,
archive_title_layout,
None,
**params
)
def generate_rss_feeds(posts, params):
rss_xml = fread("layout/rss.xml")
rss_item_xml = fread("layout/rss_item.xml")
# Create main RSS feed for 10 last entries
# Create main RSS feed for 10 last entries
nb_items = min(10, len(blog_posts))
for filename in ("_site/rss.xml", "_site/index.xml"):
make_list(
posts[:10],
blog_posts[:nb_items],
filename,
rss_xml,
rss_item_xml,
@ -503,7 +510,7 @@ def generate_rss_feeds(posts, params):
# Create RSS feed by tag
tag_post = {}
for post in posts:
for post in blog_posts:
for tag in post["tags"]:
if tag in tag_post:
tag_post[tag].append(post)
@ -521,12 +528,9 @@ def generate_rss_feeds(posts, params):
**params
)
def generate_sitemap(posts, params):
sitemap_xml = fread("layout/sitemap.xml")
sitemap_item_xml = fread("layout/sitemap_item.xml")
# Create sitemap
make_list(
posts,
blog_posts,
"_site/sitemap.xml",
sitemap_xml,
sitemap_item_xml,
@ -536,62 +540,30 @@ def generate_sitemap(posts, params):
)
def get_params(param_file):
# Default parameters.
params = {
"title": "Blog",
"subtitle": "Lorem Ipsum",
"author": "Admin",
"site_url": "http://localhost:8000",
"current_year": datetime.datetime.now().year,
"stacosys_url": "",
}
# Create notes.
notes = make_notes(
"notes",
"**/*.md",
"_site/{{ post_url }}/index.html",
note_layout,
**params
)
log("use params from " + param_file)
if os.path.isfile(param_file):
params.update(json.loads(fread(param_file)))
return params
def clean_site():
if os.path.isdir("_site"):
shutil.rmtree("_site")
shutil.copytree("static", "_site")
def main(param_file):
params = get_params(param_file)
# Create a new _site directory from scratch.
clean_site()
# Load layouts.
page_layout = fread("layout/page.html")
list_layout = fread("layout/list.html")
list_in_page_layout = render(page_layout, content=list_layout)
archive_title_layout = fread("layout/archives.html")
item_nosummary_layout = fread("layout/item_nosummary.html")
blog_posts = create_blog(page_layout, list_in_page_layout, params)
generate_categories(list_in_page_layout, item_nosummary_layout,
blog_posts, params)
generate_archives(blog_posts, list_in_page_layout, item_nosummary_layout,
archive_title_layout, params)
generate_notes(page_layout, archive_title_layout,
list_in_page_layout, params)
generate_rss_feeds(blog_posts, params)
generate_sitemap(blog_posts, params)
make_list(
notes,
"_site/notes/index.html",
list_layout,
item_note_layout,
archive_title_layout,
None,
**params
)
# Test parameter to be set temporarily by unit tests.
_test = None
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Makesite')
parser.add_argument('--params', dest='param_file', type=str,
default="params.json", help='Custom param file')
parser.add_argument('--params', dest='param_file', type=str, default="params.json", help='Custom param file')
args = parser.parse_args()
main(args.param_file)

44
makesite.spec Normal file
View file

@ -0,0 +1,44 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['makesite.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='makesite',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View file

@ -12,10 +12,6 @@ Supprimer les dépendances des paquets orphelins
pacman -Rsn $(pacman -Qtdq)
Nettoyage du cache pacman des archives de +90 jours
find ./ -maxdepth 1 -type f -mtime +90 -print0 | xargs -0 sudo /bin/rm -f
## *Downgrader* des paquets
Récupérer la liste des upgraded ([source](https://wiki.archlinux.org/title/Downgrading_packages))
@ -40,7 +36,6 @@ for i in $(cat /tmp/packages); do sudo pacman --noconfirm -U "$i"*.zst; done
(potentiel problème d'ordre, réarranger le fichier /tmp/packages en fonction des dépendances entre les paquets)
# Fedora
Historique des transactions **dnf**

View file

@ -2,8 +2,7 @@
"title": "Le blog du Yax",
"subtitle": "GNU, Linux, BSD et autres libertés",
"author": "Yax",
"site_url": "http://127.0.0.1:8000",
"site_url": "http://127.0.0.1:8000",
"stacosys_url": "http://127.0.0.1:8100/api",
"stacosys_url": "",
"external_check": "./check_git.sh"
}

View file

@ -4,7 +4,8 @@
Le titre est un peu provoc mais c'est une vraie question [que je me posais
déjà en Novembre
dernier](/2011/gnome-3-pour-un-usage-professionnel). Comment retrouver un niveau de productivité correct après
dernier](http://blogduyax.madyanne.fr/index.php?article60/gnome-3-pour-un-usage-
professionnel). Comment retrouver un niveau de productivité correct après
l'ouragan Gnome 3 / Unity dans le cadre professionnel<!-- more --> avec du matos récent (en
l'occurrence un core i7 avec de la RAM à gogo et une carte NVIDIA Optimus
achetés en décembre) ? J'ai posé deux contraintes : **une stabilité des
@ -71,11 +72,7 @@ Intel est bien suffisante pour mon usage.
Tous les outils que j'utilise quotidiennement pour le travail fonctionnent
parfaitement (Eclipse, JAVA, VMWare Player, Skype), l'environnement de bureau
est un bonheur retrouvé. Pour info, Ubuntu 10.04 démarre et s'arrête 2 fois
plus vite qu'une version 11.10.
> Que fait Canonical depuis 3 versions ?
>
> ah oui ils focalisent sur l'environnement de bureau :-(
Après un mois d'errements, j'ai enfin l'impression d'avoir la distribution qu'il me faut pour cette
plus vite qu'une version 11.10. Que fait Canonical depuis 3 versions ? - ah oui
ils focalisent sur l'environnement de bureau :-( Après un mois d'errements,
j'ai enfin l'impression d'avoir la distribution qu'il me faut pour cette
machine. J'aurais pu intituler cet article "Retour vers le futur".

View file

@ -4,7 +4,10 @@
Ma dernière tentative d'utiliser Gnome en environnement professionnel date de
[fin 2011](/2011/gnome-3-pour-un-usage-professionnel). Le changement avait été
trop brutal et déclenché [mon passage à XFCE](/2012/quelle-distribution-gnome-2-choisir-en-2012/)<!-- more --> où je suis resté depuis, que ce soit sous Arch à la maison ou au bureau sous Fedora. En 2 ans, les développeurs de Gnome ont sacrément amélioré leur bijou :
trop brutal et déclenché [mon passage à XFCE](quelle-distribution-gnome-2
-choisir-en-2012.html)<!-- more --> où je suis resté depuis, que ce soit sous Arch à la
maison ou au bureau sous Fedora. En 2 ans, les développeurs de Gnome ont
sacrément amélioré leur bijou :
- meilleures performances, cohésion de l'interface, raffinements
- ergonomiques et esthétiques à tous les étages, ouverture grâce aux

View file

@ -1,11 +0,0 @@
<!-- title: Thème : cap au Nord -->
<!-- category: GNU/Linux -->
Je ne pratique pas le *ricing* et je me contente d'un thème par défaut sur mes environnements de bureau, que ce soit Gnome, Mate ou XFCE, surtout que les thèmes fournis par défaut sont généralement bien soignés de nos jours. Mais il y a un domaine où j'ai mes exigences, c'est le terminal : c'est important de ne pas s'esquinter les yeux. Or une palette de couleur mal choisie peut compliquer la vie avec la coloration syntaxique à gogo. D'abord séduit par le classieux Solaris Dark je me suis tourné depuis deux ans vers le thème Nord. Moins contrasté et très homogène, je l'ai progressivement intégré à mes applications quotidiennes : Alacritty, Tmux, NeoVim, IntelliJ, Firefox, la page de recherche DuckDuckGo, le thème GTK. Bref je ne fais pas de *ricing* mais j'ai déjà abordé quelques applications de bureau.
Mes sources d'informations sont les suivantes :
- Sven Greb, celui par qui [tout a commencé](https://github.com/orgs/nordtheme/discussions/183) et son site officiel [NordTheme](https://www.nordtheme.com/)
- la communauté sur Reddit : [r/nordtheme](https://www.reddit.com/r/nordtheme/)
et la configuration de mes postes (toujours gérée par [ChezMoi](https://www.chezmoi.io/)) est sur [mon GitHub](https://github.com/kianby/dotfiles) et répliquée sur [mon Gitea](https://source.madyanne.fr/yax/dotfiles).

View file

@ -1,22 +0,0 @@
<!-- title: Retour en auto-hébergement -->
<!-- category: Hébergement -->
Après une dizaine d'années à héberger mes services chez différents hébergeurs je reviens à de l'auto-hébergement sur du matériel à la maison. Et quelle évolution au niveau matériel : en 2013 c'était un portable Céléron peut véloce dans un placard. En cette fin d'année j'ai acquis un mini-pc sur les conseils avisés de [MiniMachines](https://www.minimachines.net) : un Beelink Mini S12 Pro: pour la taille d'un Rubik's Cube on a un processeur Intel N100 (de la famille Alder Lake) appuyé par 16 Go de RAM et 512 Go de stockage en M.2. Ça coûte un peu plus de 200 euros et c'est très raisonnable par rapport à ce qu'on obtient en retour : une machine avec une consommation électrique sobre, quasi-silencieuse et des performances suffisantes pour des usages standards en auto-hébergement.
Pour moi l'objectif est double :
- financier d'abord avec l'opportunité de réduire mes factures bien que je sois très satisfait de mes services externes : résilier le cloud Infomaniak et le petit VPS me fera économiser d'une bonne centaine d'euros par an.
- apprendre en s'amusant : refaire de l'administration système, découvrir des nouveaux services à héberger, ça ouvre plein de possibilités.
Joli projet sur le papier mais moins simple qu'en 2012 car l'Internet est sacrément plus hostile et une grosse réflexion sur la sécurité s'est imposée avant douvrir quoi que ce soit sur Internet. Finalement la solution sera basée sur l'hyperviseur Proxmox qui apporte une souplesse sur les types de déploiement en permettant de mixer des conteneurs LXC et des machines virtuelles KVM et d'apporter une brique de sécurité avec un pare-feu à multiple niveaux. Le but étant d'isoler autant que possible les parties exposées du réseau domestique. Les machines virtuelles [regrouperont des services Docker](https://github.com/kianby/selfhosting/tree/config-vm1) exposés indirectement par un proxy NginX.
![Proxmox](/images/2024/proxmox.svg)
Le proxy NginX est directement exposé sur Internet via une redirection des ports HTTP / HTTPs depuis la box internet. C'est un container LXC Alpine avec une installation de [Nginx Proxy Manager](https://nginxproxymanager.com/) modifiée pour que les services ne s'exécutent par avec le super-utilisateur *root*. Le minimum de paquets est installé (surtout pas de service SSHD ni de SSH) et il s'administre par l'interface Web de Nginx Proxy Manager depuis le réseau local qui n'est évidemment pas exposée à l'extérieur. Il a deux cartes réseau virtuelles : une adresse sur le réseau local et l'autre sur le réseau privé constitué des machines virtuelles exécutant les services. Le proxy sert aussi de passerelle de sortie aux machines virtuelles : le routage est activé entre les deux interfaces et l'interface du réseau privé est *bridgée* sur l'interface réseau locale.
Les machines virtuelles exécutant les services appartiennent au réseau privé et le pare-feu de l'hyperviseur bloque le trafic pour qu'elles ne puisse communiquer qu'avec la passerelle. En cas de compromission elles n'ont accès ni au réseau local ni au bastion. Chaque machine virtuelle est accessible par SSH depuis le bastion, qui est un simple conteneur LXC Alpine avec deux cartes réseau qui permet par rebond d'accéder aux machines virtuelles depuis un PC du réseau local. Excepté la console Web de Proxmox c'est le seul moyen d'accéder aux machines virtuelles. Les accès SSH sont protégés par échange de clefs (aucun accès autorisé par mot de passe) et seul le bastion est autorisé. Corollaire : n'importe quelle machine du réseau local ayant un accès SSH au bastion peut accéder aux machines virtuelles.
![Archi réseau](/images/2024/archi-lan.svg)
Voilà c'est sûrement perfectible mais j'ai jugé la solution suffisamment sécurisée pour la mettre en service. La machine "vm2" avec Nextcloud et Immlich est encore un projet mais la machine "vm1" exécutant tous mes services de base est déjà opérationnelle.

View file

@ -1,27 +0,0 @@
<!-- title: Analyse de logs -->
<!-- category: Hébergement -->
Après quelques semaines [sur ma nouvelle installation de serveur](/2024/retour-en-auto-hebergement/) j'ai eu besoin de visibilité sur les visites pour estimer si la sécurité devait être renforcée. De longue date j'avais mis en favori l'outil [GoAccess](https://goaccess.io) et il semblait correspondre à ce que je cherchais, à savoir un outil passif de génération de rapports basé sur les logs d'accès du serveur HTTP : pas d'analyse des IP source mais des statistiques d'accès par site, une répartition des *User Agent* utilisés avec une reconnaissance des bots (ou crawlers) qui représentent le plus gros du trafic, les erreurs HTTP, les URI les plus recherchées ...
A ma grande satisfaction j'ai mis en place GoAccess en moins de deux heures avec deux étapes :
1. l'installation sur le container LXC Alpine. GoAccess est écrit en langage C et a peu de dépendances pour la compilation. Avec deux ou trois dépendances de librairies ajoutées, la compilation sur le container a été simple.
2. le travail principal consiste à décrire le format du log à analyser dans le formalisme de GoAccess
Nginx Proxy Manager utilise un format de log commun pour tous les proxy ce qui m'a simplifié la tâche. La documentation de GoAccess est exemplaire, notamment le formalisme des logs (https://goaccess.io/man#custom-log).
Pour ce format de log Nginx :
```
log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $server] "$http_user_agent" "$http_referer"';
```
j'ai défini ces paramètres de lancement :
```
--datetime-format='%d/%b/%Y:%H:%M:%S %z'
--log-format='[%x] - %^ %s - %m %^ %v "%U" [Client %h] [Length %b] [Gzip %^] [Sent-to %^] "%u" "%R"'
```
GoAccess peut générer son rapport HTML à partir d'une liste de fichiers de logs à la demande ou en temps-réel avec un lancement en tâche de fond (option --real-time-html --daemonize). L'option temps-réel est sympa avec une web socket qui rafraîchit la page du navigateur automatiquement mais c'est consommateur en CPU sur ce type d'installation en mini-pc où l'un des objectifs est la sobriété énergétique. J'ai préféré opter pour une génération horaire par une tâche planifiée avec CRON.
GoAccess répond à mon besoin et j'ai commencé à analyser les données pour préparer une phase de renforcement de la sécurité.

View file

@ -1,8 +0,0 @@
<!-- title: Est-ce que tout est politique ? -->
<!-- category: Humeur -->
Le retour en auto-hébergement avec [l'achat d'un mini-pc](/2024/retour-en-auto-hebergement/) a été ma meilleure idée de fin d'année. Et ce n'est pas suffisant car j'ai encore beaucoup de dépendance à des services U.S. Même si je voyais d'un œil inquiet la réélection de Donald (pas le canard rigolo qui a bercé mon enfance... l'autre), jamais je n'aurais imaginé le système fasciste qui se met en place depuis le début d'année. Naïf, je pensais qu'il y avait des garde-fous, qu'on ne pouvait pas poignarder un système démocratique sans qu'une instance siffle un arrêt de jeu, mais je me trompais lourdement. Je ne rentrerais pas dans le détail des mesures appliquées par ce nouveau gouvernement sinon cet article va devenir trop triste ; j'ai juste le cœur qui saigne pour tous ceux qui en souffrent, sûrement une vilaine faiblesse de mon côté woke ;-)
Depuis tant d'années nous baignons dans ce monde du partage communautaire et multiculturel autour de Linux, des projets libres et opensource. Alors est-ce qu'on fait de la politique ? Pas forcément consciemment mais nous essayons de vivre en accord avec nos valeur et nos goûts reflètent une vision du monde (pas tel qu'il est malheureusement mais plutôt comme nous le rêvons). Le durcissement des gouvernements au détriment de l'humain (en piétinant d'abord les minorités) et de la nature, le repli en mode écureuil (tiens si je prends le Groenland je pourrais assurer le même train de vie pendant encore quelques dizaines d'année et faire croire que tout va bien ?) nous expose peu à peu. Si on n'est pas d'accord avec cela car nous avons d'autres valeurs plus humanistes alors je crains que nous ayons une posture politique à l'insu de nous-mêmes même :-) Le fait qu'on ait jamais voté ou qu'on soit fidèle aux urnes ne change rien à l'affaire : il y a une majorité silencieuse qui n'approuve pas, ne défile pas, du moins j'aime à le croire. Quelle est la limite qui fait basculer de l'indignation à l'action, je me pose la question... à moi-même en premier lieu.
L'année va être intéressante mais pas forcément joyeuse. Prenez soin de vous, mentalement aussi, car la période est malsaine.

View file

@ -1,20 +0,0 @@
<!-- title: Migration Git et optimisation Docker -->
<!-- category: Hébergement -->
J'ai quitté GitHub (entre autres) ! Enfin presque car je n'ai pas encore recensé les sites où mon compte GitHub sert d'authentification mais j'ai fait le plus dur : supprimer tous mes projets et ne migrer que ceux encore actifs ; un peu dur car supprimer des projets, mêmes archivés, pour un développeur c'est un crève-cœur mais le spleen est vite passé avec l'engouement de repartir à zéro sur une plate-forme plus conforme à mes valeurs, la forge de [Zaclys](https://www.zaclys.com) et je les remercie grandement d'avoir ajouté ce service à leur offre "Famille".
Après migration, il me reste quatre projets actifs (hé oui il ne reste que ça) à savoir mes configurations (dotfiles), le déploiement par docker compose (selfhosting), le gestionnaire de commentaires (stacosys) et le blog. Vous avez noté la subtilité pour être retrouvé sur la [forge de Zaclys](https://gitea.zaclys.com) sans donner explicitement le lien de mon espace dans l'article ;-)
Sur mon infrastructure domestique (le mini-pc Beelink propulsé par l'hyperviseur Proxmox) j'ai déjà une instance Gitea qui réplique mes projets à l'extérieur, ça me garantit d'avoir une copie à jour en cas de gros pépin. Bref après avoir migré mes projets je me suis trouvé devant la problématique de construire et publier les images Docker de stacosys et du blog puisque cette tâche était dévolue aux actions GitHub. Je ne sais pas si la forge Zaclys propose un équivalent mais je trouve inefficace de publier ailleurs des images prêtes à l'emploi qui ne servent qu'à moi, pour ensuite les rapatrier en local. En plus dans la foulée de GitHub j'avais bien apprécié de supprimer mon compte Docker Hub enfin désactiver car la suppression ne semble pas possible :-(
En cherchant une alternative pour construire mes images localement et les transférer vers mon infra, j'ai pensé à Gitea qui a aussi la fonction de "docker registry" appelée *packages*. Je suis donc parti là dessus, une publication à la demande, pas de CI ; vu le peu d'activité des projets ça fait parfaitement l'affaire.
> C'est quand on paye de sa poche qu'on prend conscience de la valeur des choses
Après avoir mis ça en place... un changement d'URL dans 2 projets j'ai trouvé assez long la publication de l'image du blog dans Gitea alors que tout est sur le LAN. Et pour cause, elle faisait 1,2 Go alors que celle de stacosys tient dans 74 Mo. Évidemment, en tout automatisé dans une CI ça ne m'avait pas alerté auparavant. Me voilà donc à m'intéresser à un génial outil d'analyse d'images [Dive](https://github.com/wagoodman/dive) qu'on peut même exécuter en container,
alias dive="docker run -ti --rm -v /var/run/docker.sock:/var/run/docker.sock docker.io/wagoodman/dive"
à lire un peu de littérature sur les build multi-stages et à analyser où ça pêche... Bon, ça pêche à plusieurs endroits : trop de couches, installation d'outils de développement pour construire les locales MUSL de la distribution Alpine alors que sur les versions récentes elles sont proposés dans les dépôts. Après une phase d'optimisation j'ai pu ramener l'image à 128 Mo, honnête à mon sens pour un container avec Nginx, Python et des traitements Bash qui font du "git clone".

View file

@ -1,15 +0,0 @@
<!-- title: Zoraxy : changement de reverse-proxy -->
<!-- category: Hébergement -->
J'ai décidé de remplacer [Nginx Proxy Manager](/2024/retour-en-auto-hebergement/) car la maintenance du container Alpine n'est pas aisée avec ma version modifiée pour ne pas exécuter les services du proxy en *root*. Ma première idée a été de revenir à une installation de Nginx classique, fournie par les dépôts Alpine, et à maintenir les fichiers de configuration manuellement. Après tout je l'ai fait pendant des années sur mes multiples installations de serveurs... Mais il semble que la flemme vienne avec l'âge : il y a le maintien de la configuration, la mise en place de [certbot](https://certbot.eff.org/), la surveillance et l'analyse des logs.
Je me suis demandé s'il y avait une alternative à Nginx Proxy Manager et je suis tombé sur [Zoraxy](https://zoraxy.aroz.org) : un outil tout-en-un qui fait office de reverse-proxy dynamique avec un tableau de bord d'analyse du trafic. Tout-en-un techniquement aussi car c'est une application écrite en Golang donc un binaire unique. En plus de la fonction de proxy HTTP et de la gestion des certificat, des fonctions additionnelles très sympathiques sont proposées via l'interface Web :
- le support des proxy TCP / UDP (nommés Stream Proxy)
- une surveillance de la disponibilité des services backends (uptime monitor)
- une configuration des pages 404 et un hébergement de pages statiques. je n'ai pas expérimenté mais on doit pouvoir créer une page d'erreur personnalisée quand un service backend est par terre.
- un tableau de bord assez complet qui permet de réagir et de contrôler les accès en bloquant des IP ou des pays
Devant l'engouement pour son projet le créateur de Zoraxy a recadré les utilisateurs en insistant sur le fait que Zoraxy ne pouvait pas avoir la robustesse et les performances d'un NginX et qu'il fallait le réserver à des usages plus limités. En cela il se positionne comme *"The ultimate homelab networking toolbox for self-hosted services"* et non pas comme un remplacement du reverse-proxy NginX pour des sites à gros volumes. Cela colle parfaitement à mon usage.

View file

@ -1,19 +0,0 @@
<!-- title: Minimalisme et Debian sur laptop -->
<!-- category: GNU/Linux -->
En complément de la tour PC sous Fedora + Win 11 Game Launcher (oui dans cet ordre-là) le laptop Tuxedo est mon fidèle poste mobile (à utiliser non loin dune prise de courant car la batterie est fatiguée). Offert par un ami, cest une sacrée machine propulsée par un Core i7 de 8ᵉ génération, 64Go de RAM et 2To de disque. Bref un monstre de puissance par rapport à mes usages, ce qui fait que les premières années jai multiplié les installations allant jusquà des triples boots pour tester tout ce qui me passait par la tête. Puis jai eu une période distancée (ou plutôt fatiguée) où javais juste besoin dune machine opérationnelle et javais mis le classique Ubuntu 22.04 LTS. Jai toujours craqué sur le look & feel léché dUbuntu et cétait une occasion de réinstaller cette distribution que jai beaucoup appréciée par le passé malgré quelques choix / partenariats discutables. Je lai conservé 2 ans avec beaucoup de râlerie au début, car la LTS portait mal la réputation de stabilité : incapable de mettre à jour ses flatpak sans mettre le nez en ligne de commande par exemple. Les bugs se sont corrigés au fil des mois puis je lai juste utilisée… même si je trouvais exagéré de proposer un flatpak par défaut pour des programmes de base comme le shell.
Et puis depuis quelques mois jai commencé un chantier qui sapparente à un mix entre minimalisme et réduction des coûts : jai rapatrié le cloud de kDrive et tous mes petits services dun VPS vers un mini-pc hébergé à la maison. Cela fait 6 mois et avec le recul (Trumpisme, augmentation des coûts des hébergeurs, incertitude de la régulation suisse) jai bien fait denlever mes billes dInfomaniak et de reprendre la main sur mes données. Jai aussi fermé quantité de comptes sur des sites (la plupart US) dont je pouvais me passer et migré mes projets GitHub (coucou Zaclys).
Le dernier clou fut les e-mails migrés dInfomaniak vers [Ecomail](https://www.ecomail.fr/) en toute transparence car rattachés à mon nom de domaine. Là on nest pas sur une réduction de coût, car la boite e-mail était offerte avec le nom de domaine mais sur une volonté den faire un peu plus pour la planète puisque Ecomail finance des actions avec une partie de son chiffre daffaires. Ma *volonté* sest bornée à effectuer un petit paiement ! Ce sont Clio et Nathan qui font le boulot et je pense quils méritent dêtre plus connus.
Tout ça pour en revenir à mon laptop et ma volonté de prolonger ce mouvement et dagrandir ma surface de cohérence (et jai une grosse marge de progression). Jai installé une Debian testing très vite renommée Trixie, quand jai validé quelle avait tout ce quil me fallait, afin de rester sur une version stable les deux ou trois prochaines années. Je nai jamais perdu Debian de vue et jen installe souvent sur mes serveurs mais cela faisait très longtemps que je navais pas revu la version bureau. Jai choisi Mate Desktop (nostalgie quand tu nous tiens) et un thème dinspiration MacOs bien léché.
![Debian screenshot](/images/2025/debian-screenshot.png)
Que dire ? je ladore et je me suis fixé des règles :
- je limite les dépôts tiers ; là jai Sublime et Signal.
- les Flatpak / AppImage sont proscrits.
Je commence même à apprécier Synaptic et son côté rétro… Sûrement lindigestion de ces boutiques dapplications bourrées de publicité et de notation.

View file

@ -1,25 +0,0 @@
<!-- title: GrapheneOS sur Pixel 6a -->
<!-- category: Android -->
Le dernier article sur mon téléphone Android date de [2023](https://blogduyax.madyanne.fr/2023/applications-mobiles/) avec une liste des applications utilisées à lépoque. Lapproche na pas changé ; je privilégie le magasin dapplications F-Droid en priorité même si je suis toujours dépendant de Google pour certains services. A sa sortie javais précommandé le Pixel 6a pour son rapport prix / qualité photographique, un critère majeur pour moi Lautre avantage de ce téléphone, à part ses mises à jour garanties pendant des années est labsence de logiciels indésirables préinstallés et parfois impossibles à supprimer (les clones moisis de lécosystème du fabricant pour des services standards chez Google par exemple ou la brochette dapplications sociales dont je nai rien à faire). Pour mes usages jutilise le moins possible dapplications Google donc GMail, Drive, Photos étaient installés mais jamais lancés. Et à la place jai FairEmail, Seafile et Immich.
Jai été satisfait du téléphone pendant trois ans, excepté quelques agacements récents suite au remplacement de lassistant (désactivé de longue date) par Gemini qui a initié un jeu du chat et de la souris avec des réglages réinitialisés régulièrement par les mises à jour. Puis une conjonction de deux évènements ont déclenché le remplacement de la version Stock dAndroid par GrapheneOS, une version durcie dun point de vue sécurité et vie privée maintenue par une communauté très activée et financé par des sponsors individuels et des sociétés :
1. mon appareil est concerné par un problème de batterie défectueuse : Google ma indemnisé de 100$ pour ce défaut
2. un proche ma posé des questions sur la dégooglisation des téléphones et nous avons discuté de la difficulté technique et des prérequis, idéalement peu dadhérence aux services Google. Du coup jai continué cette réflexion pour mon propre cas en me demandant quelle était mon adhérence et si je pouvais aller plus loin.
Jai identifié les applications GPS : même si jutilise CoMaps essentiellement en vacances pour ses capacités hors ligne, jutilise aussi beaucoup Maps et Waze pour linfo trafic sur la région marseillaise. On ma conseillé Magic Earth en complément de CoMaps et si linfo trafic est forcément moins précise jadore la clarté de sa navigation ; ça ressemble plus aux GPS dédiés de lépoque genre TomTom et moins à un jeu vidéo (on prend quoi ce mois-ci : un avatar de combi Volkswagen et les voix rigolotes de South Park ?). Je crois que je peux vivre sans une info trafic collaborative un peu trop temps réel qui pousse parfois à des décisions sur le vif quon regrette dailleurs en changeant ditinéraire sans raison, ça me fera le plus grand bien. Lautre application nécessitant un compte est Youtube Music, un abonnement familial que je souhaite conserver encore quelques temps.
Les alternatives à la version Stock Android sur Pixel sont multiples : de la mama Lineage avec ou sans Gapps dont dérivent beaucoup de ROM custom et GrapheneOS qui sort du lot car :
- sa priorité est la sécurité et la confidentialité,
- elle ne supporte que la gamme Pixel aujourdhui, à priori, car ils ont une connaissance experte de ces modèles
- son support optionnel des Play Services nest pas une couche de compatibilité mais une version officielle en mode "bac à sable" : lapplication Play Store nest pas partie intégrante du système mais une application comme les autres
- laccès au PlayStore permet dinstaller le module photo pour les Pixel et donc de conserver la qualité des photos produites par lappareil
Selon la page Wikipedia, la fonction de sécurité introduit un accès réseau révocable et des paramètres dautorisation des capteurs pour chaque application installée. Il introduit également une option de brouillage du code PIN pour lécran de verrouillage. Une randomisation des adresses MAC Wi-Fi par connexion (à un réseau Wi-Fi) est introduite par défaut. Anecdote amusante je recevais une notification "nouvel appareil connecté au réseau" à chaque fois que je revenais à la maison à cause de cette randomisation des adresses MAC.
Étonnamment la méthode dinstallation recommandée par GrapheneOS passe par un navigateur Web (privilégiez Chrome exceptionnellement) au lieu de manipulation en lignes de commandes avec ADB. Au préalable il faut déverrouiller le bootloader (et le verrouiller après linstallation) ; cest une manipulation aisée et documentée par Google. Avec Xiaomi cest mon second constructeur préféré pour cette partie qui peut-être risquée avec certains constructeurs qui ne supportent pas les utilisateurs changeant de système.
Je suis très satisfait de GrapheneOS. Les mises à jour du système sont poussées automatiquement et il suffit de redémarrer le téléphone pour les appliquer. Jai reçu déjà trois mises à jour en quatre semaines. F-Droid reste mon magasin dapplication privilégié ; jaurais pu éviter le PlayStore et passer par Aurora, mais je ne suis pas très serein avec cette méthode. Et évidemment aucun assistant Google nest installé et probablement installable sur ce système.

View file

@ -1,32 +0,0 @@
<!-- title: Immich et les tags -->
<!-- category: Hébergement -->
[Immich](https://immich.app/) est ma fierté de l'année. J'ai trouvé l'application de gestion de photos qui colle à ma façon de gérer, c'est à dire rester maître du stockage. Je continue à organiser mes photos par dossiers — un par année avec des noms communs d'une année sur l'autre comme "travaux" — et à gérer leur sauvegarde — dans mon cas c'est un combiné synchronisation Seafile entre mes machines et sauvegarde à froid sur un disque externe — et je donne un accès en lecture seule à Immich qui importe la photothèque dans sa base de donnée. Cela double peu ou prou le stockage mais je ne veux pas de dépendance et de risque de perte de données en cas de bug de l'appli de photos. Cela reste simple : des fichiers faciles à manipuler et à sauvegarder.
> Techniquement, Seafile et Immich sont exécutés par Docker : Seafile expose 100Go de photos via un partage fuse sur l'hôte et ce répertoire est monté en volume dans le container Immich.
Immich propose une vue chronologique, une recherche par géolocalisation, une recherche par personne et un recherche par contexte. La géolocalisation se base sur les données EXIF de la photo et les deux autres sur des LLM d'analyse qui tournent localement sur le serveur. La recherche par personne est redoutable : je ne l'ai pas pris en défaut avec des photos étalées sur une cinquantaine d'années.
Mais il me manquait une fonction que j'ai eu du mal à trouver : les étiquettes (ou tags). je voulais étiqueter mon répertoire "travaux" d'année en année et avoir un point d'entrée pour le parcourir, les noms de mes dossiers d'origine étant bien sûr ignorés par Immich. Après avoir traîné sur la doc officielle et les forums j'ai trouvé la méthode : Immich crée les tags automatiquement avec ce qu'il trouve dans les données EXIF des photos analysées. A vrai dire je ne savais pas qu'EXIF pouvait stocker cette information mais une propriété "keywords" existe pour cela. Vous noterez le "s" à keyword, on peut même associer plusieurs tags à une photo.
Après avoir installé *exiftool* disponible dans toutes les bonnes distributions j'ai pu modifier mes photos :
```
# Afficher les étiquettes
exiftool -Keywords
# Supprimer les étiquettes
exiftool -Keywords=
# ajout d'une étiquette "Travaux"
exiftool -Keywords=Travaux *.jpg
```
Propriétés du fichier :
![Propriétés du fichier](/images/2025/file-props.png)
Et le lendemain après analyse par Immich mon étiquette est présente :
![Vue dans Immich](/images/2025/immich-tag.png)

View file

@ -1,19 +1,28 @@
[project]
name = "blog"
version = "1.2"
version = "1.1"
description = "Blog du Yax"
readme = "README.md"
authors = [
{ name = "Yax" }
]
requires-python = ">=3.12.9"
readme = "README.md"
requires-python = ">= 3.8"
dependencies = [
"requests>=2.31.0",
"pygments>=2.17.1",
"mistune>=3.0.2",
"pygments>=2.18.0",
"requests>=2.32.3",
]
[dependency-groups]
dev = [
"black>=24.10.0",
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.rye]
managed = true
dev-dependencies = [
"black>=23.10.1",
"pyinstaller>=6.1.0",
]
[tool.hatch.metadata]
allow-direct-references = true

29
requirements-dev.lock Normal file
View file

@ -0,0 +1,29 @@
# generated by rye
# use `rye lock` or `rye sync` to update this lockfile
#
# last locked with the following flags:
# pre: false
# features: []
# all-features: false
-e file:.
altgraph==0.17.4
black==23.11.0
certifi==2023.11.17
charset-normalizer==3.3.2
click==8.1.7
idna==3.4
mistune==3.0.2
mypy-extensions==1.0.0
packaging==23.2
pathspec==0.11.2
platformdirs==4.0.0
pygments==2.17.1
pyinstaller==6.2.0
pyinstaller-hooks-contrib==2023.10
requests==2.31.0
tomli==2.0.1
typing-extensions==4.8.0
urllib3==2.1.0
# The following packages are considered to be unsafe in a requirements file:
setuptools==68.2.2

16
requirements.lock Normal file
View file

@ -0,0 +1,16 @@
# generated by rye
# use `rye lock` or `rye sync` to update this lockfile
#
# last locked with the following flags:
# pre: false
# features: []
# all-features: false
-e file:.
certifi==2023.11.17
charset-normalizer==3.3.2
idna==3.4
mistune==3.0.2
pygments==2.17.1
requests==2.31.0
urllib3==2.1.0

View file

@ -1,47 +0,0 @@
vars: {
d2-config: {
layout-engine: elk
# Terminal theme code
theme-id: 300
}
}
dmz: {
nginx proxy: {
icon: https://icons.terrastruct.com/dev%2Fnginx.svg
shape: rectangle
}
}
private network: {
label.near: bottom-center
virtual machines: {
icon: https://icons.terrastruct.com/gcp%2FProducts%20and%20services%2FNetworking%2FVirtual%20Private%20Cloud.svg
style.multiple: true
containers: {
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
style.multiple: true
}
}
}
lan: {
bastion: {
icon: https://icons.terrastruct.com/dev%2Fssh.svg
}
devices: {
icon: https://icons.terrastruct.com/tech%2Flaptop.svg
style.multiple: true
}
}
internet box: {
icon: https://icons.terrastruct.com/tech%2Frouter.svg
width: 130
}
internet box -> dmz.nginx proxy: http/https
lan.bastion -> private network.virtual machines: ssh
dmz.nginx proxy -> private network.virtual machines: http

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 50 KiB

View file

@ -1,76 +0,0 @@
vars: {
d2-config: {
layout-engine: elk
# Terminal theme code
theme-id: 4
}
}
Proxmox: {
icon: https://www.svgrepo.com/download/331552/proxmox.svg
label: "Hyperviseur Proxmox"
Containers: {
label: "Containers LXC"
Nginx_Proxy: {
icon: https://upload.wikimedia.org/wikipedia/commons/d/dd/Linux_Containers_logo.svg
label: "Nginx Proxy"
}
Bastion: {
icon: https://upload.wikimedia.org/wikipedia/commons/d/dd/Linux_Containers_logo.svg
label: "Bastion"
}
}
vm1: {
icon : https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/Kvmbanner-logo2_1.png/320px-Kvmbanner-logo2_1.png
label: "vm1"
Heimdall: {
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
label: "Heimdall"
}
Blog: {
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
label: "Blog"
}
Selfoss: {
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
label: "Selfoss"
}
Wallabag: {
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
label: "Wallabag"
}
Shaarli: {
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
label: "Shaarli"
}
}
vm2: {
icon : https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/Kvmbanner-logo2_1.png/320px-Kvmbanner-logo2_1.png
label: "vm2"
style: {
stroke-dash: 3
}
Nextcloud: {
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
label: "Nextcloud"
}
Immich: {
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
label: "Immich"
}
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

0
test/__init__.py Normal file
View file

16
test/path.py Normal file
View file

@ -0,0 +1,16 @@
import os
import tempfile
import shutil
def temppath(*paths):
return os.path.join(tempfile.gettempdir(), *paths)
def move(src, dst):
if os.path.isfile(dst):
os.remove(dst)
elif os.path.isdir(dst):
shutil.rmtree(dst)
if os.path.exists(src):
os.rename(src, dst)

104
test/test_content.py Normal file
View file

@ -0,0 +1,104 @@
import unittest
import shutil
import os
import makesite
from test import path
class ContentTest(unittest.TestCase):
def setUp(self):
self.blog_path = path.temppath('blog')
self.undated_path = os.path.join(self.blog_path, 'foo.txt')
self.dated_path = os.path.join(self.blog_path, '2018-01-01-foo.txt')
self.normal_post_path = os.path.join(self.blog_path, 'baz.txt')
self.md_post_path = os.path.join(self.blog_path, 'qux.md')
self.no_md_post_path = os.path.join(self.blog_path, 'qux.txt')
os.makedirs(self.blog_path)
with open(self.undated_path, 'w') as f:
f.write('hello world')
with open(self.dated_path, 'w') as f:
f.write('hello world')
with open(self.normal_post_path, 'w') as f:
f.write('<!-- a: 1 -->\n<!-- b: 2 -->\nFoo')
with open(self.md_post_path, 'w') as f:
f.write('*Foo*')
with open(self.no_md_post_path, 'w') as f:
f.write('*Foo*')
def tearDown(self):
shutil.rmtree(self.blog_path)
# Rudimentary mock because unittest.mock is unavailable in Python 2.7.
def mock(self, *args):
self.mock_args = args
def test_content_content(self):
content = makesite.read_content(self.undated_path)
self.assertEqual(content['content'], 'hello world')
def test_content_date(self):
content = makesite.read_content(self.dated_path)
self.assertEqual(content['date'], '2018-01-01')
def test_content_date_missing(self):
content = makesite.read_content(self.undated_path)
self.assertEqual(content['date'], '1970-01-01')
def test_content_slug_dated(self):
content = makesite.read_content(self.dated_path)
self.assertEqual(content['slug'], 'foo')
def test_content_slug_undated(self):
content = makesite.read_content(self.undated_path)
self.assertEqual(content['slug'], 'foo')
def test_content_headers(self):
content = makesite.read_content(self.normal_post_path)
self.assertEqual(content['a'], '1')
self.assertEqual(content['b'], '2')
self.assertEqual(content['content'], 'Foo')
def test_markdown_rendering(self):
content = makesite.read_content(self.md_post_path)
self.assertEqual(content['content'], '<p><em>Foo</em></p>\n')
def test_markdown_import_error(self):
makesite._test = 'ImportError'
original_log = makesite.log
makesite.log = self.mock
self.mock_args = None
content = makesite.read_content(self.md_post_path)
makesite._test = None
makesite.log = original_log
self.assertEqual(content['content'], '*Foo*')
self.assertEqual(self.mock_args,
('WARNING: Cannot render Markdown in {}: {}',
self.md_post_path, 'Error forced by test'))
def test_no_markdown_rendering(self):
content = makesite.read_content(self.no_md_post_path)
self.assertEqual(content['content'], '*Foo*')
def test_no_markdown_import_error(self):
makesite._test = 'ImportError'
original_log = makesite.log
makesite.log = self.mock
self.mock_args = None
content = makesite.read_content(self.no_md_post_path)
makesite._test = None
makesite.log = original_log
self.assertEqual(content['content'], '*Foo*')
self.assertIsNone(self.mock_args)

39
test/test_file_io.py Normal file
View file

@ -0,0 +1,39 @@
import unittest
import os
import shutil
import makesite
from test import path
class FileIOTest(unittest.TestCase):
"""Tests for file I/O functions."""
def test_fread(self):
text = 'foo\nbar\n'
filepath = path.temppath('foo.txt')
with open(filepath, 'w') as f:
f.write(text)
text_read = makesite.fread(filepath)
os.remove(filepath)
self.assertEqual(text_read, text)
def test_fwrite(self):
text = 'baz\nqux\n'
filepath = path.temppath('foo.txt')
makesite.fwrite(filepath, text)
with open(filepath) as f:
text_read = f.read()
os.remove(filepath)
self.assertEqual(text_read, text)
def test_fwrite_makedir(self):
text = 'baz\nqux\n'
dirpath = path.temppath('foo', 'bar')
filepath = os.path.join(dirpath, 'foo.txt')
makesite.fwrite(filepath, text)
with open(filepath) as f:
text_read = f.read()
self.assertTrue(os.path.isdir(dirpath))
shutil.rmtree(path.temppath('foo'))
self.assertEqual(text_read, text)

43
test/test_headers.py Normal file
View file

@ -0,0 +1,43 @@
import unittest
import makesite
class HeaderTest(unittest.TestCase):
"""Tests for read_headers() function."""
def test_single_header(self):
text = '<!-- key1: val1 -->'
headers = list(makesite.read_headers(text))
self.assertEqual(headers, [('key1', 'val1', 19)])
def test_multiple_headers(self):
text = '<!-- key1: val1 -->\n<!-- key2: val2-->'
headers = list(makesite.read_headers(text))
self.assertEqual(headers, [('key1', 'val1', 20), ('key2', 'val2', 38)])
def test_headers_and_text(self):
text = '<!-- a: 1 -->\n<!-- b: 2 -->\nFoo\n<!-- c: 3 -->'
headers = list(makesite.read_headers(text))
self.assertEqual(headers, [('a', '1', 14), ('b', '2', 28)])
def test_headers_and_blank_line(self):
text = '<!-- a: 1 -->\n<!-- b: 2 -->\n\n<!-- c: 3 -->\n'
headers = list(makesite.read_headers(text))
self.assertEqual(headers, [('a', '1', 14),
('b', '2', 29),
('c', '3', 43)])
def test_multiline_header(self):
text = '<!--\na: 1 --><!-- b:\n2 -->\n<!-- c: 3\n-->'
headers = list(makesite.read_headers(text))
self.assertEqual(headers, [('a', '1', 13),
('b', '2', 27),
('c', '3', 40)])
def test_no_header(self):
headers = list(makesite.read_headers('Foo'))
self.assertEqual(headers, [])
def test_empty_string(self):
headers = list(makesite.read_headers(''))
self.assertEqual(headers, [])

46
test/test_list.py Normal file
View file

@ -0,0 +1,46 @@
import unittest
import shutil
import os
import makesite
from test import path
class PagesTest(unittest.TestCase):
def setUp(self):
self.site_path = path.temppath('site')
def tearDown(self):
shutil.rmtree(self.site_path)
def test_list(self):
posts = [{'content': 'Foo'}, {'content': 'Bar'}]
dst = os.path.join(self.site_path, 'list.txt')
list_layout = '<div>{{ content }}</div>'
item_layout = '<p>{{ content }}</p>'
makesite.make_list(posts, dst, list_layout, item_layout)
with open(os.path.join(self.site_path, 'list.txt')) as f:
self.assertEqual(f.read(), '<div><p>Foo</p><p>Bar</p></div>')
def test_list_params(self):
posts = [{'content': 'Foo', 'title': 'foo'},
{'content': 'Bar', 'title': 'bar'}]
dst = os.path.join(self.site_path, 'list.txt')
list_layout = '<div>{{ key }}:{{ title }}:{{ content }}</div>'
item_layout = '<p>{{ key }}:{{ title }}:{{ content }}</p>'
makesite.make_list(posts, dst, list_layout, item_layout,
key='val', title='lorem')
with open(os.path.join(self.site_path, 'list.txt')) as f:
text = f.read()
self.assertEqual(text,
'<div>val:lorem:<p>val:foo:Foo</p><p>val:bar:Bar</p></div>')
def test_dst_params(self):
posts = [{'content': 'Foo'}, {'content': 'Bar'}]
dst = os.path.join(self.site_path, '{{ key }}.txt')
list_layout = '<div>{{ content }}</div>'
item_layout = '<p>{{ content }}</p>'
makesite.make_list(posts, dst, list_layout, item_layout, key='val')
expected_path = os.path.join(self.site_path, 'val.txt')
self.assertTrue(os.path.isfile(expected_path))
with open(expected_path) as f:
self.assertEqual(f.read(), '<div><p>Foo</p><p>Bar</p></div>')

73
test/test_main.py Normal file
View file

@ -0,0 +1,73 @@
import unittest
import makesite
import os
import shutil
import json
from test import path
class MainTest(unittest.TestCase):
def setUp(self):
path.move('_site', '_site.backup')
path.move('params.json', 'params.json.backup')
def tearDown(self):
path.move('_site.backup', '_site')
path.move('params.json.backup', 'params')
def test_site_missing(self):
makesite.main()
def test_site_exists(self):
os.mkdir('_site')
with open('_site/foo.txt', 'w') as f:
f.write('foo')
self.assertTrue(os.path.isfile('_site/foo.txt'))
makesite.main()
self.assertFalse(os.path.isfile('_site/foo.txt'))
def test_default_params(self):
makesite.main()
with open('_site/blog/proin-quam/index.html') as f:
s1 = f.read()
with open('_site/blog/rss.xml') as f:
s2 = f.read()
shutil.rmtree('_site')
self.assertIn('<a href="/">Home</a>', s1)
self.assertIn('<title>Proin Quam - Lorem Ipsum</title>', s1)
self.assertIn('Published on 2018-01-01 by <b>Admin</b>', s1)
self.assertIn('<link>http://localhost:8000/</link>', s2)
self.assertIn('<link>http://localhost:8000/blog/proin-quam/</link>', s2)
def test_json_params(self):
params = {
'base_path': '/base',
'subtitle': 'Foo',
'author': 'Bar',
'site_url': 'http://localhost/base'
}
with open('params.json', 'w') as f:
json.dump(params, f)
makesite.main()
with open('_site/blog/proin-quam/index.html') as f:
s1 = f.read()
with open('_site/blog/rss.xml') as f:
s2 = f.read()
shutil.rmtree('_site')
self.assertIn('<a href="/base/">Home</a>', s1)
self.assertIn('<title>Proin Quam - Foo</title>', s1)
self.assertIn('Published on 2018-01-01 by <b>Bar</b>', s1)
self.assertIn('<link>http://localhost/base/</link>', s2)
self.assertIn('<link>http://localhost/base/blog/proin-quam/</link>', s2)

127
test/test_pages.py Normal file
View file

@ -0,0 +1,127 @@
import unittest
import os
import shutil
import makesite
from test import path
class PagesTest(unittest.TestCase):
def setUp(self):
self.blog_path = path.temppath('blog')
self.site_path = path.temppath('site')
os.makedirs(self.blog_path)
with open(os.path.join(self.blog_path, 'foo.txt'), 'w') as f:
f.write('Foo')
with open(os.path.join(self.blog_path, 'bar.txt'), 'w') as f:
f.write('Bar')
with open(os.path.join(self.blog_path, '2018-01-01-foo.txt'), 'w') as f:
f.write('Foo')
with open(os.path.join(self.blog_path, '2018-01-02-bar.txt'), 'w') as f:
f.write('Bar')
with open(os.path.join(self.blog_path, 'header-foo.txt'), 'w') as f:
f.write('<!-- tag: foo -->Foo')
with open(os.path.join(self.blog_path, 'header-bar.txt'), 'w') as f:
f.write('<!-- title: bar -->Bar')
with open(os.path.join(self.blog_path, 'placeholder-foo.txt'), 'w') as f:
f.write('<!-- title: foo -->{{ title }}:{{ author }}:Foo')
with open(os.path.join(self.blog_path, 'placeholder-bar.txt'), 'w') as f:
f.write('<!-- title: bar --><!-- render: yes -->{{ title }}:{{ author }}:Bar')
def tearDown(self):
shutil.rmtree(self.blog_path)
shutil.rmtree(self.site_path)
def test_pages_undated(self):
src = os.path.join(self.blog_path, '[fb]*.txt')
dst = os.path.join(self.site_path, '{{ slug }}.txt')
tpl = '<div>{{ content }}</div>'
makesite.make_pages(src, dst, tpl)
with open(os.path.join(self.site_path, 'foo.txt')) as f:
self.assertEqual(f.read(), '<div>Foo</div>')
with open(os.path.join(self.site_path, 'bar.txt')) as f:
self.assertEqual(f.read(), '<div>Bar</div>')
def test_pages_dated(self):
src = os.path.join(self.blog_path, '2*.txt')
dst = os.path.join(self.site_path, '{{ slug }}.txt')
tpl = '<div>{{ content }}</div>'
makesite.make_pages(src, dst, tpl)
with open(os.path.join(self.site_path, 'foo.txt')) as f:
self.assertEqual(f.read(), '<div>Foo</div>')
with open(os.path.join(self.site_path, 'bar.txt')) as f:
self.assertEqual(f.read(), '<div>Bar</div>')
def test_pages_layout_params(self):
src = os.path.join(self.blog_path, '2*.txt')
dst = os.path.join(self.site_path, '{{ slug }}.txt')
tpl = '<div>{{ slug }}:{{ title }}:{{ date }}:{{ content }}</div>'
makesite.make_pages(src, dst, tpl, title='Lorem')
with open(os.path.join(self.site_path, 'foo.txt')) as f:
self.assertEqual(f.read(), '<div>foo:Lorem:2018-01-01:Foo</div>')
with open(os.path.join(self.site_path, 'bar.txt')) as f:
self.assertEqual(f.read(), '<div>bar:Lorem:2018-01-02:Bar</div>')
def test_pages_return_value(self):
src = os.path.join(self.blog_path, '2*.txt')
dst = os.path.join(self.site_path, '{{ slug }}.txt')
tpl = '<div>{{ content }}</div>'
posts = makesite.make_pages(src, dst, tpl)
self.assertEqual(len(posts), 2)
self.assertEqual(posts[0]['date'], '2018-01-02')
self.assertEqual(posts[1]['date'], '2018-01-01')
def test_content_header_params(self):
# Test that header params from one post is not used in another
# post.
src = os.path.join(self.blog_path, 'header*.txt')
dst = os.path.join(self.site_path, '{{ slug }}.txt')
tpl = '{{ title }}:{{ tag }}:{{ content }}'
makesite.make_pages(src, dst, tpl)
with open(os.path.join(self.site_path, 'header-foo.txt')) as f:
self.assertEqual(f.read(), '{{ title }}:foo:Foo')
with open(os.path.join(self.site_path, 'header-bar.txt')) as f:
self.assertEqual(f.read(), 'bar:{{ tag }}:Bar')
def test_content_no_rendering(self):
# Test that placeholders are not populated in content rendering
# by default.
src = os.path.join(self.blog_path, 'placeholder-foo.txt')
dst = os.path.join(self.site_path, '{{ slug }}.txt')
tpl = '<div>{{ content }}</div>'
makesite.make_pages(src, dst, tpl, author='Admin')
with open(os.path.join(self.site_path, 'placeholder-foo.txt')) as f:
self.assertEqual(f.read(), '<div>{{ title }}:{{ author }}:Foo</div>')
def test_content_rendering_via_kwargs(self):
# Test that placeholders are populated in content rendering when
# requested in make_pages.
src = os.path.join(self.blog_path, 'placeholder-foo.txt')
dst = os.path.join(self.site_path, '{{ slug }}.txt')
tpl = '<div>{{ content }}</div>'
makesite.make_pages(src, dst, tpl, author='Admin', render='yes')
with open(os.path.join(self.site_path, 'placeholder-foo.txt')) as f:
self.assertEqual(f.read(), '<div>foo:Admin:Foo</div>')
def test_content_rendering_via_header(self):
# Test that placeholders are populated in content rendering when
# requested in content header.
src = os.path.join(self.blog_path, 'placeholder-bar.txt')
dst = os.path.join(self.site_path, '{{ slug }}.txt')
tpl = '<div>{{ content }}</div>'
makesite.make_pages(src, dst, tpl, author='Admin')
with open(os.path.join(self.site_path, 'placeholder-bar.txt')) as f:
self.assertEqual(f.read(), '<div>bar:Admin:Bar</div>')
def test_rendered_content_in_summary(self):
# Test that placeholders are populated in summary if and only if
# content rendering is enabled.
src = os.path.join(self.blog_path, 'placeholder*.txt')
post_dst = os.path.join(self.site_path, '{{ slug }}.txt')
list_dst = os.path.join(self.site_path, 'list.txt')
post_layout = ''
list_layout = '<div>{{ content }}</div>'
item_layout = '<p>{{ summary }}</p>'
posts = makesite.make_pages(src, post_dst, post_layout, author='Admin')
makesite.make_list(posts, list_dst, list_layout, item_layout)
with open(os.path.join(self.site_path, 'list.txt')) as f:
self.assertEqual(f.read(), '<div><p>{{ title }}:{{ author }}:Foo</p><p>bar:Admin:Bar</p></div>')

78
test/test_path.py Normal file
View file

@ -0,0 +1,78 @@
import unittest
import os
import shutil
from test import path
class PathTest(unittest.TestCase):
def test_temppath(self):
self.assertTrue(path.temppath())
def test_move_existing_file(self):
src = os.path.join(path.temppath(), 'foo.txt')
dst = os.path.join(path.temppath(), 'bar.txt')
with open(src, 'w') as f:
f.write('foo')
path.move(src, dst)
self.assertFalse(os.path.isfile(src))
self.assertTrue(os.path.isfile(dst))
with open(dst) as f:
text = f.read()
os.remove(dst)
self.assertEqual(text, 'foo')
def test_move_missing_file(self):
src = os.path.join(path.temppath(), 'foo.txt')
dst = os.path.join(path.temppath(), 'bar.txt')
path.move(src, dst)
self.assertFalse(os.path.isfile(src))
self.assertFalse(os.path.isfile(dst))
def test_move_file_cleanup(self):
src = os.path.join(path.temppath(), 'foo.txt')
dst = os.path.join(path.temppath(), 'bar.txt')
with open(dst, 'w') as f:
f.write('foo')
path.move(src, dst)
self.assertFalse(os.path.isfile(src))
self.assertFalse(os.path.isfile(dst))
def test_move_existing_dir(self):
src = os.path.join(path.temppath(), 'foo')
srcf = os.path.join(src, 'foo.txt')
dst = os.path.join(path.temppath(), 'bar')
dstf = os.path.join(dst, 'foo.txt')
os.makedirs(src)
with open(srcf, 'w') as f:
f.write('foo')
path.move(src, dst)
self.assertFalse(os.path.isdir(src))
self.assertTrue(os.path.isdir(dst))
with open(dstf) as f:
text = f.read()
shutil.rmtree(dst)
self.assertEqual(text, 'foo')
def test_move_missing_dir(self):
src = os.path.join(path.temppath(), 'foo')
dst = os.path.join(path.temppath(), 'bar')
path.move(src, dst)
self.assertFalse(os.path.isdir(src))
self.assertFalse(os.path.isdir(dst))
def test_move_dir_cleanup(self):
src = os.path.join(path.temppath(), 'foo')
dst = os.path.join(path.temppath(), 'bar')
os.makedirs(dst)
path.move(src, dst)
self.assertFalse(os.path.isdir(src))
self.assertFalse(os.path.isdir(dst))

25
test/test_render.py Normal file
View file

@ -0,0 +1,25 @@
import unittest
import makesite
class RenderTest(unittest.TestCase):
"""Tests for render() function."""
def test_oneline_template(self):
tpl = 'foo {{ key1 }} baz {{ key2 }}'
out = makesite.render(tpl, key1='bar', key2='qux')
self.assertEqual(out, 'foo bar baz qux')
def test_multiline_template(self):
tpl = 'foo {{ key1 }}\nbaz {{ key1 }}'
out = makesite.render(tpl, key1='bar')
self.assertEqual(out, 'foo bar\nbaz bar')
def test_repeated_key(self):
tpl = 'foo {{ key1 }} baz {{ key1 }}'
out = makesite.render(tpl, key1='bar')
self.assertEqual(out, 'foo bar baz bar')
def test_multiline_placeholder(self):
tpl = 'foo {{\nkey1\n}} baz {{\nkey2\n}}'
out = makesite.render(tpl, key1='bar', key2='qux')
self.assertEqual(out, 'foo bar baz qux')

View file

@ -0,0 +1,13 @@
import unittest
import makesite
class RFC822DateTest(unittest.TestCase):
def test_epoch(self):
self.assertEqual(makesite.rfc_2822_format('1970-01-01'),
'Thu, 01 Jan 1970 00:00:00 +0000')
def test_2018_06_16(self):
self.assertEqual(makesite.rfc_2822_format('2018-06-16'),
'Sat, 16 Jun 2018 00:00:00 +0000')

13
test/test_slugify.py Normal file
View file

@ -0,0 +1,13 @@
import unittest
import makesite
class SlugifyTest(unittest.TestCase):
def test_slugify(self):
self.assertEqual(makesite.slugify('NginX est brillant'), 'nginx-est-brillant')
self.assertEqual(makesite.slugify('Bilan hébergement 2023'), 'bilan-hebergement-2023')
self.assertEqual(makesite.slugify('Sécurisation Docker : des pistes'), 'securisation-docker-des-pistes')
self.assertEqual(makesite.slugify('Il court, il court, le furet'), 'il-court-il-court-le-furet')
self.assertEqual(makesite.slugify('De GNU/Linux à gnuSystemlinuxdGnomeOs'), 'de-gnulinux-a-gnusystemlinuxdgnomeos')
self.assertEqual(makesite.slugify('Au fait... mon téléphone'), 'au-fait-mon-telephone')

9
test/test_truncate.py Normal file
View file

@ -0,0 +1,9 @@
import unittest
import makesite
class TruncateTest(unittest.TestCase):
def test_truncate(self):
long_text = ' \n'.join('word' + str(i) for i in range(50))
expected_text = ' '.join('word' + str(i) for i in range(25))
self.assertEqual(makesite.truncate(long_text), expected_text)