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("