123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- #!/usr/bin/env python3
- import fnmatch
- import locale
- import os
- from collections import defaultdict
- from dataclasses import dataclass
- from datetime import datetime, timedelta
- from html import escape
- from itertools import groupby
- from pathlib import Path
- from string import Template
- from textwrap import dedent
- from time import perf_counter
-
- import mistune
- from jinja2 import Environment as Env
- from jinja2 import FileSystemLoader
- from minicli import cli, run, wrap
- from mistune.directives import DirectiveInclude
- from PIL import Image
- from slugify import slugify
-
- from typography import typographie
-
- # Useful for dates rendering within Jinja2.
- locale.setlocale(locale.LC_ALL, "fr_FR.UTF-8")
-
- HERE = Path(".")
- DAVID = HERE / "david"
- STATIC = HERE / ".." / "larlet-fr-static"
- DOMAIN = "https://larlet.fr"
- LOCAL_DOMAIN = "http://larlet.test:3579"
- # Hardcoding publication at 12 in Paris timezone.
- NORMALIZED_STRFTIME = "%Y-%m-%dT12:00:00+01:00"
- TODAY = datetime.today() + timedelta(hours=6)
- PUBLICATION_BUFFER = TODAY - timedelta(days=7)
- NB_ITEMS_IN_FEED = 30
-
- all_tags = set()
- pages_by_tags = defaultdict(list)
- pages_by_url = {}
-
-
- class MarkParser(mistune.InlineParser):
- """Parses `==foo==` as `<mark>foo</mark>`."""
-
- MARK = (
- r"(\={2})(?=[^\s*])("
- r"(?:\\[\\*]|[^*])*"
- r"(?:" + mistune.InlineParser.ESCAPE + r"|[^\s*]))\1"
- )
-
- RULE_NAMES = mistune.InlineParser.RULE_NAMES + ("mark",)
-
- def parse_mark(self, m, state):
- marker = m.group(1)
- text = m.group(2)
- return "mark", self.render(text, state)
-
-
- class MarkRenderer(mistune.HTMLRenderer):
- """To use in conjunction with `MarkParser`."""
-
- def mark(self, text):
- return "<mark>" + text + "</mark>"
-
-
- class TagsRenderer(mistune.HTMLRenderer):
- """Make the asumption each line starting with a `#` is a tag."""
-
- def paragraph(self, text):
- if text.startswith("#"):
- tags = " ".join(
- f'<a href="/david/2021/{slugify(tag.strip())}/">#{tag.strip()}</a>'
- for tag in text.split("#")
- if tag.strip()
- )
- return f"<nav><p>{tags}</p></nav>\n"
- return super().paragraph(text)
-
-
- class FrenchTypographyRenderer(mistune.HTMLRenderer):
- """Apply French typographic rules to text."""
-
- def text(self, text):
- return typographie(super().text(text))
-
-
- class InternalLinkTitleRenderer(mistune.HTMLRenderer):
- """Automatically generate the title for internal links."""
-
- def link(self, link, text=None, title=None):
- if text is None:
- text = link
-
- s = '<a href="' + self._safe_url(link) + '"'
-
- if not title and link.startswith("/david/2021/"):
- # It will not work for internal links referencing the future.
- page = pages_by_url.get(link)
- if page:
- title = page.title
-
- if title:
- s += ' title="' + mistune.escape_html(title) + '"'
- return s + ">" + (text or link) + "</a>"
-
-
- class CustomAndBlockquoteLanguageRenderer(
- FrenchTypographyRenderer, InternalLinkTitleRenderer, MarkRenderer, TagsRenderer
- ):
- """Sets the English language attribute for blockquotes with `[en]` prefix."""
-
- def _get_language(self, text):
- if text.startswith("<p>[en] "):
- return "en", text.replace("<p>[en] ", "<p>")
- else:
- return None, text
-
- def block_quote(self, text):
- language, text = self._get_language(text)
- if language:
- return f'\n<blockquote lang="{language}">\n{text}</blockquote>\n'
- else:
- return f"\n<blockquote>\n{text}</blockquote>\n"
-
-
- class ImgsWithSizesRenderer(CustomAndBlockquoteLanguageRenderer):
- """Renders images as <figure>s and add sizes."""
-
- def paragraph(self, text):
- # In case of a figure, we do not want the (non-standard) paragraph.
- if text.strip().startswith("<figure>"):
- return text
- return super().paragraph(text)
-
- def image(self, src, alt="", title=None):
- full_path = STATIC / Path(src[1:])
- image = Image.open(full_path)
- width, height = image.size
- return dedent(
- f"""\
- <figure>
- <a href="{src}"
- title="Cliquer pour une version haute résolution">
- <img src="{src}" alt="{alt}"
- width="{width}" height="{height}" />
- </a>
- <figcaption>{title}</figcaption>
- </figure>
- """
- )
-
-
- class ImgsWithSizesAndLightboxRenderer(ImgsWithSizesRenderer):
- """Renders images as <figure>s and add sizes + lazy attribute.
-
- Also, implement a lightbox markup. See:
- https://www.sylvaindurand.org/overlay-image-in-pure-css/
- """
-
- def image(self, src, alt="", title=None):
- file_name = Path(src).stem
- full_path = STATIC / Path(src[1:])
- image = Image.open(full_path)
- width, height = image.size
- return dedent(
- f"""\
- <figure>
- <a href="#{file_name}"
- title="Cliquer pour une version haute résolution">
- <img src="{src}" alt="{alt}"
- loading="lazy" width="{width}" height="{height}" />
- </a>
- <a href="#_" class="lightbox" id="{file_name}">
- <img src="{src}" alt="{alt}"
- loading="lazy" width="{width}" height="{height}" />
- </a>
- <figcaption>{title}</figcaption>
- </figure>
- """
- )
-
-
- class H2AnchorsRenderer(ImgsWithSizesAndLightboxRenderer):
- """Custom renderer for H2 titles with anchors."""
-
- def heading(self, text, level):
- if level == 2:
- slug = slugify(text)
- return (
- f'<h2 id="{slug}">'
- f"{text} "
- f'<a href="#{slug}" title="Ancre vers cette partie">#</a>'
- f"</h2>"
- )
- else:
- return super().heading(text, level)
-
-
- # We want a custom renderer to create a hash/link for each H2 headings.
- markdown_with_h2_anchors = mistune.Markdown(
- renderer=H2AnchorsRenderer(escape=False),
- inline=MarkParser(H2AnchorsRenderer(escape=False)),
- plugins=[DirectiveInclude()],
- )
- # The second markdown is pertinent to generate articles for the feed,
- # we do not need anchors in that case.
- markdown_with_img_sizes = mistune.Markdown(
- renderer=ImgsWithSizesRenderer(escape=False),
- inline=MarkParser(ImgsWithSizesRenderer(escape=False)),
- plugins=[DirectiveInclude()],
- )
-
- # This is the jinja2 configuration to locate templates.
- environment = Env(loader=FileSystemLoader(str(DAVID / "templates")))
-
-
- def neighborhood(iterable, first=None, last=None):
- """
- Yield the (previous, current, next) items given an iterable.
-
- You can specify a `first` and/or `last` item for bounds.
- """
- iterator = iter(iterable)
- previous = first
- current = next(iterator) # Throws StopIteration if empty.
- for next_ in iterator:
- yield (previous, current, next_)
- previous = current
- current = next_
- yield (previous, current, last)
-
-
- def each_markdown_from(source_dir, file_name="*.md"):
- """Walk across the `source_dir` and return the md file paths."""
- for filename in fnmatch.filter(os.listdir(source_dir), file_name):
- yield filename
-
-
- @dataclass
- class Page:
- title: str
- content: str
- tags: list
- file_path: str
- lang: str = "fr"
-
- def __post_init__(self):
- date_str, _ = self.file_path.split(" - ", 1)
- self.url = f"/david/{date_str.replace('-', '/')}/"
- self.date = datetime.strptime(date_str, "%Y-%m-%d").date()
- self.full_url = f"{DOMAIN}{self.url}"
- self.normalized_date = self.date.strftime(NORMALIZED_STRFTIME)
- self.escaped_title = escape(self.title)
- tag_template = Template(
- f'<a href="{DOMAIN}/david/2021/$tag_slug/">#$tag_name</a>'
- )
- tag_links = " ".join(
- tag_template.substitute(tag_slug=slugify(tag), tag_name=tag)
- for tag in self.tags
- )
- self.escaped_content = escape(
- self.content.replace('href="/', f'href="{DOMAIN}/')
- .replace('src="/', f'src="{DOMAIN}/')
- .replace('href="#', f'href="{self.full_url}#')
- + f"<nav><p>{tag_links}</p></nav>"
- + '<hr/><p><a href="mailto:david@larlet.fr">Réagir ?</a></p>'
- )
- # Extract first paragraph.
- self.extract = self.content.split("</p>", 1)[0] + "</p>"
-
- def __eq__(self, other):
- return self.url == other.url
-
- def __lt__(self, other: "Page"):
- if not isinstance(other, Page):
- return NotImplemented
- return self.date < other.date
-
- @staticmethod
- def all(source: Path, only_published=True, with_h2_anchors=True):
- """Retrieve all pages sorted by desc."""
- page_list = []
- md = markdown_with_h2_anchors if with_h2_anchors else markdown_with_img_sizes
- for file_name in sorted(each_markdown_from(source)):
- result = md.read(source / file_name)
- # Extract (and remove) the title from the generated page.
- title, content = result.split("</h1>", 1)
- h1_opening_size = len("<h1>")
- title = title[h1_opening_size:]
- tags = {}
- if "<nav><p>" in content:
- # Extract the tags from the generated page.
- content, tags_links = content.split("<nav><p>", 1)
- nav_closing_size = len("</p></nav>\n")
- tags_links = tags_links[:-nav_closing_size]
- tags = sorted(
- {
- tag.strip().split("#", 1)[1]
- for tag in tags_links.split("</a>")
- if tag.strip()
- },
- key=lambda tag: slugify(tag),
- )
- page = Page(title, content, tags, file_name)
- pages_by_url[page.url] = page
- if not page.is_draft:
- all_tags.update(tags)
- for tag in tags:
- if page not in pages_by_tags[tag]:
- pages_by_tags[tag].append(page)
- if only_published and page.is_draft:
- continue
- page_list.append(page)
- return sorted(page_list, reverse=True)
-
- @property
- def is_draft(self):
- return (
- datetime(year=self.date.year, month=self.date.month, day=self.date.day)
- > PUBLICATION_BUFFER
- )
-
-
- @cli
- def pages():
- """Build article pages."""
- root_path = DAVID / "2021"
- source_path = root_path / "sources"
- for previous, page, next_ in neighborhood(
- reversed(Page.all(source=source_path, only_published=False)),
- first={
- "url": "/david/2020/",
- "title": "Publications 2020",
- "is_draft": False,
- },
- ):
- template = environment.get_template("article_2020.html")
- content = template.render(page=page, prev=previous, next=next_, slugify=slugify)
- target_path = Path(page.url[1:])
- target_path.mkdir(parents=True, exist_ok=True)
- open(target_path / "index.html", "w").write(content)
- if page.is_draft:
- print(f"Draft: {LOCAL_DOMAIN}{page.url} ({page.title})")
- template = environment.get_template("archives_2020.html")
- content = template.render(page_list=reversed(Page.all(source=source_path)))
- open(root_path / "index.html", "w").write(content)
-
-
- @cli
- def tags():
- """Build tags pages."""
- root_path = DAVID / "2021"
- source_path = root_path / "sources"
- # Parse all pages to collect tags.
- Page.all(source=source_path, only_published=True)
- for tag in all_tags:
- template = environment.get_template("tag_2021.html")
- content = template.render(
- page_list=pages_by_tags[tag],
- tag_name=tag,
- )
- target_path = DAVID / "2021" / slugify(tag)
- target_path.mkdir(parents=True, exist_ok=True)
- open(target_path / "index.html", "w").write(content)
-
-
- @cli
- def home():
- """Build the home page with last published items."""
-
- def group_by_month_year(item):
- return item.date.strftime("%B %Y").title()
-
- template = environment.get_template("profil.html")
- page_list = Page.all(source=DAVID / "2021" / "sources")
- tags = sorted((slugify(tag), tag, len(pages_by_tags[tag])) for tag in all_tags)
- content = template.render(
- page_list=groupby(page_list, key=group_by_month_year), tags=tags
- )
- open(DAVID / "index.html", "w").write(content)
-
-
- @cli
- def feed():
- """Generate a feed from last published items."""
- template = environment.get_template("feed.xml")
- page_list = Page.all(source=DAVID / "2021" / "sources", with_h2_anchors=False)
- content = template.render(
- page_list=page_list[:NB_ITEMS_IN_FEED],
- current_dt=TODAY.strftime(NORMALIZED_STRFTIME),
- BASE_URL=f"{DOMAIN}/david/",
- )
- open(DAVID / "log" / "index.xml", "w").write(content)
-
-
- @wrap
- def perf_wrapper():
- start = perf_counter()
- yield
- elapsed = perf_counter() - start
- print(f"Done in {elapsed:.5f} seconds.")
-
-
- if __name__ == "__main__":
- run()
|