Repository with sources and generator of https://larlet.fr/david/ https://larlet.fr/david/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

site.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. #!/usr/bin/env python3
  2. import codecs
  3. import fnmatch
  4. import locale
  5. import os
  6. from collections import namedtuple
  7. from dataclasses import dataclass
  8. from datetime import date, datetime
  9. from html import escape
  10. from operator import attrgetter
  11. from pathlib import Path
  12. from time import perf_counter
  13. import markdown
  14. import mistune
  15. from jinja2 import Environment as Env
  16. from jinja2 import FileSystemLoader
  17. from minicli import cli, run, wrap
  18. from mistune.directives import DirectiveInclude
  19. from slugify import slugify
  20. # Useful for dates rendering within Jinja2.
  21. locale.setlocale(locale.LC_ALL, "fr_FR.UTF-8")
  22. HERE = Path(".")
  23. DAVID = HERE / "david"
  24. DOMAIN = "https://larlet.fr"
  25. # Hardcoding publication at 12 in Paris timezone.
  26. NORMALIZED_STRFTIME = "%Y-%m-%dT12:00:00+01:00"
  27. class CustomHTMLRenderer(mistune.HTMLRenderer):
  28. def heading(self, text, level):
  29. # Set an anchor to h2 headings.
  30. if level == 2:
  31. slug = slugify(text)
  32. return (
  33. f'<h2 id="{slug}">'
  34. f"{text} "
  35. f'<a href="#{slug}" title="Ancre vers cette partie">•</a>'
  36. f"</h2>"
  37. )
  38. else:
  39. return super().heading(text, level)
  40. mistune_markdown = mistune.create_markdown(
  41. renderer=CustomHTMLRenderer(escape=False), plugins=[DirectiveInclude()]
  42. )
  43. environment = Env(loader=FileSystemLoader(str(DAVID / "templates")))
  44. def neighborhood(iterable, first=None, last=None):
  45. """
  46. Yield the (previous, current, next) items given an iterable.
  47. You can specify a `first` and/or `last` item for bounds.
  48. """
  49. iterator = iter(iterable)
  50. previous = first
  51. current = next(iterator) # Throws StopIteration if empty.
  52. for next_ in iterator:
  53. yield (previous, current, next_)
  54. previous = current
  55. current = next_
  56. yield (previous, current, last)
  57. def parse_markdown(file_path):
  58. """Extract title, (HTML) content and metadata from a markdown file."""
  59. parser = markdown.Markdown(extensions=["meta"])
  60. with codecs.open(file_path, "r") as source:
  61. source = source.read()
  62. # Avoid replacing quotes from code #PoorManParsing.
  63. if ":::" not in source and "`" not in source:
  64. source = source.replace("'", "’")
  65. content = parser.convert(source)
  66. metadata = parser.Meta if hasattr(parser, "Meta") else None
  67. title = metadata["title"][0] if metadata is not None else ""
  68. return title, content, metadata
  69. def each_markdown_from(source_dir, file_name="index.md"):
  70. """Walk across the `source_dir` and return the md file paths."""
  71. for root, dirnames, filenames in os.walk(source_dir):
  72. for filename in fnmatch.filter(filenames, file_name):
  73. yield os.path.join(root, filename)
  74. @dataclass
  75. class Item:
  76. title: str
  77. content: str
  78. file_path: str
  79. def __post_init__(self):
  80. self.full_url = f"{DOMAIN}{self.url}"
  81. self.normalized_date = self.date.strftime(NORMALIZED_STRFTIME)
  82. self.escaped_title = escape(self.title)
  83. self.escaped_content = escape(
  84. self.content.replace('href="/', f'href="{DOMAIN}/')
  85. .replace('src="/', f'src="{DOMAIN}/')
  86. .replace('href="#', f'href="{self.full_url}#')
  87. )
  88. @property
  89. def is_draft(self):
  90. return self.date > date.today()
  91. @dataclass
  92. class Note(Item):
  93. lang: str = "fr"
  94. def __post_init__(self):
  95. suffix = len("/index.md")
  96. prefix = len("YYYY/MM/DD") + suffix
  97. date_str = self.file_path[-prefix:-suffix]
  98. self.url = f"/david/stream/{date_str}/"
  99. self.date = datetime.strptime(date_str, "%Y/%m/%d").date()
  100. super().__post_init__()
  101. self.extract = self.content.split("</p>", 1)[0] + "</p>"
  102. @staticmethod
  103. def all(source, only_published=True):
  104. """Retrieve all (published) notes sorted by date desc."""
  105. note_list = []
  106. for file_path in each_markdown_from(source):
  107. title, content, _ = parse_markdown(file_path)
  108. note = Note(title, content, file_path)
  109. if only_published and note.is_draft:
  110. continue
  111. note_list.append(note)
  112. return sorted(note_list, key=attrgetter("date"), reverse=True)
  113. @dataclass
  114. class Page(Item):
  115. lang: str = "fr"
  116. def __post_init__(self):
  117. suffix = len(".md")
  118. prefix = len("YYYY/MM-DD") + suffix
  119. date_str = self.file_path[-prefix:-suffix].replace("-", "/")
  120. self.url = f"/david/{date_str}/"
  121. self.date = datetime.strptime(date_str, "%Y/%m/%d").date()
  122. super().__post_init__()
  123. # Extract first paragraph.
  124. self.extract = self.content.split("</p>", 1)[0] + "</p>"
  125. @staticmethod
  126. def all(source):
  127. """Retrieve all pages sorted by desc."""
  128. page_list = []
  129. for file_path in each_markdown_from(source, file_name="*.md"):
  130. if "/fragments/" in file_path:
  131. continue
  132. result = mistune_markdown.read(file_path)
  133. title, content = result.split("</h1>", 1)
  134. title = title[len("<h1>") :]
  135. page = Page(title, content, file_path)
  136. page_list.append(page)
  137. return sorted(page_list, reverse=True)
  138. @dataclass
  139. class Post(Item):
  140. date: str
  141. slug: str
  142. chapo: str
  143. lang: str
  144. def __post_init__(self):
  145. self.url = f"/david/blog/{self.date.year}/{self.slug}/"
  146. super().__post_init__()
  147. self.url_image = f"/static/david/blog/{self.date.year}/{self.slug}.jpg"
  148. self.url_image_thumbnail = (
  149. f"/static/david/blog/{self.date.year}/thumbnails/{self.slug}.jpg"
  150. )
  151. self.full_img_url = f"{DOMAIN}{self.url_image}"
  152. self.full_img_url_thumbnail = f"{DOMAIN}{self.url_image_thumbnail}"
  153. self.escaped_content = self.escaped_content + escape(
  154. f'<img src="{self.full_img_url_thumbnail}" width="500px" height="500px" />'
  155. )
  156. self.escaped_chapo = escape(self.chapo)
  157. @staticmethod
  158. def all(source, only_published=True):
  159. """Retrieve all (published) posts sorted by date desc."""
  160. post_list = []
  161. for file_path in each_markdown_from(source):
  162. title, content, metadata = parse_markdown(file_path)
  163. date = datetime.strptime(metadata["date"][0], "%Y-%m-%d").date()
  164. slug = metadata["slug"][0]
  165. chapo = metadata["chapo"][0]
  166. lang = metadata.get("lang", ["fr"])[0]
  167. post = Post(title, content, file_path, date, slug, chapo, lang)
  168. if only_published and post.is_draft:
  169. continue
  170. post_list.append(post)
  171. return sorted(post_list, key=attrgetter("date"), reverse=True)
  172. @cli
  173. def note(when=None):
  174. """Create a new note and open it in iA Writer.
  175. :when: Optional date in ISO format (YYYY-MM-DD)
  176. """
  177. when = datetime.strptime(when, "%Y-%m-%d") if when else date.today()
  178. note_path = DAVID / "stream" / str(when.year) / str(when.month) / str(when.day)
  179. os.makedirs(note_path)
  180. filename = note_path / "index.md"
  181. open(filename, "w+").write("title: ")
  182. os.popen(f'open -a "iA Writer" "{filename}"')
  183. @cli
  184. def stream():
  185. """Generate articles and archives for the stream."""
  186. template_article = environment.get_template("stream_2019_article.html")
  187. template_archives = environment.get_template("stream_2019_archives.html")
  188. # Default when you reach the last item.
  189. FakeNote = namedtuple("FakeNote", ["url", "title"])
  190. notes_2018 = FakeNote(url="/david/stream/2018/", title="Anciennes notes (2018)")
  191. note_base = DAVID / "stream" / "2019"
  192. unpublished = Note.all(source=note_base, only_published=False)
  193. published = [note for note in unpublished if not note.is_draft]
  194. for previous, note, next_ in neighborhood(unpublished, last=notes_2018):
  195. if note.is_draft:
  196. print(f"Soon: http://larlet.test:3579{note.url} ({note.title})")
  197. # Detect if there is code for syntax highlighting + monospaced font.
  198. has_code = "<code>" in note.content
  199. # Do not link to unpublished notes.
  200. previous = previous and not previous.is_draft and previous or None
  201. page_article = template_article.render(
  202. note=note,
  203. next=previous,
  204. prev=next_,
  205. has_code=has_code,
  206. note_list=published,
  207. )
  208. open(
  209. note_base / f"{note.date.month:02}" / f"{note.date.day:02}" / "index.html",
  210. "w",
  211. ).write(page_article)
  212. page_archive = template_archives.render(note_list=published)
  213. open(note_base / "index.html", "w").write(page_archive)
  214. print(f"Done: http://larlet.test:3579/{note_base}/")
  215. @cli
  216. def blog():
  217. """Generate articles and archives for the blog."""
  218. template_article = environment.get_template("blog_article.html")
  219. template_archives = environment.get_template("blog_archives.html")
  220. # Default when you reach the last item.
  221. FakePost = namedtuple("FakePost", ["url", "title"])
  222. posts_2012 = FakePost(
  223. url="/david/thoughts/", title="Pensées précédentes (en anglais)"
  224. )
  225. post_base = DAVID / "blog"
  226. unpublished = Post.all(source=post_base, only_published=False)
  227. published = [post for post in unpublished if not post.is_draft]
  228. published_en = [post for post in published if post.lang == "en"]
  229. note_list = Note.all(source=DAVID / "stream" / "2019")
  230. for previous, post, next_ in neighborhood(unpublished, last=posts_2012):
  231. if post.date.year < 2018:
  232. continue # Speed up + do not overwrite old comments.
  233. if post.is_draft:
  234. print(f"Soon: http://larlet.test:3579{post.url} ({post.title})")
  235. # Detect if there is code for syntax highlighting + monospaced font.
  236. has_code = "<code>" in post.content
  237. # Do not link to unpublished posts.
  238. previous = previous and not previous.is_draft and previous or None
  239. page_article = template_article.render(
  240. post=post,
  241. next=previous,
  242. prev=next_,
  243. has_code=has_code,
  244. post_list=published,
  245. published_posts_en=published_en,
  246. note_list=note_list,
  247. )
  248. open(post_base / str(post.date.year) / post.slug / "index.html", "w",).write(
  249. page_article
  250. )
  251. page_archive = template_archives.render(posts=published, note_list=note_list)
  252. open(post_base / "index.html", "w").write(page_archive)
  253. print(f"Done: http://larlet.test:3579/{post_base}/")
  254. @cli
  255. def pages():
  256. """Build the agregations from fragments."""
  257. root_path = DAVID / "2020"
  258. page_list = Page.all(source=root_path)
  259. for page in page_list:
  260. template = environment.get_template("article_2020.html")
  261. content = template.render(page=page)
  262. target_path = Path(page.url[1:])
  263. target_path.mkdir(parents=True, exist_ok=True)
  264. open(target_path / "index.html", "w").write(content)
  265. template = environment.get_template("archives_2020.html")
  266. content = template.render(page_list=page_list)
  267. open(root_path / "index.html", "w").write(content)
  268. @cli
  269. def home():
  270. """Build the home page with last published items."""
  271. template = environment.get_template("profil.html")
  272. content = template.render(note_list=Note.all(source=DAVID / "stream" / "2019"),)
  273. open(DAVID / "index.html", "w").write(content)
  274. @cli
  275. def feed():
  276. """Generate a feed from last published items."""
  277. template = environment.get_template("feed.xml")
  278. content = template.render(
  279. page_list=Page.all(source=DAVID / "2020"),
  280. note_list=Note.all(source=DAVID / "stream" / "2019")[:15],
  281. post_list=Post.all(source=DAVID / "blog")[:5],
  282. current_dt=datetime.now().strftime(NORMALIZED_STRFTIME),
  283. BASE_URL=f"{DOMAIN}/david/",
  284. )
  285. open(DAVID / "log" / "index.xml", "w").write(content)
  286. @wrap
  287. def perf_wrapper():
  288. start = perf_counter()
  289. yield
  290. elapsed = perf_counter() - start
  291. print(f"Done in {elapsed:.5f} seconds.")
  292. if __name__ == "__main__":
  293. run()