From 74e95924c03ad0025e0c2816d1120a3f0c044768 Mon Sep 17 00:00:00 2001
From: Henning Wendtland <156231187+HenningWendtland@users.noreply.github.com>
Date: Tue, 29 Sep 2026 23:49:23 +0200
Subject: [PATCH 1/2] feat: add pages written in Desk (Compendium Page) (#28)
Co-authored-by: barredterra <14891507+barredterra@users.noreply.github.com>
---
README.md | 1 +
compendium/compendium/doctype/__init__.py | 0
.../doctype/compendium_page/__init__.py | 0
.../compendium_page/compendium_page.js | 34 ++++
.../compendium_page/compendium_page.json | 115 ++++++++++++
.../compendium_page/compendium_page.py | 41 ++++
.../compendium_page/test_compendium_page.py | 175 ++++++++++++++++++
.../doctype/compendium_page_role/__init__.py | 0
.../compendium_page_role.json | 35 ++++
.../compendium_page_role.py | 9 +
compendium/compendium/page/docs/docs.css | 84 +++++++--
compendium/compendium/page/docs/docs.js | 141 +++++++++++---
compendium/docs.py | 169 +++++++++++++++--
.../docs/de/compendium/docs/authoring.md | 13 +-
.../docs/de/compendium/docs/desk-pages.md | 83 +++++++++
compendium/docs/de/compendium/docs/index.md | 1 +
compendium/docs/de/compendium/docs/search.md | 7 +-
compendium/docs/de/compendium/index.md | 3 +-
.../docs/en/compendium/docs/authoring.md | 12 +-
.../docs/en/compendium/docs/desk-pages.md | 74 ++++++++
compendium/docs/en/compendium/docs/index.md | 1 +
compendium/docs/en/compendium/docs/search.md | 5 +-
compendium/docs/en/compendium/index.md | 3 +-
compendium/locale/de.po | 96 ++++++++--
compendium/locale/main.pot | 98 ++++++++--
compendium/search.py | 9 +-
compendium/tests/test_docs.py | 28 ++-
cypress/integration/docs.js | 28 ++-
28 files changed, 1153 insertions(+), 112 deletions(-)
create mode 100644 compendium/compendium/doctype/__init__.py
create mode 100644 compendium/compendium/doctype/compendium_page/__init__.py
create mode 100644 compendium/compendium/doctype/compendium_page/compendium_page.js
create mode 100644 compendium/compendium/doctype/compendium_page/compendium_page.json
create mode 100644 compendium/compendium/doctype/compendium_page/compendium_page.py
create mode 100644 compendium/compendium/doctype/compendium_page/test_compendium_page.py
create mode 100644 compendium/compendium/doctype/compendium_page_role/__init__.py
create mode 100644 compendium/compendium/doctype/compendium_page_role/compendium_page_role.json
create mode 100644 compendium/compendium/doctype/compendium_page_role/compendium_page_role.py
create mode 100644 compendium/docs/de/compendium/docs/desk-pages.md
create mode 100644 compendium/docs/en/compendium/docs/desk-pages.md
diff --git a/README.md b/README.md
index 7b2aca8..bcbc438 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/compendium/compendium/doctype/__init__.py b/compendium/compendium/doctype/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/compendium/compendium/doctype/compendium_page/__init__.py b/compendium/compendium/doctype/compendium_page/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/compendium/compendium/doctype/compendium_page/compendium_page.js b/compendium/compendium/doctype/compendium_page/compendium_page.js
new file mode 100644
index 0000000..1ee99cc
--- /dev/null
+++ b/compendium/compendium/doctype/compendium_page/compendium_page.js
@@ -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);
+ }
+ },
+});
diff --git a/compendium/compendium/doctype/compendium_page/compendium_page.json b/compendium/compendium/doctype/compendium_page/compendium_page.json
new file mode 100644
index 0000000..3371999
--- /dev/null
+++ b/compendium/compendium/doctype/compendium_page/compendium_page.json
@@ -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"
+}
\ No newline at end of file
diff --git a/compendium/compendium/doctype/compendium_page/compendium_page.py b/compendium/compendium/doctype/compendium_page/compendium_page.py
new file mode 100644
index 0000000..93e7b1d
--- /dev/null
+++ b/compendium/compendium/doctype/compendium_page/compendium_page.py
@@ -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("-")
diff --git a/compendium/compendium/doctype/compendium_page/test_compendium_page.py b/compendium/compendium/doctype/compendium_page/test_compendium_page.py
new file mode 100644
index 0000000..be51194
--- /dev/null
+++ b/compendium/compendium/doctype/compendium_page/test_compendium_page.py
@@ -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")
+ content = get_page("quote", locale="en")["content"]
+
+ self.assertTrue(doc.content.startswith("> [!NOTE]"))
+ self.assertIn("docs-alert-note", content)
+ self.assertNotIn("