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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ See the [built-in docs](compendium/docs) for authoring conventions and a getting
- Role-gated pages via frontmatter (`title`, `order`, `roles`)
- Multi-language trees with locale picker and fallback (`de-CH` → `de` → `en`)
- Later apps override the same logical path
- Pages written in Desk (Compendium Page), on top of the app docs
- Relative images served safely from each app's `docs/` tree
- Mermaid diagrams in fenced code blocks
- Syntax highlighting for fenced code blocks
Expand Down
Empty file.
Empty file.
34 changes: 34 additions & 0 deletions compendium/compendium/doctype/compendium_page/compendium_page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2026, ALYF GmbH and contributors
// For license information, please see license.txt

frappe.ui.form.on("Compendium Page", {
onload(frm) {
if (frm.is_new() && !frm.doc.language) {
frm.set_value("language", frappe.boot.lang);
}
},

refresh(frm) {
// the Markdown Editor attaches dropped images only to a saved page
frm.set_intro(frm.is_new() ? __("Save the page before adding images.") : "");

if (!frm.is_new() && frm.doc.published) {
frm.add_custom_button(__("View"), () => {
frappe.set_route(["docs", frm.doc.language, ...frm.doc.path.split("/")]);
});
}
},

title(frm) {
// the docs sidebar opens a new page with its group as "guides/"; add the slug to it
const group = frm.doc.path || "";
if (!group || group.endsWith("/")) {
// same slug as slugify() in compendium_page.py
const slug = (frm.doc.title || "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "");
frm.set_value("path", group + slug);
}
},
});
115 changes: 115 additions & 0 deletions compendium/compendium/doctype/compendium_page/compendium_page.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
{
"actions": [],
"creation": "2026-09-21 13:07:20.055891",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"roles",
"order",
"column_break_dmvv",
"language",
"path",
"published",
"section_break_jnxq",
"content"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "content",
"fieldtype": "Markdown Editor",
"ignore_xss_filter": 1,
"label": "Content"
},
{
"description": "Who can read the page. Empty means Desk User. Ignored for a translation: it takes the roles of the page in the canonical language (English where one exists).",
"fieldname": "roles",
"fieldtype": "Table MultiSelect",
"label": "Roles",
"options": "Compendium Page Role"
},
{
"fieldname": "order",
"fieldtype": "Int",
"label": "Sort Order"
},
{
"description": "Filled from the title when empty. Use slashes to nest pages, e.g. guides/setup. A page at the path of an app page replaces it.",
"fieldname": "path",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Path"
},
{
"default": "1",
"fieldname": "published",
"fieldtype": "Check",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Published"
},
{
"fieldname": "column_break_dmvv",
"fieldtype": "Column Break"
},
{
"fieldname": "language",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Language",
"options": "Language",
"reqd": 1
},
{
"fieldname": "section_break_jnxq",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
"links": [],
"modified": "2026-09-21 14:17:24.768175",
"modified_by": "Administrator",
"module": "Compendium",
"name": "Compendium Page",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Compendium Contributor",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title"
}
41 changes: 41 additions & 0 deletions compendium/compendium/doctype/compendium_page/compendium_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright (c) 2026, ALYF GmbH and contributors
# For license information, please see license.txt

import re

import frappe
from frappe import _
from frappe.model.document import Document

from compendium.docs import normalize_path


class CompendiumPage(Document):
def validate(self):
self.path = normalize_path(self.path or slugify(self.title))
if not self.path:
frappe.throw(_("Please set a path for this page"))

duplicate = frappe.db.exists(
self.doctype, {"language": self.language, "path": self.path, "name": ("!=", self.name)}
)
if duplicate:
frappe.throw(
_("{0} already documents the path {1} in this language").format(
frappe.get_desk_link(self.doctype, duplicate), frappe.bold(self.path)
),
frappe.DuplicateEntryError,
)


def on_doctype_update():
# validate() gives the readable error; the index also catches concurrent saves
frappe.db.add_unique("Compendium Page", ["language", "path"])


def slugify(title):
"""`Getting Started` → `getting-started`, like the Markdown file names apps ship.

Mirrored in compendium_page.js, which fills the path while the title is typed.
"""
return re.sub(r"[\W_]+", "-", (title or "").lower()).strip("-")
175 changes: 175 additions & 0 deletions compendium/compendium/doctype/compendium_page/test_compendium_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Copyright (c) 2026, ALYF GmbH and Contributors
# See license.txt

from unittest.mock import patch

import frappe
from frappe.tests.utils import FrappeTestCase

from compendium.docs import discover_pages, get_asset, get_page, get_view, make_override
from compendium.search import awesomebar_results
from compendium.tests.test_docs import DocsTestEnvironment


class TestCompendiumPage(FrappeTestCase):
def setUp(self):
frappe.set_user("Administrator")
frappe.local.lang = "en"
self.next_request()

def tearDown(self):
frappe.db.rollback()
frappe.set_user("Administrator")
frappe.local.lang = "en"

def next_request(self):
if hasattr(frappe.local, "request_cache"):
frappe.local.request_cache.clear()

def make_page(self, title, content="", **kwargs):
roles = kwargs.pop("roles", [])
doc = frappe.get_doc(
{
"doctype": "Compendium Page",
"title": title,
"language": "en",
"content": content,
"roles": [{"role": role} for role in roles],
**kwargs,
}
).insert()
self.next_request()
return doc

def test_path_is_slugged_from_title_and_normalized(self):
self.assertEqual(self.make_page("Getting Started — Übersicht!").path, "getting-started-übersicht")
self.assertEqual(self.make_page("Nested", path="/guides//nested/").path, "guides/nested")

with self.assertRaises(frappe.ValidationError):
self.make_page("Escape", path="../secrets")
with self.assertRaises(frappe.ValidationError):
self.make_page("!!!")

def test_override_copies_app_page(self):
files = {"en/guides/setup.md": "---\ntitle: Setup\norder: 3\nroles: System Manager\n---\nShipped"}
with DocsTestEnvironment(files):
doc = make_override("guides/setup", locale="en")

self.assertTrue(doc.is_new())
self.assertEqual(
(doc.title, doc.path, doc.language, doc.order, doc.content),
("Setup", "guides/setup", "en", 3, "Shipped"),
)
self.assertEqual([row.role for row in doc.roles], ["System Manager"])

def test_markdown_is_stored_verbatim_and_sanitized_on_render(self):
with DocsTestEnvironment({}, db_pages=True):
doc = self.make_page("Quote", "> [!NOTE]\n> Quoted\n\n<script>alert(1)</script>")
content = get_page("quote", locale="en")["content"]

self.assertTrue(doc.content.startswith("> [!NOTE]"))
self.assertIn("docs-alert-note", content)
self.assertNotIn("<script>", content)

def test_path_is_unique_per_language(self):
self.make_page("Wiki Unique")
self.make_page("Wiki Unique", language="de")
with self.assertRaises(frappe.DuplicateEntryError):
self.make_page("Wiki Unique")

def test_page_is_added_on_top_of_app_docs(self):
with DocsTestEnvironment({"en/shipped.md": "# Shipped"}, db_pages=True):
self.make_page("Site Wiki", "Our own notes")
pages = discover_pages("en")

self.assertIn("shipped", pages)
self.assertEqual(pages["site-wiki"].body, "Our own notes")
self.assertEqual(pages["site-wiki"].roles, ["Desk User"])

def test_page_overrides_app_page_at_same_path(self):
with DocsTestEnvironment({"en/shared.md": "---\ntitle: Shipped\n---\nWrong for us"}, db_pages=True):
self.make_page("Corrected", "Right for us", path="shared", roles=["System Manager"])
page = discover_pages("en")["shared"]

self.assertEqual(page.title, "Corrected")
self.assertEqual(page.roles, ["System Manager"])

def test_unpublished_page_neither_shows_nor_hides(self):
with DocsTestEnvironment({"en/shared.md": "---\ntitle: Shipped\n---\nBody"}, db_pages=True):
self.make_page("Draft Override", path="shared", published=0)
self.make_page("Draft Page", published=0)
pages = discover_pages("en")

self.assertEqual(pages["shared"].title, "Shipped")
self.assertNotIn("draft-page", pages)

def test_translation_takes_roles_of_canonical_page(self):
files = {"en/guides/setup.md": "---\ntitle: Setup\nroles: System Manager\n---\nEnglish"}
with DocsTestEnvironment(files, db_pages=True):
self.make_page("Einrichtung", "Deutsch", path="guides/setup", language="de", roles=["Desk User"])
page = discover_pages("de")["guides/setup"]

self.assertEqual(page.body, "Deutsch")
self.assertEqual(page.roles, ["System Manager"])

def test_edit_route_replaces_github_link(self):
with DocsTestEnvironment(
{}, repository="https://github.com/alyf-de/compendium", docs_branch="main", db_pages=True
):
doc = self.make_page("Editable")
payload = get_page("editable", locale="en")

self.assertIsNone(payload["edit_url"])
self.assertEqual(payload["edit_route"], f"/app/compendium-page/{doc.name}")

def test_serves_only_attached_private_files(self):
with DocsTestEnvironment({}, db_pages=True):
doc = self.make_page("With Image")
attached = self.make_private_file("attached.png", b"attached", doc.name)
elsewhere = self.make_private_file("elsewhere.png", b"elsewhere")
doc.content = f"![Image]({attached.file_url})\n\n[Other]({elsewhere.file_url})"
doc.save()
self.next_request()

content = get_view("with-image", locale="en")["page"]["content"]
self.assertNotIn('"/private/files/', content)
self.assertIn("compendium.docs.get_asset", content)

get_asset("with-image", attached.file_url, locale="en")
self.assertEqual(frappe.response["filecontent"], b"attached")

with self.assertRaises(frappe.DoesNotExistError):
get_asset("with-image", elsewhere.file_url, locale="en")

def test_private_files_require_page_access(self):
with DocsTestEnvironment({}, db_pages=True):
doc = self.make_page("Restricted Image", roles=["System Manager"])
attached = self.make_private_file("restricted.png", b"secret", doc.name)

frappe.set_user("test@example.com")
with patch("compendium.docs.get_user_roles", return_value=["Desk User"]):
with self.assertRaises(frappe.PermissionError):
get_asset("restricted-image", attached.file_url, locale="en")

def test_search_sees_saved_and_deleted_pages(self):
with DocsTestEnvironment({}, db_pages=True):
self.assertEqual(awesomebar_results("zymurgy"), [])

doc = self.make_page("Brewing", "All about zymurgy.")
self.assertEqual([item["label"] for item in awesomebar_results("zymurgy")], ["Brewing"])

doc.delete()
self.next_request()
self.assertEqual(awesomebar_results("zymurgy"), [])

def make_private_file(self, file_name, content, attached_to_name=None):
return frappe.get_doc(
{
"doctype": "File",
"file_name": file_name,
"content": content,
"is_private": 1,
"attached_to_doctype": "Compendium Page" if attached_to_name else None,
"attached_to_name": attached_to_name,
}
).insert()
Empty file.
Loading
Loading