diff --git a/compendium/compendium/page/docs/docs.js b/compendium/compendium/page/docs/docs.js index 9581d9d..d7703b3 100644 --- a/compendium/compendium/page/docs/docs.js +++ b/compendium/compendium/page/docs/docs.js @@ -24,6 +24,7 @@ frappe.ui.DocsBrowser = class DocsBrowser { this.current_path = null; this.current_locale = frappe.boot.lang || "en"; this.locales = []; + this._view_seq = 0; this.layout_ready = Promise.resolve(); this.setup_layout(); } @@ -83,24 +84,28 @@ frappe.ui.DocsBrowser = class DocsBrowser { } show() { + if (this._ignore_next_show) { + this._ignore_next_show = false; + return; + } + + // Bump before ensure_locales so an in-flight switch_locale cannot apply + // after this navigation started but before load_view takes the sequence. + const seq = ++this._view_seq; const route = frappe.get_route(); this.ensure_locales().then(() => { + if (seq !== this._view_seq) { + return; + } const parsed = this.parse_route(route); if (!parsed.locale) { return this.resolve_and_redirect(parsed.path); } this.current_locale = parsed.locale; - this.load_tree().then(() => { - this.update_locale_picker(); - if (parsed.path === null) { - if (!this.tree_data.length) { - this.show_empty_state(); - return; - } - return this.select_first_page(); - } - this.load_page(parsed.path); - }); + if (parsed.path === null) { + return this.load_view("", true); + } + this.load_view(parsed.path); }); } @@ -139,44 +144,37 @@ frappe.ui.DocsBrowser = class DocsBrowser { } } - update_locale_picker() { - if (!this.locales.length) { - return Promise.resolve(); - } - - return frappe - .xcall("compendium.docs.get_page_variants", { path: this.current_path || "" }) - .then((variants) => { - this.render_locale_picker(variants || []); - }) - .catch(() => { - this.render_locale_picker(this.locales); - }); - } - switch_locale(locale) { if (!locale || locale === this.current_locale) { return; } const path = this.current_path || ""; - const route = ["docs", locale]; - if (path) { - route.push(...path.split("/")); - } + const seq = ++this._view_seq; + frappe.xcall("compendium.docs.get_view", { path, locale }).then((view) => { + if (seq !== this._view_seq) { + if (this.$locale_select.val() === locale) { + this.$locale_select.val(this.current_locale); + } + return; + } - frappe - .xcall("compendium.docs.get_page", { path, locale }) - .then(() => frappe.set_route(route)) - .catch(() => { - frappe.xcall("compendium.docs.get_first_page", { locale }).then((first_path) => { - const fallback = ["docs", locale]; - if (first_path) { - fallback.push(...first_path.split("/")); - } - frappe.set_route(fallback); - }); - }); + const target_path = view.page ? path : view.first_path; + const route = ["docs", locale]; + if (target_path) { + route.push(...target_path.split("/")); + } + + if (view.page || target_path == null || target_path === path) { + this.current_locale = locale; + this._ignore_next_show = true; + frappe.set_route(route); + this.apply_view(view); + return; + } + + frappe.set_route(route); + }); } parse_route(route) { @@ -203,9 +201,13 @@ frappe.ui.DocsBrowser = class DocsBrowser { } resolve_and_redirect(path) { + const seq = this._view_seq; return frappe .xcall("compendium.docs.resolve_locale", { path: path || "" }) .then((result) => { + if (seq !== this._view_seq) { + return; + } const route = ["docs", result.locale]; if (result.path) { route.push(...result.path.split("/")); @@ -214,19 +216,9 @@ frappe.ui.DocsBrowser = class DocsBrowser { }); } - load_tree() { - return frappe - .xcall("compendium.docs.get_tree", { locale: this.current_locale }) - .then((tree) => { - this.tree_data = tree || []; - this.page_paths = this.collect_page_paths(this.tree_data); - this.render_tree(); - }) - .catch(() => { - this.tree_data = []; - this.page_paths = new Set(); - this.render_tree(); - }); + apply_tree(tree) { + this.tree_data = tree || []; + this.page_paths = this.collect_page_paths(this.tree_data); } collect_page_paths(nodes) { @@ -328,22 +320,6 @@ frappe.ui.DocsBrowser = class DocsBrowser { } } - select_first_page() { - return frappe - .xcall("compendium.docs.get_first_page", { locale: this.current_locale }) - .then((path) => { - if (path === null || path === undefined) { - this.show_empty_state(); - return; - } - if (path === "") { - this.load_page(""); - return; - } - this.navigate_to(path, true); - }); - } - navigate_to(path, replace_route = false) { const route = ["docs", this.current_locale]; if (path) { @@ -359,28 +335,60 @@ frappe.ui.DocsBrowser = class DocsBrowser { frappe.set_route(route); return; } - this.load_page(path); + this.load_view(path); } - load_page(path) { - this.current_path = path; - this.expand_ancestors(path); - this.render_tree(); + load_view(path, resolve_first = false) { this.show_loading(); + const seq = ++this._view_seq; return frappe.call({ - method: "compendium.docs.get_page", - args: { path, locale: this.current_locale }, + method: "compendium.docs.get_view", + args: { + path: path || "", + locale: this.current_locale, + resolve_first: resolve_first ? 1 : 0, + }, callback: (response) => { - if (response.exc_type) { + if (seq !== this._view_seq) { + return; + } + if (response.exc_type && response.message == null) { this.show_error(response, path); return; } - this.show_page(response.message); + this.apply_view(response.message || {}, { resolve_first }); }, }); } + apply_view(view, { resolve_first = false } = {}) { + this.apply_tree(view.tree); + this.render_locale_picker(view.variants || []); + + if (resolve_first && view.path != null && view.path !== "") { + this._ignore_next_show = true; + this.navigate_to(view.path, true); + } + + if (!view.page) { + this.current_path = view.path; + this.expand_ancestors(view.path); + this.render_tree(); + if (!view.exc_type) { + this.show_empty_state(); + } else { + this.show_error({ exc_type: view.exc_type }, view.path); + } + return; + } + + this.current_path = view.page.path; + this.expand_ancestors(this.current_path); + this.render_tree(); + this.show_page(view.page, view.variants); + } + show_loading() { this.$state.addClass("hide"); this.$reading.removeClass("hide").addClass("docs-loading").html(""); @@ -388,7 +396,7 @@ frappe.ui.DocsBrowser = class DocsBrowser { this.$content.removeClass("has-toc"); } - show_page(doc) { + show_page(doc, variants) { this.$reading.removeClass("docs-loading hide"); this.$state.addClass("hide"); this.page.set_title(doc.title || __("Documentation")); @@ -405,7 +413,7 @@ frappe.ui.DocsBrowser = class DocsBrowser { this.scroll_to_heading(); this.highlight_code(); this.update_breadcrumbs(doc.path, doc.title); - this.update_locale_picker(); + this.render_locale_picker(variants || []); } follow_in_page_anchor(event) { @@ -561,7 +569,7 @@ frappe.ui.DocsBrowser = class DocsBrowser { this.$state.removeClass("hide"); this.$state_message.text(__("No documentation is available for your account.")); this.update_breadcrumbs(); - this.update_locale_picker(); + this.render_locale_picker([]); } show_error(response, path) { diff --git a/compendium/docs.py b/compendium/docs.py index 11d46a4..101af4f 100644 --- a/compendium/docs.py +++ b/compendium/docs.py @@ -31,6 +31,70 @@ def get_page(path: str = "", locale: str | None = None): """Return rendered HTML and metadata for a documentation page at the given logical path.""" locale = normalize_locale(locale) page = get_page_record(normalize_path(path), locale=locale, check_permission=True) + return build_page_payload(page, locale) + + +@frappe.whitelist() +def get_view(path: str = "", locale: str | None = None, resolve_first: int | bool = 0): + """Return everything the docs browser needs for one screen. + + Always includes the navigation tree. When the requested page is missing or not + permitted, `page` is None and `exc_type` explains why — the request still + succeeds so the client can render the sidebar. Pass `resolve_first` when the + route has a locale but no path; the first accessible page is selected. + """ + locale = normalize_locale(locale) + pages = discover_pages(locale) + tree = build_navigation_tree(locale) + first_path = get_first_page_path(pages) + + if cint(resolve_first): + path = first_path + else: + path = normalize_path(path) + + if path is None: + return { + "tree": tree, + "page": None, + "variants": [], + "path": None, + "first_path": None, + "exc_type": None, + } + + page_record = pages.get(path) + if not page_record: + return { + "tree": tree, + "page": None, + "variants": [], + "path": path, + "first_path": first_path, + "exc_type": "DoesNotExistError", + } + + if not is_permitted(page_record): + return { + "tree": tree, + "page": None, + "variants": [], + "path": path, + "first_path": first_path, + "exc_type": "PermissionError", + } + + return { + "tree": tree, + "page": build_page_payload(page_record, locale), + "variants": get_page_variants(path), + "path": path, + "first_path": first_path, + "exc_type": None, + } + + +def build_page_payload(page, locale): content = render_page_content(page.body, page.path, locale) user_roles = set(get_user_roles()) matching_roles = [role for role in page.roles if role in user_roles] @@ -52,14 +116,7 @@ def get_first_page(locale: str | None = None): """Return the logical path of the first accessible documentation page.""" locale = normalize_locale(locale) pages = discover_pages(locale) - permitted = sorted( - (page for page in pages.values() if is_permitted(page)), - key=lambda page: (page.order, page.title.lower(), page.path), - ) - if not permitted: - return None - - return permitted[0].path + return get_first_page_path(pages) @frappe.whitelist() diff --git a/compendium/tests/test_docs.py b/compendium/tests/test_docs.py index 22f2f41..6a17f03 100644 --- a/compendium/tests/test_docs.py +++ b/compendium/tests/test_docs.py @@ -15,6 +15,7 @@ get_page_record, get_page_variants, get_tree, + get_view, merge_translated_page, normalize_path, parse_roles, @@ -541,13 +542,48 @@ def test_discover_raw_pages_is_request_cached(self): } ): with patch("compendium.docs.build_page_record", wraps=build_page_record) as mocked: - get_tree("en") - get_page("guide", locale="en") - get_page_variants("guide") + get_view("guide", locale="en") # two markdown files; without request cache each consumer would re-walk them self.assertEqual(mocked.call_count, 2) self.assertIs(discover_raw_pages(), discover_raw_pages()) + def test_get_view_returns_tree_page_and_variants(self): + with self.docs_environment( + { + "en/guide.md": "---\ntitle: Guide\n---\n# English", + "de/guide.md": "---\ntitle: Leitfaden\n---\n# Deutsch", + } + ): + view = get_view("guide", locale="en") + self.assertEqual(view["page"]["title"], "Guide") + self.assertIn("English", view["page"]["content"]) + self.assertEqual([node["path"] for node in view["tree"]], ["guide"]) + self.assertEqual([variant["locale"] for variant in view["variants"]], ["de", "en"]) + self.assertIsNone(view["exc_type"]) + + def test_get_view_keeps_tree_when_page_is_missing(self): + with self.docs_environment( + { + "en/guide.md": "---\ntitle: Guide\n---\n# English", + } + ): + view = get_view("missing", locale="en") + self.assertIsNone(view["page"]) + self.assertEqual(view["exc_type"], "DoesNotExistError") + self.assertEqual([node["path"] for node in view["tree"]], ["guide"]) + self.assertEqual(view["first_path"], "guide") + + def test_get_view_resolve_first_selects_opening_page(self): + with self.docs_environment( + { + "en/b.md": "---\ntitle: B\norder: 2\n---\n# B", + "en/a.md": "---\ntitle: A\norder: 1\n---\n# A", + } + ): + view = get_view(locale="en", resolve_first=True) + self.assertEqual(view["path"], "a") + self.assertEqual(view["page"]["title"], "A") + def docs_environment(self, files): return DocsTestEnvironment(files)