Bootstrap
Committed 9f85bd
index 0000000..e38eefc
--- /dev/null
+name: Run Tests and Analysis
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ build-linux:
+ strategy:
+ matrix:
+ python-version: ["3.10"]
+ runs-on: "ubuntu-latest"
+ steps:
+ - name: Install graphviz
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y graphviz
+
+ - uses: actions/checkout@v3
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Remove development dependencies
+ run: sed -i '/\[tool.poetry.group.dev.dependencies\]/,/\[/d' pyproject.toml
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: 1.2.2
+ virtualenvs-in-project: true
+
+ - name: Install dependencies
+ run: poetry install --no-interaction --no-root
+
+ - name: Install library
+ run: poetry install --no-interaction
+
+ - name: Install development tools
+ run: poetry add gmpg
+
+ - uses: psf/black@stable
+ with:
+ options: "--check --verbose"
+ src: "."
+ version: "23.7"
+
+ - uses: isort/isort-action@v1
+ with:
+ configuration: "--profile black"
+
+ # - name: Run tests
+ # run: poetry run gmpg test
+
+ - name: Run analysis
+ run: poetry run gmpg analyze
+
+ - name: Generate dependency graph
+ run: poetry run gmpg graph
+
+ - uses: actions/upload-artifact@v3
+ with:
+ name: analysis
+ path: |
+ test_coverage.xml
+ test_results.xml
+ api_python.json
+ deps.svg
index 0000000..7367b6a
--- /dev/null
+[tool.poetry]
+name = "webint-code"
+version = "0.0.23"
+description = "manage code on your website"
+keywords = ["webint", "Git", "PyPI"]
+homepage = "https://ragt.ag/code/projects/webint-code"
+repository = "https://ragt.ag/code/projects/webint-code.git"
+documentation = "https://ragt.ag/code/projects/webint-code/api"
+authors = ["Angelo Gladding <angelo@ragt.ag>"]
+license = "AGPL-3.0-or-later"
+packages = [{include="webint_code"}]
+
+[tool.poetry.plugins."webapps"]
+code = "webint_code:app"
+
+[tool.poetry.dependencies]
+python = ">=3.10,<3.11"
+pygments = "^2.14.0"
+webint = ">=0.0"
+webagt = ">=0.0"
+gmpg = ">=0.0"
+bgq = ">=0.0"
+
+[tool.poetry.group.dev.dependencies]
+gmpg = {path="../gmpg", develop=true}
+bgq = {path="../bgq", develop=true}
+webint = {path="../webint", develop=true}
+webagt = {path="../webagt", develop=true}
+newmath = {path="../newmath", develop=true}
+sqlyte = {path="../sqlyte", develop=true}
+microformats = {path="../python-microformats", develop=true}
+
+# [[tool.poetry.source]]
+# name = "main"
+# url = "https://ragt.ag/code/pypi"
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
index 0000000..4d51eb7
--- /dev/null
+"""
+Manage code on your website.
+
+- Implements [PEP 503 -- Simple Repository API][0] managing Python packages.
+
+[0]: https://www.python.org/dev/peps/pep-0503/
+
+"""
+
+# TODO PEP 592 -- Adding "Yank" Support to the Simple API
+# TODO PEP 658 -- Serve Distribution Metadata in the Simple Repository API
+
+import os
+import pathlib
+import re
+import shutil
+import subprocess
+import time
+
+import gmpg
+import pkg_resources
+import semver
+import web
+import webagt
+
+app = web.application(
+ __name__,
+ prefix="code",
+ args={
+ "project": r"[A-Za-z0-9\.-][A-Za-z0-9\._-]+",
+ "commit_id": r"[a-f0-9]{3,40}",
+ "release": r"((\d+\.)?\d+\.)?\d+",
+ "filename": r"[\w./\-]+",
+ "package": r"[\w.-]+",
+ "namespace": r"[\w._/]+",
+ },
+ model={
+ "projects": {
+ "name": "TEXT UNIQUE",
+ "pypi": "TEXT UNIQUE",
+ "visibility": "TEXT",
+ },
+ "packages": {
+ "project_id": "INTEGER",
+ "filename": "TEXT",
+ "author": "TEXT",
+ "author_email": "TEXT",
+ "classifiers": "JSON",
+ "home_page": "TEXT",
+ "keywords": "JSON",
+ "license": "TEXT",
+ "project_urls": "JSON",
+ "requires_dist": "JSON",
+ "requires_python": "TEXT",
+ "sha256_digest": "TEXT",
+ "summary": "TEXT",
+ "version": "TEXT",
+ },
+ },
+)
+
+code_dir = pathlib.Path("code")
+meta_dir = code_dir / "meta"
+# XXX working_dir = code_dir / "working"
+
+
+# XXX def update_system():
+# XXX print(
+# XXX subprocess.run(
+# XXX ["poetry", "update"],
+# XXX cwd=working_dir / "ragt.ag",
+# XXX capture_output=True,
+# XXX )
+# XXX )
+
+
+def run_ci(project):
+ """
+ Run continuous integration pipeline.
+
+ Execute tests.
+
+ """
+ project_dir = meta_dir / project
+ testing_dir = project_dir / "testing"
+ shutil.rmtree(testing_dir, ignore_errors=True)
+ gmpg.clone_repo(project_dir / "source.git", testing_dir)
+ admin_home = "/home/admin"
+ env = os.environ.copy()
+ env["HOME"] = admin_home
+ print(
+ subprocess.run(
+ [f"{admin_home}/bin/act", "--artifact-server-path", "artifacts"],
+ cwd=testing_dir,
+ env=env,
+ )
+ )
+ for artifact in (testing_dir / "artifacts/1/analysis").iterdir():
+ shortened = artifact.name[:-2]
+ artifact.rename(testing_dir / shortened)
+ subprocess.run(["gunzip", shortened], cwd=testing_dir)
+
+
+@app.query
+def search(db, query):
+ """Search for `query` in commited code."""
+ files = {}
+ context = "2"
+ for file in (
+ subprocess.run(
+ [
+ "ag",
+ "--ackmate",
+ "-B",
+ context,
+ "-A",
+ context,
+ "-G",
+ ".*/working",
+ query,
+ ],
+ cwd=meta_dir,
+ capture_output=True,
+ )
+ .stdout.decode()
+ .split("\n\n")
+ ):
+ filename, _, blocks_text = file.partition("\n")
+ blocks = {}
+ for block_text in blocks_text.split("\n--\n"):
+ starting_line = block_text.partition(":")[0].partition(";")[0]
+ block = "\n".join(
+ [line.partition(":")[2] for line in block_text.splitlines()]
+ )
+ blocks[starting_line] = block
+ files[filename.lstrip(":").partition("/working/")[::2]] = blocks
+ return files
+
+
+# XXX @app.wrap
+# XXX def set_working_dir(handler, main_app):
+# XXX web.tx.host.working_dir = working_dir
+# XXX yield
+
+
+@app.query
+def create_project(db, name):
+ """Create a project."""
+ db.insert("projects", name=name, pypi=name, visibility="public")
+ project_dir = meta_dir / name
+ bare_repo = project_dir / "source.git"
+ working_repo = project_dir / "working"
+ repo = gmpg.get_repo(bare_repo, init=True, bare=True)
+ repo.update_server_info()
+ repo.config("http.receivepack", "true")
+ post_receive_hook = bare_repo / "hooks/post-receive"
+ with post_receive_hook.open("w") as fp:
+ fp.write(
+ "\n".join(
+ (
+ "#!/bin/sh",
+ "git -C $PWD/../working --git-dir=.git pull origin main --rebase",
+ f"wget --method=post -qO- {web.tx.origin}/code/projects/{name}",
+ )
+ )
+ )
+ gmpg.clone_repo(bare_repo, working_repo)
+ subprocess.run(["chmod", "775", post_receive_hook])
+ subprocess.run(["chgrp", "www-data", bare_repo, working_repo, "-R"])
+ subprocess.run(["chmod", "g+w", bare_repo, working_repo, "-R"])
+ if not (meta_dir / "gitpasswd").exists():
+ token = web.application("webint_auth").model.generate_local_token(
+ "/code", "webint_code", "git_owner"
+ )
+ subprocess.run(["htpasswd", "-cb", meta_dir / "gitpasswd", "owner", token])
+ # XXX subprocess.run(["sudo", "service", "nginx", "restart"])
+
+ # XXX gmpg.clone_repo(
+ # XXX f"{web.tx.origin}/code/projects/{name}.git", f"{working_dir}/{name}"
+ # XXX )
+
+ web.application("webint_posts").model.create(
+ "entry",
+ url=f"/code/projects/{name}",
+ content=(
+ f"Created repository <a href=/code/projects/{name}><code>{name}</code></a>"
+ ),
+ )
+
+
+@app.query
+def get_projects(db):
+ """Return a list of project names."""
+ visibility_wheres = ["public"]
+ if web.tx.user.is_owner:
+ visibility_wheres.extend(["protected", "private"])
+ return [
+ r["name"]
+ for r in db.select(
+ "projects",
+ what="name",
+ order="name",
+ where=" OR ".join(len(visibility_wheres) * ["visibility = ?"]),
+ vals=visibility_wheres,
+ )
+ ]
+
+
+@app.query
+def get_pypi_projects(db):
+ """Return a list of PyPI project names."""
+ return [r["pypi"] for r in db.select("projects", what="pypi", order="name")]
+
+
+@app.query
+def get_project_from_name(db, name):
+ """Return the project associated with project name."""
+ try:
+ return db.select("projects", where="name = ?", vals=[name])[0]
+ except IndexError:
+ return None
+
+
+@app.query
+def get_project_from_pypi_name(db, pypi_name):
+ """Return the project name associated with pypi package name."""
+ try:
+ return db.select("projects", where="pypi = ?", vals=[pypi_name])[0]
+ except IndexError:
+ return None
+
+
+@app.query
+def create_package(db, form):
+ """Create a project."""
+ project_id = db.select(
+ "projects", what="rowid, name", where="pypi = ?", vals=[form.name]
+ )[0]["rowid"]
+ return db.insert(
+ "packages",
+ project_id=project_id,
+ filename=form.content.fileobj.filename,
+ author=form.author,
+ author_email=form.author_email,
+ # classifiers=form.classifiers,
+ home_page=form.home_page,
+ # keywords=form.keywords.split(","),
+ license=form.license,
+ # project_urls=form.project_urls if "project_urls" in form else [],
+ # requires_dist=form.requires_dist,
+ requires_python=form.requires_python,
+ sha256_digest=form.sha256_digest,
+ summary=form.summary,
+ version=form.version,
+ )
+
+
+@app.query
+def get_packages(db, project):
+ """Return a list of packages for given project."""
+ return db.select(
+ "packages",
+ join="""projects ON packages.project_id = projects.rowid""",
+ where="projects.pypi = ?",
+ vals=[project],
+ )
+
+
+@app.query
+def get_package_versions(db, project):
+ """Return a list of packages for given project."""
+ return [
+ r["version"]
+ for r in db.select(
+ "packages",
+ what="DISTINCT version",
+ join="""projects ON packages.project_id = projects.rowid""",
+ where="projects.name = ?",
+ vals=[project],
+ order="version",
+ )
+ ]
+
+
+@app.control("")
+class Code:
+ """Code index."""
+
+ def get(self):
+ """Return a list of projects."""
+ return app.view.index(
+ None, # get_versions("webint"),
+ web.get_apps(),
+ app.model.get_projects(),
+ )
+
+
+@app.control("projects")
+class Projects:
+ """List of projects."""
+
+ owner_only = ["post"]
+
+ def get(self):
+ """Return a list of projects."""
+ return app.view.projects(app.model.get_projects())
+
+ def post(self):
+ """Create a project."""
+ project = web.form("project").project
+ app.model.create_project(project)
+ return web.Created(app.view.project.created(project), f"/{project}")
+
+
+@app.control("projects/{project}")
+class Project:
+ """Project index."""
+
+ def get(self, project):
+ """Return details about the project."""
+ mentions = web.application(
+ "webint_mentions"
+ ).model.get_received_mentions_by_target(
+ f"{web.tx.origin}/{web.tx.request.uri.path}"
+ )
+ project_dir = meta_dir / project
+ try:
+ with (project_dir / "working" / "README.md").open() as fp:
+ readme = fp.read()
+ except FileNotFoundError:
+ readme = None
+ try:
+ pyproject = gmpg.get_current_project(project_dir / "testing")
+ except FileNotFoundError:
+ pyproject = None
+ testing_dir = project_dir / "testing"
+ try:
+ api_python = web.load(path=testing_dir / "api_python.json")
+ except FileNotFoundError:
+ api_python = {}
+ try:
+ test_results = gmpg.analysis._parse_junit(testing_dir / "test_results.xml")
+ except FileNotFoundError:
+ test_results = {}
+ try:
+ test_coverage = gmpg.analysis._parse_coverage(
+ testing_dir / "test_coverage.xml"
+ )
+ except FileNotFoundError:
+ test_coverage = {}
+ return app.view.project.index(
+ project,
+ gmpg.get_repo(project_dir / "working"),
+ readme,
+ app.model.get_package_versions(project),
+ pyproject,
+ api_python,
+ test_results,
+ test_coverage,
+ mentions,
+ )
+
+ def post(self, project):
+ web.enqueue(run_ci, project)
+ return "CI enqueued"
+
+ def delete(self, project):
+ """Delete the project."""
+ return "deleted"
+
+
+@app.control("projects/{project}.git")
+class ProjectGitRedirect:
+ """Project .git redirect."""
+
+ def get(self, project):
+ """Redirect to main project index."""
+ raise web.SeeOther(project)
+
+
+@app.control("projects/{project}/api/{namespace}.svg")
+class ProjectAPIDeps:
+ """Project's API in JSON."""
+
+ def get(self, project, namespace):
+ """Return the API's JSON."""
+ return meta_dir / project / "testing" / f"deps.svg"
+
+
+@app.control("projects/{project}/api/{namespace}")
+class ProjectAPINamespace:
+ """Project's API namespace."""
+
+ def get(self, project, namespace):
+ """Return the API's namespace."""
+ details = web.load(path=meta_dir / project / "testing" / "api_python.json")
+ return app.view.project.namespace(project, namespace, details)
+
+
+@app.control("projects/{project}/api.json")
+class ProjectAPIJSON:
+ """Project's API in JSON."""
+
+ def get(self, project):
+ """Return the API's JSON."""
+ return meta_dir / project / "testing" / "api_python.json"
+
+
+@app.control("projects/{project}/settings")
+class ProjectSettings:
+ """Project settings."""
+
+ def get(self, project):
+ """Return settings for the project."""
+ return app.view.project.settings(project)
+
+ def post(self, project):
+ form = web.form("visibility")
+ return form.visibility
+
+
+@app.control("projects/{project}/files(/{filename})?")
+class ProjectRepoFile:
+ """A file in a project's repository."""
+
+ def get(self, project, filename=""):
+ """Return a view of the repository's file."""
+ project_dir = meta_dir / project
+ filepath = project_dir / "working" / filename
+ try:
+ with filepath.open() as fp:
+ content = fp.read()
+ except IsADirectoryError:
+ content = filepath.iterdir()
+ except UnicodeDecodeError:
+ content = None
+ testing_dir = project_dir / "testing"
+ try:
+ test_coverage = gmpg.analysis._parse_coverage(
+ testing_dir / "test_coverage.xml"
+ )[filename][1]
+ except (FileNotFoundError, KeyError):
+ test_coverage = None
+ return app.view.project.repository_file(
+ project, filename, content, test_coverage
+ )
+
+
+@app.control("projects/{project}/raw(/{filename})?")
+class ProjectRepoRawFile:
+ """A file in a project's repository."""
+
+ def get(self, project, filename=""):
+ """Return a view of the repository's file."""
+ return meta_dir / project / "working" / filename
+
+
+@app.control("projects/{project}/commits")
+class ProjectCommitLog:
+ """A commit log of a project's repository."""
+
+ def get(self, project):
+ """Return a view of the repository's commit."""
+ repo = gmpg.get_repo(meta_dir / project / "working")
+ return app.view.project.commit_log(project, repo)
+
+
+@app.control("projects/{project}/commits/{commit_id}")
+class ProjectCommit:
+ """A commit to a project's repository."""
+
+ def get(self, project, commit_id=None):
+ """Return a view of the repository's commit."""
+ repo = gmpg.get_repo(meta_dir / project / "working")
+ full_commit_id = repo.git("rev-parse", commit_id)[0]
+ if commit_id != full_commit_id:
+ raise web.SeeOther(f"/code/projects/{project}/commits/{full_commit_id}")
+ return app.view.project.commit(project, repo, commit_id)
+
+
+@app.control("projects/{project}/releases")
+class ProjectReleases:
+ """A project's release."""
+
+ def get(self, project):
+ """Return a view of the package file."""
+ return f"releases for {project}"
+ # files = sorted((meta_dir / project / "releases" / release).iterdir())
+ # return app.view.project.release(project, release, files)
+
+
+@app.control("projects/{project}/releases/{release}")
+class ProjectRelease:
+ """A project's release."""
+
+ def get(self, project, release):
+ """Return a view of the package file."""
+ pypi_name = app.model.get_project_from_name(project)["pypi"]
+ files = sorted(
+ (meta_dir / project / "releases" / f"{pypi_name}-{release}").iterdir()
+ )
+ return app.view.project.release(project, release, files)
+
+
+@app.control("projects/{project}/releases/{release}/files(/{filename})?")
+class ProjectReleaseFile:
+ """A file in a project's release."""
+
+ def get(self, project, release, filename=""):
+ """Return a view of the release's file."""
+ pypi_name = app.model.get_project_from_name(project)["pypi"]
+ filepath = meta_dir / project / "releases" / f"{pypi_name}-{release}" / filename
+ try:
+ with filepath.open() as fp:
+ content = fp.read()
+ except IsADirectoryError:
+ content = filepath.iterdir()
+ return app.view.project.release_file(project, release, filename, content)
+
+
+@app.control("projects/{project}/issues")
+class ProjectIssues:
+ """A project's issues."""
+
+ def get(self, project):
+ """Return a view of the package's issues."""
+ mentions = web.application(
+ "webint_mentions"
+ ).model.get_received_mentions_by_target(
+ f"{web.tx.origin}/{web.tx.request.uri.path}"
+ )
+ return [dict(r) for r in mentions]
+ # files = sorted((meta_dir / project / "releases" / release).iterdir())
+ # return app.view.project.release(project, release, files)
+
+
+def split_release(release):
+ """Return a 4-tuple of the parts in given `release` (eg foo-1.2.3 -> foo,1,2,3)."""
+ return re.match(r"([\w.-]+)\-(\d+\.\d+\.\d+.*)", release).groups()
+
+
+@app.control("pypi")
+class PyPIIndex:
+ """PyPI repository in Simple Repository format."""
+
+ # TODO owner_only = ["post"]
+
+ def get(self):
+ """Return a view of the simplified list of projects in repository."""
+ return app.view.pypi.index(app.model.get_pypi_projects())
+
+ def post(self):
+ """Accept PyPI package upload."""
+ form = web.form(":action")
+ if form[":action"] != "file_upload":
+ raise web.BadRequest(f"Provided `:action={form[':action']}` not supported.")
+ try:
+ release_file = form.content.save(file_dir="/tmp")
+ except FileExistsError:
+ return
+ release_name, release_remaining = split_release(release_file.name)
+ project = app.model.get_project_from_pypi_name(
+ release_name.replace("_", "-").replace(".", "-")
+ )
+ releases_dir = meta_dir / project["name"] / "releases"
+ releases_dir.mkdir(exist_ok=True)
+ release_file = release_file.replace(
+ releases_dir / f"{release_name}-{release_remaining}"
+ )
+ if release_file.suffix == ".gz":
+ subprocess.run(
+ [
+ "tar",
+ "xf",
+ release_file.name,
+ ],
+ cwd=releases_dir,
+ )
+ app.model.create_package(form)
+ # web.application("webint_posts").model.create(
+ # "entry",
+ # url=f"/code/{form.name}/releases/{release_file.name}",
+ # content=(
+ # f"Released <a href=/code/{form.name}><code>{form.name}</code></a> "
+ # f"version <code>{release_remaining}</code>"
+ # ),
+ # )
+ raise web.Created(
+ "Package has been uploaded.",
+ "/{form.name}/packages/{form.content.fileobj.filename}",
+ )
+
+
+@app.control("pypi/{project}")
+class PyPIProject:
+ """PyPI project in Simple Repository format."""
+
+ def get(self, project):
+ """Return a view of the simplified list of packages in given `project`."""
+ if packages := app.model.get_packages(project):
+ return app.view.pypi.project(project, packages)
+ raise web.SeeOther(f"https://pypi.org/simple/{project}")
+
+
+@app.control("search")
+class Search:
+ """Search all code."""
+
+ def get(self):
+ """"""
+ try:
+ query = web.form("q").q
+ except web.BadRequest:
+ return app.view.search.index()
+ return app.view.search.results(query, app.model.search(query))
+
+
+# XXX @app.control("system")
+# XXX class System:
+# XXX """System code."""
+# XXX
+# XXX def get(self):
+# XXX """"""
+# XXX return app.view.system(web.tx.app)
+# XXX
+# XXX
+# XXX @app.control("system/update")
+# XXX class SystemUpdate:
+# XXX """Update the system."""
+# XXX
+# XXX def post(self):
+# XXX """"""
+# XXX web.enqueue(update_system)
+# XXX return "update has been started"
index 0000000..ac0e025
--- /dev/null
+import re
+from inspect import getsourcefile
+from itertools import chain
+from pprint import pformat
+from sys import stdlib_module_names
+
+import emoji
+import semver
+from gmpg.git import colorize_diff
+from radon.metrics import mi_rank
+from web import tx
+from web.slrzd import highlight
+
+__all__ = [
+ "chain",
+ "emoji",
+ "pformat",
+ "re",
+ "tx",
+ "semver",
+ "highlight",
+ "stdlib_module_names",
+ "mi_rank",
+ "colorize_diff",
+ "getsourcefile",
+]
index 0000000..755a6e8
--- /dev/null
+$def with (system, apps, projects)
+$var title: Code
+
+<h2>Projects</h2>
+<ul>
+$for project in projects:
+ <li><a href=/projects/$project>$project</a></li>
+</ul>
index 0000000..78e2a56
--- /dev/null
+$def with (project, repo, commit_id)
+$ commit = repo.log[commit_id]
+$ title, _, description = commit["message"].partition("\n\n")
+$ ts = commit["timestamp"]
+$ short_hash = commit_id[:6]
+$ breadcrumbs = ("projects", "Projects", project, (None, ["u-ragt.ag-change-of"],
+$ f"<b>{project}</b>"), "commits", "Commits")
+
+$var breadcrumbs = breadcrumbs
+$var title: $:str(mkdn(title))[3:-4]
+$var title_classes = ["p-name"]
+$var classes = ["h-entry"]
+
+<link rel=stylesheet href=$tx.origin/static/solarized.css media=screen>
+<style>
+div.highlight {
+ font-size: .75em; }
+.linenodiv .normal {
+ display: none; }
+</style>
+
+<p style=font-size:.75em>Committed <time class=dt-published datetime=$ts.isoformat()>$ts.diff_for_humans()</time>
+<a class=p-ragt.ag-change-id href=/projects/$project/commits/$short_hash>$short_hash</a></p>
+$if description:
+ <p class=p-content>$:mkdn(description)</p>
+$for file in colorize_diff("\n".join(repo.show(commit_id))):
+ <h3><span style=color:#dc322f>$file["from"]</span><br>
+ <span style=color:#859900>$file["to"]</span></h3>
+ $for changed_linenos, changed_lines in file["changes"]:
+ $changed_linenos<br>
+ $:highlight(changed_lines, "a.py", diff=changed_linenos)
index 0000000..3d5efb5
--- /dev/null
+$def with (project, repo)
+$var breadcrumbs = ("projects", "Projects", project, (None, ["u-ragt.ag-change-of"], f"<b>{project}</b>"))
+$var title: Commit Log
+$var title_classes = ["p-name"]
+$var classes = ["h-feed"]
+
+$for commit_id, commit in repo.log.items():
+ $ title, _, description = commit["message"].partition("\n\n")
+ <div class=h-entry><a
+ href=/projects/$project/commits/$commit_id>$commit_id[:7]</a>
+ <span class=p-name>$title</span>
+ <time class=dt-published
+ datetime=$commit["timestamp"].isoformat()>$commit["timestamp"].diff_for_humans()</time>
+ $if description:
+ <p class=p-content>$description</p>
+ </div>
index 0000000..65511c3
--- /dev/null
+$def with (project)
+$var title: Project Created
+<p>The <a href=/$project>$project</a> project has been created.</p>
index 0000000..7de1c97
--- /dev/null
+$def with (project, repo, readme, package_releases, pyproject, api_python, test_results, test_coverage, mentions)
+$var breadcrumbs = ("projects", "Projects")
+$ title = project
+$var title_classes = ["p-name"]
+$var classes = ["h-ragt-ag-project"]
+
+<style>
+/* h2 {
+ display: none; } */
+.files, .commits {
+ list-style: none;
+ padding-left: 0; }
+.testindicator {
+ border-radius: 50%;
+ display: inline-block;
+ height: .9em;
+ width: .9em; }
+</style>
+
+$ issues = 0
+$ likes = 0
+$for mention in mentions:
+ $if mention["data"]["comment_type"][0] == "like":
+ $ likes += 1
+ $elif mention["data"]["comment_type"][0] == "like":
+ $ likes += 1
+
+<p>
+$if issues:
+ $emoji.emojize(":note:")
+$if likes:
+ $emoji.emojize(":red_heart:") <sub>$likes</sub>
+</p>
+
+ $# <div>
+ $# $ data = mention["data"]
+ $# $if name := data.get("name", None):
+ $# <h3>$name</h3>
+ $# $if data:
+ $# $ a = data["author"]
+ $# $ comment_type = data["comment_type"][0]
+ $# $if comment_type == "like":
+ $# $emoji.emojize(":red_heart:")
+ $# $if content := data.get("content"):
+ $# <div>$:content</div>
+ $# $ published = data["published"][0]
+ $# <small>Opened <a href=$data["url"]><time class=dt-published
+ $# datetime="$published.isoformat()">$published.diff_for_humans()</time></a>
+ $# $if data:
+ $# by <a href=$a["url"]>$a.get("name", a["url"])</a>,
+ $# $if comment := data.get("comment"):
+ $# $ comment_count = len(comment)
+ $# $if comment_count:
+ $# 💬 $comment_count comment$("s" if comment_count > 1 else "")
+ $# </small>
+ $# </div>
+
+$if pyproject:
+ $ py_project = pyproject["tool"]["poetry"]
+ $ title += f" <code style=font-size:.5em>{py_project.pop('version')}</code>"
+ <p><big>$py_project.pop("description")</big>
+ $if keywords := py_project.pop("keywords", None):
+ <small>
+ $for keyword in keywords:
+ $keyword\
+ $if not loop.last:
+ ,
+ </small>
+ </p>
+ $ license = py_project.pop("license")
+ $ licenses = {"0BSD": "BSD Zero Clause License",
+ $ "BSD-2-Clause": 'BSD 2-Clause "Simplified" License',
+ $ "BSD-3-Clause": 'BSD 3-Clause "Modified" License',
+ $ "AGPL-3.0-or-later": "GNU Affero General Public License v3.0 or later"}
+ <p><small><strong>Licensed:</strong> <a href=https://spdx.org/licenses/$(license).html><code>$licenses[license]</code></a></small></p>
+ $ plugins = py_project.pop("plugins", None)
+ $ scripts = py_project.pop("scripts", None)
+ $if plugins or scripts:
+ <p><small><strong>Provides:</strong></small>
+ $if plugins:
+ $if webapps := plugins.pop("webapps", None):
+ webapps (
+ $for webapp, webapp_callable in webapps.items():
+ $ webapp_path = webapp_callable.replace(".", "/").replace(":", "#")
+ <a href="/projects/$project/api/$webapp_path">$webapp</a>\
+ $if not loop.last:
+ , \
+ )
+ $if websites := plugins.pop("websites", None):
+ websites (
+ $for webapp, webapp_callable in websites.items():
+ $ webapp_path = webapp_callable.replace(".", "/").replace(":", "#")
+ <a href="/projects/$project/api/$webapp_path">$webapp</a>\
+ $if not loop.last:
+ , \
+ )
+ $if scripts:
+ scripts (
+ $for script, script_callable in scripts.items():
+ $ script_path = script_callable.replace(".", "/").replace(":", "#")
+ <a href="/projects/$project/api/$script_path">$script</a>\
+ $if not loop.last:
+ , \
+ )
+ $if plugins or scripts:
+ </p>
+
+$if "errors" in test_results:
+ <p>
+ $if test_results["errors"]:
+ $ test_color = "red"
+ $ test_indicator = "■"
+ $ test_msg = f"{test_results['errors']} tests of {test_results['tests']} failing"
+ $else:
+ $ test_color = "green"
+ $ test_indicator = "●"
+ $ test_msg = f"{test_results['tests']} tests passing"
+ <span style=color:$test_color>$:test_indicator</span>
+ $test_msg in $test_results["time"] s</p>
+
+<hr>
+
+$if readme:
+ <div>$:mkdn(readme)</div>
+ <hr>
+
+$if api_members := api_python.get("members"):
+ $ _imports, _globals, _exceptions, _functions, _classes = api_members
+ <img style=float:right;width:12em src=/projects/$project/api/$(api_python["name"]).svg>
+ <h3><a href=/projects/$project/api/$api_python["name"]>$api_python["name"]</a></h3>
+ $if mod_doc := api_python["mod"].get("doc"):
+ $:mkdn(mod_doc.partition("\n\n")[0])
+ <small>
+ $for module in set(dict(_imports).keys()).difference(stdlib_module_names).difference(set(api_python["descendants"].keys())):
+ $module\
+ $if not loop.last:
+ ,
+ </small></p>
+
+ $ metrics = api_python["mod"]["metrics"]
+ <p><code>$metrics["lines"][1]</code> <abbr title="Logical Lines Of Code">LLOC</abbr>,
+ <abbr title=$round(metrics["maintainability"])>
+ $if metrics["maintainability"] >= 19:
+ highly maintainable
+ $elif metrics["maintainability"] >= 9:
+ moderately maintainable
+ $else:
+ difficult to maintain
+ </abbr>
+ </p>
+ $for func_name, func_details in _functions:
+ $if func_name not in api_python["mod"]["all"]:
+ $continue
+ <h4 style="margin-bottom:0;padding:0 2em;text-indent:-2em"><a
+ href=/projects/$project/api/$api_python["name"]#$func_name>$func_name</a>\
+ <em style=font-weight:normal>$func_details["sig"]</em></h4>
+ <div style=font-size:.8em>
+ $if func_doc := func_details.get("doc"):
+ $:mkdn(func_doc.partition("\n\n")[0])
+ </div>
+
+ <ul>
+ $for descendant, desc_details in api_python["descendants"].items():
+ $if descendant == "__main__":
+ $continue
+ <li>
+ <h5><a href=/projects/$project/api/$api_python["name"]/$descendant>$descendant</a></h5>
+ $if desc_doc := desc_details["mod"].get("doc"):
+ $:mkdn(desc_doc.partition("\n\n")[0])
+
+ $ metrics = desc_details["mod"]["metrics"]
+ <p><code>$metrics["lines"][1]</code> <abbr title="Logical Lines Of Code">LLOC</abbr>,
+ <abbr title=$round(metrics["maintainability"])>
+ $if metrics["maintainability"] >= 19:
+ highly maintainable
+ $elif metrics["maintainability"] >= 9:
+ moderately maintainable
+ $else:
+ difficult to maintain
+ </abbr>
+ <br>
+ <small><code>
+ $for obj_name, obj_complexity in metrics["complexity"].items():
+ $obj_name $obj_complexity
+ </code></small>
+ </p>
+ </li>
+ </ul>
+ <hr>
+
+$if pyproject:
+ <p><small><strong>Package:</strong> <code>$py_project.pop("name")</code>
+ $for source in py_project.pop("source", []):
+ $if source["name"] == "main":
+ <code>@<a href="$source['url']">$source["url"]</a></code>
+ </small></p>
+ $if py_deps := py_project.pop("dependencies", None):
+ <p><small><strong>Requires:</strong>
+ <code>python $py_deps.pop("python")</code>,
+ $for dep, version in sorted(py_deps.items()):
+ $ dep_nobreak = dep.replace("-", "‍-‍")
+ <code><a href=https://pypi.org/project/$dep>$:dep_nobreak</a></code>\
+ $if not loop.last:
+ ,
+ </small></p>
+ <h2>Releases</h2>
+ <ul class=h-feed>
+ $for release in list(reversed(sorted(package_releases, key=semver.parse_version_info)))[:1]:
+ <li class=h-entry><a href=/projects/$project/releases/$release>$release</a></li>
+ </ul>
+ <hr>
+
+$ git_clone = f"git clone {tx.origin}/code/projects/{project}.git"
+<script>
+copyText = () => {
+ navigator.clipboard.writeText('$git_clone')
+}
+</script>
+$ ignorable_files = (".gitignore",
+$ "README",
+$ "README.md",
+$ "pyproject.toml",
+$ "pyrightconfig.json",
+$ "tsconfig.json",
+$ "webpack.config.js",
+$ "package.json")
+
+$if repo.exists():
+ <div>
+ <pre><small><button onclick=copyText()>📋</button> \
+ $git_clone</small></pre>
+ <h2>Commit Log</h2>
+ <ul class="commits h-feed">
+ $for commit in list(repo.log.values())[:1]:
+ $ author_url = commit['author_email']
+ $if "@" in author_url:
+ $ author_url = f"mailto:{author_url}"
+ $else:
+ $ author_url = f"https://{author_url}"
+ <li class=h-entry>
+ $# <small><small><a class=u-url href=/projects/$project/commits/$commit["hash"]>\
+ $# <code>$commit["hash"][:7].upper()</code></a></small></small>
+ <span class=p-name>$:str(mkdn(commit["message"]))[3:-4]</span>
+ <small><small>
+ $# <a class=u-author href="$author_url">$commit["author_name"]</a>
+ <a class=u-url href=/projects/$project/commits/$commit["hash"]><time
+ class=dt-published datetime=$commit["timestamp"].isoformat()>\
+ $commit["timestamp"].diff_for_humans()</time></a>
+ </small></small>
+ </li>
+ </ul>
+ <h2>Files</h2>
+ $ files = sorted(repo.files)
+ $ prev_file = None
+ $ known_files = [f for f in files if f.name in ignorable_files]
+ <p style=font-size:.8em>\
+ $for file in known_files:
+ $ file, is_dir, _ = str(file).partition("/")
+ $if file == prev_file:
+ $continue
+ <a style=color:#888 href=/projects/$project/files/$file>$file</a>\
+ $if is_dir:
+ /
+ $ prev_file = file
+ $if not loop.last:
+ ,
+ </p>
+ $ prev_file = None
+ <ul class=files>
+ $ unique_files = [f for f in files if f.name not in ignorable_files]
+ $for file in unique_files:
+ $ file, is_dir, _ = str(file).partition("/")
+ $if file == prev_file:
+ $continue
+ <li><a href=/projects/$project/files/$file>$file</a>\
+ $if is_dir:
+ /
+ </li>
+ $ prev_file = file
+ </ul>
+
+$var title = title
index 0000000..f2b2f68
--- /dev/null
+$def with (mentions)
+
+$for mention in mentions:
+ <div>
+ $ data = mention["data"]
+ $if name := data.get("name", None):
+ <h3>$name</h3>
+ $if data:
+ $ a = data["author"]
+ $ comment_type = data["comment_type"][0]
+ $if comment_type == "like":
+ $emoji.emojize(":red_heart:")
+ $if content := data.get("content"):
+ <div>$:content</div>
+ $ published = data["published"][0]
+ <small>Opened <a href=$data["url"]><time class=dt-published
+ datetime="$published.isoformat()">$published.diff_for_humans()</time></a>
+ $if data:
+ by <a href=$a["url"]>$a.get("name", a["url"])</a>,
+ $if comment := data.get("comment"):
+ $ comment_count = len(comment)
+ $if comment_count:
+ 💬 $comment_count comment$("s" if comment_count > 1 else "")
+ </small>
+ </div>
index 0000000..9f42c38
--- /dev/null
+$def with (project, namespace, details)
+$var breadcrumbs = ("projects", "Projects", project, f"<b>{project}</b>", "api", "API")
+$var title: $namespace
+
+$ namespaces = namespace.split("/")
+$if len(namespaces) > 1:
+ $ details = details["descendants"][namespaces[1]]
+
+$:mkdn(details["mod"]["doc"])
+
+<hr>
+
+$ _imports, _globals, _exceptions, _functions, _classes = details["members"]
+
+$# $ unique_imports = set(dict(_imports).keys()).difference(stdlib_module_names).difference(set(details["descendants"].keys()))
+$# $if unique_imports:
+$# <p><small>
+$# $for module in unique_imports:
+$# $module\
+$# $if not loop.last:
+$# ,
+$# </small></p>
+$# <ul>
+$# $for descendant, desc_details in details["descendants"].items():
+$# <li><a href=/projects/$project/api/$details["name"]/$descendant>$descendant</a></li>
+$# </ul>
+$# <p>$details["mod"]["all"]</p>
+$# $for global_name, global_details in _globals:
+$# $if global_name == "__path__":
+$# $continue
+$# <h4 id=$global_name>$(details["name"]).<big><strong>$global_name</strong></big>\
+$# $if isinstance(global_details, (bool, str, int, float)):
+$# = $global_details</h4>
+$# $continue
+$# $if global_details.get("type") in ("function", "class"):
+$# $global_details["sig"]
+$# <a href=/projects/$project/api/$details["name"]#$global_name>¶</a></h4>
+$# $if doc := global_details.get("doc"):
+$# <p>$doc</p>
+
+$ sname = details["name"].replace(".", "/")
+
+$for glob_name, glob_details in _globals:
+ $if glob_name not in details["mod"]["all"]:
+ $continue
+ $# $glob_name $glob_details["type"]
+ $# <h4 id=$func_name style="padding:0 2em;text-indent:-2em">$func_name\
+ $# <span style=font-weight:normal>\
+ $# <em>$func_details["sig"]</em>
+ $# <small><a href=/projects/$project/api/$details["name"]#$func_name>#</a>
+ $# [<a href=/projects/$project/files/$(details["name"]).py>source</a>]</small>
+ $# </span></h4>
+ $# $:mkdn(glob_details["doc"])
+
+$for func_name, func_details in _functions:
+ $if func_name not in details["mod"]["all"]:
+ $continue
+ <h4 id=$func_name style="padding:0 2em;text-indent:-2em">$func_name\
+ <span style=font-weight:normal>\
+ <em>$func_details["sig"]</em>
+ <small><a href=/projects/$project/api/$sname#$func_name>#</a>
+ [<a href=/projects/$project/files/$(sname).py>source</a>]</small>
+ </span></h4>
+ $:mkdn(func_details["doc"])
+
+$for cls_name, cls_details in _classes:
+ $if cls_name not in details["mod"]["all"]:
+ $continue
+ <h4 id=$func_name style="padding:0 2em;text-indent:-2em">$cls_name\
+ <span style=font-weight:normal>\
+ <em>$cls_details["sig"]</em>
+ <small><a href=/projects/$project/api/$sname#$cls_name>#</a>
+ [<a href=/projects/$project/files/$(sname).py>source</a>]</small>
+ </span></h4>
+ $:mkdn(cls_details["doc"])
+
+$# <pre>$pformat(details)</pre>
index 0000000..80c7c6b
--- /dev/null
+$def with (project, release, files)
+$var breadcrumbs = ("projects", "Projects", project, f"<b>{project}</b>", "releases", "Releases")
+$var title: $project $release
+
+<ul>
+$for file in sorted(files):
+ <li><a href=/projects/$project/releases/$release/files/$file.name>$file.name</a></li>
+</ul>
index 0000000..8b59666
--- /dev/null
+$def with (project, release, filename, content)
+$ prefix, _, name = filename.rpartition("/")
+$if prefix:
+ $ file_tree = chain(*[(p, f"<b>{p}</b>") for p in prefix.split("/")])
+$else:
+ $ file_tree = []
+$var breadcrumbs = ("projects", "Projects", project, f"<b>{project}</b>", "releases", "Releases", release, f"<b>{release}</b>", "files", "Files") + tuple(file_tree)
+$var title = name
+
+<link rel=stylesheet href=$tx.origin/static/solarized.css media=screen>
+<style>
+div.highlight {
+ font-size: .75em; }
+.linenodiv .normal {
+ display: none; }
+</style>
+
+$if isinstance(content, str):
+ $:highlight(content, ".py")
+$else:
+ <ul>
+ $for file in content:
+ <li><a href=/projects/$project/releases/$release/files/$filename/$file.name>$file.name</a></li>
+ </ul>
index 0000000..2336eca
--- /dev/null
+$def with (project, filename, content, test_coverage)
+$ prefix, _, name = filename.rpartition("/")
+$if prefix:
+ $ file_tree = chain(*[(p, f"<b>{p}</b>") for p in prefix.split("/")])
+$else:
+ $ file_tree = []
+$ breadcrumbs = ("projects", "Projects", project, f"<b>{project}</b>")
+$if filename:
+ $ breadcrumbs += ("files", "Files") + tuple(file_tree)
+$var breadcrumbs = breadcrumbs
+$var title = name
+
+<link rel=stylesheet href=$tx.origin/static/solarized.css media=screen>
+<style>
+div.highlight {
+ font-size: .65em; }
+.linenodiv .normal {
+ display: none; }
+</style>
+
+$if content is None:
+ <img src=/projects/$project/raw/$filename>
+$elif isinstance(content, str):
+ <p style=text-align:right><a href=/projects/$project/raw/$filename>Raw</a></p>
+ $:highlight(content, name, coverage=test_coverage)
+$else:
+ $if filename:
+ $ filename = f"/{filename}"
+ <ul>
+ $for file in sorted(content):
+ <li><a href=/projects/$project/files$filename/$file.name>$file.name</a>\
+ $if file.is_dir():
+ /
+ </li>
+ </ul>
index 0000000..3701ffc
--- /dev/null
+$def with (project)
+$var breadcrumbs = ("projects", "Projects", project, f"<b>{project}</b>")
+$var title: Settings
+
+<form method=post action=/projects/$project/settings>
+<select name=visibility>
+<option value=public>Public</option>
+<option value=private>Private</option>
+<option value=protected>Protected</option>
+</select>
+<button>Save</button>
+</form>
+
+<form method=delete action=/projects/$project>
+<input type=hidden name=_http_method value=delete>
+<button>Delete</button>
+</form>
index 0000000..3dd54ad
--- /dev/null
+$def with (projects)
+$var title: Projects
+
+<form action=/search>
+<input name=q> <button>Search</button>
+</form>
+
+<p>$len(projects) projects</p>
+
+<ul>
+$for project in projects:
+ <li><a href=/projects/$project>$project</a></li>
+</ul>
+
+$if tx.user.is_owner:
+ <form method=post action=/projects>
+ <input name=project type=text>
+ <button>Create</button>
+ </form>
index 0000000..c842525
--- /dev/null
+$def with (projects)
+$var title: PyPI Repository
+
+<ul>
+$for project in projects:
+ <li><a href=/pypi/$project>$project</a></li>
+</ul>
index 0000000..779cd0a
--- /dev/null
+$def with (project, packages)
+$var title: Links for $project
+
+$for package in packages:
+ <a href="/projects/$package['name']/releases/$package['filename']\
+ #sha256=$package['sha256_digest']">$package['filename']</a><br>
index 0000000..d81c859
--- /dev/null
+$def with ()
+$var title: Search
+
+<form>
+<input name=q>
+<button>Search</button>
+</form>
index 0000000..be05f45
--- /dev/null
+$def with (query, files)
+$var breadcrumbs = ("search", "Search")
+$var title: Results for
+
+<link rel=stylesheet href=$tx.origin/static/solarized.css media=screen>
+<style>
+.linenodiv .normal {
+ display: none; }
+</style>
+
+<form action=/search>
+<input name=q value="$query"> <button>Search</button>
+</form>
+
+<h2>Projects</h2>
+$ previous_project = ""
+<ul style=font-size:.75em>
+$for (project, file), blocks in sorted(files.items()):
+ $if project == previous_project:
+ $continue
+ $ previous_project = project
+ <li><a href=#$project>$project</a></li>
+</ul>
+
+$ previous_project = ""
+$for (project, file), blocks in sorted(files.items()):
+ $if project != previous_project:
+ <h3 id=$project><a href=/projects/$project>$project</a></h3>
+ $ previous_project = project
+ <h4 id="$file"><a href=/projects/$project/files/$file>$file</a></h4>
+ $for starting_line, block in sorted(blocks.items()):
+ <pre style=font-size:.75em>$:highlight(block, file, starting_line=starting_line, search=query)</pre>
index 0000000..531e38a
--- /dev/null
+$def with (app)
+$# , understory_version, applications)
+$var title: System
+
+<p>$app.name</p>
+
+$# <h2>Theme</h2>
+$# <form action=/system/theme method=post>
+$# $ action = "deactivate" if tx.host.theme else "activate"
+$# <button name=action value=$action>$action.capitalize()</button>
+$# </form>
+
+<h2>Routes</h2>
+
+$def render_parent_controllers(location, controllers):
+ <ul id="$(location)_controllers">
+ $for route, controller in controllers:
+ <li>
+ $ parts = re.split(r"\(\?P<(.+?)>(.+?)\)", route)
+ $if len(parts) == 1:
+ <a href=$tx.origin/$route>/$controller.__web__[0]</a>
+ $else:
+ /$controller.__web__[0]
+ $ project_mod = "/".join(controller.handler.__module__.split("."))
+ $ project = getsourcefile(controller.handler).removeprefix(str(tx.host.working_dir)).lstrip("/").partition("/")[0]
+ <small><strong>
+ <a class=controller href=/projects/$project/api/$project_mod#$controller.handler.__name__>$controller.handler.__name__</a>
+ </strong></small>
+ </li>
+ </ul>
+
+$:render_parent_controllers("before", app.controllers)
+
+<ul id=mounts>
+$for prefix, subapp in sorted(app.mounts):
+ $if subapp.controllers:
+ <li>
+ $if len(subapp.controllers) > 1:
+ <details><summary>
+ <a href=$tx.origin/$prefix>/$prefix</a>
+ $ root = subapp.controllers[0]
+ $if root[0] == "":
+ $ project_mod = "/".join(root[1].handler.__module__.split("."))
+ $ project = getsourcefile(root[1].handler).removeprefix(str(tx.host.working_dir)).lstrip("/").partition("/")[0]
+ <small><strong><a class=controller href=/projects/$project/api/$project_mod#$root[1].handler.__name__>$root[1].handler.__name__</a></strong></small>
+ $:str(mkdn((root[1].__doc__.strip() + "\n").splitlines()[0])).removeprefix("<p>").removesuffix("</p>")
+ $if len(subapp.controllers) > 1:
+ </summary>
+ <ul>
+ $for route, controller in subapp.controllers[1:]:
+ $ parts = re.split(r"\(\?P<(.+?)>(.+?)\)", route)
+ <li>\
+ $if len(parts) == 1:
+ $if parts[0]:
+ <a href=$tx.origin/$prefix/$parts[0]>/$parts[0]</a>\
+ $else:
+ /\
+ $for a, b, c in zip(parts[0::3], parts[1::3], parts[2::3]):
+ $:a.replace("(", '<span class="optional">')\
+ <span title="$c">{$b}</span>\
+ $:parts[-1].replace(")?", "</span>")
+ <br>
+ $ project_mod = "/".join(controller.handler.__module__.split("."))
+ $ project = getsourcefile(controller.handler).removeprefix(str(tx.host.working_dir)).lstrip("/").partition("/")[0]
+ <small><strong><a class=controller href=/projects/$project/api/$project_mod#$controller.handler.__name__>$controller.handler.__name__</a></strong></small>
+ $:str(mkdn((controller.__doc__.strip() + "\n").splitlines()[0])).removeprefix("<p>").removesuffix("</p>")
+ </li>
+ </ul>
+ </details>
+ </li>
+</ul>
+
+$:render_parent_controllers("after", app.after_controllers)
+
+<h3>Wrappers</h3>
+<ol id=wrappers>
+$for wrapper in app.wrappers:
+ <li>$wrapper.__name__<br>
+ <small>$wrapper.__module__</small>
+ </li>
+</ol>
+
+$def aside():
+ <form action=/code/system/update method=post><button>Update</button></form>
+ $# <h4><a href=/system/applications>Applications</a></h4>
+ $# <ul id=applications>
+ $# $for application in applications:
+ $# <li>$application.project_name<br>
+ $# <small>$application.version</small></li>
+ $# </ul>
+ $# <form method=post action=/system/applications>
+ $# <label><small>Application URL</small><br>
+ $# <label class=bounding><input type=text name=application_url
+ $# placeholder="github path, pypi path or .git url"></label></label>
+ $# <div class=buttons><button>Install</button></div>
+ $# </form>
+$var aside = aside
+
+$# XXX <ul>
+$# XXX $for prefix, subapp in app.mounts:
+$# XXX $if subapp.wrappers:
+$# XXX $for wrapper in subapp.wrappers:
+$# XXX <li>$wrapper</li>
+$# XXX </ul>
+
+<style>
+ul#before_controllers, ul#after_controllers, ul#mounts, ul#wrappers {
+ list-style: none;
+ padding-left: 0; }
+ul#mounts > li {
+ margin: 1em 0; }
+ul#mounts > li li {
+ margin: .5em 0; }
+em {
+ color: #586e75; }
+select {
+ border: 0; }
+#mounts label.bounding {
+ display: inline; }
+a.controller {
+ color: #b58900;
+ font-family: mono;
+ font-size: .9em;
+ text-decoration: none; }
+.optional {
+ border-style: dotted;
+ border-width: .2em; }
+@media (prefers-color-scheme: light) {
+ .optional {
+ border-color: #93a1a1; }
+}
+@media (prefers-color-scheme: dark) {
+ .optional {
+ border-color: #586e75; }
+}
+
+#applications {
+ font-size: .8em;
+ list-style: none;
+ padding-left: 0; }
+</style>
index 0000000..cc5b23c
--- /dev/null
+$def with (resource)
+<!doctype html>
+<html>
+<head>
+<title>$resource.title</title>
+</head>
+<body>
+<h1>$resource.title</h1>
+$:resource
+</body>
+</html>