Component Style
"; }' + component_root.clientScript = {"js": block_javascript, "css": block_css} + component_root.attach_children(component_content) + component = frappe.get_doc( + { + "doctype": "Builder Component", + "block": component_root.as_json(), + } + ).insert() + + body = Block( + element="div", + originalElement="body", + ) + component_root_copy = Block(extendedFromComponent=component.name) + component_content_copy = Block( + isChildOfComponent=component.name, + referenceBlockId="comp-content", + ) + component_root_copy.attach_children(component_content_copy) + body.attach_children(component_root_copy) + + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Component Client Script Test", + "published": 1, + "route": "/component-client-script-test", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + try: + content = get_response_content("/component-client-script-test") + self.assertNotIn(block_javascript, content) + self.assertNotIn(block_css, content) + self.assertIn(r"<\/script>Component Script
", content) + self.assertIn(r"<\/style>Component Style
", content) + finally: + page.delete() + component.delete() + + def test_component_client_script_receives_context(self): + component_data_for_script = """ +component.update({ + "component_data": {"greeting": "hello from component data"}, +}) +""" + component_root = Block( + element="div", + blockId="script-root", + clientScript={"js": 'this.dataset.received = "ok";'}, + props={ + "title": { + "label": "Title", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": "Default Title", + "propOptions": { + "isRequired": False, + "type": "string", + "options": {"defaultValue": "Default Title"}, + }, + }, + }, + ) + component = frappe.get_doc( + { + "doctype": "Builder Component", + "block": component_root.as_json(), + "component_data_script": component_data_for_script, + } + ).insert() + + body = Block(element="div", originalElement="body") + component_root_copy = Block( + extendedFromComponent=component.name, + props={ + "title": { + "label": "Title", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": "Overridden Title", + "propOptions": { + "isRequired": False, + "type": "string", + "options": {"defaultValue": "Default Title"}, + }, + }, + }, + ) + body.attach_children(component_root_copy) + + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Component Client Script Args Test", + "published": 1, + "route": "/component-client-script-args-test", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + try: + content = get_response_content("/component-client-script-args-test") + self.assertIn("component_data, props", content) + self.assertIn('"greeting": "hello from component data"', content) + self.assertIn('"title": "Overridden Title"', content) + self.assertNotIn("/assets/builder/js/reactivity.js", content) + self.assertRegex( + content, + r"client_script_[a-z0-9_]+\)\.call\(" + r"document\.querySelector\('\[data-block-uid=\"[^\"]+\"\]'\), " + r'\{[^}]*"greeting": "hello from component data"[^}]*\}, ' + r'\{[^}]*"title": "Overridden Title"[^}]*\}\)', + ) + finally: + page.delete() + component.delete() + + def test_block_client_script_is_registered_once_and_invoked_per_block(self): + javascript = 'this.dataset.message = "Block Script";' + css = 'span::after { content: "Block Style"; }' + body = Block(element="div", originalElement="body") + for block_id in ("script-block-one", "script-block-two"): + body.attach_children( + Block( + element="div", + blockId=block_id, + clientScript={"js": javascript, "css": css}, + ) + ) + + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Block Client Script Test", + "published": 1, + "route": "/block-client-script-test", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + try: + content = get_response_content("/block-client-script-test") + self.assertNotIn(javascript, content) + self.assertNotIn(css, content) + self.assertIn(r"<\/script>Block Script", content) + self.assertIn(r"<\/style>Block Style", content) + self.assertEqual(content.count("async function client_script_"), 1) + self.assertEqual(content.count(").call(document.querySelector"), 2) + finally: + page.delete() + + def test_legacy_block_client_script_fallback(self): + javascript = 'this.dataset.legacy = "supported";' + legacy_block = Block(element="div", blockId="legacy-script-block").as_dict() + legacy_block.pop("clientScript") + legacy_block["blockClientScript"] = javascript + body = Block(element="div", originalElement="body").as_dict() + body["children"] = [legacy_block] + + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Legacy Block Client Script Test", + "published": 1, + "route": "/legacy-block-client-script-test", + "blocks": frappe.as_json([body]), + } + ).insert() + + try: + content = get_response_content("/legacy-block-client-script-test") + self.assertIn(javascript, content) + finally: + page.delete() + + normalized_block = Block(blockClientScript=javascript).as_dict() + self.assertEqual(normalized_block["clientScript"], {"js": javascript}) + self.assertNotIn("blockClientScript", normalized_block) + + def test_block_template_root_props(self): + template_root = Block( + element="section", + blockId="template-props-root", + props={ + "title": { + "label": "Title", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": None, + "propOptions": { + "isRequired": False, + "type": "string", + "options": {"defaultValue": "Template Title"}, + }, + } + }, + ) + title = Block(element="h2", blockId="template-title", innerHTML="Fallback") + title.set_dynamic_value("title", "key", "innerHTML", "props") + template_root.attach_children(title) + body = Block(element="div", originalElement="body") + body.attach_children(template_root) + + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Block Template Props Test", + "published": 1, + "route": "/block-template-props-test", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + try: + content = get_response_content("/block-template-props-test") + self.assertEqual("Template Title", get_html_for(content, "tag", "h2", only_content=True)) + finally: + page.delete() + + def test_component_data_uses_root_prop_defaults(self): + from builder.builder.doctype.builder_component.builder_component import get_component_data + + component_root = Block( + element="div", + props={ + "title": { + "isStandard": True, + "value": None, + "propOptions": { + "type": "string", + "options": {"defaultValue": "Default Title"}, + }, + } + }, + ) + component = frappe.get_doc( + { + "doctype": "Builder Component", + "block": component_root.as_json(), + "component_data_script": 'component["title"] = props.title', + } + ).insert() + + try: + self.assertEqual(get_component_data(component.name), {"title": "Default Title"}) + finally: + component.delete() + + def test_pinned_component_version_keeps_block_client_script(self): + old_script = 'this.dataset.version = "old";' + new_script = 'this.dataset.version = "new";' + component_root = Block( + element="div", + blockId="pinned-script-root", + clientScript={"js": old_script}, + props={ + "title": { + "isStandard": True, + "isPassedDown": True, + "value": "Pinned Prop Old", + } + }, + ) + component = frappe.get_doc( + { + "doctype": "Builder Component", + "block": component_root.as_json(), + } + ).insert() + pinned_version = ensure_component_version(component.name) + + component_root.clientScript = {"js": new_script} + component_root.props["title"]["value"] = "Pinned Prop New" + frappe.db.set_value( + "Builder Component", + component.name, + "block", + component_root.as_json(), + update_modified=False, + ) + frappe.clear_document_cache("Builder Component", component.name) + + body = Block(element="div", originalElement="body") + body.attach_children( + Block( + extendedFromComponent=component.name, + componentVersion=pinned_version, + ) + ) + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Pinned Block Client Script Test", + "published": 1, + "route": "/pinned-block-client-script-test", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + try: + content = get_response_content("/pinned-block-client-script-test") + self.assertIn(old_script, content) + self.assertNotIn(new_script, content) + self.assertIn("Pinned Prop Old", content) + self.assertNotIn("Pinned Prop New", content) + finally: + page.delete() + component.delete() + frappe.db.delete( + "Builder Snapshot", + {"reference_doctype": "Builder Component", "reference_name": component.name}, + ) + + def test_component_props(self): + component_root = Block(element="div", blockId="wrapper-block") + content_static_prop = Block( + blockId="static-content", element="h4", innerHTML="Component Props Content" + ) + content_dynamic_prop = Block( + blockId="dynamic-content", element="h4", innerHTML="Component Props Content" + ) + content_last_name = Block( + blockId="last-name-content", element="h4", innerHTML="Component Props Content" + ) + content_fallback = Block( + blockId="fallback-content", element="h4", innerHTML="Component Props Content" + ) + + content_static_prop.set_dynamic_value("first_name", "key", "innerHTML", "props") + content_dynamic_prop.set_dynamic_value("name", "key", "innerHTML", "componentData") + content_last_name.set_dynamic_value("last_name", "key", "innerHTML", "props") + content_fallback.set_dynamic_value("middle_name", "key", "innerHTML", "props") + + component_root.attach_children( + content_static_prop, content_dynamic_prop, content_last_name, content_fallback + ) + component_root.props = { + "first_name": { + "label": "First Name", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": "John", + "propOptions": { + "isRequired": False, + "type": "string", + "options": {"defaultValue": ""}, + }, + }, + "last_name": { + "label": "Last Name", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": "Doe", + "propOptions": { + "isRequired": False, + "type": "string", + "options": {"defaultValue": ""}, + }, + }, + } + component = frappe.get_doc( + { + "doctype": "Builder Component", + "block": component_root.as_json(), + "component_data_script": component_data_script, + } + ).insert() + + body = Block( + element="div", + originalElement="body", + ) + component_root_copy = Block(extendedFromComponent=component.name) + content_static_copy = Block(isChildOfComponent=component.name, referenceBlockId="static-content") + content_dynamic_copy = Block(isChildOfComponent=component.name, referenceBlockId="dynamic-content") + content_last_name_copy = Block( + isChildOfComponent=component.name, referenceBlockId="last-name-content" + ) + content_fallback_copy = Block(isChildOfComponent=component.name, referenceBlockId="fallback-content") + component_root_copy.attach_children( + content_static_copy, + content_dynamic_copy, + content_last_name_copy, + content_fallback_copy, + ) + body.attach_children(component_root_copy) + + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Component Props Test", + "published": 1, + "route": "/component-props-test", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + try: + content = get_response_content("/component-props-test") + self.assertEqual("John", get_html_for(content, "tag", "h4", only_content=True)) + self.assertEqual("John Doe", get_html_for(content, "tag", "h4", index=1, only_content=True)) + self.assertEqual("Doe", get_html_for(content, "tag", "h4", index=2, only_content=True)) + self.assertEqual( + "Component Props Content", get_html_for(content, "tag", "h4", index=3, only_content=True) + ) + finally: + page.delete() + component.delete() + + def test_std_props(self): + component_root = Block( + blockId="header-block", + element="header", + blockName="header", + ) + + component_title_block = Block(blockId="title-block", element="h1", innerHTML="Header Title") + component_title_block.set_dynamic_value("title", "key", "innerHTML", "props") + + component_age_block = Block(blockId="age-block", element="h4", innerHTML="Age") + component_age_block.set_dynamic_value("age", "key", "innerHTML", "props") + + component_badge_block = Block(blockId="badge-block", element="h6", innerHTML="Badge") + component_badge_block.visibilityCondition = { + "key": "show_badge", + "comesFrom": "props", + } + + component_root.attach_children(component_title_block, component_age_block, component_badge_block) + component_root.props = { + "title": { + "label": "Title", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": None, + "propOptions": { + "isRequired": False, + "type": "string", + "options": {"defaultValue": "Default Header Title"}, + }, + }, + "age": { + "label": "Age", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": None, + "propOptions": { + "isRequired": False, + "type": "number", + "options": {"defaultValue": 25}, + }, + }, + "show_badge": { + "label": "Show Badge", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": None, + "propOptions": { + "isRequired": False, + "type": "boolean", + "options": {"defaultValue": False}, + }, + }, + } + component = frappe.get_doc( + { + "doctype": "Builder Component", + "block": component_root.as_json(), + } + ).insert() + + body = Block( + element="div", + originalElement="body", + ) + + component_root_copy = Block(extendedFromComponent=component.name) + component_title_block_copy = Block(isChildOfComponent=component.name, referenceBlockId="title-block") + component_age_block_copy = Block(isChildOfComponent=component.name, referenceBlockId="age-block") + component_badge_block_copy = Block(isChildOfComponent=component.name, referenceBlockId="badge-block") + + component_root_copy.attach_children( + component_title_block_copy, component_age_block_copy, component_badge_block_copy + ) + body.attach_children(component_root_copy) + + page_with_default_values = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Std Props Test", + "published": 1, + "route": "/block-std-props-test-no-overrides", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + component_root_copy.props = { + "title": { + "label": "Title", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": "Overridden Header Title", + "propOptions": { + "isRequired": False, + "type": "string", + "options": {"defaultValue": "Default Header Title"}, + }, + }, + "age": { + "label": "Age", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": 29, + "propOptions": { + "isRequired": False, + "type": "number", + "options": {"defaultValue": 25}, + }, + }, + "show_badge": { + "label": "Show Badge", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": True, + "propOptions": { + "isRequired": False, + "type": "boolean", + "options": {"defaultValue": False}, + }, + }, + } + + page_with_overridden_values = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Std Props Test With Overrides", + "published": 1, + "route": "/block-std-props-test-overrides", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + try: + content_with_default_values = get_response_content("/block-std-props-test-no-overrides") + content_with_overridden_values = get_response_content("/block-std-props-test-overrides") + + self.assertEqual( + "Default Header Title", + get_html_for(content_with_default_values, "tag", "h1", only_content=True), + ) + self.assertEqual( + "25.0", get_html_for(content_with_default_values, "tag", "h4", only_content=True) + ) + self.assertFalse("Badge" in get_html_for(content_with_default_values, "tag", "h6")) + + self.assertEqual( + "Overridden Header Title", + get_html_for(content_with_overridden_values, "tag", "h1", only_content=True), + ) + self.assertEqual( + "29.0", get_html_for(content_with_overridden_values, "tag", "h4", only_content=True) + ) + self.assertTrue("Badge" in get_html_for(content_with_overridden_values, "tag", "h6")) + finally: + page_with_default_values.delete() + page_with_overridden_values.delete() + component.delete() + + def test_repeater_from_std_props(self): + component_root = Block( + blockId="navbar-wrapper-block", + element="header", + blockName="navbar", + ) + component_repeater_block = Block( + blockId="repeater-block", + element="nav", + blockName="nav", + isRepeaterBlock=True, + ) + component_repeater_block.attach_data_key("links", "innerHTML", type="key", comesFrom="props") + component_link_block = Block( + blockId="link-block", element="a", innerHTML="Home", attributes={"href": "/home"} + ) + component_link_block.set_dynamic_value("key", "key", "innerHTML") + component_link_block.set_dynamic_value("value", "attribute", "href") + + component_repeater_block.attach_children(component_link_block) + component_root.attach_children(component_repeater_block) + component_root.props = { + "links": { + "label": "Links", + "isStandard": True, + "isDynamic": False, + "isPassedDown": True, + "comesFrom": None, + "value": None, + "propOptions": { + "isRequired": False, + "type": "object", + "options": { + "minItems": None, + "maxItems": None, + "defaultValue": { + "1. Home": "/", + "2. Products": "/products", + "3. About Us": "/about", + }, + }, + }, + } + } + + component = frappe.get_doc( + { + "doctype": "Builder Component", + "block": component_root.as_json(), + } + ).insert() + + body = Block( + element="div", + originalElement="body", + ) + component_root_copy = Block(extendedFromComponent=component.name) + component_repeater_block_copy = Block( + isChildOfComponent=component.name, referenceBlockId="repeater-block", isRepeaterBlock=True + ) + component_link_block_copy = Block(isChildOfComponent=component.name, referenceBlockId="link-block") + component_repeater_block_copy.attach_children(component_link_block_copy) + component_root_copy.attach_children(component_repeater_block_copy) + body.attach_children(component_root_copy) + + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Std Props Repeater Test", + "published": 1, + "route": "/block-std-props-test", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + try: + content = get_response_content("/block-std-props-test") + self.assertEqual("1. Home", get_html_for(content, "tag", "a", only_content=True)) + self.assertTrue('href="/"' in get_html_for(content, "tag", "a", only_content=False)) + self.assertEqual("2. Products", get_html_for(content, "tag", "a", index=1, only_content=True)) + self.assertTrue( + 'href="/products"' in get_html_for(content, "tag", "a", index=1, only_content=False) + ) + self.assertEqual("3. About Us", get_html_for(content, "tag", "a", index=2, only_content=True)) + self.assertTrue('href="/about"' in get_html_for(content, "tag", "a", index=2, only_content=False)) + finally: + page.delete() + component.delete() + + def test_dark_mode_img(self): + body = Block( + element="div", + originalElement="body", + ) + image_block = Block( + element="img", + attributes={ + "src": "/files/light-mode-image.png", + "darkSrc": "/files/dark-mode-image.png", + "alt": "Test Image", + }, + ) + image_block_only_dark_mode = Block( + element="img", + attributes={ + "darkSrc": "/files/another-dark-mode-image.png", + "alt": "Test Image", + }, + ) + body.attach_children(image_block, image_block_only_dark_mode) + + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Dark Mode Image Test", + "published": 1, + "route": "/dark-mode-image-test", + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + try: + content = get_response_content("/dark-mode-image-test") + self.assertTrue( + 'src="/files/light-mode-image.png"' in get_html_for(content, "tag", "img", only_content=False) + ) + self.assertTrue( + 'srcset="/files/dark-mode-image.png"' + in get_html_for(content, "tag", "source", only_content=False) + ) + self.assertTrue( + 'src="/files/another-dark-mode-image.png"' + in get_html_for(content, "tag", "img", index=1, only_content=False) + ) + self.assertTrue("--builder-image-dim: brightness(0.85) contrast(1.05)" in content) + self.assertTrue("img { filter: var(--builder-image-dim, none) }" in content) + finally: + page.delete() + + def test_nested_repeater_from_page_data(self): + body = Block( + element="div", + originalElement="body", + ) + parent_repeater = Block(element="div", isRepeaterBlock=True) + child_repeater = Block(element="div", isRepeaterBlock=True) + wrapper_div = Block(element="div") + + parent_repeater.attach_data_key("item_group", "dataKey") + child_repeater.attach_data_key("group", "dataKey") + + item_name = Block(element="h2") + item_price = Block(element="span") + + item_name.set_dynamic_value("name", "key", "innerHTML") + item_price.set_dynamic_value("price", "key", "innerHTML") + + wrapper_div.attach_children(item_name, item_price) + child_repeater.attach_children(wrapper_div) + parent_repeater.attach_children(child_repeater) + body.attach_children(parent_repeater) + + page = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Nested Repeater Blocks Test", + "published": 1, + "route": "/nested-repeater-blocks-test", + "page_data_script": repeater_page_data_script, + "blocks": body.as_json(wrap_in_array=True), + } + ).insert() + + try: + content = get_response_content("/nested-repeater-blocks-test") + self.assertTrue("Item A1" in get_html_for(content, "tag", "h2")) + self.assertTrue("$10" in get_html_for(content, "tag", "span")) + self.assertTrue("Item A2" in get_html_for(content, "tag", "h2", index=1)) + self.assertTrue("$20" in get_html_for(content, "tag", "span", index=1)) + self.assertFalse("Item B1" in get_html_for(content, "tag", "h2")) + self.assertFalse("$15" in get_html_for(content, "tag", "span")) + self.assertFalse("Item B2" in get_html_for(content, "tag", "h2", index=1)) + self.assertFalse("$25" in get_html_for(content, "tag", "span", index=1)) + finally: + page.delete() + + def test_set_fonts(self): + from builder.builder.doctype.builder_page.builder_page import set_fonts + + font_map = {} + styles = [ + {"fontFamily": "Inter", "fontWeight": "bold"}, + {"fontFamily": "Inter", "fontWeight": 400}, + {"fontFamily": "'Open Sans'", "fontWeight": "600"}, + {"fontFamily": "Impact", "fontWeight": "800"}, # System font, should be skipped + {"fontFamily": "Inter", "fontWeight": "bold"}, # Duplicate + ] + + set_fonts(styles, font_map) + + self.assertIn("Inter", font_map) + self.assertIn("Open Sans", font_map) + self.assertNotIn("Impact", font_map) + + # Weights should be normalized to integers and deduplicated + self.assertEqual(font_map["Inter"]["weights"], [400, 700]) + self.assertEqual(font_map["Open Sans"]["weights"], [600]) + + def test_set_fonts_uses_primary_family_from_fallback_list(self): + from builder.builder.doctype.builder_page.builder_page import set_fonts + + font_map = {} + set_fonts([{"fontFamily": "Inter, sans-serif", "fontWeight": "500"}], font_map) + + # Only the first family is requested, not the whole CSS stack + self.assertIn("Inter", font_map) + self.assertNotIn("Inter, sans-serif", font_map) + + def test_get_google_font_urls(self): + from builder.builder.doctype.builder_page.builder_page import get_google_font_urls + + font_map = { + "Newsreader": {"weights": [500]}, + "Open Sans": {"weights": [700, 400]}, + "Foo & Bar": {"weights": [400]}, + } + urls = get_google_font_urls(font_map) + + # One combined request per family: 400 always included, weights sorted, family + # name URL-encoded (spaces -> +, reserved chars escaped so the URL can't break) + self.assertEqual( + urls, + [ + "https://fonts.googleapis.com/css2?family=Newsreader:wght@400;500&display=swap", + "https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap", + "https://fonts.googleapis.com/css2?family=Foo+%26+Bar:wght@400&display=swap", + ], + ) + + def test_get_google_font_urls_with_italics(self): + """Fonts used in italic get the ital axis in the same single request, + with 400 italic always included as a fallback instance.""" + from builder.builder.doctype.builder_page.builder_page import get_google_font_urls + + font_map = { + "Roboto": {"weights": [400, 700], "italics": [400]}, + "Lora": {"weights": [400], "italics": [600]}, + # untouched fonts keep the exact legacy URL shape + "Open Sans": {"weights": [400]}, + } + urls = get_google_font_urls(font_map) + self.assertEqual( + urls, + [ + "https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400&display=swap", + "https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;1,400;1,600&display=swap", + "https://fonts.googleapis.com/css2?family=Open+Sans:wght@400&display=swap", + ], + ) + + def test_italics_cascade_like_font_family(self): + """Italic usage is resolved on the rendered block tree with CSS cascade + semantics, not per style dict.""" + from builder.builder.doctype.builder_page.builder_page import get_block_html + + def block(styles, children=None, element="div"): + return {"element": element, "baseStyles": styles, "children": children or []} + + # child sets fontStyle without a family: italics land on the ancestor font + _, _, font_map, _ = get_block_html( + [block({"fontFamily": "Fraunces"}, [block({"fontStyle": "italic", "fontWeight": "600"})])] + ) + self.assertEqual(font_map["Fraunces"]["italics"], [600]) + + # italic parent, child only switches family: font-style inherits, so the + # child family needs its italic faces too + _, _, font_map, _ = get_block_html( + [block({"fontFamily": "Fraunces", "fontStyle": "italic"}, [block({"fontFamily": "Lora"})])] + ) + self.assertEqual(font_map["Fraunces"]["italics"], [400]) + self.assertEqual(font_map["Lora"]["italics"], [400]) + + # a child resetting fontStyle: normal breaks the cascade again + _, _, font_map, _ = get_block_html( + [ + block( + {"fontFamily": "Fraunces", "fontStyle": "italic"}, + [block({"fontFamily": "Lora", "fontStyle": "normal"})], + ) + ] + ) + self.assertNotIn("italics", font_map["Lora"]) + + def test_set_italics_from_html(self): + """/ and inline font-style inside innerHTML register italic usage + for the block's resolved font.""" + import bs4 as bs + + from builder.builder.doctype.builder_page.builder_page import set_italics_from_html + + font_map = {"Fraunces": {"weights": [400]}, "Lora": {"weights": [400]}} + soup = bs.BeautifulSoup( + "Fire is the only recipe and " + 'this too', + "html.parser", + ) + set_italics_from_html(soup, font_map, ancestor_font="Fraunces") + self.assertEqual(font_map["Fraunces"].get("italics"), [400]) + self.assertEqual(font_map["Lora"].get("italics"), [400]) + + # fonts that never made it into the map (e.g. system fonts) are ignored + font_map_2 = {} + set_italics_from_html(bs.BeautifulSoup("hi", "html.parser"), font_map_2, "Arial") + self.assertEqual(font_map_2, {}) + + def test_set_fonts_inherits_font_family_from_ancestor(self): + """set_fonts should use inherited_font when a style has fontWeight but no fontFamily.""" + from builder.builder.doctype.builder_page.builder_page import set_fonts + + font_map = {} + styles = [{"fontWeight": "600"}] + + # Without inherited_font, nothing should be added + set_fonts(styles, font_map) + self.assertEqual(font_map, {}) + + # With inherited_font, the ancestor font should be registered + set_fonts(styles, font_map, inherited_font="Newsreader") + self.assertIn("Newsreader", font_map) + self.assertIn(600, font_map["Newsreader"]["weights"]) + + def test_font_weight_inherited_from_parent_block(self): + """Child block with only fontWeight should inherit fontFamily from parent in font_map.""" + from builder.builder.doctype.builder_page.builder_page import get_block_html + + blocks = [ + { + "element": "div", + "originalElement": "body", + "baseStyles": {"fontFamily": "Newsreader"}, + "children": [ + { + "element": "h1", + "innerHTML": "Headline", + "baseStyles": {"fontWeight": "700"}, + "children": [], + } + ], + } + ] + _, _, font_map, _ = get_block_html(blocks) + self.assertIn("Newsreader", font_map) + self.assertIn(700, font_map["Newsreader"]["weights"]) + + def test_intervar_font_skipped(self): + """InterVar should not appear in the font_map — it is loaded via reset.css.""" + from builder.builder.doctype.builder_page.builder_page import get_block_html + + blocks = [ + { + "element": "div", + "originalElement": "body", + "baseStyles": {"fontFamily": "InterVar", "fontWeight": "400"}, + "children": [], + } + ] + _, _, font_map, _ = get_block_html(blocks) + self.assertNotIn("InterVar", font_map) + self.assertNotIn("intervar", font_map) + + def test_renders_blocks_with_stripped_empty_values(self): + """Blocks are saved with empty defaults (attributes={}, classes=[], dataKey=null, + empty styles, etc.) stripped out to keep documents small""" + import re + + from builder.builder.doctype.builder_page.builder_page import get_block_html + + def empties(): + return { + "rawStyles": {}, + "mobileStyles": {}, + "tabletStyles": {}, + "attributes": {}, + "customAttributes": {}, + "classes": [], + "props": {}, + "dynamicValues": [], + "dataKey": None, + "activeState": None, + } + + full = [ + { + "blockId": "root", + "element": "div", + "originalElement": "body", + "baseStyles": {"display": "flex"}, + "children": [ + { + "blockId": "child1", + "element": "h1", + "innerHTML": "Hello World!", + "baseStyles": {"color": "red"}, + "children": [], + **empties(), + } + ], + **empties(), + } + ] + stripped = [ + { + "blockId": "root", + "element": "div", + "originalElement": "body", + "baseStyles": {"display": "flex"}, + "children": [ + { + "blockId": "child1", + "element": "h1", + "innerHTML": "Hello World!", + "baseStyles": {"color": "red"}, + } + ], + } + ] + + # CSS class names are a random hash per render (frappe.generate_hash) — ignore them. + def normalize(text): + return re.sub(r"[0-9a-f]{8,}", "H", text) + + html_full, css_full, _, _ = get_block_html(full) + html_stripped, css_stripped, _, _ = get_block_html(stripped) + + self.assertIn("Hello World!", html_stripped) + self.assertEqual(normalize(html_full), normalize(html_stripped)) + self.assertEqual(normalize(css_full), normalize(css_stripped)) + + # A block carrying dynamicValues but with attributes/styles stripped used to + # raise KeyError in set_dynamic_content_placeholders — guard against regression. + dynamic = [ + { + "blockId": "root", + "element": "div", + "originalElement": "body", + "baseStyles": {"display": "flex"}, + "children": [ + { + "blockId": "img1", + "element": "img", + "dynamicValues": [ + {"key": "logo", "type": "attribute", "property": "src", "comesFrom": "dataScript"} + ], + } + ], + } + ] + html_dynamic, _, _, _ = get_block_html(dynamic) + self.assertIn("logo", html_dynamic) + + with_unset_style = [ + { + "blockId": "root", + "element": "div", + "originalElement": "body", + "baseStyles": {"color": "red", "display": None}, + "children": [], + } + ] + _, css_unset, _, _ = get_block_html(with_unset_style) + self.assertIn("color: red", css_unset) + self.assertNotIn("display:", css_unset) + self.assertNotIn("None", css_unset) + + def test_renders_legacy_raw_styles_from_base_styles(self): + from builder.builder.doctype.builder_page.builder_page import get_block_html + + blocks = [ + { + "blockId": "legacy", + "element": "button", + "baseStyles": {"background": "red"}, + "rawStyles": {"background": "blue", "hover:background-color": "black"}, + "children": [], + } + ] + + _, css, _, _ = get_block_html(blocks) + + self.assertIn("background: blue", css) + self.assertIn(":hover", css) + self.assertIn("background-color: black", css) + self.assertNotIn("background: red", css) + + def test_renders_legacy_raw_styles_from_component(self): + from builder.builder.doctype.builder_page.builder_page import get_block_html + + component_root = { + "blockId": "comp-root", + "element": "div", + "rawStyles": {"text-overflow": "ellipsis"}, + "children": [{"blockId": "comp-child", "element": "span", "rawStyles": {"flex-shrink": "0"}}], + } + component = frappe.get_doc( + {"doctype": "Builder Component", "block": frappe.as_json(component_root)} + ).insert() + + blocks = [ + { + "blockId": "instance", + "extendedFromComponent": component.name, + "children": [{"blockId": "comp-child", "isChildOfComponent": component.name}], + } + ] + + try: + _, css, _, _ = get_block_html(blocks) + self.assertIn("text-overflow: ellipsis", css) + self.assertIn("flex-shrink: 0", css) + finally: + component.delete() + + def test_renders_blocks_with_only_responsive_styles(self): + from builder.builder.doctype.builder_page.builder_page import get_block_html + + blocks = [{"blockId": "mobile-only", "element": "div", "mobileStyles": {"textOverflow": "ellipsis"}}] + + html, css, _, _ = get_block_html(blocks) + + self.assertIn("fb-", html) + self.assertIn("@media only screen and (max-width: 576px)", css) + self.assertIn("text-overflow: ellipsis", css) + + def test_conflicting_routes_picks_last_published(self): + """Pages sharing a route should resolve to the most recently published one.""" + from frappe.utils import add_to_date, now_datetime + from frappe.website.utils import clear_cache as clear_page_cache + + from builder.builder.doctype.builder_page.builder_page import find_page_with_path + + # Frappe strips leading slashes from routes during validation; use without slash + route = "conflicting-route-test" + + page_older = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Older Published Page", + "published": 1, + "route": route, + "blocks": Block( + element="div", + originalElement="body", + children=[Block(element="h1", innerHTML="Older Published Content")], + ).as_json(wrap_in_array=True), + } + ).insert() + + page_newer = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Newer Published Page", + "published": 1, + "route": route, + "blocks": Block( + element="div", + originalElement="body", + children=[Block(element="h1", innerHTML="Newer Published Content")], + ).as_json(wrap_in_array=True), + } + ).insert() + + def clear_caches(): + find_page_with_path.clear_cache() + clear_page_cache(route) + + try: + page_older.db_set("published_at", add_to_date(now_datetime(), days=-2)) + page_newer.db_set("published_at", add_to_date(now_datetime(), days=-1)) + clear_caches() + + content = get_response_content(f"/{route}") + self.assertIn("Newer Published Content", content) + + # Republish the older page — it should now be picked + page_older.db_set("published_at", now_datetime()) + clear_caches() + + content = get_response_content(f"/{route}") + self.assertIn("Older Published Content", content) + finally: + clear_caches() + page_older.delete() + page_newer.delete() + + def test_conflicting_routes_no_published_at_picks_last_created(self): + """When published_at is absent, the most recently created page should win.""" + from frappe.utils import add_to_date, now_datetime + from frappe.website.utils import clear_cache as clear_page_cache + + from builder.builder.doctype.builder_page.builder_page import find_page_with_path + + # Frappe strips leading slashes from routes during validation; use without slash + route = "conflicting-route-no-published-at-test" + + page_first = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "First Created Page", + "published": 1, + "route": route, + "blocks": Block( + element="div", + originalElement="body", + children=[Block(element="h1", innerHTML="First Created Content")], + ).as_json(wrap_in_array=True), + } + ).insert() + + page_second = frappe.get_doc( + { + "doctype": "Builder Page", + "page_title": "Second Created Page", + "published": 1, + "route": route, + "blocks": Block( + element="div", + originalElement="body", + children=[Block(element="h1", innerHTML="Second Created Content")], + ).as_json(wrap_in_array=True), + } + ).insert() + + # Ensure page_first has an older creation timestamp as a tiebreaker + page_first.db_set("creation", add_to_date(now_datetime(), seconds=-10)) + + def clear_caches(): + find_page_with_path.clear_cache() + clear_page_cache(route) + + try: + # Both pages have no published_at; creation order should determine the winner + clear_caches() + content = get_response_content(f"/{route}") + self.assertIn("Second Created Content", content) + finally: + clear_caches() + page_first.delete() + page_second.delete() + @classmethod def tearDownClass(cls): cls.page.delete() cls.page_with_dynamic_route.delete() -def get_html_for(html, type, value, index=None): +def get_html_for(html, type, value, index=None, only_content=False, list_all=False): from bs4 import BeautifulSoup soup = BeautifulSoup(html, "html.parser") if type == "tag": results = soup.find_all(value) + if list_all: + return [result.decode_contents() if only_content else str(result) for result in results] result = ( results[index] if index is not None and index < len(results) else results[0] if results else None ) + if only_content and result: + return result.decode_contents() return str(result) if result else "" if type == "attribute": - results = soup.find_all(attrs=value) + results = soup.find_all(attrs={value: True}) + if list_all: + return [result.get(value) for result in results if result.get(value)] result = ( results[index] if index is not None and index < len(results) else results[0] if results else None ) - return str(result) if result else "" + return result.get(value) if result and result.get(value) else "" diff --git a/frontend/src/components/Controls/BlockStyleManager.vue b/builder/builder/doctype/builder_page_click/__init__.py similarity index 100% rename from frontend/src/components/Controls/BlockStyleManager.vue rename to builder/builder/doctype/builder_page_click/__init__.py diff --git a/builder/builder/doctype/builder_page_click/builder_page_click.json b/builder/builder/doctype/builder_page_click/builder_page_click.json new file mode 100644 index 000000000..647a10c2e --- /dev/null +++ b/builder/builder/doctype/builder_page_click/builder_page_click.json @@ -0,0 +1,86 @@ +{ + "actions": [], + "creation": "2026-06-23 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "path", + "element", + "text", + "is_unique", + "visitor_id" + ], + "fields": [ + { + "fieldname": "path", + "fieldtype": "Data", + "label": "Path", + "search_index": 1, + "set_only_once": 1 + }, + { + "fieldname": "element", + "fieldtype": "Data", + "label": "Element", + "set_only_once": 1 + }, + { + "fieldname": "text", + "fieldtype": "Data", + "label": "Text", + "set_only_once": 1 + }, + { + "fieldname": "is_unique", + "fieldtype": "Check", + "label": "Is Unique" + }, + { + "fieldname": "visitor_id", + "fieldtype": "Data", + "label": "Visitor ID", + "read_only": 1, + "search_index": 1 + } + ], + "in_create": 1, + "links": [], + "modified": "2026-06-23 00:00:00.000000", + "modified_by": "Administrator", + "module": "Builder", + "name": "Builder Page Click", + "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": "Website Manager", + "share": 1, + "write": 1 + } + ], + "read_only": 1, + "row_format": "Compressed", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "path", + "track_changes": 0 +} diff --git a/builder/builder/doctype/builder_page_click/builder_page_click.py b/builder/builder/doctype/builder_page_click/builder_page_click.py new file mode 100644 index 000000000..d0861d0c9 --- /dev/null +++ b/builder/builder/doctype/builder_page_click/builder_page_click.py @@ -0,0 +1,30 @@ +# Copyright (c) 2026, Frappe Technologies Pvt Ltd and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class BuilderPageClick(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + element: DF.Data | None + is_unique: DF.Check + path: DF.Data | None + text: DF.Data | None + visitor_id: DF.Data | None + # end: auto-generated types + + @staticmethod + def clear_old_logs(days=180): + from frappe.query_builder import Interval + from frappe.query_builder.functions import Now + + table = frappe.qb.DocType("Builder Page Click") + frappe.db.delete(table, filters=(table.creation < (Now() - Interval(days=days)))) diff --git a/builder/builder/doctype/builder_project_folder/builder_project_folder.js b/builder/builder/doctype/builder_project_folder/builder_project_folder.js index 630d2761b..0a7278931 100644 --- a/builder/builder/doctype/builder_project_folder/builder_project_folder.js +++ b/builder/builder/doctype/builder_project_folder/builder_project_folder.js @@ -1,8 +1,21 @@ // Copyright (c) 2024, Frappe Technologies Pvt Ltd and contributors // For license information, please see license.txt -// frappe.ui.form.on("Builder Project Folder", { -// refresh(frm) { +frappe.ui.form.on("Builder Project Folder", { + refresh(frm) { + frm.get_field("is_standard").df.read_only = !frappe.boot.developer_mode; + frm.refresh_field("is_standard"); -// }, -// }); + if (frm.doc.is_standard && !frappe.boot.developer_mode) { + frm.disable_form(); + frm.dashboard.clear_comment(); + frm.dashboard.add_comment( + __( + "Standard folders cannot be modified. Please enable developer mode to edit standard folders.", + ), + "orange", + true, + ); + } + }, +}); diff --git a/builder/builder/doctype/builder_project_folder/builder_project_folder.json b/builder/builder/doctype/builder_project_folder/builder_project_folder.json index 7507969b7..db813b83f 100644 --- a/builder/builder/doctype/builder_project_folder/builder_project_folder.json +++ b/builder/builder/doctype/builder_project_folder/builder_project_folder.json @@ -6,6 +6,7 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "is_standard", "folder_name" ], "fields": [ @@ -14,11 +15,18 @@ "fieldtype": "Data", "label": "Folder Name", "unique": 1 + }, + { + "default": "0", + "fieldname": "is_standard", + "fieldtype": "Check", + "label": "Is Standard" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2024-11-26 17:04:15.337801", + "modified": "2025-11-11 11:51:16.422175", "modified_by": "Administrator", "module": "Builder", "name": "Builder Project Folder", @@ -50,7 +58,8 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/builder/builder/doctype/builder_project_folder/builder_project_folder.py b/builder/builder/doctype/builder_project_folder/builder_project_folder.py index d36fd16d4..a2a910c2b 100644 --- a/builder/builder/doctype/builder_project_folder/builder_project_folder.py +++ b/builder/builder/doctype/builder_project_folder/builder_project_folder.py @@ -1,7 +1,7 @@ # Copyright (c) 2024, Frappe Technologies Pvt Ltd and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document @@ -15,6 +15,37 @@ class BuilderProjectFolder(Document): from frappe.types import DF folder_name: DF.Data | None + is_standard: DF.Check # end: auto-generated types - pass + def validate(self): + """Validate that standard folders cannot be edited if not in developer mode""" + if self.is_standard and not frappe.conf.get("developer_mode"): + if not is_system_activity(): + frappe.throw( + frappe._( + "Standard folders cannot be modified. Please enable developer mode to edit standard folders." + ), + frappe.PermissionError, + ) + + def on_trash(self): + """Prevent deletion of standard folders when not in developer mode""" + if self.is_standard and not frappe.conf.get("developer_mode"): + if not is_system_activity(): + frappe.throw( + frappe._( + "Standard folders cannot be deleted. Please enable developer mode to delete standard folders." + ), + frappe.PermissionError, + ) + + +def is_system_activity(): + return ( + frappe.flags.in_import + or frappe.flags.in_patch + or frappe.flags.in_migrate + or frappe.in_test + or frappe.flags.in_install + ) diff --git a/builder/builder/doctype/builder_variable/test_builder_variable.py b/builder/builder/doctype/builder_project_folder/test_builder_project_folder.py similarity index 60% rename from builder/builder/doctype/builder_variable/test_builder_variable.py rename to builder/builder/doctype/builder_project_folder/test_builder_project_folder.py index d20d8d6c8..965059ed8 100644 --- a/builder/builder/doctype/builder_variable/test_builder_variable.py +++ b/builder/builder/doctype/builder_project_folder/test_builder_project_folder.py @@ -2,7 +2,7 @@ # See license.txt # import frappe -from frappe.tests import IntegrationTestCase, UnitTestCase +from frappe.tests import IntegrationTestCase # On IntegrationTestCase, the doctype test records and all # link-field test record dependencies are recursively loaded @@ -11,18 +11,9 @@ IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -class UnitTestbuilderVariable(UnitTestCase): +class IntegrationTestBuilderProjectFolder(IntegrationTestCase): """ - Unit tests for builderVariable. - Use this class for testing individual functions and methods. - """ - - pass - - -class IntegrationTestbuilderVariable(IntegrationTestCase): - """ - Integration tests for builderVariable. + Integration tests for BuilderProjectFolder. Use this class for testing interactions between multiple components. """ diff --git a/builder/builder/doctype/builder_settings/builder_settings.json b/builder/builder/doctype/builder_settings/builder_settings.json index 5c927b034..bb1cbc2f7 100644 --- a/builder/builder/doctype/builder_settings/builder_settings.json +++ b/builder/builder/doctype/builder_settings/builder_settings.json @@ -13,9 +13,16 @@ "style_public_url", "favicon", "auto_convert_images_to_webp", + "disable_auto_dark_mode", "default_language", "landing_page_section", - "home_page" + "home_page", + "developer_options_section", + "execute_block_scripts_in_editor", + "restrict_click_handlers", + "ai_section", + "ai_api_key", + "persona_survey_done" ], "fields": [ { @@ -65,6 +72,13 @@ "fieldtype": "Check", "label": "Auto convert images to WebP" }, + { + "default": "0", + "description": "Disable automatic dark mode color scheme adjustments for all pages globally.", + "fieldname": "disable_auto_dark_mode", + "fieldtype": "Check", + "label": "Disable Auto Dark Mode" + }, { "default": "en", "description": "Default HTML lang code (e.g., en, es, fr)", @@ -85,13 +99,50 @@ "fieldtype": "Code", "label": "Body HTML", "options": "HTML" + }, + { + "fieldname": "developer_options_section", + "fieldtype": "Section Break", + "label": "Developer Options" + }, + { + "default": "Restricted", + "fieldname": "execute_block_scripts_in_editor", + "fieldtype": "Select", + "label": "Execute Block Scripts in Editor", + "options": "Don't Execute\nRestricted\nUnrestricted" + }, + { + "default": "1", + "fieldname": "restrict_click_handlers", + "fieldtype": "Check", + "label": "Restrict Click Handlers" + }, + { + "fieldname": "ai_section", + "fieldtype": "Section Break", + "label": "AI Settings" + }, + { + "description": "API key for the selected AI model provider", + "fieldname": "ai_api_key", + "fieldtype": "Password", + "label": "AI API Key" + }, + { + "default": "0", + "description": "Internal flag: set once the persona onboarding survey has been shown. Site-wide (Builder Settings is a Single) — acceptable proxy for per-user on single-user trial sites.", + "fieldname": "persona_survey_done", + "fieldtype": "Check", + "hidden": 1, + "label": "Persona Survey Done" } ], "hide_toolbar": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-02-19 14:34:18.392867", + "modified": "2026-06-05 16:00:00.000000", "modified_by": "Administrator", "module": "Builder", "name": "Builder Settings", @@ -113,8 +164,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/builder/builder/doctype/builder_settings/builder_settings.py b/builder/builder/doctype/builder_settings/builder_settings.py index cab59e0e8..c28489429 100644 --- a/builder/builder/doctype/builder_settings/builder_settings.py +++ b/builder/builder/doctype/builder_settings/builder_settings.py @@ -5,6 +5,9 @@ from frappe.model.document import Document from frappe.utils import get_files_path from frappe.utils.caching import redis_cache +from frappe.website.utils import clear_cache + +from builder.utils import has_page_read, has_page_write class BuilderSettings(Document): @@ -18,9 +21,13 @@ class BuilderSettings(Document): auto_convert_images_to_webp: DF.Check body_html: DF.Code | None + default_language: DF.Data | None + disable_auto_dark_mode: DF.Check + execute_block_scripts_in_editor: DF.Literal["Don't Execute", "Restricted", "Unrestricted"] favicon: DF.AttachImage | None head_html: DF.Code | None home_page: DF.Data | None + restrict_click_handlers: DF.Check script: DF.Code | None script_public_url: DF.ReadOnly | None style: DF.Code | None @@ -32,6 +39,9 @@ def on_update(self): self.handle_script_update("style", "css", "css", "page_styles") if self.has_value_changed("home_page"): frappe.cache.delete_key("home_page") + if self.has_value_changed("disable_auto_dark_mode"): + # Clear cache for all pages since this is a global setting + clear_cache() def handle_script_update(self, attribute, script_type, extension, folder_name): if self.has_value_changed(attribute): @@ -71,18 +81,17 @@ def get_website_user_home_page(session_user=None): @frappe.whitelist() +@has_page_read() def get_components(): # in label value format return frappe.get_all("Builder Component", fields=["name as value", "component_name as label"]) @frappe.whitelist() -def replace_component(target_component: str, replace_with: str, filters=None): +@has_page_write("You don't have permission to access this component") +def replace_component(target_component: str, replace_with: str, filters: str | None = None): if not target_component or not replace_with: return - # check permissions - if not frappe.has_permission("Builder Page", ptype="write"): - frappe.throw(_("You don't have permission to access this component"), frappe.PermissionError) # check if the replace_with component exists if not frappe.db.exists("Builder Component", replace_with): @@ -92,7 +101,7 @@ def replace_component(target_component: str, replace_with: str, filters=None): pages = frappe.get_all( "Builder Page", fields=["name"], - filters=filters, + filters=frappe.parse_json(filters) if filters else {}, or_filters={ "blocks": ["like", f"%{target_component}%"], "draft_blocks": ["like", f"%{target_component}%"], @@ -105,10 +114,12 @@ def replace_component(target_component: str, replace_with: str, filters=None): @frappe.whitelist() @redis_cache() -def get_component_usage_count(component_id: str, filters=None): +def get_component_usage_count(component_id: str, filters: str | None = None): + if not frappe.has_permission("Builder Page", ptype="read"): + return {"count": 0, "pages": []} pages = frappe.get_all( "Builder Page", - filters=filters, + filters=frappe.parse_json(filters) if filters else {}, fields=["name", "page_title", "route", "preview"], or_filters={ "blocks": ["like", f"%{component_id}%"], diff --git a/builder/builder/doctype/builder_snapshot/__init__.py b/builder/builder/doctype/builder_snapshot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/builder/builder/doctype/builder_snapshot/builder_snapshot.json b/builder/builder/doctype/builder_snapshot/builder_snapshot.json new file mode 100644 index 000000000..3d5eba9a2 --- /dev/null +++ b/builder/builder/doctype/builder_snapshot/builder_snapshot.json @@ -0,0 +1,87 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:SNAP-{####}", + "creation": "2026-06-10 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "reference_doctype", + "reference_name", + "snapshot_type", + "label", + "data" + ], + "fields": [ + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Reference Doctype", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "reference_name", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Reference Name", + "reqd": 1 + }, + { + "fieldname": "snapshot_type", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Snapshot Type" + }, + { + "fieldname": "label", + "fieldtype": "Data", + "label": "Label" + }, + { + "fieldname": "data", + "fieldtype": "Code", + "label": "Data", + "options": "JSON", + "reqd": 1 + } + ], + "hide_toolbar": 1, + "index_web_pages_for_search": 0, + "links": [], + "modified": "2026-06-10 00:00:00.000000", + "modified_by": "Administrator", + "module": "Builder", + "name": "Builder Snapshot", + "naming_rule": "By script", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 0, + "export": 1, + "print": 0, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 0, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "read": 1, + "role": "Website Manager", + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 0 +} diff --git a/builder/builder/doctype/builder_snapshot/builder_snapshot.py b/builder/builder/doctype/builder_snapshot/builder_snapshot.py new file mode 100644 index 000000000..cdadc47bd --- /dev/null +++ b/builder/builder/doctype/builder_snapshot/builder_snapshot.py @@ -0,0 +1,111 @@ +# Copyright (c) 2026, Frappe Technologies Pvt Ltd and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + +from builder.utils import compact_json + + +class BuilderSnapshot(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + data: DF.Code + label: DF.Data | None + reference_doctype: DF.Link + reference_name: DF.Data + snapshot_type: DF.Data | None + # end: auto-generated types + pass + + +def take_snapshot(reference_doctype, reference_name, fields, label=None, snapshot_type=None, transform=None): + """Capture the current value of `fields` on a document as a snapshot. + + Stores `{fieldname: value}` as JSON in the snapshot's `data` field. + Returns the new snapshot's name. + + `transform`, if given, is a callable that receives the captured + `{fieldname: value}` dict and returns a (possibly rewritten) dict to store. + It lets a consuming app post-process the captured values — e.g. pin + dependency versions into a JSON field — without this generic layer needing + any domain knowledge. + """ + doc = frappe.get_doc(reference_doctype, reference_name) + data = {field: doc.get(field) for field in fields} + if transform: + data = transform(data) + snapshot = frappe.get_doc( + { + "doctype": "Builder Snapshot", + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "data": compact_json(data), + "label": label, + "snapshot_type": snapshot_type, + } + ).insert(ignore_permissions=True) + return snapshot.name + + +def get_snapshot_data(snapshot_name) -> dict: + """Return the stored `{fieldname: value}` dict for a snapshot.""" + snapshot = frappe.get_doc("Builder Snapshot", snapshot_name) + return frappe.parse_json(snapshot.data) + + +def get_versioned_doc(snapshot_name): + """Return the referenced doc with this snapshot's captured fields overlaid (unsaved). + + Like `get_doc`, but as the document looked at `snapshot_name` for the captured fields — + every other field comes from the current doc. If the referenced doc was deleted, the + captured fields are overlaid onto a fresh doc (non-captured fields are doctype defaults) + so the version stays resolvable. + """ + snapshot = frappe.get_doc("Builder Snapshot", snapshot_name) + try: + doc = frappe.get_doc(snapshot.reference_doctype, snapshot.reference_name) + except frappe.DoesNotExistError: + doc = frappe.new_doc(snapshot.reference_doctype) + doc.name = snapshot.reference_name + for field, value in frappe.parse_json(snapshot.data).items(): + doc.set(field, value) + return doc + + +def restore_snapshot(snapshot_name, save=True): + """Generic write-back: apply a snapshot's stored fields onto its document. + + Apps that need custom restore semantics (e.g. routing the value into a draft + field for review) should use `get_versioned_doc` / `get_snapshot_data` and apply + it themselves rather than calling this. + """ + doc = get_versioned_doc(snapshot_name) + if save: + doc.save() + return doc + + +def prune_snapshots(reference_doctype, reference_name, keep, snapshot_type=None): + """Delete the oldest snapshots beyond `keep` for a document. + + Optionally restrict pruning to a single `snapshot_type` so that other types + (e.g. manual checkpoints) are never auto-deleted. + """ + filters = {"reference_doctype": reference_doctype, "reference_name": reference_name} + if snapshot_type: + filters["snapshot_type"] = snapshot_type + names = frappe.get_all( + "Builder Snapshot", + filters=filters, + order_by="creation desc", + pluck="name", + ) + for name in names[keep:]: + frappe.delete_doc("Builder Snapshot", name, ignore_permissions=True) diff --git a/builder/builder/doctype/builder_token/__init__.py b/builder/builder/doctype/builder_token/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/builder/builder/doctype/builder_variable/builder_variable.js b/builder/builder/doctype/builder_token/builder_token.js similarity index 87% rename from builder/builder/doctype/builder_variable/builder_variable.js rename to builder/builder/doctype/builder_token/builder_token.js index d38eda6ff..92cf72f6e 100644 --- a/builder/builder/doctype/builder_variable/builder_variable.js +++ b/builder/builder/doctype/builder_token/builder_token.js @@ -1,7 +1,7 @@ // Copyright (c) 2025, Frappe Technologies Pvt Ltd and contributors // For license information, please see license.txt -frappe.ui.form.on("Builder Variable", { +frappe.ui.form.on("Builder Token", { refresh: function (frm) { // Only show is_standard field in developer mode frm.get_field("is_standard").toggle(frappe.boot.developer_mode); diff --git a/builder/builder/doctype/builder_variable/builder_variable.json b/builder/builder/doctype/builder_token/builder_token.json similarity index 78% rename from builder/builder/doctype/builder_variable/builder_variable.json rename to builder/builder/doctype/builder_token/builder_token.json index 9919cdb5c..f12244c7e 100644 --- a/builder/builder/doctype/builder_variable/builder_variable.json +++ b/builder/builder/doctype/builder_token/builder_token.json @@ -5,7 +5,8 @@ "engine": "InnoDB", "field_order": [ "is_standard", - "variable_name", + "token_name", + "group", "type", "value", "dark_value" @@ -16,7 +17,7 @@ "fieldname": "type", "fieldtype": "Select", "label": "Type", - "options": "Color\nSpacing" + "options": "Color\nDimension\nFont" }, { "fieldname": "value", @@ -27,9 +28,10 @@ "reqd": 1 }, { - "fieldname": "variable_name", + "fieldname": "token_name", "fieldtype": "Data", - "label": "Variable Name", + "in_list_view": 1, + "label": "Token Name", "reqd": 1 }, { @@ -43,16 +45,23 @@ "fieldname": "dark_value", "fieldtype": "Data", "label": "Dark Value" + }, + { + "fieldname": "group", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Group" } ], "grid_page_length": 50, "hide_toolbar": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-08-18 19:54:11.776268", + "modified": "2026-05-24 12:00:00.000000", "modified_by": "Administrator", "module": "Builder", - "name": "Builder Variable", + "name": "Builder Token", "naming_rule": "By script", "owner": "Administrator", "permissions": [ @@ -80,5 +89,5 @@ "sort_field": "creation", "sort_order": "DESC", "states": [], - "title_field": "variable_name" + "title_field": "token_name" } diff --git a/builder/builder/doctype/builder_token/builder_token.py b/builder/builder/doctype/builder_token/builder_token.py new file mode 100644 index 000000000..e7177063f --- /dev/null +++ b/builder/builder/doctype/builder_token/builder_token.py @@ -0,0 +1,96 @@ +# Copyright (c) 2025, Frappe Technologies Pvt Ltd and contributors +# For license information, please see license.txt + +import uuid + +import frappe +from frappe.model.document import Document +from frappe.modules.export_file import delete_folder, export_to_files +from frappe.utils.caching import redis_cache +from frappe.website.utils import delete_page_cache + + +class BuilderToken(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + dark_value: DF.Data | None + group: DF.Data | None + is_standard: DF.Check + type: DF.Literal["Color", "Dimension", "Font"] + value: DF.Data + token_name: DF.Data + # end: auto-generated types + + def autoname(self): + if not self.name: + self.name = str(uuid.uuid4()) + + def after_insert(self): + clear_builder_token_cache() + + def on_update(self): + clear_builder_token_cache() + if self.is_standard: + export_to_files( + record_list=[["Builder Token", self.name, "builder_token"]], record_module="builder" + ) + + if self.has_value_changed("is_standard") and not self.is_standard: + delete_folder("builder", "builder_token", self.name) + + def on_trash(self): + clear_builder_token_cache() + if self.is_standard: + delete_folder("builder", "builder_token", self.name) + + +@redis_cache(ttl=10 * 24 * 3600) +def get_css_variables(): + builder_tokens = frappe.get_all("Builder Token", fields=["name", "value", "dark_value"]) + css_variables = {} + dark_mode_css_variables = {} + + for builder_token in builder_tokens: + if not builder_token.value: + continue + key = f"--{builder_token.name}" + css_variables[key] = builder_token.value + if builder_token.dark_value: + dark_mode_css_variables[key] = builder_token.dark_value + + return css_variables, dark_mode_css_variables + + +def get_variables_css() -> str: + """Render the CSS variables as an inline `:root {...}` rule. + + The /builder_assets/tokens.css route is a dynamically rendered page, not a + real file, so the preview/PDF generator can't fetch it (it blocks access to + non-existent local paths). Preview rendering inlines this string instead of + linking the route. Mirrors www/builder_assets/tokens.css.""" + css_variables, dark_mode_css_variables = get_css_variables() + if not css_variables: + return "" + + declarations = [] + for key, value in css_variables.items(): + dark_value = (dark_mode_css_variables or {}).get(key) + if dark_value is not None and dark_value != value: + declarations.append(f"{key}: light-dark({value}, {dark_value});") + else: + declarations.append(f"{key}: {value};") + + return ":root {\n" + "\n".join(declarations) + "\n}" + + +def clear_builder_token_cache(doc=None, method=None): + get_css_variables.clear_cache() + # bust the rendered page cache for tokens.css and its compat alias variables.css + delete_page_cache("builder_assets/tokens.css") + delete_page_cache("builder_assets/variables.css") diff --git a/builder/builder/doctype/builder_token/test_builder_token.py b/builder/builder/doctype/builder_token/test_builder_token.py new file mode 100644 index 000000000..eff2fa63a --- /dev/null +++ b/builder/builder/doctype/builder_token/test_builder_token.py @@ -0,0 +1,179 @@ +# Copyright (c) 2025, Frappe Technologies Pvt Ltd and Contributors +# See license.txt + +import json +import os +import tempfile + +import frappe +from frappe.tests.utils import FrappeTestCase + +from builder.builder.doctype.builder_page.builder_page import get_font_family, resolve_font_token +from builder.builder.doctype.builder_token.builder_token import get_css_variables, get_variables_css +from builder.builder.patches.refactor_builder_variables import build_maps, rewrite_doctype_blocks +from builder.utils import import_fixture_record, normalize_renamed_doc, sync_builder_tokens + + +def make_token(**kwargs): + defaults = {"doctype": "Builder Token", "token_name": "test-token", "type": "Color", "value": "#123456"} + return frappe.get_doc({**defaults, **kwargs}).insert() + + +class TestBuilderToken(FrappeTestCase): + def setUp(self): + get_css_variables.clear_cache() + + def test_token_is_named_with_a_uuid(self): + token = make_token(token_name="brand") + self.assertRegex(token.name, r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + def test_css_variable_uses_the_token_id_as_its_handle(self): + token = make_token(token_name="with-value", value="#abcdef") + css_variables, _ = get_css_variables() + self.assertEqual(css_variables[f"--{token.name}"], "#abcdef") + + def test_dark_value_renders_as_light_dark(self): + token = make_token(token_name="duotone", value="#ffffff", dark_value="#000000") + self.assertIn(f"--{token.name}: light-dark(#ffffff, #000000);", get_variables_css()) + + def test_matching_dark_value_renders_once(self): + token = make_token(token_name="monotone", value="#ffffff", dark_value="#ffffff") + self.assertIn(f"--{token.name}: #ffffff;", get_variables_css()) + + def test_deleting_a_token_drops_it_from_the_css(self): + token = make_token(token_name="short-lived", value="#333333") + handle = f"--{token.name}" + self.assertIn(handle, get_variables_css()) + token.delete() + self.assertNotIn(handle, get_variables_css()) + + def test_editing_a_token_busts_the_css_cache(self): + token = make_token(token_name="cache-check", value="#111111") + self.assertIn("#111111", get_variables_css()) + token.value = "#222222" + token.save() + self.assertIn("#222222", get_variables_css()) + + +class TestFontToken(FrappeTestCase): + def setUp(self): + get_css_variables.clear_cache() + + def test_font_token_resolves_to_its_family(self): + token = make_token(token_name="display", type="Font", value="Fraunces") + self.assertEqual(resolve_font_token(f"var(--{token.name})"), "Fraunces") + self.assertEqual(get_font_family(f"var(--{token.name})"), "Fraunces") + + def test_unknown_font_token_resolves_to_nothing(self): + # an unresolvable token must not reach the Google Fonts URL builder + self.assertEqual(resolve_font_token("var(--does-not-exist)"), "") + + def test_plain_font_stack_is_left_alone(self): + self.assertEqual(get_font_family("Inter, sans-serif"), "Inter") + + +class TestRenamedFixtures(FrappeTestCase): + """Fixtures and template bundles written before the Builder Token rename.""" + + def test_pre_rename_doc_is_mapped(self): + docdict = {"doctype": "Builder Variable", "variable_name": "legacy", "value": "#fff"} + normalize_renamed_doc(docdict) + self.assertEqual(docdict["doctype"], "Builder Token") + self.assertEqual(docdict["token_name"], "legacy") + self.assertNotIn("variable_name", docdict) + + def test_a_current_doc_is_left_alone(self): + docdict = {"doctype": "Builder Token", "token_name": "current", "value": "#fff"} + self.assertEqual(normalize_renamed_doc(dict(docdict)), docdict) + + def test_token_name_already_present_wins(self): + docdict = {"doctype": "Builder Variable", "variable_name": "old", "token_name": "new"} + normalize_renamed_doc(docdict) + self.assertEqual(docdict["token_name"], "new") + + def test_pre_rename_fixture_imports_as_a_token(self): + name = frappe.generate_hash(length=10) + fixture = { + "doctype": "Builder Variable", + "name": name, + "variable_name": "fixture-color", + "type": "Color", + "value": "#123456", + "modified": "2026-01-01 00:00:00", + } + with tempfile.TemporaryDirectory() as folder: + path = os.path.join(folder, "fixture.json") + with open(path, "w") as f: + json.dump(fixture, f) + import_fixture_record(path) + + self.assertEqual(frappe.db.get_value("Builder Token", name, "token_name"), "fixture-color") + + def test_current_fixture_imports_unchanged(self): + name = frappe.generate_hash(length=10) + fixture = { + "doctype": "Builder Token", + "name": name, + "token_name": "current-fixture-color", + "type": "Color", + "value": "#abcdef", + "modified": "2026-01-01 00:00:00", + } + with tempfile.TemporaryDirectory() as folder: + path = os.path.join(folder, "fixture.json") + with open(path, "w") as f: + json.dump(fixture, f) + import_fixture_record(path) + + self.assertEqual(frappe.db.get_value("Builder Token", name, "value"), "#abcdef") + + def test_syncing_standard_tokens_is_safe_without_fixtures(self): + # after_install/after_migrate call this; builder ships no token fixtures + sync_builder_tokens() + + def test_a_missing_fixture_is_not_an_error(self): + with tempfile.TemporaryDirectory() as folder: + import_fixture_record(os.path.join(folder, "nope.json")) + + +class TestUUIDRefactorPatch(FrappeTestCase): + def test_build_maps_covers_every_legacy_name_shape(self): + tokens = [ + frappe._dict(name="brand_primary", token_name="brandPrimary"), + frappe._dict(name="a1b2c3d4e5", token_name="Accent"), + ] + rename_map, css_rewrite_map = build_maps(tokens) + + self.assertEqual(set(rename_map), {"brand_primary", "a1b2c3d4e5"}) + # kebab-cased label, snake-cased doc name and the older hex hash all resolve + self.assertEqual(css_rewrite_map["brand-primary"], rename_map["brand_primary"]) + self.assertEqual(css_rewrite_map["accent"], rename_map["a1b2c3d4e5"]) + self.assertEqual(css_rewrite_map["a1b2c3d4e5"], rename_map["a1b2c3d4e5"]) + + def test_rewrite_reaches_svg_markup(self): + blocks = [ + { + "blockId": "root", + "baseStyles": {"color": "var(--brand)", "border": "1px solid var(--brand-dark)"}, + "attributes": {"fill": "var(--brand)"}, + "innerHTML": '', + } + ] + page = frappe.get_doc( + {"doctype": "Builder Page", "page_title": "rewrite-probe", "blocks": json.dumps(blocks)} + ).insert() + + updated = rewrite_doctype_blocks( + "Builder Page", ["blocks"], {"brand": "new-brand", "brand-dark": "new-brand-dark"} + ) + self.assertGreaterEqual(updated, 1) + + rewritten = frappe.db.get_value("Builder Page", page.name, "blocks") + self.assertIn("var(--new-brand)", rewritten) + self.assertIn("var(--new-brand, #eee)", rewritten) + # longer keys match first, so brand-dark is not rewritten as brand + self.assertIn("var(--new-brand-dark)", rewritten) + self.assertNotIn("var(--brand", rewritten) + + def test_rewrite_without_a_map_touches_nothing(self): + self.assertEqual(rewrite_doctype_blocks("Builder Page", ["blocks"], {}), 0) diff --git a/builder/builder/doctype/builder_variable/builder_variable.py b/builder/builder/doctype/builder_variable/builder_variable.py deleted file mode 100644 index 265b38ddd..000000000 --- a/builder/builder/doctype/builder_variable/builder_variable.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright (c) 2025, Frappe Technologies Pvt Ltd and contributors -# For license information, please see license.txt - -import frappe -from frappe.model.document import Document -from frappe.model.naming import append_number_if_name_exists -from frappe.modules.export_file import delete_folder, export_to_files -from frappe.utils.caching import redis_cache - -from builder.utils import camel_case_to_kebab_case - - -class BuilderVariable(Document): - # begin: auto-generated types - # This code is auto-generated. Do not modify anything in this block. - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from frappe.types import DF - - dark_value: DF.Data | None - is_standard: DF.Check - type: DF.Literal["Color", "Spacing"] - value: DF.Data - variable_name: DF.Data - # end: auto-generated types - - def autoname(self): - self.name = append_number_if_name_exists("Builder Variable", frappe.scrub(self.variable_name)) - - def after_insert(self): - get_css_variables.clear_cache() - - def on_update(self): - get_css_variables.clear_cache() - if self.is_standard: - export_to_files( - record_list=[["Builder Variable", self.name, "builder_variable"]], record_module="builder" - ) - - if self.has_value_changed("is_standard") and not self.is_standard: - delete_folder("builder", "builder_variable", self.name) - - def on_trash(self): - get_css_variables.clear_cache() - if self.is_standard: - delete_folder("builder", "builder_variable", self.name) - - -@redis_cache(ttl=10 * 24 * 3600) -def get_css_variables(): - builder_variables = frappe.get_all("Builder Variable", fields=["variable_name", "value", "dark_value"]) - css_variables = {} - dark_mode_css_variables = {} - - for builder_variable in builder_variables: - if builder_variable.variable_name and builder_variable.value: - variable_name = f"--{camel_case_to_kebab_case(builder_variable.variable_name, True)}" - css_variables[variable_name] = builder_variable.value - - if hasattr(builder_variable, "dark_value") and builder_variable.dark_value: - dark_mode_css_variables[variable_name] = builder_variable.dark_value - - return css_variables, dark_mode_css_variables - - -def clear_builder_variable_cache(doc, method): - get_css_variables.clear_cache() diff --git a/builder/builder/doctype/user_font/user_font.py b/builder/builder/doctype/user_font/user_font.py index b0a848589..bdd7db1eb 100644 --- a/builder/builder/doctype/user_font/user_font.py +++ b/builder/builder/doctype/user_font/user_font.py @@ -1,9 +1,22 @@ # Copyright (c) 2024, Frappe Technologies Pvt Ltd and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document +from frappe.utils.caching import redis_cache + + +@redis_cache(ttl=60 * 60) +def get_all_user_fonts() -> list: + return frappe.get_all("User Font", fields=["font_name", "font_file"]) class UserFont(Document): - pass + def after_insert(self): + get_all_user_fonts.clear_cache() + + def on_update(self): + get_all_user_fonts.clear_cache() + + def on_trash(self): + get_all_user_fonts.clear_cache() diff --git a/builder/builder/patches/__init__.py b/builder/builder/patches/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/builder/builder/patches/refactor_builder_variables.py b/builder/builder/patches/refactor_builder_variables.py new file mode 100644 index 000000000..d109c7793 --- /dev/null +++ b/builder/builder/patches/refactor_builder_variables.py @@ -0,0 +1,103 @@ +import re +import uuid + +import frappe + +from builder.builder.doctype.builder_token.builder_token import get_css_variables +from builder.utils import camel_case_to_kebab_case + +UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + +def execute(): + """Migrate Builder Tokens to UUID names. + + 1. Assign a UUID to every token that doesn't already have one. + 2. Rewrite `var(--old-name)` → `var(--