Add table of contents to help pages

This commit is contained in:
rubenwardy 2021-02-02 20:05:24 +00:00
parent 5017a9ba7e
commit 14810b2cc5
13 changed files with 143 additions and 52 deletions

View File

@ -21,7 +21,7 @@ import flask_menu as menu
from flask_mail import Mail from flask_mail import Mail
from flask_github import GitHub from flask_github import GitHub
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from flask_flatpages import FlatPages, pygments_style_defs from flask_flatpages import FlatPages
from flask_babel import Babel from flask_babel import Babel
from flask_login import logout_user, current_user, LoginManager from flask_login import logout_user, current_user, LoginManager
import os, redis import os, redis
@ -29,7 +29,7 @@ import os, redis
app = Flask(__name__, static_folder="public/static") app = Flask(__name__, static_folder="public/static")
app.config["FLATPAGES_ROOT"] = "flatpages" app.config["FLATPAGES_ROOT"] = "flatpages"
app.config["FLATPAGES_EXTENSION"] = ".md" app.config["FLATPAGES_EXTENSION"] = ".md"
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = ["fenced_code", "tables", "codehilite"] app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = ["fenced_code", "tables", "codehilite", 'toc']
app.config["FLATPAGES_EXTENSION_CONFIG"] = { app.config["FLATPAGES_EXTENSION_CONFIG"] = {
"fenced_code": {}, "fenced_code": {},
"tables": {}, "tables": {},
@ -69,7 +69,7 @@ if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
app.logger.addHandler(build_handler(app)) app.logger.addHandler(build_handler(app))
from .markdown import init_app from app.utils.markdown import init_app
init_app(app) init_app(app)
# @babel.localeselector # @babel.localeselector

View File

@ -21,7 +21,7 @@ from flask_wtf import FlaskForm
from wtforms import * from wtforms import *
from wtforms.validators import * from wtforms.validators import *
from app.markdown import render_markdown from app.utils.markdown import render_markdown
from app.models import * from app.models import *
from app.tasks.emails import send_user_email from app.tasks.emails import send_user_email
from app.utils import rank_required, addAuditLog from app.utils import rank_required, addAuditLog

View File

@ -19,8 +19,8 @@ from flask_login import current_user, login_required
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from app import csrf from app import csrf
from app.markdown import render_markdown from app.utils.markdown import render_markdown
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Tags, Permission, ForumTopic, MinetestRelease, APIToken, PackageScreenshot from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, MinetestRelease, APIToken, PackageScreenshot
from app.querybuilder import QueryBuilder from app.querybuilder import QueryBuilder
from app.utils import is_package_page from app.utils import is_package_page
from . import bp from . import bp

View File

@ -19,7 +19,7 @@ from flask import redirect, render_template, session, request, flash, url_for
from app.models import db, User, UserRank from app.models import db, User, UserRank
from app.utils import randomString, login_user_set_active from app.utils import randomString, login_user_set_active
from app.tasks.forumtasks import checkForumAccount from app.tasks.forumtasks import checkForumAccount
from app.tasks.phpbbparser import getProfile from app.utils.phpbbparser import getProfile
import re import re

View File

@ -1,4 +1,5 @@
title: Help title: Help
toc: False
## General Help ## General Help

View File

@ -11,16 +11,13 @@ curl -H "Authorization: Bearer YOURTOKEN" https://content.minetest.net/api/whoam
Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
## Endpoints
### Misc
* GET `/api/whoami/` - JSON dictionary with the following keys: * GET `/api/whoami/` - JSON dictionary with the following keys:
* `is_authenticated` - True on successful API authentication * `is_authenticated` - True on successful API authentication
* `username` - Username of the user authenticated as, null otherwise. * `username` - Username of the user authenticated as, null otherwise.
* 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors. * 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors.
### Packages
## Packages
* GET `/api/packages/` - See [Package Queries](#package-queries) * GET `/api/packages/` - See [Package Queries](#package-queries)
* GET `/api/scores/` - See [Package Queries](#package-queries) * GET `/api/scores/` - See [Package Queries](#package-queries)
@ -42,7 +39,31 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `high_reviewed` - highest reviewed * `high_reviewed` - highest reviewed
* `tags` * `tags`
### Releases ### Package Queries
Example:
/api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
Supported query parameters:
* `type` - Package types (`mod`, `game`, `txp`).
* `q` - Query string.
* `author` - Filter by author.
* `tag` - Filter by tags.
* `random` - When present, enable random ordering and ignore `sort`.
* `limit` - Return at most `limit` packages.
* `hide` - Hide content based on [Content Flags](/help/content_flags/).
* `sort` - Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`).
* `order` - Sort ascending (`asc`) or descending (`desc`).
* `protocol_version` - Only show packages supported by this Minetest protocol version.
* `engine_version` - Only show packages supported by this Minetest engine version, eg: `5.3.0`.
* `fmt` - How the response is formated.
* `keys` - author/name only.
* `short` - stuff needed for the Minetest client.
## Releases
* GET `/api/packages/<username>/<name>/releases/` (List) * GET `/api/packages/<username>/<name>/releases/` (List)
* Returns array of release dictionaries with keys: * Returns array of release dictionaries with keys:
@ -87,7 +108,8 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
-H "Authorization: Bearer YOURTOKEN" -H "Authorization: Bearer YOURTOKEN"
``` ```
### Screenshots
## Screenshots
* GET `/api/packages/<username>/<name>/screenshots/` (List) * GET `/api/packages/<username>/<name>/screenshots/` (List)
* Returns array of screenshot dictionaries with keys: * Returns array of screenshot dictionaries with keys:
@ -129,42 +151,14 @@ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots
-d "[13, 2, 5, 7]" -d "[13, 2, 5, 7]"
``` ```
### Topics
## Topics
* GET `/api/topics/` - Supports [Package Queries](#package-queries), and the following two options: * GET `/api/topics/` - Supports [Package Queries](#package-queries), and the following two options:
* `show_added` - Show topics which exist as packages, default true. * `show_added` - Show topics which exist as packages, default true.
* `show_discarded` - Show topics which have been marked as outdated, default false. * `show_discarded` - Show topics which have been marked as outdated, default false.
### Minetest ### Topic Queries
* GET `/api/minetest_versions/`
## Package Queries
Example:
/api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
Supported query parameters:
* `type` - Package types (`mod`, `game`, `txp`).
* `q` - Query string.
* `author` - Filter by author.
* `tag` - Filter by tags.
* `random` - When present, enable random ordering and ignore `sort`.
* `limit` - Return at most `limit` packages.
* `hide` - Hide content based on [Content Flags](/help/content_flags/).
* `sort` - Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`).
* `order` - Sort ascending (`asc`) or descending (`desc`).
* `protocol_version` - Only show packages supported by this Minetest protocol version.
* `engine_version` - Only show packages supported by this Minetest engine version, eg: `5.3.0`.
* `fmt` - How the response is formated.
* `keys` - author/name only.
* `short` - stuff needed for the Minetest client.
## Topic Queries
Example: Example:
@ -178,3 +172,8 @@ Supported query parameters:
* `show_added` - Show topics that have an existing package. * `show_added` - Show topics that have an existing package.
* `show_discarded` - Show topics marked as discarded. * `show_discarded` - Show topics marked as discarded.
* `limit` - Return at most `limit` topics. * `limit` - Return at most `limit` topics.
## Minetest
* GET `/api/minetest_versions/`

View File

@ -148,3 +148,24 @@ blockquote {
border-bottom: none; border-bottom: none;
} }
} }
.toc {
.nav-link {
color: #ADADAD;
padding: 0.25rem 0.5rem;
&:hover, &.active {
color: #DDD;
}
}
.nav .nav {
margin: 0.1em 0 0.1em 0.7rem;
padding-left: 0.25em;
border-left: 3px solid rgba(173, 173, 173, 0.25);
}
& > .nav > * > .nav {
margin-bottom: 0.5em;
}
}

View File

@ -18,7 +18,7 @@
import json, re, sys import json, re, sys
from app.models import * from app.models import *
from app.tasks import celery from app.tasks import celery
from .phpbbparser import getProfile, getTopicsFromForum from app.utils.phpbbparser import getProfile, getTopicsFromForum
import urllib.request import urllib.request
@celery.task() @celery.task()

View File

@ -6,6 +6,9 @@ from flask_babel import format_timedelta, gettext
from urllib.parse import urlparse from urllib.parse import urlparse
from datetime import datetime as dt from datetime import datetime as dt
from .utils.markdown import get_headings
@app.context_processor @app.context_processor
def inject_debug(): def inject_debug():
return dict(debug=app.debug) return dict(debug=app.debug)
@ -13,7 +16,9 @@ def inject_debug():
@app.context_processor @app.context_processor
def inject_functions(): def inject_functions():
check_global_perm = Permission.checkPerm check_global_perm = Permission.checkPerm
return dict(abs_url_for=abs_url_for, url_set_query=url_set_query, check_global_perm=check_global_perm) return dict(abs_url_for=abs_url_for, url_set_query=url_set_query,
check_global_perm=check_global_perm,
get_headings=get_headings)
@app.context_processor @app.context_processor
def inject_todo(): def inject_todo():

View File

@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title> <title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title>
<link rel="stylesheet" type="text/css" href="/static/bootstrap.css"> <link rel="stylesheet" type="text/css" href="/static/bootstrap.css">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=20"> <link rel="stylesheet" type="text/css" href="/static/custom.css?v=21">
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" /> <link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
<link rel="shortcut icon" href="/favicon-16.png" sizes="16x16"> <link rel="shortcut icon" href="/favicon-16.png" sizes="16x16">
<link rel="icon" href="/favicon-128.png" sizes="128x128"> <link rel="icon" href="/favicon-128.png" sizes="128x128">

View File

@ -5,9 +5,44 @@
{% endblock %} {% endblock %}
{% block container %} {% block container %}
<main class="container mt-4 content">
{% if not page["no_h1"] %}<h1>{{ page['title'] }}</h1>{% endif %}
{{ page.html | safe }} {% set html = page.html %}
</main> {% if page.meta.get("toc", True) %}
<div class="container mt-4">
<main class="row">
<article class="col-md-9 content">
{% if not page["no_h1"] %}<h1>{{ page['title'] }}</h1>{% endif %}
{{ html | safe }}
</article>
<nav class="col-md-3 toc">
{% set headings = get_headings(html) %}
<ul class="nav flex-column" role="menu">
{% for item in headings recursive %}
<li class="nav-item">
<a class="nav-link" href="#{{ item.link }}">
{{ item.text }}
</a>
{% if item.children %}
<ul class="nav flex-column" role="menu">
{{ loop(item.children) }}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
</main>
</div>
{% else %}
<div class="container mt-4">
<article class="content">
{% if not page["no_h1"] %}<h1>{{ page['title'] }}</h1>{% endif %}
{{ html | safe }}
</article>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -3,6 +3,7 @@ from functools import partial
import bleach import bleach
from bleach import Cleaner from bleach import Cleaner
from bleach.linkifier import LinkifyFilter from bleach.linkifier import LinkifyFilter
from bs4 import BeautifulSoup
from markdown import Markdown from markdown import Markdown
from flask import Markup from flask import Markup
@ -40,6 +41,10 @@ def allow_class(_tag, name, value):
return name == "class" and value in ALLOWED_CSS return name == "class" and value in ALLOWED_CSS
ALLOWED_ATTRIBUTES = { ALLOWED_ATTRIBUTES = {
"h1": ["id"],
"h2": ["id"],
"h3": ["id"],
"h4": ["id"],
"a": ["href", "title"], "a": ["href", "title"],
"img": ["src", "title", "alt"], "img": ["src", "title", "alt"],
"code": allow_class, "code": allow_class,
@ -51,6 +56,7 @@ ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
md = None md = None
def render_markdown(source): def render_markdown(source):
html = md.convert(source) html = md.convert(source)
@ -61,6 +67,7 @@ def render_markdown(source):
filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)]) filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)])
return cleaner.clean(html) return cleaner.clean(html)
def init_app(app): def init_app(app):
global md global md
@ -69,3 +76,26 @@ def init_app(app):
@app.template_filter() @app.template_filter()
def markdown(source): def markdown(source):
return Markup(render_markdown(source)) return Markup(render_markdown(source))
def get_headings(html: str):
soup = BeautifulSoup(html, "html.parser")
headings = soup.find_all(["h1", "h2", "h3"])
root = []
stack = []
for heading in headings:
this = { "link": heading.get("id") or "", "text": heading.text, "children": [] }
this_level = int(heading.name[1:]) - 1
while this_level <= len(stack):
stack.pop()
if len(stack) > 0:
stack[-1]["children"].append(this)
else:
root.append(this)
stack.append(this)
return root