|
| 1 | +""" |
| 2 | +Script to build a website from a bunch of markdown files. |
| 3 | +Inspired by https://github.com/sunainapai/makesite |
| 4 | +Tweaked for almarklein.org |
| 5 | +Then for pygfx.org |
| 6 | +""" |
| 7 | + |
| 8 | +import os |
| 9 | +import shutil |
| 10 | +import webbrowser |
| 11 | + |
| 12 | +import markdown |
| 13 | +import pygments |
| 14 | +from pygments.formatters import HtmlFormatter |
| 15 | +from pygments.lexers import get_lexer_by_name |
| 16 | + |
| 17 | + |
| 18 | +TITLE = "pygfx.org" |
| 19 | + |
| 20 | +NAV = { |
| 21 | + "Main": "index", |
| 22 | + "Sponsor": "sponsor", |
| 23 | + # "Blog": "blog", |
| 24 | + # "Archive": "archive", |
| 25 | + # "Social": { |
| 26 | + # 'Twitter': 'https://twitter.com/pygfx', |
| 27 | + # }, |
| 28 | +} |
| 29 | + |
| 30 | +NEWS = { |
| 31 | + "Released pygfx v0.5.0": "https://github.com/pygfx/pygfx/releases/tag/v0.5.0", |
| 32 | + "Released wgpu-py v0.18.1": "https://github.com/pygfx/wgpu-py/releases/tag/v0.18.1", |
| 33 | +} |
| 34 | + |
| 35 | +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) |
| 36 | +OUT_DIR = os.path.join(THIS_DIR, "output") |
| 37 | +STATIC_DIR = os.path.join(THIS_DIR, "static") |
| 38 | +PAGES_DIR = os.path.join(THIS_DIR, "pages") |
| 39 | +POSTS_DIR = os.path.join(THIS_DIR, "posts") |
| 40 | + |
| 41 | + |
| 42 | +REDIRECT = '<html><head><meta HTTP-EQUIV="REFRESH" content="0; url=URL"></head></html>' |
| 43 | + |
| 44 | + |
| 45 | +def create_menu(page): |
| 46 | + """ Create the menu for the given page. |
| 47 | + """ |
| 48 | + menu = [""] |
| 49 | + |
| 50 | + menu.append('<span class="header">Pages</span>') |
| 51 | + for title, target in NAV.items(): |
| 52 | + if isinstance(target, str): |
| 53 | + if target.startswith(("https://", "http://", "/")): |
| 54 | + menu.append(f"<a href='{target}'>{title}</a>") |
| 55 | + else: |
| 56 | + menu.append(f"<a href='{target}.html'>{title}</a>") |
| 57 | + if target == page.name: |
| 58 | + menu[-1] = menu[-1].replace("<a ", '<a class="current" ') |
| 59 | + elif isinstance(target, dict): |
| 60 | + menu.append(f"<a href='{target.get('', '#')}.html'>{title}</a>") |
| 61 | + if target.get("", None) == page.name: |
| 62 | + menu[-1] = menu[-1].replace("<a ", '<a class="current" ') |
| 63 | + if True: # any(page.name == subtarget for subtarget in target.values()): |
| 64 | + for subtitle, subtarget in target.items(): |
| 65 | + if not subtitle: |
| 66 | + continue |
| 67 | + if subtarget.startswith(("https://", "http://", "/")): |
| 68 | + menu.append(f"<a class='sub' href='{subtarget}'>{subtitle}</a>") |
| 69 | + else: |
| 70 | + menu.append( |
| 71 | + f"<a class='sub' href='{subtarget}.html'>{subtitle}</a>" |
| 72 | + ) |
| 73 | + if subtarget == page.name: |
| 74 | + menu[-1] = menu[-1].replace("class='", "class='current ") |
| 75 | + else: |
| 76 | + raise RuntimeError(f"Unexpected NAV entry {type(target)}") |
| 77 | + |
| 78 | + subtitles = [title for level, title in page.headers if level == 2] |
| 79 | + if subtitles: |
| 80 | + menu.append("<br /><span class='header'>Current page</span>") |
| 81 | + menu += [ |
| 82 | + f"<a class='sub' href='#{title.lower()}'>{title}</a>" for title in subtitles |
| 83 | + ] |
| 84 | + |
| 85 | + if NEWS: |
| 86 | + menu.append('<br /><span class="header">News</span>') |
| 87 | + for title, url in NEWS.items(): |
| 88 | + # menu.append(f"<a class='sub' href='{url}'>{title}</a>") |
| 89 | + menu.append(f"<a href='{url}'>{title}</a>") |
| 90 | + |
| 91 | + return "<br />".join(menu) |
| 92 | + |
| 93 | + |
| 94 | +def create_blog_relatated_pages(posts): |
| 95 | + """ Create blog overview page. |
| 96 | + """ |
| 97 | + |
| 98 | + # Filter and sort |
| 99 | + posts = [ |
| 100 | + post for post in posts.values() if post.date and not post.name.startswith("_") |
| 101 | + ] |
| 102 | + posts.sort(key=lambda p: p.date) |
| 103 | + |
| 104 | + blogpages = {} |
| 105 | + |
| 106 | + # Generate overview page |
| 107 | + html = ["<h1>Blog</h1>"] |
| 108 | + for page in reversed(posts): |
| 109 | + text = page.md |
| 110 | + if "<!-- END_SUMMARY -->" in text: |
| 111 | + summary = text.split("<!-- START_SUMMARY -->")[-1].split( |
| 112 | + "<!-- END_SUMMARY -->" |
| 113 | + )[0] |
| 114 | + else: |
| 115 | + summary = text.split("## ")[0] |
| 116 | + summary = summary.split("-->")[-1] |
| 117 | + |
| 118 | + # html.append("<hr />" + page.date_and_tags_html) |
| 119 | + html.append("<div style='border-top: 1px solid #ddd;'>" + page.date_and_tags_html + "</div>") |
| 120 | + html.append(f'<a class="header" href="{page.name}.html"><h3>{page.title}</h3></a>') |
| 121 | + if page.thumbnail: |
| 122 | + html.append(f"<a href='{page.name}.html'><img src='{page.thumbnail}' class='thumb' /></a>") |
| 123 | + # html.append(f'<h2>{page.title}</h2>') |
| 124 | + html.append("<p>" + summary + "</p>") |
| 125 | + html.append(f"<a href='{page.name}.html'>read more ...</a><br /><br />") |
| 126 | + html.append("<div style='clear: both;'></div>") |
| 127 | + blogpages["overview"] = "\n".join(html) |
| 128 | + |
| 129 | + # Generate archive page |
| 130 | + year = "" |
| 131 | + html = ["<h1>Archive</h1>\n"] |
| 132 | + for page in reversed(posts): |
| 133 | + if page.date[:4] != year: |
| 134 | + year = page.date[:4] |
| 135 | + html.append(f"<h2>{year}</h2>") |
| 136 | + html.append(f'{page.date}: <a href="{page.name}.html">{page.title}</a><br />') |
| 137 | + blogpages["archive"] = "\n".join(html) |
| 138 | + |
| 139 | + # todo: Generate page for each tag |
| 140 | + |
| 141 | + return blogpages |
| 142 | + |
| 143 | + |
| 144 | +def create_assets(): |
| 145 | + """ Returns a dict of all the assets representing the website. |
| 146 | + """ |
| 147 | + assets = {} |
| 148 | + |
| 149 | + # Load all static files |
| 150 | + for root, dirs, files in os.walk(STATIC_DIR): |
| 151 | + for fname in files: |
| 152 | + filename = os.path.join(root, fname) |
| 153 | + with open(filename, "rb") as f: |
| 154 | + assets[os.path.relpath(filename, STATIC_DIR)] = f.read() |
| 155 | + |
| 156 | + # Collect pages |
| 157 | + pages = {} |
| 158 | + for fname in os.listdir(PAGES_DIR): |
| 159 | + if fname.lower().endswith(".md"): |
| 160 | + name = fname.split(".")[0].lower() |
| 161 | + with open(os.path.join(PAGES_DIR, fname), "rb") as f: |
| 162 | + md = f.read().decode() |
| 163 | + pages[name] = Page(name, md) |
| 164 | + |
| 165 | + # Collect blog posts |
| 166 | + posts = {} |
| 167 | + for fname in os.listdir(POSTS_DIR): |
| 168 | + if fname.lower().endswith(".md"): |
| 169 | + name = fname.split(".")[0].lower() |
| 170 | + assert name not in pages, f"blog post slug not allowed: {name}" |
| 171 | + with open(os.path.join(POSTS_DIR, fname), "rb") as f: |
| 172 | + md = f.read().decode() |
| 173 | + posts[name] = Page(name, md) |
| 174 | + |
| 175 | + # Get template |
| 176 | + with open(os.path.join(THIS_DIR, "template.html"), "rb") as f: |
| 177 | + html_template = f.read().decode() |
| 178 | + |
| 179 | + with open(os.path.join(THIS_DIR, "style.css"), "rb") as f: |
| 180 | + css = f.read().decode() |
| 181 | + css += "/* Pygments CSS */\n" + HtmlFormatter(style="vs").get_style_defs( |
| 182 | + ".highlight" |
| 183 | + ) |
| 184 | + |
| 185 | + # Generate posts |
| 186 | + for page in posts.values(): |
| 187 | + page.prepare(pages.keys()) |
| 188 | + title = page.title |
| 189 | + menu = create_menu(page) |
| 190 | + html = html_template.format( |
| 191 | + title=title, style=css, body=page.to_html(), menu=menu |
| 192 | + ) |
| 193 | + print("generating post", page.name + ".html") |
| 194 | + assets[page.name + ".html"] = html.encode() |
| 195 | + |
| 196 | + # Generate pages |
| 197 | + for page in pages.values(): |
| 198 | + page.prepare(pages.keys()) |
| 199 | + title = TITLE if page.name == "index" else TITLE + " - " + page.title |
| 200 | + menu = create_menu(page) |
| 201 | + html = html_template.format( |
| 202 | + title=title, style=css, body=page.to_html(), menu=menu |
| 203 | + ) |
| 204 | + print("generating page", page.name + ".html") |
| 205 | + assets[page.name + ".html"] = html.encode() |
| 206 | + |
| 207 | + # Generate special pages |
| 208 | + fake_md = "" # "##index\n## archive\n## tags" |
| 209 | + for name, html in create_blog_relatated_pages(posts).items(): |
| 210 | + name = "blog" if name == "overview" else name |
| 211 | + print("generating page", name + ".html") |
| 212 | + assets[f"{name}.html"] = html_template.format( |
| 213 | + title=TITLE, style=css, body=html, menu=create_menu(Page("", fake_md)) |
| 214 | + ).encode() |
| 215 | + |
| 216 | + # Backwards compat with previous site |
| 217 | + for page in pages.values(): |
| 218 | + assets["pages/" + page.name + ".html"] = REDIRECT.replace( |
| 219 | + "URL", f"/{page.name}.html" |
| 220 | + ).encode() |
| 221 | + |
| 222 | + # Fix backslashes on Windows |
| 223 | + for key in list(assets.keys()): |
| 224 | + if "\\" in key: |
| 225 | + assets[key.replace("\\", "/")] = assets.pop(key) |
| 226 | + |
| 227 | + return assets |
| 228 | + |
| 229 | + |
| 230 | +def main(): |
| 231 | + """ Main function that exports the page to the file system. |
| 232 | + """ |
| 233 | + # Create / clean output dir |
| 234 | + if os.path.isdir(OUT_DIR): |
| 235 | + shutil.rmtree(OUT_DIR) |
| 236 | + os.mkdir(OUT_DIR) |
| 237 | + |
| 238 | + # Write all assets to the directory |
| 239 | + for fname, bb in create_assets().items(): |
| 240 | + filename = os.path.join(OUT_DIR, fname) |
| 241 | + dirname = os.path.dirname(filename) |
| 242 | + if not os.path.isdir(dirname): |
| 243 | + os.makedirs(dirname) |
| 244 | + with open(filename, "wb") as f: |
| 245 | + f.write(bb) |
| 246 | + |
| 247 | + |
| 248 | +class Page: |
| 249 | + """ Representation of a page. It takes in markdown and produces HTML. |
| 250 | + """ |
| 251 | + |
| 252 | + def __init__(self, name, markdown): |
| 253 | + self.name = name |
| 254 | + self.md = markdown |
| 255 | + self.parts = [] |
| 256 | + self.headers = [] |
| 257 | + |
| 258 | + self.title = name |
| 259 | + if markdown.startswith("# "): |
| 260 | + self.title = markdown.split("\n")[0][1:].strip() |
| 261 | + |
| 262 | + self.date = None |
| 263 | + if "<!-- DATE:" in markdown: |
| 264 | + self.date = markdown.split("<!-- DATE:")[-1].split("-->")[0].strip() or None |
| 265 | + if self.date is not None: |
| 266 | + assert ( |
| 267 | + len(self.date) == 10 and self.date.count("-") == 2 |
| 268 | + ), f"Weird date in {name}.md" |
| 269 | + |
| 270 | + self.author = None |
| 271 | + if "<!-- AURTHOR:" in markdown: |
| 272 | + self.author = markdown.split("<!-- AURTHOR:")[-1].split("-->")[0].strip() |
| 273 | + |
| 274 | + self.tags = [] |
| 275 | + if "<!-- TAGS:" in markdown: |
| 276 | + self.tags = [ |
| 277 | + x.strip() |
| 278 | + for x in markdown.split("<!-- TAGS:")[-1].split("-->")[0].split(",") |
| 279 | + ] |
| 280 | + |
| 281 | + self.date_and_tags_html = "" |
| 282 | + if self.date: |
| 283 | + self.date_and_tags_html = f"<span class='post-date-tags'>{', '.join(self.tags)} - {self.date}</span>" |
| 284 | + |
| 285 | + self.thumbnail = None |
| 286 | + for fname in ["thumbs/" + self.name + ".jpg"]: |
| 287 | + if os.path.isfile(os.path.join(THIS_DIR, "static", fname)): |
| 288 | + self.thumbnail = fname |
| 289 | + |
| 290 | + def prepare(self, page_names): |
| 291 | + # Convert markdown to HTML |
| 292 | + self.md = self._fix_links(self.md, page_names) |
| 293 | + self.md = self._highlight(self.md) |
| 294 | + self._split() # populates self.parts and self.headers |
| 295 | + |
| 296 | + def _fix_links(self, text, page_names): |
| 297 | + """ Fix the markdown links based on the pages that we know. |
| 298 | + """ |
| 299 | + for n in page_names: |
| 300 | + text = text.replace(f"]({n})", f"]({n}.html)") |
| 301 | + text = text.replace(f"]({n}.md)", f"]({n}.html)") |
| 302 | + return text |
| 303 | + |
| 304 | + def _highlight(self, text): |
| 305 | + """ Apply syntax highlighting. |
| 306 | + """ |
| 307 | + lines = [] |
| 308 | + code = [] |
| 309 | + for i, line in enumerate(text.splitlines()): |
| 310 | + if line.startswith("```"): |
| 311 | + if code: |
| 312 | + formatter = HtmlFormatter() |
| 313 | + try: |
| 314 | + lexer = get_lexer_by_name(code[0]) |
| 315 | + except Exception: |
| 316 | + lexer = get_lexer_by_name("text") |
| 317 | + lines.append( |
| 318 | + pygments.highlight("\n".join(code[1:]), lexer, formatter) |
| 319 | + ) |
| 320 | + code = [] |
| 321 | + else: |
| 322 | + code.append(line[3:].strip()) # language |
| 323 | + elif code: |
| 324 | + code.append(line) |
| 325 | + else: |
| 326 | + lines.append(line) |
| 327 | + return "\n".join(lines).strip() |
| 328 | + |
| 329 | + def _split(self): |
| 330 | + """ Split the markdown into parts based on sections. |
| 331 | + Each part is either text or a tuple representing a section. |
| 332 | + """ |
| 333 | + text = self.md |
| 334 | + self.parts = parts = [] |
| 335 | + self.headers = headers = [] |
| 336 | + lines = [] |
| 337 | + |
| 338 | + # Split in parts |
| 339 | + for line in text.splitlines(): |
| 340 | + if line.startswith(("# ", "## ", "### ", "#### ", "##### ")): |
| 341 | + # Finish pending lines |
| 342 | + parts.append("\n".join(lines)) |
| 343 | + lines = [] |
| 344 | + # Process header |
| 345 | + level = len(line.split(" ")[0]) |
| 346 | + title = line.split(" ", 1)[1] |
| 347 | + title_short = title.split("(")[0].split("<")[0].strip().replace("`", "") |
| 348 | + headers.append((level, title_short)) |
| 349 | + parts.append((level, title_short, title)) |
| 350 | + else: |
| 351 | + lines.append(line) |
| 352 | + parts.append("\n".join(lines)) |
| 353 | + |
| 354 | + # Now convert all text to html |
| 355 | + for i in range(len(parts)): |
| 356 | + if not isinstance(parts[i], tuple): |
| 357 | + parts[i] = markdown.markdown(parts[i], extensions=[]) + "\n\n" |
| 358 | + |
| 359 | + def to_html(self): |
| 360 | + htmlparts = [] |
| 361 | + for part in self.parts: |
| 362 | + if isinstance(part, tuple): |
| 363 | + level, title_short, title = part |
| 364 | + title_html = ( |
| 365 | + title.replace("``", "`") |
| 366 | + .replace("`", "<code>", 1) |
| 367 | + .replace("`", "</code>", 1) |
| 368 | + ) |
| 369 | + ts = title_short.lower() |
| 370 | + if part[0] == 1: |
| 371 | + htmlparts.append(self.date_and_tags_html) |
| 372 | + htmlparts.append("<h1>%s</h1>" % title_html) |
| 373 | + elif part[0] == 2 and title_short: |
| 374 | + htmlparts.append( |
| 375 | + "<a class='anch' name='{}' href='#{}'>".format(ts, ts) |
| 376 | + ) |
| 377 | + htmlparts.append("<h%i>%s</h%i>" % (level, title_html, level)) |
| 378 | + htmlparts.append("</a>") |
| 379 | + else: |
| 380 | + htmlparts.append("<h%i>%s</h%i>" % (level, title_html, level)) |
| 381 | + else: |
| 382 | + htmlparts.append(part) |
| 383 | + return "\n".join(htmlparts) |
| 384 | + |
| 385 | + |
| 386 | +if __name__ == "__main__": |
| 387 | + main() |
| 388 | + # webbrowser.open(os.path.join(OUT_DIR, "index.html")) |
0 commit comments