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.

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