Compare commits
10 Commits
f6bbb3aef9
...
ef8da4e6cc
Author | SHA1 | Date | |
---|---|---|---|
ef8da4e6cc
|
|||
40bd2111bf
|
|||
335c2ddd7f
|
|||
3626cd7980
|
|||
67948f81c4
|
|||
270767c3cd | |||
26f7909c33 | |||
2b71048b65 | |||
5e9eaba794 | |||
1e75eaf836 |
11
README.md
Normal file
11
README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Run
|
||||||
|
```bash
|
||||||
|
uwsgi --need-plugin /usr/lib/uwsgi/python_plugin.so \
|
||||||
|
--need-plugin /usr/lib/uwsgi/gevent_plugin.so \
|
||||||
|
-H venv \
|
||||||
|
--http :1180 \
|
||||||
|
-w md2html.uwsgi_handler \
|
||||||
|
--http-keepalive \
|
||||||
|
--http-auto-chunked \
|
||||||
|
--gevent 10
|
||||||
|
```
|
10
build_docker_image.sh
Executable file
10
build_docker_image.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
venv/bin/python -m build
|
||||||
|
mkdir -p docker/build
|
||||||
|
cp dist/md2html-*.whl docker/build/
|
||||||
|
cp docker/Dockerfile docker/build/Dockerfile
|
||||||
|
cp docker/uwsgi.ini docker/build/uwsgi.ini
|
||||||
|
|
||||||
|
docker build docker/build --tag alpine:md2html
|
@@ -1,15 +1,20 @@
|
|||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
MAINTAINER Oggioni Walter <oggioni.walter@gmail.com>
|
MAINTAINER Oggioni Walter <oggioni.walter@gmail.com>
|
||||||
RUN apk update
|
RUN apk update
|
||||||
RUN apk add python3 uwsgi uwsgi-python3
|
RUN apk add python3 py3-pip uwsgi uwsgi-python3 graphviz uwsgi-gevent3
|
||||||
RUN mkdir /srv/http
|
RUN mkdir /srv/http
|
||||||
|
RUN mkdir /var/md2html
|
||||||
|
WORKDIR /var/md2html
|
||||||
|
RUN python -m venv venv
|
||||||
|
ADD uwsgi.ini /var/md2html
|
||||||
|
ADD md2html-*.whl /
|
||||||
|
RUN venv/bin/pip install /md2html-*.whl && rm /md2html-*.whl
|
||||||
VOLUME /srv/http
|
VOLUME /srv/http
|
||||||
WORKDIR /srv/http
|
WORKDIR /srv/http
|
||||||
ADD md2html-*.whl /
|
|
||||||
RUN pip3 install /md2html-*.whl && rm /md2html-*.whl
|
|
||||||
ENTRYPOINT ["uwsgi"]
|
ENTRYPOINT ["uwsgi"]
|
||||||
EXPOSE 1180/tcp
|
EXPOSE 1910/tcp
|
||||||
EXPOSE 1180/udp
|
EXPOSE 1910/udp
|
||||||
USER nobody
|
USER nobody
|
||||||
CMD ["--plugin", "/usr/lib/uwsgi/python_plugin.so", "-s", ":1180", "-w", "md2html.uwsgi"]
|
CMD [ "--ini", "/var/md2html/uwsgi.ini" ]
|
||||||
|
|
||||||
|
|
||||||
|
8
docker/uwsgi.ini
Normal file
8
docker/uwsgi.ini
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[uwsgi]
|
||||||
|
#logformat = "%(proto) - %(method) %(uri) %(status) %(addr)
|
||||||
|
need-plugin=/usr/lib/uwsgi/python_plugin.so
|
||||||
|
need-plugin=/usr/lib/uwsgi/gevent3_plugin.so
|
||||||
|
socket = :1910
|
||||||
|
module = md2html.uwsgi_handler
|
||||||
|
virtualenv = /var/md2html/venv
|
||||||
|
gevent = 1000
|
@@ -1,213 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
||||||
from os.path import basename, dirname, abspath, join
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import markdown
|
|
||||||
|
|
||||||
STATIC_CACHE = {}
|
|
||||||
|
|
||||||
|
|
||||||
def load_from_cache(path):
|
|
||||||
global STATIC_CACHE
|
|
||||||
if path not in STATIC_CACHE:
|
|
||||||
with open(join(dirname(__file__), 'static') + path, 'r') as static_file:
|
|
||||||
STATIC_CACHE[path] = static_file.read()
|
|
||||||
return STATIC_CACHE[path]
|
|
||||||
|
|
||||||
|
|
||||||
def compile_html(mdfile=None, extensions=None, raw=None, **kwargs):
|
|
||||||
html = None
|
|
||||||
with mdfile and open(mdfile, 'r') or sys.stdin as instream:
|
|
||||||
html = markdown.markdown(instream.read(), extensions=extensions, output_format='html5')
|
|
||||||
if raw:
|
|
||||||
doc = html
|
|
||||||
else:
|
|
||||||
css = ' <style>%s\n%s\n </style>' % (
|
|
||||||
load_from_cache('/github-markdown.css'),
|
|
||||||
load_from_cache('/custom.css')
|
|
||||||
)
|
|
||||||
doc = load_from_cache('/template.html').format(content=html, script='', css=css)
|
|
||||||
return doc
|
|
||||||
|
|
||||||
|
|
||||||
class MarkdownHTTPServer(ThreadingHTTPServer):
|
|
||||||
|
|
||||||
def __init__(self, mdfile, extensions=(), handler=BaseHTTPRequestHandler, interface="127.0.0.1", port=8080):
|
|
||||||
import inotify
|
|
||||||
import inotify.adapters
|
|
||||||
import signal
|
|
||||||
|
|
||||||
self.stop = False
|
|
||||||
|
|
||||||
def sigint_handler(signum, frame):
|
|
||||||
self.stop = True
|
|
||||||
|
|
||||||
handlers = (sigint_handler, signal.getsignal(signal.SIGINT))
|
|
||||||
signal.signal(signal.SIGINT, lambda signum, frame: [handler(signum, frame) for handler in handlers])
|
|
||||||
|
|
||||||
self.mdfile = mdfile
|
|
||||||
self.extensions = extensions
|
|
||||||
self.condition_variable = threading.Condition()
|
|
||||||
self.hash = None
|
|
||||||
self.etag = None
|
|
||||||
|
|
||||||
def watch_file():
|
|
||||||
watcher = inotify.adapters.Inotify()
|
|
||||||
watcher.add_watch(dirname(abspath(self.mdfile)))
|
|
||||||
target_file = basename(self.mdfile)
|
|
||||||
while True:
|
|
||||||
if self.stop:
|
|
||||||
break
|
|
||||||
for event in watcher.event_gen(yield_nones=True, timeout_s=1):
|
|
||||||
if not event:
|
|
||||||
continue
|
|
||||||
(_, event_type, path, filename) = event
|
|
||||||
if filename == target_file and len(set(event_type).intersection(
|
|
||||||
{'IN_CLOSE_WRITE'})):
|
|
||||||
self.condition_variable.acquire()
|
|
||||||
if self.update_file_digest():
|
|
||||||
self.condition_variable.notify_all()
|
|
||||||
self.condition_variable.release()
|
|
||||||
|
|
||||||
file_watcher = threading.Thread(target=watch_file)
|
|
||||||
file_watcher.start()
|
|
||||||
super().__init__((interface, port), handler)
|
|
||||||
|
|
||||||
def update_file_digest(self):
|
|
||||||
md5 = hashlib.md5()
|
|
||||||
with open(self.mdfile, 'rb') as mdfile:
|
|
||||||
md5.update(mdfile.read())
|
|
||||||
digest = md5.digest()
|
|
||||||
if not self.hash or self.hash != digest:
|
|
||||||
self.hash = digest
|
|
||||||
self.etag = md5.hexdigest()
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class MarkdownRequestHandler(BaseHTTPRequestHandler):
|
|
||||||
status_map = {
|
|
||||||
200: "OK",
|
|
||||||
204: "No Content",
|
|
||||||
304: "Not Modified",
|
|
||||||
400: "Bad Request",
|
|
||||||
401: "Unauthorized",
|
|
||||||
404: "Not Found",
|
|
||||||
499: "Service Error",
|
|
||||||
500: "Internal Server Error",
|
|
||||||
501: "Not Implemented",
|
|
||||||
503: "Service Unavailable"
|
|
||||||
}
|
|
||||||
|
|
||||||
def answer(self, code, reply=None, content_type="text/plain",
|
|
||||||
headers=()):
|
|
||||||
output = self.wfile
|
|
||||||
if not reply:
|
|
||||||
reply = MarkdownRequestHandler.status_map[code]
|
|
||||||
try:
|
|
||||||
self.send_response(code, MarkdownRequestHandler.status_map[code])
|
|
||||||
for header in headers:
|
|
||||||
self.send_header(*header)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header('Content-Length', len(reply))
|
|
||||||
self.end_headers()
|
|
||||||
output.write(reply.encode("UTF-8"))
|
|
||||||
output.flush()
|
|
||||||
except BrokenPipeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def markdown_answer(self):
|
|
||||||
if not self.server.etag:
|
|
||||||
self.server.condition_variable.acquire()
|
|
||||||
self.server.update_file_digest()
|
|
||||||
self.server.condition_variable.release()
|
|
||||||
self.answer(200, headers=(('Etag', self.server.etag),),
|
|
||||||
reply=compile_html(mdfile=self.server.mdfile, extensions=self.server.extensions, raw=True),
|
|
||||||
content_type='text/html')
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
path = urlparse(self.path)
|
|
||||||
if path.path == '/':
|
|
||||||
self.answer(200, reply=load_from_cache('/template.html').format(
|
|
||||||
content='',
|
|
||||||
script='<script src="/hot-reload.js", type="text/javascript"></script>',
|
|
||||||
css='<link rel="stylesheet" href="github-markdown.css">'
|
|
||||||
'<link rel="stylesheet" href="custom.css">'),
|
|
||||||
content_type='text/html')
|
|
||||||
elif path.path in {'/github-markdown.css', '/custom.css', '/hot-reload.js'}:
|
|
||||||
self.answer(200, load_from_cache(path.path), content_type='text/css')
|
|
||||||
elif path.path == '/markdown':
|
|
||||||
self.markdown_answer()
|
|
||||||
elif path.path == '/reload':
|
|
||||||
if 'If-None-Match' not in self.headers or self.headers['If-None-Match'] != self.server.etag:
|
|
||||||
self.markdown_answer()
|
|
||||||
else:
|
|
||||||
self.server.condition_variable.acquire()
|
|
||||||
self.server.condition_variable.wait(timeout=10)
|
|
||||||
self.server.condition_variable.release()
|
|
||||||
if self.server.stop:
|
|
||||||
self.answer(503)
|
|
||||||
elif self.headers['If-None-Match'] == self.server.etag:
|
|
||||||
self.answer(304)
|
|
||||||
else:
|
|
||||||
self.answer(200, headers=(('Etag', self.server.etag),),
|
|
||||||
reply=compile_html(mdfile=self.server.mdfile,
|
|
||||||
extensions=self.server.extensions,
|
|
||||||
raw=True),
|
|
||||||
content_type='text/html')
|
|
||||||
else:
|
|
||||||
self.answer(404)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(args=None):
|
|
||||||
parser = argparse.ArgumentParser(description='Make a complete, styled HTML document from a Markdown file.')
|
|
||||||
parser.add_argument('mdfile', help='File to convert. Defaults to stdin.')
|
|
||||||
parser.add_argument('-o', '--out', help='Output file name. Defaults to stdout.')
|
|
||||||
parser.add_argument('-r', '--raw', action='store_true',
|
|
||||||
help='Just output a raw html fragment, as returned from the markdown module')
|
|
||||||
parser.add_argument('-e', '--extensions', nargs='+', default=['extra', 'smarty', 'tables'],
|
|
||||||
help='Activate specified markdown extensions (defaults to "extra smarty tables")')
|
|
||||||
|
|
||||||
try:
|
|
||||||
import inotify
|
|
||||||
import gevent
|
|
||||||
import signal
|
|
||||||
parser.add_argument('-w', '--watch', action='store_true',
|
|
||||||
help='Watch specified source file and rerun the compilation for every time it changes')
|
|
||||||
parser.add_argument('-p', '--port', default=5000, type=int,
|
|
||||||
help='Specify http server port (defaults to 5000)')
|
|
||||||
parser.add_argument('-i', '--interface', default='',
|
|
||||||
help='Specify http server listen interface (defaults to localhost)')
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
return parser.parse_args(args)
|
|
||||||
|
|
||||||
|
|
||||||
def write_html(out=None, **kwargs):
|
|
||||||
doc = compile_html(**kwargs)
|
|
||||||
with (out and open(out, 'w')) or sys.stdout as outstream:
|
|
||||||
outstream.write(doc)
|
|
||||||
|
|
||||||
|
|
||||||
def main(args=None):
|
|
||||||
args = parse_args(args)
|
|
||||||
if hasattr(args, 'watch') and args.watch:
|
|
||||||
server = MarkdownHTTPServer(args.mdfile,
|
|
||||||
extensions=args.extensions,
|
|
||||||
interface=args.interface,
|
|
||||||
port=args.port,
|
|
||||||
handler=MarkdownRequestHandler)
|
|
||||||
server.serve_forever()
|
|
||||||
else:
|
|
||||||
write_html(**vars(args))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
108
md2html/uwsgi.py
108
md2html/uwsgi.py
@@ -1,108 +0,0 @@
|
|||||||
import logging
|
|
||||||
from os import getcwd, listdir
|
|
||||||
from os.path import exists, splitext, isfile, join, relpath, isdir, basename
|
|
||||||
from mimetypes import init as mimeinit, guess_type
|
|
||||||
import hashlib
|
|
||||||
from .md2html import compile_html
|
|
||||||
|
|
||||||
mimeinit()
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
|
|
||||||
|
|
||||||
cwd = getcwd()
|
|
||||||
|
|
||||||
def is_markdown(filepath):
|
|
||||||
_, ext = splitext(filepath)
|
|
||||||
return ext == ".md"
|
|
||||||
|
|
||||||
cache = dict()
|
|
||||||
|
|
||||||
def file_hash(filepath, bufsize=4096):
|
|
||||||
if bufsize <= 0:
|
|
||||||
raise ValueError("Buffer size must be greater than 0")
|
|
||||||
md5 = hashlib.md5()
|
|
||||||
with open(filepath, 'rb') as f:
|
|
||||||
while True:
|
|
||||||
buf = f.read(bufsize)
|
|
||||||
if len(buf) == 0:
|
|
||||||
break
|
|
||||||
md5.update(buf)
|
|
||||||
return md5.digest()
|
|
||||||
|
|
||||||
def application(env, start_response):
|
|
||||||
path = join(cwd, relpath(env['PATH_INFO'], '/'))
|
|
||||||
|
|
||||||
if exists(path):
|
|
||||||
if isfile(path):
|
|
||||||
if path not in cache:
|
|
||||||
digest = file_hash(path).hex()
|
|
||||||
cache[path] = digest
|
|
||||||
else:
|
|
||||||
digest = cache[path]
|
|
||||||
|
|
||||||
def parse_etag(etag):
|
|
||||||
if etag is None:
|
|
||||||
return
|
|
||||||
start = etag.find('"')
|
|
||||||
if start < 0:
|
|
||||||
return
|
|
||||||
end = etag.find('"', start + 1)
|
|
||||||
return etag[start + 1: end]
|
|
||||||
|
|
||||||
etag = parse_etag(env.get('HTTP_IF_NONE_MATCH'))
|
|
||||||
if etag and etag == digest:
|
|
||||||
start_response('304 Not Modified', [
|
|
||||||
('Etag', '"%s"' % digest),
|
|
||||||
('Cache-Control', 'no-cache, must-revalidate, max-age=86400'),
|
|
||||||
])
|
|
||||||
return []
|
|
||||||
elif is_markdown(path):
|
|
||||||
body = compile_html(path, ['extra', 'smarty', 'tables']).encode()
|
|
||||||
start_response('200 OK', [('Content-Type', 'text/html; charset=UTF-8'),
|
|
||||||
('Etag', '"%s"' % digest),
|
|
||||||
('Cache-Control', 'no-cache, must-revalidate, max-age=86400'),
|
|
||||||
])
|
|
||||||
return [body]
|
|
||||||
else:
|
|
||||||
def read_file(file_path):
|
|
||||||
buffer_size = 1024
|
|
||||||
with open(file_path, 'rb') as f:
|
|
||||||
while True:
|
|
||||||
result = f.read(buffer_size)
|
|
||||||
if len(result) == 0:
|
|
||||||
break
|
|
||||||
yield result
|
|
||||||
start_response('200 OK', [('Content-Type', guess_type(basename(path))[0] or 'application/octet-stream'),
|
|
||||||
('Etag', '"%s"' % digest),
|
|
||||||
('Cache-Control', 'no-cache, must-revalidate, max-age=86400'),
|
|
||||||
])
|
|
||||||
return read_file(path)
|
|
||||||
elif isdir(path):
|
|
||||||
body = directory_listing(env['PATH_INFO'], path).encode()
|
|
||||||
start_response('200 OK', [
|
|
||||||
('Content-Type', 'text/html; charset=UTF-8'),
|
|
||||||
])
|
|
||||||
return [body]
|
|
||||||
start_response('404 NOT_FOUND', [])
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def directory_listing(path_info, path):
|
|
||||||
title = "Directory listing for %s" % path_info
|
|
||||||
result = "<!DOCTYPE html><html><head>"
|
|
||||||
result += "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">"
|
|
||||||
result += "<title>" + title + "</title></head>"
|
|
||||||
result += "<body><h1>" + title + "</h1><hr>"
|
|
||||||
result += "<ul>"
|
|
||||||
if path_info != '/':
|
|
||||||
result += "<li><a href=\"../\"/>../</li>"
|
|
||||||
|
|
||||||
def ls(filter):
|
|
||||||
return (entry for entry in sorted(listdir(path)) if filter(join(path, entry)))
|
|
||||||
|
|
||||||
for entry in ls(isdir):
|
|
||||||
result += '<li><a href="' + entry + '/' + '"/>' + entry + '/' + '</li>'
|
|
||||||
for entry in ls(lambda entry: isfile(entry) and is_markdown(entry)):
|
|
||||||
result += '<li><a href="' + entry + '"/>' + entry + '</li>'
|
|
||||||
return result
|
|
49
pyproject.toml
Normal file
49
pyproject.toml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "md2html"
|
||||||
|
version = "0.3"
|
||||||
|
authors = [
|
||||||
|
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
|
||||||
|
]
|
||||||
|
description = "Markdown to HTML renderer"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
classifiers = [
|
||||||
|
'Development Status :: 3 - Alpha',
|
||||||
|
'Topic :: Utilities',
|
||||||
|
'License :: OSI Approved :: MIT License',
|
||||||
|
'Intended Audience :: System Administrators',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'Environment :: Console',
|
||||||
|
'License :: OSI Approved :: MIT License',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"gevent",
|
||||||
|
"greenlet",
|
||||||
|
"Markdown",
|
||||||
|
"Pygments",
|
||||||
|
"watchdog",
|
||||||
|
"zope.event",
|
||||||
|
"zope.interface"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
md2html = ['static/*']
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://github.com/woggioni/md2html"
|
||||||
|
"Bug Tracker" = "https://github.com/woggioni/md2html/issues"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.10"
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
show_error_codes = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
exclude = ["scripts", "docs", "test"]
|
||||||
|
strict = true
|
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
build==1.0.3
|
||||||
|
gevent==23.9.1
|
||||||
|
greenlet==3.0.0
|
||||||
|
Markdown==3.5
|
||||||
|
packaging==23.2
|
||||||
|
Pygments==2.16.1
|
||||||
|
pyproject_hooks==1.0.0
|
||||||
|
watchdog==3.0.0
|
||||||
|
zope.event==5.0
|
||||||
|
zope.interface==6.1
|
43
setup.py
43
setup.py
@@ -1,43 +0,0 @@
|
|||||||
from os.path import join, dirname
|
|
||||||
from setuptools import setup, find_packages
|
|
||||||
|
|
||||||
|
|
||||||
def read(fname):
|
|
||||||
return open(join(dirname(__file__), fname)).read()
|
|
||||||
|
|
||||||
|
|
||||||
config = {
|
|
||||||
'name': "md2html",
|
|
||||||
'version': "0.2",
|
|
||||||
'author': "Walter Oggioni",
|
|
||||||
'author_email': "oggioni.walter@gmail.com",
|
|
||||||
'description': ("Various development utility scripts"),
|
|
||||||
'long_description': '',
|
|
||||||
'license': "MIT",
|
|
||||||
'keywords': "build",
|
|
||||||
'url': "https://github.com/oggio88/md2html",
|
|
||||||
'packages': ['md2html'],
|
|
||||||
'package_data': {
|
|
||||||
'md2html': ['static/*.html', 'static/*.css', 'static/*.js'],
|
|
||||||
},
|
|
||||||
'include_package_data': True,
|
|
||||||
'classifiers': [
|
|
||||||
'Development Status :: 3 - Alpha',
|
|
||||||
'Topic :: Utilities',
|
|
||||||
'License :: OSI Approved :: MIT License',
|
|
||||||
'Intended Audience :: System Administrators',
|
|
||||||
'Intended Audience :: Developers',
|
|
||||||
'Environment :: Console',
|
|
||||||
'License :: OSI Approved :: MIT License',
|
|
||||||
'Programming Language :: Python :: 3',
|
|
||||||
],
|
|
||||||
'install_requires': [
|
|
||||||
'markdown'
|
|
||||||
],
|
|
||||||
"entry_points": {
|
|
||||||
'console_scripts': [
|
|
||||||
'md2html=md2html.md2html:main',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setup(**config)
|
|
92
src/md2html/file_watch.py
Normal file
92
src/md2html/file_watch.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from threading import Lock
|
||||||
|
from typing import Optional, Callable
|
||||||
|
from os import getcwd
|
||||||
|
from watchdog.events import PatternMatchingEventHandler, FileSystemEvent, \
|
||||||
|
FileCreatedEvent, FileModifiedEvent, FileClosedEvent, FileMovedEvent
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
import logging
|
||||||
|
from gevent.event import Event
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription:
|
||||||
|
|
||||||
|
def __init__(self, unsubscribe: Callable[['Subscription'], None]):
|
||||||
|
self._unsubscribe_callback = unsubscribe
|
||||||
|
self._event: Event = Event()
|
||||||
|
|
||||||
|
def unsubscribe(self) -> None:
|
||||||
|
self._unsubscribe_callback(self)
|
||||||
|
|
||||||
|
def wait(self, tout: float) -> bool:
|
||||||
|
return self._event.wait(tout)
|
||||||
|
|
||||||
|
def notify(self) -> None:
|
||||||
|
self._event.set()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._event.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class FileWatcher(PatternMatchingEventHandler):
|
||||||
|
def __init__(self, path):
|
||||||
|
super().__init__(patterns=['*.md'],
|
||||||
|
ignore_patterns=None,
|
||||||
|
ignore_directories=False,
|
||||||
|
case_sensitive=True)
|
||||||
|
self.subscriptions: dict[str, set[Subscription]] = dict()
|
||||||
|
self.observer: Observer = Observer()
|
||||||
|
self.observer.schedule(self, path=path, recursive=True)
|
||||||
|
self.observer.start()
|
||||||
|
self.logger = logging.getLogger(FileWatcher.__name__)
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def subscribe(self, path: str) -> Subscription:
|
||||||
|
subscriptions = self.subscriptions
|
||||||
|
subscriptions_per_path = subscriptions.setdefault(path, set())
|
||||||
|
|
||||||
|
def unsubscribe_callback(subscription):
|
||||||
|
with self._lock:
|
||||||
|
subscriptions_per_path.remove(subscription)
|
||||||
|
|
||||||
|
result = Subscription(unsubscribe_callback)
|
||||||
|
subscriptions_per_path.add(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.observer.stop()
|
||||||
|
self.observer.join()
|
||||||
|
|
||||||
|
def on_any_event(self, event: FileSystemEvent) -> None:
|
||||||
|
what = "directory" if event.is_directory else "file"
|
||||||
|
|
||||||
|
def notify_subscriptions(path):
|
||||||
|
with self._lock:
|
||||||
|
subscriptions = self.subscriptions
|
||||||
|
subscriptions_per_path = subscriptions.get(path, None)
|
||||||
|
if subscriptions_per_path:
|
||||||
|
for s in subscriptions_per_path:
|
||||||
|
s.notify()
|
||||||
|
|
||||||
|
if isinstance(event, FileClosedEvent):
|
||||||
|
self.logger.debug("Closed %s: %s", what, event.src_path)
|
||||||
|
# update_subscriptions()
|
||||||
|
elif isinstance(event, FileMovedEvent):
|
||||||
|
self.logger.debug("Moved %s: %s to %s", what, event.src_path, event.dest_path)
|
||||||
|
notify_subscriptions(event.dest_path)
|
||||||
|
elif isinstance(event, FileCreatedEvent):
|
||||||
|
self.logger.debug("Created %s: %s", what, event.src_path)
|
||||||
|
notify_subscriptions(event.src_path)
|
||||||
|
elif isinstance(event, FileModifiedEvent):
|
||||||
|
self.logger.debug("Modified %s: %s", what, event.src_path)
|
||||||
|
notify_subscriptions(event.src_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format='%(asctime)s [%(threadName)s] (%(name)s) %(levelname)s %(message)s'
|
||||||
|
)
|
||||||
|
watcher = FileWatcher(getcwd())
|
||||||
|
watcher.observer.join()
|
||||||
|
|
48
src/md2html/md2html.py
Normal file
48
src/md2html/md2html.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import sys
|
||||||
|
from os.path import dirname, join, relpath
|
||||||
|
from time import time
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from _typeshed import StrOrBytesPath
|
||||||
|
|
||||||
|
STATIC_RESOURCES: set[str] = {
|
||||||
|
'/github-markdown.css',
|
||||||
|
'/custom.css',
|
||||||
|
'/hot-reload.js',
|
||||||
|
'/pygment.css',
|
||||||
|
'/markdown.svg'
|
||||||
|
}
|
||||||
|
STATIC_CACHE: dict[str, tuple[str, float]] = {}
|
||||||
|
|
||||||
|
MARDOWN_EXTENSIONS = ['extra', 'smarty', 'tables', 'codehilite']
|
||||||
|
|
||||||
|
|
||||||
|
def load_from_cache(path) -> tuple[str, float]:
|
||||||
|
global STATIC_CACHE
|
||||||
|
if path not in STATIC_CACHE:
|
||||||
|
with open(join(dirname(__file__), 'static') + path, 'r') as static_file:
|
||||||
|
STATIC_CACHE[path] = (static_file.read(), time())
|
||||||
|
return STATIC_CACHE[path]
|
||||||
|
|
||||||
|
|
||||||
|
def compile_html(url_path,
|
||||||
|
mdfile: 'StrOrBytesPath',
|
||||||
|
prefix: Optional['StrOrBytesPath'] = None,
|
||||||
|
extensions: Optional[list[str]] = None,
|
||||||
|
raw: bool = False) -> str:
|
||||||
|
with mdfile and open(mdfile, 'r') or sys.stdin as instream:
|
||||||
|
html = markdown.markdown(instream.read(), extensions=extensions, output_format='html')
|
||||||
|
if raw:
|
||||||
|
doc = html
|
||||||
|
else:
|
||||||
|
parent = dirname(url_path)
|
||||||
|
prefix = prefix or relpath('/', start=parent)
|
||||||
|
script = f'<script src="{prefix}/hot-reload.js", type="text/javascript" defer="true"></script>'
|
||||||
|
css = f'<link rel="icon" type="image/x-icon" href="{prefix}/markdown.svg">'
|
||||||
|
for css_file in ('github-markdown.css', 'pygment.css', 'custom.css'):
|
||||||
|
css += f' <link rel="stylesheet" href="{prefix}/{css_file}">'
|
||||||
|
doc = load_from_cache('/template.html')[0].format(content=html, script=script, css=css)
|
||||||
|
return doc
|
240
src/md2html/server.py
Normal file
240
src/md2html/server.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import logging
|
||||||
|
from os import getcwd, listdir
|
||||||
|
from os.path import exists, splitext, isfile, join, relpath, isdir, basename, getmtime, dirname, normpath
|
||||||
|
from mimetypes import init as mimeinit, guess_type
|
||||||
|
import hashlib
|
||||||
|
from .md2html import compile_html, load_from_cache, STATIC_RESOURCES, MARDOWN_EXTENSIONS
|
||||||
|
from shutil import which
|
||||||
|
from subprocess import check_output
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Callable, TYPE_CHECKING, BinaryIO, Optional
|
||||||
|
from .file_watch import FileWatcher
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from _typeshed import StrOrBytesPath
|
||||||
|
|
||||||
|
mimeinit()
|
||||||
|
|
||||||
|
cwd: 'StrOrBytesPath' = getcwd()
|
||||||
|
|
||||||
|
|
||||||
|
def has_extension(filepath, extension):
|
||||||
|
_, ext = splitext(filepath)
|
||||||
|
return ext == extension
|
||||||
|
|
||||||
|
|
||||||
|
def is_markdown(filepath):
|
||||||
|
return has_extension(filepath, ".md")
|
||||||
|
|
||||||
|
|
||||||
|
def is_dotfile(filepath):
|
||||||
|
return has_extension(filepath, ".dot")
|
||||||
|
|
||||||
|
|
||||||
|
class Server:
|
||||||
|
|
||||||
|
def __init__(self, root_dir: 'StrOrBytesPath' = getcwd(), prefix: Optional['StrOrBytesPath'] = None):
|
||||||
|
self.root_dir = root_dir
|
||||||
|
self.cache = dict['StrOrBytesPath', tuple[str, float]]()
|
||||||
|
self.file_watcher = FileWatcher(cwd)
|
||||||
|
self.logger = logging.getLogger(Server.__name__)
|
||||||
|
self.prefix = prefix and normpath(f'{prefix.decode()}')
|
||||||
|
|
||||||
|
def handle_request(self, method: str, url_path: str, etag: Optional[str], query_string: Optional[str], start_response):
|
||||||
|
if method != 'GET':
|
||||||
|
start_response('405', [])
|
||||||
|
return []
|
||||||
|
relative_path = relpath(url_path, start=self.prefix or '/')
|
||||||
|
url_path: 'StrOrBytesPath' = normpath(join('/', relative_path))
|
||||||
|
path: 'StrOrBytesPath' = join(self.root_dir, relative_path)
|
||||||
|
if url_path in STATIC_RESOURCES:
|
||||||
|
content, mtime = load_from_cache(url_path)
|
||||||
|
content = content.encode()
|
||||||
|
etag, digest = self.compute_etag_and_digest(
|
||||||
|
etag,
|
||||||
|
url_path,
|
||||||
|
lambda: BytesIO(content),
|
||||||
|
lambda: mtime
|
||||||
|
)
|
||||||
|
if etag and etag == digest:
|
||||||
|
return self.not_modified(start_response, digest, ('Cache-Control', 'must-revalidate, max-age=86400'))
|
||||||
|
elif content:
|
||||||
|
mime_type = guess_type(basename(url_path))[0] or 'application/octet-stream'
|
||||||
|
start_response('200 OK', [
|
||||||
|
('Content-Type', f'{mime_type}; charset=UTF-8'),
|
||||||
|
('Etag', 'W/"%s"' % digest),
|
||||||
|
('Cache-Control', 'must-revalidate, max-age=86400'),
|
||||||
|
])
|
||||||
|
return content
|
||||||
|
elif exists(path):
|
||||||
|
if isfile(path):
|
||||||
|
etag, digest = self.compute_etag_and_digest(
|
||||||
|
etag,
|
||||||
|
path,
|
||||||
|
lambda: open(path, 'rb'),
|
||||||
|
lambda: getmtime(path)
|
||||||
|
)
|
||||||
|
if etag and etag == digest:
|
||||||
|
if is_markdown(path) and query_string == 'reload':
|
||||||
|
subscription = self.file_watcher.subscribe(path)
|
||||||
|
try:
|
||||||
|
has_changed = subscription.wait(30)
|
||||||
|
if has_changed:
|
||||||
|
_, digest = self.compute_etag_and_digest(
|
||||||
|
etag,
|
||||||
|
path,
|
||||||
|
lambda: open(path, 'rb'),
|
||||||
|
lambda: getmtime(path)
|
||||||
|
)
|
||||||
|
if etag != digest:
|
||||||
|
if exists(path) and isfile(path):
|
||||||
|
return self.render_markdown(url_path, path, True, digest, start_response)
|
||||||
|
else:
|
||||||
|
return self.not_found(start_response)
|
||||||
|
finally:
|
||||||
|
subscription.unsubscribe()
|
||||||
|
return self.not_modified(start_response, digest)
|
||||||
|
elif is_markdown(path):
|
||||||
|
raw = query_string == 'reload'
|
||||||
|
return self.render_markdown(url_path, path, raw, digest, start_response)
|
||||||
|
elif is_dotfile(path) and which("dot"):
|
||||||
|
body = check_output(['dot', '-Tsvg', basename(path)], cwd=dirname(path))
|
||||||
|
start_response('200 OK', [('Content-Type', 'image/svg+xml; charset=UTF-8'),
|
||||||
|
('Etag', 'W/"%s"' % digest),
|
||||||
|
('Cache-Control', 'no-cache'),
|
||||||
|
])
|
||||||
|
return [body]
|
||||||
|
else:
|
||||||
|
def read_file(file_path):
|
||||||
|
buffer_size = 1024
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
while True:
|
||||||
|
result = f.read(buffer_size)
|
||||||
|
if len(result) == 0:
|
||||||
|
break
|
||||||
|
yield result
|
||||||
|
|
||||||
|
start_response('200 OK',
|
||||||
|
[('Content-Type', guess_type(basename(path))[0] or 'application/octet-stream'),
|
||||||
|
('Etag', 'W/"%s"' % digest),
|
||||||
|
('Cache-Control', 'no-cache'),
|
||||||
|
])
|
||||||
|
return read_file(path)
|
||||||
|
elif isdir(path):
|
||||||
|
body = self.directory_listing(url_path, path).encode()
|
||||||
|
start_response('200 OK', [
|
||||||
|
('Content-Type', 'text/html; charset=UTF-8'),
|
||||||
|
])
|
||||||
|
return [body]
|
||||||
|
return self.not_found(start_response)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def stream_hash(source: BinaryIO, bufsize=0x1000) -> bytes:
|
||||||
|
if bufsize <= 0:
|
||||||
|
raise ValueError("Buffer size must be greater than 0")
|
||||||
|
md5 = hashlib.md5()
|
||||||
|
while True:
|
||||||
|
buf = source.read(bufsize)
|
||||||
|
if len(buf) == 0:
|
||||||
|
break
|
||||||
|
md5.update(buf)
|
||||||
|
return md5.digest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def file_hash(filepath, bufsize=0x1000) -> bytes:
|
||||||
|
if bufsize <= 0:
|
||||||
|
raise ValueError("Buffer size must be greater than 0")
|
||||||
|
md5 = hashlib.md5()
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
while True:
|
||||||
|
buf = f.read(bufsize)
|
||||||
|
if len(buf) == 0:
|
||||||
|
break
|
||||||
|
md5.update(buf)
|
||||||
|
return md5.digest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_etag(etag: str) -> Optional[str]:
|
||||||
|
if etag is None:
|
||||||
|
return
|
||||||
|
start = etag.find('"')
|
||||||
|
if start < 0:
|
||||||
|
return
|
||||||
|
end = etag.find('"', start + 1)
|
||||||
|
return etag[start + 1: end]
|
||||||
|
|
||||||
|
def compute_etag_and_digest(
|
||||||
|
self,
|
||||||
|
etag_header: str,
|
||||||
|
path: str,
|
||||||
|
stream_source: Callable[[], BinaryIO],
|
||||||
|
mtime_supplier: Callable[[], float]
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
cache_result = self.cache.get(path)
|
||||||
|
_mtime: Optional[float] = None
|
||||||
|
|
||||||
|
def mtime() -> float:
|
||||||
|
nonlocal _mtime
|
||||||
|
if not _mtime:
|
||||||
|
_mtime = mtime_supplier()
|
||||||
|
return _mtime
|
||||||
|
|
||||||
|
if not cache_result or cache_result[1] < mtime():
|
||||||
|
with stream_source() as stream:
|
||||||
|
digest = Server.stream_hash(stream).hex()
|
||||||
|
self.cache[path] = digest, mtime()
|
||||||
|
else:
|
||||||
|
digest = cache_result[0]
|
||||||
|
|
||||||
|
etag = Server.parse_etag(etag_header)
|
||||||
|
return etag, digest
|
||||||
|
|
||||||
|
def render_markdown(self,
|
||||||
|
url_path: 'StrOrBytesPath',
|
||||||
|
path: str,
|
||||||
|
raw: bool,
|
||||||
|
digest: str,
|
||||||
|
start_response) -> list[bytes]:
|
||||||
|
body = compile_html(url_path,
|
||||||
|
path,
|
||||||
|
self.prefix,
|
||||||
|
MARDOWN_EXTENSIONS,
|
||||||
|
raw=raw).encode()
|
||||||
|
start_response('200 OK', [('Content-Type', 'text/html; charset=UTF-8'),
|
||||||
|
('Etag', 'W/"%s"' % digest),
|
||||||
|
('Cache-Control', 'no-cache'),
|
||||||
|
])
|
||||||
|
return [body]
|
||||||
|
@staticmethod
|
||||||
|
def not_modified(start_response, digest: str, cache_control=('Cache-Control', 'no-cache')) -> []:
|
||||||
|
start_response('304 Not Modified', [
|
||||||
|
('Etag', f'W/"{digest}"'),
|
||||||
|
cache_control,
|
||||||
|
])
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def not_found(start_response) -> list[bytes]:
|
||||||
|
start_response('404 NOT_FOUND', [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
def directory_listing(self, path_info, path) -> str:
|
||||||
|
icon_path = join(self.prefix or '', 'markdown.svg')
|
||||||
|
title = "Directory listing for %s" % path_info
|
||||||
|
result = "<!DOCTYPE html><html><head>"
|
||||||
|
result += f'<link rel="icon" type="image/x-icon" href="{icon_path}">'
|
||||||
|
result += "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">"
|
||||||
|
result += "<title>" + title + "</title></head>"
|
||||||
|
result += "<body><h1>" + title + "</h1><hr>"
|
||||||
|
result += "<ul>"
|
||||||
|
if path_info != '/':
|
||||||
|
result += "<li><a href=\"../\"/>../</li>"
|
||||||
|
|
||||||
|
def ls(filter):
|
||||||
|
return (entry for entry in sorted(listdir(path)) if filter(join(path, entry)))
|
||||||
|
|
||||||
|
for entry in ls(isdir):
|
||||||
|
result += '<li><a href="' + entry + '/' + '"/>' + entry + '/' + '</li>'
|
||||||
|
for entry in ls(lambda entry: isfile(entry) and is_markdown(entry)):
|
||||||
|
result += '<li><a href="' + entry + '"/>' + entry + '</li>'
|
||||||
|
return result
|
@@ -13,7 +13,7 @@ function req(first) {
|
|||||||
console.log(xmlhttp.status, xmlhttp.statusText);
|
console.log(xmlhttp.status, xmlhttp.statusText);
|
||||||
setTimeout(req, 1000, false);
|
setTimeout(req, 1000, false);
|
||||||
};
|
};
|
||||||
xmlhttp.open("GET", first ? "/markdown" : "/reload", true);
|
xmlhttp.open("GET", location.pathname + "?reload", true);
|
||||||
xmlhttp.send();
|
xmlhttp.send();
|
||||||
}
|
}
|
||||||
req(true);
|
req(true);
|
1
src/md2html/static/markdown.svg
Normal file
1
src/md2html/static/markdown.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><rect fill="#fff" height="512" rx="15%" width="512"/><path d="m410 366h-308c-14 0-26-12-26-26v-170c0-14 12-26 26-26h307c14 0 26 12 26 26v170c0 14-11 26-25 26zm-308-204c-4 0-9 4-9 9v170c0 5 4 9 9 9h307c5 0 9-4 9-9v-171c0-5-4-9-9-9h-307zm26 153v-119h34l34 43 34-43h35v118h-34v-68l-34 43-34-43v68zm216 0-52-57h34v-61h34v60h34z"/></svg>
|
After Width: | Height: | Size: 394 B |
75
src/md2html/static/pygment.css
Normal file
75
src/md2html/static/pygment.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
pre { line-height: 125%; }
|
||||||
|
td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
|
||||||
|
span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
|
||||||
|
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
|
||||||
|
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
|
||||||
|
.codehilite .hll { background-color: #ffffcc }
|
||||||
|
.codehilite { background: #f8f8f8; }
|
||||||
|
.codehilite .c { color: #3D7B7B; font-style: italic } /* Comment */
|
||||||
|
.codehilite .err { border: 1px solid #FF0000 } /* Error */
|
||||||
|
.codehilite .k { color: #008000; font-weight: bold } /* Keyword */
|
||||||
|
.codehilite .o { color: #666666 } /* Operator */
|
||||||
|
.codehilite .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */
|
||||||
|
.codehilite .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */
|
||||||
|
.codehilite .cp { color: #9C6500 } /* Comment.Preproc */
|
||||||
|
.codehilite .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */
|
||||||
|
.codehilite .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */
|
||||||
|
.codehilite .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */
|
||||||
|
.codehilite .gd { color: #A00000 } /* Generic.Deleted */
|
||||||
|
.codehilite .ge { font-style: italic } /* Generic.Emph */
|
||||||
|
.codehilite .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */
|
||||||
|
.codehilite .gr { color: #E40000 } /* Generic.Error */
|
||||||
|
.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */
|
||||||
|
.codehilite .gi { color: #008400 } /* Generic.Inserted */
|
||||||
|
.codehilite .go { color: #717171 } /* Generic.Output */
|
||||||
|
.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
|
||||||
|
.codehilite .gs { font-weight: bold } /* Generic.Strong */
|
||||||
|
.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
|
||||||
|
.codehilite .gt { color: #0044DD } /* Generic.Traceback */
|
||||||
|
.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
|
||||||
|
.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
|
||||||
|
.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
|
||||||
|
.codehilite .kp { color: #008000 } /* Keyword.Pseudo */
|
||||||
|
.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
|
||||||
|
.codehilite .kt { color: #B00040 } /* Keyword.Type */
|
||||||
|
.codehilite .m { color: #666666 } /* Literal.Number */
|
||||||
|
.codehilite .s { color: #BA2121 } /* Literal.String */
|
||||||
|
.codehilite .na { color: #687822 } /* Name.Attribute */
|
||||||
|
.codehilite .nb { color: #008000 } /* Name.Builtin */
|
||||||
|
.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */
|
||||||
|
.codehilite .no { color: #880000 } /* Name.Constant */
|
||||||
|
.codehilite .nd { color: #AA22FF } /* Name.Decorator */
|
||||||
|
.codehilite .ni { color: #717171; font-weight: bold } /* Name.Entity */
|
||||||
|
.codehilite .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */
|
||||||
|
.codehilite .nf { color: #0000FF } /* Name.Function */
|
||||||
|
.codehilite .nl { color: #767600 } /* Name.Label */
|
||||||
|
.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
|
||||||
|
.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */
|
||||||
|
.codehilite .nv { color: #19177C } /* Name.Variable */
|
||||||
|
.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
|
||||||
|
.codehilite .w { color: #bbbbbb } /* Text.Whitespace */
|
||||||
|
.codehilite .mb { color: #666666 } /* Literal.Number.Bin */
|
||||||
|
.codehilite .mf { color: #666666 } /* Literal.Number.Float */
|
||||||
|
.codehilite .mh { color: #666666 } /* Literal.Number.Hex */
|
||||||
|
.codehilite .mi { color: #666666 } /* Literal.Number.Integer */
|
||||||
|
.codehilite .mo { color: #666666 } /* Literal.Number.Oct */
|
||||||
|
.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */
|
||||||
|
.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */
|
||||||
|
.codehilite .sc { color: #BA2121 } /* Literal.String.Char */
|
||||||
|
.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */
|
||||||
|
.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
|
||||||
|
.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */
|
||||||
|
.codehilite .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */
|
||||||
|
.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */
|
||||||
|
.codehilite .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */
|
||||||
|
.codehilite .sx { color: #008000 } /* Literal.String.Other */
|
||||||
|
.codehilite .sr { color: #A45A77 } /* Literal.String.Regex */
|
||||||
|
.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */
|
||||||
|
.codehilite .ss { color: #19177C } /* Literal.String.Symbol */
|
||||||
|
.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */
|
||||||
|
.codehilite .fm { color: #0000FF } /* Name.Function.Magic */
|
||||||
|
.codehilite .vc { color: #19177C } /* Name.Variable.Class */
|
||||||
|
.codehilite .vg { color: #19177C } /* Name.Variable.Global */
|
||||||
|
.codehilite .vi { color: #19177C } /* Name.Variable.Instance */
|
||||||
|
.codehilite .vm { color: #19177C } /* Name.Variable.Magic */
|
||||||
|
.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */
|
@@ -3,9 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
{css}
|
{css}
|
||||||
|
{script}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{script}
|
|
||||||
<article class="markdown-body">
|
<article class="markdown-body">
|
||||||
{content}
|
{content}
|
||||||
</article>
|
</article>
|
25
src/md2html/uwsgi_handler.py
Normal file
25
src/md2html/uwsgi_handler.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import logging
|
||||||
|
from .server import Server
|
||||||
|
from uwsgi import log, opt
|
||||||
|
class UwsgiHandler(logging.Handler):
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
log(self.formatter.format(record))
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(threadName)s] (%(name)s) %(levelname)s %(message)s',
|
||||||
|
handlers=[UwsgiHandler()]
|
||||||
|
)
|
||||||
|
|
||||||
|
server = Server(prefix=opt.get('prefix', None))
|
||||||
|
|
||||||
|
def application(env, start_response):
|
||||||
|
return server.handle_request(
|
||||||
|
env['REQUEST_METHOD'],
|
||||||
|
env['PATH_INFO'],
|
||||||
|
env.get('HTTP_IF_NONE_MATCH', None),
|
||||||
|
env.get('QUERY_STRING', None),
|
||||||
|
start_response
|
||||||
|
)
|
Reference in New Issue
Block a user