Skip to content

Commit

Permalink
Merge pull request #10 from tucked/render-modes
Browse files Browse the repository at this point in the history
Make rendering-mode selection more explicit
  • Loading branch information
tucked authored Mar 22, 2023
2 parents 31dd6a9 + 3b23333 commit 836f40e
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 113 deletions.
6 changes: 3 additions & 3 deletions pbnh/static/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,14 @@ Append a `.` with no extension (i.e. `GET /<hashid>.`) to use the type associate

If only the paste ID is requested (i.e. `GET /<hashid>` or `GET /<hashid>/`),
the paste will be rendered for a Web browser according to the paste's associated MIME type.
The associated MIME type can be overridden by appending `/<extension>` to the URI.
A rendering mode can be explicitly selected by appending `/<mode>` to the URI.

Currently, the following types can be rendered:
Currently, the following rendering modes are supported:

- [Asciicasts](https://asciinema.org/) (`application/x-asciicast`): `GET /<hashid>/cast`

- [Markdown](https://en.wikipedia.org/wiki/Markdown) (`text/markdown`): `GET /<hashid>/md`

- [reStructuredText](https://en.wikipedia.org/wiki/ReStructuredText) (`text/x-rst`): `GET /<hashid>/rst`

Additionally, syntax highlighting is supported for many other text types.
Additionally, syntax highlighting is supported for many other text types: `GET /<hashid>/text`
2 changes: 1 addition & 1 deletion pbnh/templates/asciinema.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<div id="player-container"></div>
<script>
AsciinemaPlayer.create(
"/{{pasteid}}.json",
"{{url}}",
document.getElementById("player-container"),
{{params|tojson}},
);
Expand Down
4 changes: 2 additions & 2 deletions pbnh/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
<textarea id="paste" name="content">{{paste}}</textarea>

<script>
CodeMirror.modeURL = "{{ url_for('static', filename='codemirror/langs/%N.js') }}";
CodeMirror.modeURL = "{{ url_for('static', filename='codemirror/langs') }}/%N.js";
var editor = CodeMirror.fromTextArea(paste, {
lineNumbers: true,
theme: 'monokai',
Expand All @@ -75,7 +75,7 @@
});
var info = CodeMirror.findModeByMIME('{{mime}}');
if (!info) {
info = CodeMirror.findModeByExtension('{{mime}}');
info = CodeMirror.findModeByExtension('{{extension}}');
}
if (info) {
editor.setOption('mode', info.mime);
Expand Down
1 change: 0 additions & 1 deletion pbnh/templates/raw.html

This file was deleted.

287 changes: 187 additions & 100 deletions pbnh/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import json
import mimetypes
from pathlib import Path
from typing import Any
from typing import Any, Callable

from docutils.core import publish_parts
from flask import (
Expand All @@ -24,6 +24,147 @@
REDIRECT_MIME = "text/x.pbnh.redirect"


def _decoded_data(data: bytes, *, encoding: str = "utf-8") -> str:
try:
return data.decode(encoding)
except UnicodeDecodeError as exc:
abort(422, f"The paste cannot be decoded as text ({exc}).")


def _get_paste(hashid: str) -> dict[str, Any]:
with db.paster_context() as paster:
return paster.query(hashid=hashid) or abort(404)


def _guess_mime(url: str) -> str | None:
return mimetypes.guess_type(url, strict=False)[0] or {
".cast": "application/x-asciicast",
".rst": "text/x-rst", # https://github.com/python/cpython/issues/101137
}.get(Path(url).suffix)


def _mode_for_mime(mime: str) -> str:
if mime in {REDIRECT_MIME, "redirect"}:
return "redirect"
if mime.startswith("text/"):
if mime == "text/markdown":
return "md"
if mime in {"text/x-rst", "text/prs.fallenstein.rst"}:
return "rst"
return "text"
if mime in {"application/asciicast+json", "application/x-asciicast"}:
return "cast"
return "raw"


def _render_asciicast(*, hashid: str, extension: str = "", **_: Any) -> str:
if not extension:
extension = "cast"
# Prepare query params such that
# {{params|tojson}} produces a valid JS object:
params = {}
for key, value in request.args.items():
try:
params[key] = json.loads(value)
except json.JSONDecodeError:
params[key] = str(value)
return render_template(
"asciinema.html", url=f"/{hashid}.{extension}", params=params
)


def _render_markdown(*, hashid: str, extension: str = "", **_: Any) -> str:
if not extension:
extension = "md"
return render_template("markdown.html", url=f"/{hashid}.{extension}")


def _render_raw(
*,
hashid: str,
extension: str = "",
paste: dict[str, Any] | None = None,
**_: Any,
) -> Response:
mime = ""
if extension:
mime = _guess_mime(f"/{hashid}.{extension}") or abort(
400,
"There is no media type associated with"
f" the provided extension (.{extension}).",
)
if not paste:
paste = _get_paste(hashid)
return Response(io.BytesIO(paste["data"]), mimetype=mime or paste["mime"])


def _render_redirect(
*,
hashid: str,
extension: str = "",
paste: dict[str, Any] | None = None,
**_: Any,
) -> flask.typing.ResponseReturnValue:
if extension:
abort(400, "Extensions are not supported for redirects.")
if not paste:
paste = _get_paste(hashid)
return redirect(_decoded_data(paste["data"]), 302)


def _render_restructuredtext(
*,
hashid: str,
extension: str = "",
paste: dict[str, Any] | None = None,
**_: Any,
) -> Response:
if extension:
abort(400, "Extensions are not supported for reStructedText rendering.")
if not paste:
paste = _get_paste(hashid)
return Response(
publish_parts(_decoded_data(paste["data"]), writer_name="html")["html_body"]
)


def _render_text(
*,
hashid: str,
extension: str = "",
paste: dict[str, Any] | None = None,
**_: Any,
) -> str:
if not paste:
paste = _get_paste(hashid)
if extension:
mime = _guess_mime(f"/{hashid}.{extension}") or extension
else:
mime = paste["mime"]
extension = (mimetypes.guess_extension(mime, strict=False) or "")[1:] or mime
return render_template(
"paste.html", paste=_decoded_data(paste["data"]), mime=mime, extension=extension
)


def _renderer_for_mode(
mode: str,
) -> Callable[..., flask.typing.ResponseReturnValue]:
try:
# https://github.com/python/mypy/issues/12053
return { # type: ignore
"cast": _render_asciicast,
"md": _render_markdown,
"raw": _render_raw,
"redirect": _render_redirect,
"rst": _render_restructuredtext,
"text": _render_text,
"txt": _render_text, # legacy
}[mode]
except KeyError as exc:
abort(400, f"{exc} is not a recognized rendering mode.")


@blueprint.get("/")
def index() -> str:
return render_template("index.html")
Expand Down Expand Up @@ -79,117 +220,63 @@ def create_paste() -> tuple[dict[str, str], int]:


@blueprint.get("/about")
def about() -> str:
@blueprint.get("/about.md")
def about() -> flask.typing.ResponseReturnValue:
if str(request.url_rule) == "/about.md":
# /about used to be /about.md:
return redirect("/about", 301)
return render_template(
"markdown.html", url=f"{current_app.static_url_path}/about.md"
)


def _rendered(paste: dict[str, Any], mime: str) -> Response | str:
if mime.startswith("text/"):
try:
text = paste["data"].decode("utf-8")
except UnicodeDecodeError as exc:
abort(422, f"The paste cannot be decoded as text ({exc}).")
if mime == "text/markdown":
return render_template("markdown.html", url=f"/{paste['hashid']}.md")
if mime in {"text/x-rst", "text/prs.fallenstein.rst"}:
# https://github.com/python/cpython/issues/101137
return Response(publish_parts(text, writer_name="html")["html_body"])
return render_template("paste.html", paste=text, mime=mime)
if mime in {"application/asciicast+json", "application/x-asciicast"}:
# Prepare query params such that
# {{params|tojson}} produces a valid JS object:
params = {}
for key, value in request.args.items():
try:
params[key] = json.loads(value)
except json.JSONDecodeError:
params[key] = str(value)
return render_template(
"asciinema.html",
pasteid=paste["hashid"],
params=params,
)
return Response(io.BytesIO(paste["data"]), mimetype=mime)
@blueprint.get("/<string:hashid>/")
@blueprint.get("/<string:hashid>.<string:extension>/")
def redirect_to_mode(
hashid: str, extension: str = ""
) -> flask.typing.ResponseReturnValue:
if extension:
return redirect(f"/{hashid}.{extension}/raw", 301)
paste = _get_paste(hashid)
mode = _mode_for_mime(paste["mime"])
return redirect(f"/{hashid}/{mode}", 301)


@blueprint.get("/<string:hashid>")
def view_paste(hashid: str) -> flask.typing.ResponseReturnValue | str:
"""Render according to the MIME type."""
with db.paster_context() as paster:
paste = paster.query(hashid=hashid) or abort(404)
if paste["mime"] in {REDIRECT_MIME, "redirect"}:
return redirect(paste["data"].decode("utf-8"), 302)
return _rendered(paste, paste["mime"])


def _guess_type(url: str) -> str:
suffix = Path(url).suffix
return (
mimetypes.guess_type(url, strict=False)[0]
or {
".cast": "application/x-asciicast",
".rst": "text/x-rst",
}.get(suffix)
or abort(
422,
"There is no media type associated with"
f" the provided extension ({suffix[1:]}).",
)
@blueprint.get("/<string:hashid>.")
@blueprint.get("/<string:hashid>./<string:mode>")
def redirect_to_raw(hashid: str, mode: str = "") -> flask.typing.ResponseReturnValue:
paste = _get_paste(hashid)
extension = mimetypes.guess_extension(paste["mime"], strict=False) or abort(
422,
"There is no extension associated with"
f" the paste's media type ({paste['mime']}).",
)
location = f"/{hashid}.{extension}"
if mode:
location += f"/{mode}"
return redirect(location, 301)


@blueprint.get("/<string:hashid>.")
@blueprint.get("/<string:hashid>")
@blueprint.get("/<string:hashid>/<string:mode>")
@blueprint.get("/<string:hashid>.<string:extension>")
def view_paste_with_extension(
hashid: str, extension: str = ""
@blueprint.get("/<string:hashid>.<string:extension>/<string:mode>")
def view_paste(
hashid: str, extension: str = "", mode: str = ""
) -> flask.typing.ResponseReturnValue:
"""Let the browser handle rendering."""
if hashid == "about" and extension == "md":
# /about used to be /about.md:
return redirect("/about", 301)
if extension == "asciinema":
# .asciinema is a legacy pbnh thing...
# asciinema used to use .json (application/asciicast+json),
# and now it uses .cast (application/x-asciicast).
return redirect(f"/{hashid}/cast", 301)
with db.paster_context() as paster:
paste = paster.query(hashid=hashid) or abort(404)
if not extension:
# The user didn't provide an extension. Try to guess it...
extension = (mimetypes.guess_extension(paste["mime"], strict=False) or "")[1:]
if extension:
return redirect(f"/{hashid}.{extension}", 301)
# No dice, send them to the base paste page
# (which will probably just return the raw bytes).
return redirect(f"/{hashid}", 302)
return Response(
io.BytesIO(paste["data"]),
# Response will default to text/html
# (which is not what the user asked for),
# so fail if the type cannot be guessed:
mimetype=_guess_type(request.url),
)


@blueprint.get("/<string:hashid>/")
@blueprint.get("/<string:hashid>/<string:extension>")
def view_paste_with_highlighting(
hashid: str, extension: str = ""
) -> Response | flask.typing.ResponseReturnValue | str:
"""Render as a requested type."""
with db.paster_context() as paster:
paste = paster.query(hashid=hashid) or abort(404)
if not extension:
# The user didn't provide an extension. Try to guess it...
extension = (mimetypes.guess_extension(paste["mime"], strict=False) or "")[1:]
if extension:
return redirect(f"/{hashid}/{extension}", 301)
# No dice, send them to the base paste page
# (which will probably just return the raw bytes).
return redirect(f"/{hashid}", 302)
return _rendered(paste, _guess_type(f"{hashid}.{extension}"))
if mode:
renderer = _renderer_for_mode(mode)
return renderer(hashid=hashid, extension=extension)
if extension:
if extension == "asciinema":
# .asciinema is a legacy pbnh thing...
# asciinema used to use .json (application/asciicast+json),
# and now it uses .cast (application/x-asciicast).
return redirect(request.url.replace(".asciinema", "/cast"), 301)
return _render_raw(hashid=hashid, extension=extension)
paste = _get_paste(hashid)
renderer = _renderer_for_mode(_mode_for_mime(paste["mime"]))
return renderer(hashid=hashid, extension=extension, paste=paste)


@blueprint.errorhandler(404)
Expand Down
Loading

0 comments on commit 836f40e

Please sign in to comment.