Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion compendium/compendium/page/docs/docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -385,14 +385,18 @@
font-size: var(--text-sm);
}

.docs-page-roles {
.docs-page-footer {
margin-top: var(--margin-xl);
padding-top: var(--padding-md);
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: var(--text-sm);
}

.docs-page-footer .docs-edit-link {
color: var(--text-muted);
}

@media (min-width: 1200px) {
.docs-browser.has-toc {
display: grid;
Expand Down
34 changes: 25 additions & 9 deletions compendium/compendium/page/docs/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ frappe.ui.DocsBrowser = class DocsBrowser {
this.$content.toggleClass("has-toc", Boolean(toc_html));
this.render_heading_anchors();
this.render_fallback_notice(doc);
this.render_roles(doc.roles);
this.render_page_footer(doc);
// Mermaid replaces fences with SVG asynchronously and shifts later headings;
// fragment scrolls wait on layout_ready so they use the final layout.
this.layout_ready = this.render_mermaid();
Expand Down Expand Up @@ -533,17 +533,33 @@ frappe.ui.DocsBrowser = class DocsBrowser {
).prependTo(this.$reading);
}

render_roles(roles) {
if (!roles?.length) {
render_page_footer(doc) {
const roles = doc.roles || [];
const parts = [];

if (roles.length) {
const labels = roles.map((role) => frappe.utils.escape_html(role)).join(", ");
parts.push(
roles.length === 1
? __("You can see this page because you have the role {0}.", [labels])
: __("You can see this page because you have the roles {0}.", [labels])
);
}

if (doc.edit_url) {
const label = frappe.utils.escape_html(__("Edit on GitHub"));
parts.push(
`<a class="docs-edit-link" href="${frappe.utils.escape_html(
doc.edit_url
)}" target="_blank" rel="noopener noreferrer">${label}</a>`
);
}

if (!parts.length) {
return;
}

const labels = roles.map((role) => frappe.utils.escape_html(role)).join(", ");
const message =
roles.length === 1
? __("You can see this page because you have the role {0}.", [labels])
: __("You can see this page because you have the roles {0}.", [labels]);
this.$reading.append(`<footer class="docs-page-roles">${message}</footer>`);
this.$reading.append(`<footer class="docs-page-footer">${parts.join("<br>")}</footer>`);
}

render_mermaid() {
Expand Down
161 changes: 160 additions & 1 deletion compendium/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
import importlib.util
import os
import re
import subprocess
from collections import Counter
from urllib.parse import urlencode
from urllib.parse import quote, urlencode

import frappe
from frappe import _
from frappe.translate import get_parent_language
from frappe.utils import cint, get_url, has_common, sanitize_html
from frappe.utils.change_log import parse_github_url
from frappe.website.utils import extract_title, get_frontmatter

from compendium.permissions import CONTRIBUTOR_ROLE

DOCS_FOLDER = "docs"
DEFAULT_LANG = "en"
DEFAULT_ROLE = "Desk User"
Expand Down Expand Up @@ -108,6 +112,7 @@ def build_page_payload(page, locale):
"content": content,
"toc_html": render_toc(page.body),
"roles": [_(role) for role in matching_roles],
"edit_url": get_edit_url(page),
}


Expand Down Expand Up @@ -536,6 +541,160 @@ def is_permitted(page):
return has_common(get_user_roles(), page.roles)


def can_edit_on_github():
"""Only contributors see the Edit on GitHub control."""
if frappe.session.user == "Administrator":
return True

return CONTRIBUTOR_ROLE in get_user_roles()


def get_edit_url(page):
"""GitHub edit URL for the Markdown file currently rendered, or None."""
if not can_edit_on_github():
return None

repository = get_app_repository_url(page.app)
if not repository:
return None

try:
owner, repo = parse_github_url(repository)
except ValueError:
return None

if not owner or not repo:
return None

branch = get_app_git_branch(page.app)
if not branch or branch == "HEAD":
return None

relative_path = get_repo_relative_path(page)
if not relative_path:
return None

# Branch names may contain slashes (e.g. feat/foo); encode so GitHub can
# tell the ref apart from the file path.
return f"https://github.com/{owner}/{repo}/edit/{quote(branch, safe='')}/{relative_path}"


@frappe.request_cache
def get_app_repository_url(app):
"""Repository URL from the app's pyproject.toml `[project.urls]` Repository key."""
from tomli import load

pyproject_path = os.path.join(os.path.dirname(get_app_path(app)), "pyproject.toml")
if not os.path.isfile(pyproject_path):
return None

with open(pyproject_path, "rb") as f:
data = load(f)

url = (data.get("project") or {}).get("urls", {}).get("Repository")
return url.rstrip("/") if url else None


def get_app_git_branch(app):
"""Git branch to use in GitHub links for this app.

Uses a ref from the git remote that matches `[project.urls].Repository`, so
forks and local-only branches do not produce 404s. Order: current branch if
on that remote → its upstream if on that remote → that remote's default.
Returns empty if none of those are available.
"""
repo_root = os.path.dirname(get_app_path(app))
remote = _remote_for_repository(repo_root, get_app_repository_url(app))
if not remote:
return ""

local = _git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
if local and local != "HEAD" and _git_ref_exists(repo_root, f"refs/remotes/{remote}/{local}"):
Comment thread
barredterra marked this conversation as resolved.
return local

upstream = _git_output(repo_root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
tracking_remote, _, branch = upstream.partition("/")
if tracking_remote == remote and branch:
return branch

prefix = f"{remote}/"
default = _git_output(repo_root, "symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD")
if default.startswith(prefix):
return default[len(prefix) :]

return ""


def _git_output(repo_root, *args):
try:
with open(os.devnull, "wb") as null_stream:
result = subprocess.check_output(
["git", "-C", repo_root, *args],
shell=False,
stdin=null_stream,
stderr=null_stream,
)
except (OSError, subprocess.CalledProcessError):
return ""

return result.decode().strip()


def _git_ref_exists(repo_root, ref):
try:
with open(os.devnull, "wb") as null_stream:
subprocess.check_call(
["git", "-C", repo_root, "show-ref", "--verify", "--quiet", ref],
shell=False,
stdin=null_stream,
stdout=null_stream,
stderr=null_stream,
)
except (OSError, subprocess.CalledProcessError):
return False

return True


def _remote_for_repository(repo_root, repository):
"""Git remote whose URL points at the same GitHub owner/repo as `repository`."""
target = _github_repo(repository)
if not target:
return ""

for remote in _git_output(repo_root, "remote").split():
if _github_repo(_git_output(repo_root, "remote", "get-url", remote)) == target:
return remote
return ""


def _github_repo(url):
if not url:
return None
try:
owner, repo = parse_github_url(url)
except ValueError:
return None
if not owner or not repo:
return None
return (owner.lower(), repo.lower())


def get_repo_relative_path(page):
"""Path of the page's Markdown file relative to the app repository root."""
repo_root = os.path.realpath(os.path.dirname(page.app_path))
filepath = os.path.realpath(page.filepath)
try:
relative = os.path.relpath(filepath, repo_root)
except ValueError:
return None

if relative.startswith(".."):
return None

return relative.replace(os.sep, "/")


def build_navigation_tree(locale=None):
locale = normalize_locale(locale or frappe.local.lang or DEFAULT_LANG)
permitted_pages = {path: page for path, page in discover_pages(locale).items() if is_permitted(page)}
Expand Down
11 changes: 11 additions & 0 deletions compendium/docs/de/compendium/docs/authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ Verweisen Sie im Markdown mit relativen Pfaden auf Bilder. Compendium liefert si
über `compendium.docs.get_asset` aus, sodass Assets im `docs/`-Baum der jeweiligen
App bleiben.

## Auf GitHub bearbeiten

Wenn eine App unter `[project.urls]` in ihrer `pyproject.toml` eine GitHub-
`Repository`-URL angibt, sehen Nutzer mit der Rolle **Compendium Contributor**
einen Link **Edit on GitHub**, der die Markdown-Datei der aktuellen Seite öffnet.

```toml
[project.urls]
Repository = "https://github.com/alyf-de/compendium.git"
```

## Mermaid-Diagramme

`mermaid`-Codeblöcke werden im Lesebereich als Diagramm dargestellt:
Expand Down
11 changes: 11 additions & 0 deletions compendium/docs/en/compendium/docs/authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ that owns the canonical page or a later installed app.
Reference images with relative paths in Markdown. Compendium serves them through
`compendium.docs.get_asset`, so assets stay inside the owning app's `docs/` tree.

## Edit on GitHub

When an app declares a GitHub `Repository` URL under `[project.urls]` in its
`pyproject.toml`, contributors with the **Compendium Contributor** role see an
**Edit on GitHub** link that opens the Markdown file for the current page.

```toml
[project.urls]
Repository = "https://github.com/alyf-de/compendium.git"
```

## Mermaid diagrams

Fenced `mermaid` blocks render as diagrams in the reading pane:
Expand Down
12 changes: 12 additions & 0 deletions compendium/install.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import frappe

from compendium.permissions import CONTRIBUTOR_ROLE

DOCUMENTATION_ROUTE = "/app/docs"
COMPENDIUM_LABEL = "Compendium"


def after_install():
add_documentation_help_item()
ensure_contributor_role()


def add_documentation_help_item():
Expand All @@ -25,3 +28,12 @@ def add_documentation_help_item():
},
)
navbar_settings.save()


def ensure_contributor_role():
if frappe.db.exists("Role", CONTRIBUTOR_ROLE):
return

frappe.get_doc({"doctype": "Role", "role_name": CONTRIBUTOR_ROLE, "desk_access": 1}).insert(
ignore_permissions=True
)
Loading
Loading