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.

runner.py 5.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. #!/usr/bin/env python3
  2. import codecs
  3. import fnmatch
  4. import locale
  5. import os
  6. import socketserver
  7. from dataclasses import dataclass
  8. from datetime import date, datetime
  9. from html import escape
  10. from http.server import SimpleHTTPRequestHandler
  11. from operator import attrgetter
  12. from pathlib import Path
  13. from time import perf_counter
  14. import markdown
  15. from jinja2 import Environment as Env
  16. from jinja2 import FileSystemLoader
  17. from minicli import cli, run, wrap
  18. # Useful for dates rendering within Jinja2.
  19. locale.setlocale(locale.LC_ALL, "fr_FR.UTF-8")
  20. HERE = Path(".")
  21. DAVID = HERE / "david"
  22. DOMAIN = "https://larlet.fr"
  23. # Hardcoding publication at 12 in Paris timezone.
  24. NORMALIZED_STRFTIME = "%Y-%m-%dT12:00:00+01:00"
  25. environment = Env(loader=FileSystemLoader(str(DAVID / "templates")))
  26. def neighborhood(iterable, first=None, last=None):
  27. """
  28. Yield the (previous, current, next) items given an iterable.
  29. You can specify a `first` and/or `last` item for bounds.
  30. """
  31. iterator = iter(iterable)
  32. previous = first
  33. current = next(iterator) # Throws StopIteration if empty.
  34. for next_ in iterator:
  35. yield (previous, current, next_)
  36. previous = current
  37. current = next_
  38. yield (previous, current, last)
  39. def parse_markdown(file_path):
  40. """Extract title, (HTML) content and metadata from a markdown file."""
  41. parser = markdown.Markdown(extensions=["meta"])
  42. with codecs.open(file_path, "r") as source:
  43. content = parser.convert(source.read())
  44. metadata = parser.Meta if hasattr(parser, "Meta") else None
  45. title = metadata["title"][0] if metadata is not None else ""
  46. return title, content, metadata
  47. def each_markdown_from(source_dir, file_name="index.md"):
  48. """Walk across the `source_dir` and return the md file paths."""
  49. for root, dirnames, filenames in os.walk(source_dir):
  50. for filename in fnmatch.filter(filenames, file_name):
  51. yield os.path.join(root, filename)
  52. @dataclass
  53. class Note:
  54. title: str
  55. content: str
  56. file_path: str
  57. def __post_init__(self):
  58. suffix = len("/index.md")
  59. prefix = len("YYYY/MM/DD") + suffix
  60. date_str = self.file_path[-prefix:-suffix]
  61. self.url = f"/david/stream/{date_str}/"
  62. self.full_url = f"{DOMAIN}{self.url}"
  63. self.date = datetime.strptime(date_str, "%Y/%m/%d").date()
  64. self.normalized_date = self.date.strftime(NORMALIZED_STRFTIME)
  65. self.escaped_title = escape(self.title)
  66. self.escaped_content = escape(
  67. self.content.replace('href="/', f'href="{DOMAIN}/').replace(
  68. 'src="/', f'src="{DOMAIN}/'
  69. )
  70. )
  71. self.extract = self.content.split("</p>", 1)[0] + "</p>"
  72. @property
  73. def is_draft(self):
  74. return self.date > date.today()
  75. @staticmethod
  76. def all(source, only_published=True):
  77. """Retrieve all (published) notes sorted by date desc."""
  78. note_list = []
  79. for file_path in each_markdown_from(source):
  80. title, content, _ = parse_markdown(file_path)
  81. note = Note(title, content, file_path)
  82. if only_published and note.date > date.today():
  83. continue
  84. note_list.append(note)
  85. return sorted(note_list, key=attrgetter("date"), reverse=True)
  86. @cli
  87. def note(when=None):
  88. """Create a new note and open it in iA Writer.
  89. :when: Optional date in ISO format (YYYY-MM-DD)
  90. """
  91. when = datetime.strptime(when, "%Y-%m-%d") if when else date.today()
  92. note_path = DAVID / "stream" / str(when.year) / str(when.month) / str(when.day)
  93. os.makedirs(note_path)
  94. filename = note_path / "index.md"
  95. open(filename, "w+").write("title: ")
  96. os.popen(f'open -a "iA Writer" "{filename}"')
  97. @cli
  98. def stream():
  99. """Generate articles and archives for the stream."""
  100. template_article = environment.get_template("stream_2019_article.html")
  101. template_archives = environment.get_template("stream_2019_archives.html")
  102. # Default when you reach the last item.
  103. notes_2018 = Note(
  104. title="Anciennes notes (2018)",
  105. content="",
  106. file_path="/david/stream/2018/12/31/index.md",
  107. )
  108. note_base = DAVID / "stream" / "2019"
  109. published = Note.all(source=note_base)
  110. unpublished = Note.all(source=note_base, only_published=False)
  111. for previous, note, next_ in neighborhood(unpublished, last=notes_2018):
  112. if note.is_draft:
  113. print(f"Soon: http://larlet.test:8001/{note.url} ({note.title})")
  114. # Detect if there is code for syntax highlighting + monospaced font.
  115. has_code = "<code>" in note.content
  116. # Do not link to unpublished notes.
  117. previous = previous and not previous.is_draft and previous or None
  118. page_article = template_article.render(
  119. note=note,
  120. next=previous,
  121. prev=next_,
  122. has_code=has_code,
  123. note_list=published,
  124. )
  125. open(
  126. note_base / f"{note.date.month:02}" / f"{note.date.day:02}" / "index.html",
  127. "w",
  128. ).write(page_article)
  129. page_archive = template_archives.render(note_list=published)
  130. open(note_base / "index.html", "w").write(page_archive)
  131. print(f"Done: http://larlet.test:8001/{note_base}/")
  132. @cli
  133. def feed():
  134. """Generate a feed from 15 last published Notes in stream."""
  135. template = environment.get_template("feed.xml")
  136. content = template.render(
  137. note_list=Note.all(source=DAVID / "stream" / "2019")[:15],
  138. current_dt=datetime.now().strftime(NORMALIZED_STRFTIME),
  139. BASE_URL=f"{DOMAIN}/david/",
  140. )
  141. open(DAVID / "log" / "index.xml", "w").write(content)
  142. @cli
  143. def serve():
  144. httpd = socketserver.TCPServer(("larlet.test", 8001), SimpleHTTPRequestHandler)
  145. print("Serving at http://larlet.test:8001/david/")
  146. httpd.serve_forever()
  147. @wrap
  148. def perf_wrapper():
  149. start = perf_counter()
  150. yield
  151. elapsed = perf_counter() - start
  152. print(f"Done in {elapsed:.5f} seconds.")
  153. if __name__ == "__main__":
  154. run()