Support for folders for pages/articles
This commit is contained in:
parent
2d18dd5bd6
commit
31e1db359d
7 changed files with 179 additions and 58 deletions
10
README.md
10
README.md
|
@ -81,7 +81,7 @@ categories:
|
||||||
|
|
||||||
## Markdown files
|
## Markdown files
|
||||||
|
|
||||||
Articles are Markdown files stored under `pages`. For an article to be correctly rendered,
|
Articles are Markdown files stored under `markdown`. For an article to be correctly rendered,
|
||||||
you need to start the Markdown file with the following metadata header:
|
you need to start the Markdown file with the following metadata header:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
|
@ -92,6 +92,14 @@ you need to start the Markdown file with the following metadata header:
|
||||||
[//]: # (published: 2022-01-01)
|
[//]: # (published: 2022-01-01)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If no `markdown` folder exists in the base directory, then the base directory itself will be treated as a root for
|
||||||
|
Markdown files.
|
||||||
|
|
||||||
|
### Folders
|
||||||
|
|
||||||
|
You can organize Markdown files in folders. If multiple folders are present, pages on the home will be grouped by
|
||||||
|
folders.
|
||||||
|
|
||||||
## Images
|
## Images
|
||||||
|
|
||||||
Images are stored under `img`. You can reference them in your articles through the following syntax:
|
Images are stored under `img`. You can reference them in your articles through the following syntax:
|
||||||
|
|
49
madblog/_sorters.py
Normal file
49
madblog/_sorters.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Any, Iterable, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class PagesSorter(ABC):
|
||||||
|
_default_published = date.fromtimestamp(0)
|
||||||
|
|
||||||
|
def __init__(self, pages: Iterable[dict]):
|
||||||
|
self.pages = pages
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def __call__(self, page: dict) -> Any:
|
||||||
|
raise NotImplemented()
|
||||||
|
|
||||||
|
|
||||||
|
class PagesSortByTime(PagesSorter):
|
||||||
|
def __call__(self, page: dict) -> datetime:
|
||||||
|
return page.get('published', self._default_published)
|
||||||
|
|
||||||
|
|
||||||
|
class PagesSortByFolderAndTime(PagesSorter):
|
||||||
|
def __call__(self, page: dict) -> Tuple:
|
||||||
|
return (
|
||||||
|
page.get('folder'),
|
||||||
|
date.today() - page.get(
|
||||||
|
'published', self._default_published
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PagesSortByTimeGroupedByFolder(PagesSorter):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
st = {}
|
||||||
|
for page in self.pages:
|
||||||
|
folder = page.get('folder', '')
|
||||||
|
published = page.get('published', self._default_published)
|
||||||
|
st[folder] = st.get(folder, published)
|
||||||
|
st[folder] = max(st[folder], published)
|
||||||
|
|
||||||
|
self._max_date_by_folder = st
|
||||||
|
|
||||||
|
def __call__(self, page: dict) -> Tuple:
|
||||||
|
return (
|
||||||
|
self._max_date_by_folder[page.get('folder', '')],
|
||||||
|
page.get('published', self._default_published)
|
||||||
|
)
|
|
@ -1,14 +1,14 @@
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from glob import glob
|
from typing import Optional, List, Tuple, Type
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from flask import Flask, abort
|
from flask import Flask, abort
|
||||||
from markdown import markdown
|
from markdown import markdown
|
||||||
|
|
||||||
from .config import config
|
from .config import config
|
||||||
from .latex import MarkdownLatex
|
from .latex import MarkdownLatex
|
||||||
|
from ._sorters import PagesSorter, PagesSortByTime
|
||||||
|
|
||||||
|
|
||||||
class BlogApp(Flask):
|
class BlogApp(Flask):
|
||||||
|
@ -46,11 +46,11 @@ class BlogApp(Flask):
|
||||||
if not page.endswith('.md'):
|
if not page.endswith('.md'):
|
||||||
page = page + '.md'
|
page = page + '.md'
|
||||||
|
|
||||||
if not os.path.isfile(os.path.join(self.pages_dir, page)):
|
md_file = os.path.join(self.pages_dir, page)
|
||||||
|
if not os.path.isfile(md_file):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
metadata = {}
|
metadata = {}
|
||||||
md_file = os.path.join(self.pages_dir, page)
|
|
||||||
with open(md_file, 'r') as f:
|
with open(md_file, 'r') as f:
|
||||||
metadata['uri'] = '/article/' + page[:-3]
|
metadata['uri'] = '/article/' + page[:-3]
|
||||||
|
|
||||||
|
@ -123,19 +123,37 @@ class BlogApp(Flask):
|
||||||
skip_header=skip_header
|
skip_header=skip_header
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_pages(self, with_content: bool = False, skip_header: bool = False) -> list:
|
def get_pages(
|
||||||
return sorted(
|
self,
|
||||||
[
|
with_content: bool = False,
|
||||||
{
|
skip_header: bool = False,
|
||||||
'path': path[len(app.pages_dir)+1:],
|
sorter: Type[PagesSorter] = PagesSortByTime,
|
||||||
'content': self.get_page(path[len(app.pages_dir)+1:], skip_header=skip_header) if with_content else '',
|
reverse: bool = True,
|
||||||
**self.get_page_metadata(os.path.basename(path)),
|
) -> List[Tuple[int, dict]]:
|
||||||
}
|
pages_dir = app.pages_dir.rstrip('/')
|
||||||
for path in glob(os.path.join(app.pages_dir, '*.md'))
|
pages = [
|
||||||
],
|
{
|
||||||
key=lambda page: page.get('published', datetime.date.fromtimestamp(0)),
|
'path': os.path.join(root[len(pages_dir)+1:], f),
|
||||||
reverse=True
|
'folder': root[len(pages_dir)+1:],
|
||||||
)
|
'content': (
|
||||||
|
self.get_page(
|
||||||
|
os.path.join(root, f),
|
||||||
|
skip_header=skip_header
|
||||||
|
)
|
||||||
|
if with_content else ''
|
||||||
|
),
|
||||||
|
**self.get_page_metadata(
|
||||||
|
os.path.join(root[len(pages_dir)+1:], f)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for root, _, files in os.walk(pages_dir, followlinks=True)
|
||||||
|
for f in files
|
||||||
|
if f.endswith('.md')
|
||||||
|
]
|
||||||
|
|
||||||
|
sorter_func = sorter(pages)
|
||||||
|
pages.sort(key=sorter_func, reverse=reverse)
|
||||||
|
return [(i, page) for i, page in enumerate(pages)]
|
||||||
|
|
||||||
|
|
||||||
app = BlogApp(__name__)
|
app = BlogApp(__name__)
|
||||||
|
|
|
@ -13,7 +13,7 @@ class Config:
|
||||||
language = 'en-US'
|
language = 'en-US'
|
||||||
logo = '/img/icon.png'
|
logo = '/img/icon.png'
|
||||||
header = True
|
header = True
|
||||||
content_dir = None
|
content_dir = '.'
|
||||||
categories = None
|
categories = None
|
||||||
|
|
||||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
|
@ -5,6 +5,7 @@ from flask import request, Response, send_from_directory as send_from_directory_
|
||||||
|
|
||||||
from .app import app
|
from .app import app
|
||||||
from .config import config
|
from .config import config
|
||||||
|
from ._sorters import PagesSortByTimeGroupedByFolder
|
||||||
|
|
||||||
|
|
||||||
def send_from_directory(path: str, file: str, alternative_path: Optional[str] = None, *args, **kwargs):
|
def send_from_directory(path: str, file: str, alternative_path: Optional[str] = None, *args, **kwargs):
|
||||||
|
@ -15,7 +16,11 @@ def send_from_directory(path: str, file: str, alternative_path: Optional[str] =
|
||||||
|
|
||||||
@app.route('/', methods=['GET'])
|
@app.route('/', methods=['GET'])
|
||||||
def home_route():
|
def home_route():
|
||||||
return render_template('index.html', pages=app.get_pages(), config=config)
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
pages=app.get_pages(sorter=PagesSortByTimeGroupedByFolder),
|
||||||
|
config=config
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/img/<img>', methods=['GET'])
|
@app.route('/img/<img>', methods=['GET'])
|
||||||
|
@ -38,9 +43,14 @@ def fonts_route(file: str):
|
||||||
return send_from_directory(app.fonts_dir, file, config.default_fonts_dir)
|
return send_from_directory(app.fonts_dir, file, config.default_fonts_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/article/<path:path>/<article>', methods=['GET'])
|
||||||
|
def article_with_path_route(path: str, article: str):
|
||||||
|
return app.get_page(os.path.join(path, article))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/article/<article>', methods=['GET'])
|
@app.route('/article/<article>', methods=['GET'])
|
||||||
def article_route(article: str):
|
def article_route(article: str):
|
||||||
return app.get_page(article)
|
return article_with_path_route('', article)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/rss', methods=['GET'])
|
@app.route('/rss', methods=['GET'])
|
||||||
|
@ -71,7 +81,7 @@ def rss_route():
|
||||||
link=config.link,
|
link=config.link,
|
||||||
categories=','.join(config.categories),
|
categories=','.join(config.categories),
|
||||||
language=config.language,
|
language=config.language,
|
||||||
last_pub_date=pages[0]['published'].strftime('%a, %d %b %Y %H:%M:%S GMT'),
|
last_pub_date=pages[0][1]['published'].strftime('%a, %d %b %Y %H:%M:%S GMT'),
|
||||||
items='\n\n'.join([
|
items='\n\n'.join([
|
||||||
'''
|
'''
|
||||||
<item>
|
<item>
|
||||||
|
@ -89,7 +99,7 @@ def rss_route():
|
||||||
content=page.get('description', '') if short_description else page.get('content', ''),
|
content=page.get('description', '') if short_description else page.get('content', ''),
|
||||||
image=page.get('image', ''),
|
image=page.get('image', ''),
|
||||||
)
|
)
|
||||||
for page in pages
|
for _, page in pages
|
||||||
]),
|
]),
|
||||||
), mimetype='application/rss+xml')
|
), mimetype='application/rss+xml')
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,26 @@ main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder .folder-title {
|
||||||
|
width: calc(100% - 0.66em);
|
||||||
|
display: flex;
|
||||||
|
background: linear-gradient(45deg, #f0f0f0, #fafaffe0);
|
||||||
|
box-shadow: 1px 1px 1px 1px #e0e0e080;
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 0.33em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder .folder-title a {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.articles {
|
.articles {
|
||||||
|
@ -16,7 +35,6 @@ main {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
overflow: auto;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,11 +54,11 @@ main {
|
||||||
border-radius: .5em;
|
border-radius: .5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 767px) {
|
/* @media screen and (min-width: 767px) { */
|
||||||
.article {
|
/* .article { */
|
||||||
max-height: 55%;
|
/* max-height: 55%; */
|
||||||
}
|
/* } */
|
||||||
}
|
/* } */
|
||||||
|
|
||||||
@media screen and (min-width: 640px) and (max-width: 767px) {
|
@media screen and (min-width: 640px) and (max-width: 767px) {
|
||||||
.article {
|
.article {
|
||||||
|
|
|
@ -1,41 +1,59 @@
|
||||||
{% with title=title or config.title or 'Blog', skip_header=not config.header, styles=['/css/home.css'] %}
|
{% with title=title or config.title or 'Blog', skip_header=not config.header, styles=['/css/home.css'] %}
|
||||||
{% include 'common-head.html' %}
|
{% include 'common-head.html' %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="index">
|
<div class="index">
|
||||||
|
{% set state = {} %}
|
||||||
|
{% for i, page in pages %}
|
||||||
|
{% if 'cur_folder' not in state or page.get('folder') != state.get('cur_folder') %}
|
||||||
|
<div class="folder">
|
||||||
|
{% set folder = page.get('folder') %}
|
||||||
|
{% if folder %}
|
||||||
|
<div id="{{ folder.replace('/', '-') }}" class="folder-title">
|
||||||
|
<a href="#{{ folder.replace('/', '-') }}">
|
||||||
|
{{ folder }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="articles">
|
<div class="articles">
|
||||||
{% for page in pages %}
|
{% endif %}
|
||||||
<a class="article" href="{{ page['uri'] }}">
|
{% if state.update({'cur_folder': page.get('folder')}) %}{% endif %}
|
||||||
<div class="container">
|
|
||||||
{% if page['image'] %}
|
|
||||||
<div class="image">
|
|
||||||
<img src="{{ page['image'] }}" alt="">
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="title">
|
<a class="article" href="{{ page['uri'] }}">
|
||||||
{{ page['title'] }}
|
<div class="container">
|
||||||
</div>
|
{% if page['image'] %}
|
||||||
|
<div class="image">
|
||||||
|
<img src="{{ page['image'] }}" alt="">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if page['published'] %}
|
<div class="title">
|
||||||
<div class="published-date">
|
{{ page['title'] }}
|
||||||
{{ page['published'].strftime('%b %d, %Y') }}
|
</div>
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page['description'] %}
|
{% if page['published'] %}
|
||||||
<div class="description">
|
<div class="published-date">
|
||||||
{{ page['description'] }}
|
{{ page['published'].strftime('%b %d, %Y') }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</a>
|
{% if page['description'] %}
|
||||||
{% endfor %}
|
<div class="description">
|
||||||
|
{{ page['description'] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if i == pages|length - 1 or pages[i+1][1].get('folder') != page.get('folder') %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% include 'footer.html' %}
|
{% include 'footer.html' %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% include 'common-tail.html' %}
|
{% include 'common-tail.html' %}
|
||||||
|
|
Loading…
Reference in a new issue