diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 71d3588f2..39075cff7 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1 +1,2 @@ b3ddeda50bf11d04ee3b82f38af7355aad006fe0 +fad996a6275cd1fab5e62e930219fc2d9ccbc75c diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..160740ee5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..e301d68ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: feature request +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 000000000..2c7373563 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,65 @@ +# When updating this file, please also update the linter_workflow_template in frappe/utils/boilerplate.py +name: Linters + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.number }} + cancel-in-progress: true + +jobs: + linter: + name: 'Semgrep Rules' + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip + + - name: Download Semgrep rules + run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules + + - name: Run Semgrep rules + run: | + pip install semgrep + semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness + + deps-vulnerable-check: + name: 'Vulnerable Dependency Check' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip + + - name: Install and run pip-audit + run: | + pip install pip-audit + pip-audit --desc on --ignore-vuln PYSEC-2023-312 --ignore-vuln CVE-2026-28684 . + + precommit: + name: 'Pre-Commit' + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip + - uses: pre-commit/action@v3.0.1 + env: + SKIP: prettier \ No newline at end of file diff --git a/.github/workflows/on_release.yml b/.github/workflows/on_release.yml index 1650a21b3..bae683f0a 100644 --- a/.github/workflows/on_release.yml +++ b/.github/workflows/on_release.yml @@ -15,9 +15,9 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Setup dependencies run: | npm install @semantic-release/git @semantic-release/exec --no-save diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 76c95057d..730561c0e 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -39,16 +39,18 @@ jobs: steps: - name: Clone uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.14' - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 24 check-latest: true - name: Cache pip @@ -76,7 +78,7 @@ jobs: run: | bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh pip install frappe-bench - bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench --frappe-branch develop mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" @@ -112,14 +114,16 @@ jobs: steps: - name: Clone uses: actions/checkout@v3 + with: + submodules: recursive - name: Download artifacts uses: actions/download-artifact@v4 - name: Upload coverage data - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: name: Server token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + fail_ci_if_error: false verbose: true diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index a74b84ef4..0e43abd8c 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -36,15 +36,18 @@ jobs: steps: - name: Clone uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.14' - - uses: actions/setup-node@v3 + - name: Setup Node + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 24 check-latest: true - name: Add to Hosts diff --git a/.gitignore b/.gitignore index 3e19a5565..196a03d2b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,5 @@ node_modules builder/public/frontend builder/public/page_scripts builder/public/page_styles -builder/www/_builder.html +**/www/_builder.html builder/public/dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cc1cc5ac..0d14004f9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,12 @@ exclude: 'node_modules|.git' default_stages: [pre-commit] +default_install_hook_types: [pre-commit, commit-msg] fail_fast: false repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.13 + rev: v0.14.10 hooks: - id: ruff name: "Run ruff import sorter" @@ -24,21 +25,27 @@ repos: types_or: - javascript - vue - additional_dependencies: - - prettier@3.3.3 - - prettier-plugin-tailwindcss args: - --plugin=prettier-plugin-tailwindcss + additional_dependencies: + - prettier@3.3.3 + - prettier-plugin-tailwindcss@0.6.11 exclude: | (?x)^( - frappe/public/dist/.*| + builder/public/dist/.*| .*node_modules.*| .*boilerplate.*| .*src.*.js| builder/public/js/identify.js| )$ + - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.23.0 + hooks: + - id: commitlint + stages: [commit-msg] + additional_dependencies: ['conventional-changelog-conventionalcommits'] + ci: autoupdate_schedule: weekly - skip: [] submodules: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..fdb04d306 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Code Taste + +Preferences for this codebase. Follow existing patterns first. Apply these unless a specific case has a stronger reason. + +## General + +- Keep changes small and focused. +- Look for existing patterns before adding new ones. +- Reuse or extend existing components/helpers when possible. +- Create abstractions only when similar patterns repeat. + +## Python + +- No `_` prefix for helper functions — they aren't truly private here and the prefix adds noise. Use plain `snake_case`. +- Keep functions/methods small (~10 lines when practical). +- Prefer OOP and keep logic close to the object it belongs to. +- Avoid comments that explain what the next line does. +- Add comments only for non-obvious reasons: constraints, workarounds, or important assumptions. +- Don't repeat docstrings in comments. + +## Vue / TypeScript + +- Use existing frappe-ui components before creating custom UI +- Keep page templates small. +- Move large/repeated UI pieces into feature-specific `.vue` components. +- Check for similar code before building something new. Extract common patterns when repetition appears. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..eef4bd20c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/LICENSE b/LICENSE index 0ad25db4b..3c6d7966b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,21 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +MIT License + +Copyright (c) 2023-2026 Frappe Technologies Pvt. Ltd. and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fb9e88a4f..706bff0de 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ [![codecov](https://codecov.io/github/frappe/builder/branch/develop/graph/badge.svg)](https://codecov.io/github/frappe/builder) [![unittests](https://github.com/frappe/builder/actions/workflows/server-tests.yml/badge.svg)](https://github.com/frappe/builder/actions/workflows/server-tests.yml) +frappe%2Fbuilder | Trendshift +
@@ -35,11 +37,14 @@ Most existing solutions were either too complex, too restrictive, or difficult t ### Key Features - **Intuitive Visual Builder:** Simplify your workflow with a Figma-like editor. +- **AI-Powered Page Generation:** Generate complete web pages instantly using AI with a simple text prompt. - **Responsive Views:** Ensure your sites look great on any device without the fuss. -- **Frappe CMS Integration:** Easily fetch data from your database and create dynamic pages. -- **Scripting Capabilities:** Customize with client scripts, global scripts, and styles. +- **Dark Mode Support:** Built-in dark mode with automatic system preference detection and a manual override option. +- **Built-in CMS:** Leverage Frappe Framework's inbuilt CMS to manage structured content, fetch dynamic data from your database, and power data-driven pages without a separate backend. +- **Advanced Scripting:** Customize every layer of your page like global scripts, client scripts, per-block data scripts, and typed block props. Bind any property or style to dynamic values sourced from scripts, page data, or query parameters. - **One-Click Publishing:** Instantly share your creation with the world in a single click. - **Performance Excellence:** Frappe Builder does not bloat web pages with unnecessary scripts hence pages are highly performant, consistently scoring high on Google Lighthouse tests. +- **Page Analytics:** Built-in analytics dashboard with traffic insights including page views, unique visitors, and top referrers. - **Production Ready**: [Frappe.io](https://frappe.io) built on Frappe Builder, stands as a testament to its reliability in delivering production-ready solutions. ### Under the Hood @@ -150,6 +155,7 @@ yarn dev --host - [Discuss Forum](https://discuss.frappe.io/c/frappe-builder/83) - [Documentation](https://docs.frappe.io/builder) - [Figma Plugin (Beta)](https://www.figma.com/community/plugin/1417835732014419099/figma-to-frappe-builder) +- [Frappe Script Editor](https://github.com/frappe/frappe-script-editor)

diff --git a/builder/__init__.py b/builder/__init__.py index 6cea18d86..6c7936ab3 100644 --- a/builder/__init__.py +++ b/builder/__init__.py @@ -1 +1 @@ -__version__ = "1.18.0" +__version__ = "1.0.0-dev" diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py new file mode 100644 index 000000000..f18360585 --- /dev/null +++ b/builder/ai_page_generator.py @@ -0,0 +1,673 @@ +import json +import re + +import frappe +import litellm +import yaml +from frappe import _ +from frappe.utils.telemetry import capture + +from builder.utils import has_page_write, to_compact_yaml + +litellm.drop_params = True + + +TASK_PARAMS = { + "simple": {"max_tokens": 1000, "temperature": 0.5}, + "complex": {"max_tokens": 22000, "temperature": 0.7}, +} + + +# System Prompts + +MODIFY_PROMPT = ( + "You modify web sections in Frappe Builder's block system.\n" + "Return ONLY valid and compact YAML array. No markdown, no explanations.\n\n" + "# Schema\n" + "el: str\n" + "id: str # MUST preserve existing\n" + "name?: str\n" + "style?: dict # CSS-in-JS camelCase. Support interactive states like hover:backgroundColor, active:color.\n" + "c?: [el]\n" + "attrs?: dict\n" + "text?: str\n" + "m_style?: dict\n\n" + "Rules: Preserve ALL existing 'id' values. Only change what requested. Return COMPLETE structure. " + "Use %, rem for responsive widths. Top-level sections MUST be 100% width.\n" + "Wrap text in semantic elements — never place text directly in div/section.\n" + "Formatting: use flow style for all style dicts e.g. style: {color: '#fff', 'hover:backgroundColor': '#eee'}. " + "All images must be external URLs with proper alt text if replacing." + "Omit any key whose value is empty, null, or {}.\n" + "Gradients: ALWAYS use 'backgroundImage' (NOT 'background') for gradients. " + "The gradient value MUST be quoted to avoid YAML parse errors. " + "Example: backgroundImage: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'. " + "Never leave gradient strings unquoted." +) + +REWRITE_TEXT_PROMPT = ( + "You are a professional copywriter. Rewrite the provided text content to be more engaging and professional.\n" + "Return ONLY the rewritten text. No markdown, no explanations, no quotes." +) + +REPLACE_IMAGE_PROMPT = ( + "You are an image finder. Suggest a highly relevant, high-quality publicly available image URL " + "(different from what is provided).\n" + "Return ONLY the image URL. No markdown, no explanations, no quotes." +) + +GENERATE_PROMPT = """You are an expert web designer specializing in creating modern, responsive web pages using the Frappe Builder block system. + +Critical: Return ONLY a valid and compact YAML object. No markdown, no explanations. + +# Structure: +Return a single root block that represents the page (el: div, id: root). This block contains all sections in its 'c' (children) property. + +# Schema for the Page Container Object: +- el: div +- id: root +- name: body +- style: CSS-in-JS camelCase object for page-wide styles (e.g. { backgroundColor: '#f8f9fa', fontFamily: 'Inter', display: 'flex', flexDirection: 'column', alignItems: 'center' }) +- c: array of content blocks (sections, header, footer, etc.) + +# Content Block Schema: +- el: semantic HTML tag (section, nav, header, footer, h1-h3, p, span, button, a, img) +- name: descriptive name +- style: CSS-in-JS camelCase object. Include interactive states (e.g., 'hover:backgroundColor', 'active:transform', 'hover:color') for buttons and links. +- m_style: mobile overrides +- t_style: tablet overrides +- attrs: HTML attrs (src, alt, href, target) +- text: text content +- c: nested blocks array +- classes: CSS class names + +# Rules: +- The top-level Page block must have 'display: flex', 'flexDirection: column', and 'alignItems: center' to layout sections properly. +- All top-level sections inside 'c' MUST have 'width: 100%'. +- Modern harmonious color palettes. Good spacing. Professional concise copy. +- Interactive: Use hover states for buttons/links to make the page feel alive. +- Google Fonts via fontFamily (use ONLY the font name and not the fallback). +- Semantic HTML with alt texts. +- Create maximum 5 high quality sections +- Use semantic tags and wrap text in them. Never place text directly in a div/section without a semantic tag. +- Avoid using emojis in text content. Focus on professional tone. +- Gradients: ALWAYS use 'backgroundImage' (NOT 'background') for gradients. The value MUST be a quoted YAML string to avoid parse errors. Example: backgroundImage: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'. Never use unquoted gradient values.""" + +MODIFY_PROMPT_MAP = { + "rewrite_text": REWRITE_TEXT_PROMPT, + "replace_image": REPLACE_IMAGE_PROMPT, +} + + +def get_system_prompt(is_modify: bool, task_type: str | None = None) -> str: + if is_modify: + return MODIFY_PROMPT_MAP.get(task_type or "", MODIFY_PROMPT) + return GENERATE_PROMPT + + +def classify_task(is_modify: bool, task_type: str | None = None) -> str: + if is_modify and task_type in {"rewrite_text", "replace_image"}: + return "simple" + return "complex" + + +def compress_block_to_yaml(block: dict, depth: int = 0, task_tier: str = "complex") -> dict: + if not isinstance(block, dict): + return block + + out = {} + if block.get("element"): + out["el"] = block["element"] + if block.get("blockId"): + out["id"] = block["blockId"] + if block.get("blockName"): + out["name"] = block["blockName"] + + base_styles = block.get("baseStyles") or {} + if base_styles: + out["style"] = base_styles + + attrs = block.get("attributes") or {} + if attrs: + out["attrs"] = attrs + + if block.get("classes"): + out["classes"] = block["classes"] + if block.get("innerHTML"): + out["text"] = block["innerHTML"] + + mob = block.get("mobileStyles") or {} + if mob: + out["m_style"] = mob + + tab = block.get("tabletStyles") or {} + if tab and (task_tier == "complex" or depth <= 1): + out["t_style"] = tab + + children = [ + compress_block_to_yaml(c, depth + 1, task_tier) + for c in block.get("children", []) + if isinstance(c, dict) + ] + if children: + out["c"] = children + + return out + + +def extract_block_id(block_context: str) -> str | None: + """Extract blockId from raw JSON context without full re-parse later.""" + try: + data = json.loads(block_context) + if isinstance(data, list): + data = data[0] if data else {} + return data.get("blockId") if isinstance(data, dict) else None + except Exception: + return None + + +def strip_block_context(block_context: str, task_tier: str, task_type: str | None = None) -> str: + """Convert block JSON to compact YAML to reduce input tokens.""" + try: + data = json.loads(block_context) + except (json.JSONDecodeError, TypeError): + return block_context + + if isinstance(data, list): + data = data[0] if data else {} + if not isinstance(data, dict): + return block_context + + if task_type == "rewrite_text": + return data.get("innerHTML") or data.get("innerText") or "" + if task_type == "replace_image": + attrs = data.get("attributes", {}) + return to_compact_yaml({"src": attrs.get("src", ""), "alt": attrs.get("alt", "")}) + return to_compact_yaml([compress_block_to_yaml(data, 0, task_tier)]) + + +def expand_yaml_to_block(node: dict) -> dict: + """Expand compact YAML node back to Frappe Builder block schema.""" + if not isinstance(node, dict): + return node + + block = { + "element": node.get("el", "div"), + "blockName": node.get("name", ""), + "baseStyles": node.get("style", {}), + "attributes": node.get("attrs", {}), + "children": [expand_yaml_to_block(c) for c in node.get("c", []) if isinstance(c, dict)], + } + for yaml_key, block_key in [ + ("id", "blockId"), + ("text", "innerHTML"), + ("m_style", "mobileStyles"), + ("t_style", "tabletStyles"), + ("classes", "classes"), + ]: + if yaml_key in node: + block[block_key] = node[yaml_key] + + return block + + +def validate_image_data(image_data: str) -> str: + """Validate that image_data is a safe base64-encoded image data URL.""" + if not image_data.startswith("data:image/"): + frappe.throw(_("Invalid image data: must be a base64-encoded image data URL")) + if ";base64," not in image_data: + frappe.throw(_("Invalid image data: must be a base64-encoded data URL")) + # ~5 MB image ≈ ~6.7 MB base64 string + if len(image_data) > 7 * 1024 * 1024: + frappe.throw(_("Image is too large. Please use an image smaller than 5 MB.")) + return image_data + + +def build_user_message( + prompt: str, + is_modify: bool, + block_context: str | None = None, + task_type: str | None = None, + image_url: str | None = None, +) -> str | list: + if is_modify and block_context: + if task_type == "rewrite_text": + text = f'Text content to rewrite: "{block_context}"\n\nInstruction: {prompt}' + elif task_type == "replace_image": + text = f"Current image attributes:\n{block_context}\n\nInstruction: {prompt}" + else: + text = f"Block:\n{block_context}\n\nChange: {prompt}" + else: + text = f"Create a page for: {prompt}" + + if image_url: + return [ + {"type": "text", "text": text}, + {"type": "image_url", "image_url": {"url": image_url}}, + ] + return text + + +def call_llm(model: str, messages: list, params: dict, *, stream: bool, api_key: str | None = None): + """Call litellm. Returns chunk iterator (stream=True) or string (stream=False).""" + if model.startswith("gemini-"): + model = f"gemini/{model}" + + if "claude-" in model: + for m in messages: + if m["role"] == "system" and isinstance(m.get("content"), str): + m["content"] = [{"type": "text", "text": m["content"]}] + resp = litellm.completion(model=model, messages=messages, stream=stream, api_key=api_key, **params) + return resp if stream else (resp.choices[0].message.content or "") + + +def strip_fences(text: str) -> str: + text = re.sub(r"^```(?:yaml|json)?\s*\n?", "", text.strip()) + return re.sub(r"\n?```\s*$", "", text).strip() + + +def parse_blocks(content: str) -> dict: + """Parse LLM YAML output into a single block object.""" + parsed = yaml.safe_load(strip_fences(content)) + if isinstance(parsed, dict): + block = parsed + elif isinstance(parsed, list): + block = parsed[0] if parsed else {} + else: + raise ValueError("Not a valid block object") + + if not block: + raise ValueError("No valid blocks in response") + + if isinstance(block, dict) and not block.get("id"): + block["id"] = "root" + + return expand_yaml_to_block(block) + + +def run_llm_job( + prompt: str, + model: str, + api_key: str, + event_prefix: str, + is_modify: bool, + user: str | None = None, + page_id: str | None = None, + block_context: str | None = None, + task_type: str | None = None, + image_url: str | None = None, +): + user = user or frappe.session.user + + def emit(suffix, **kwargs): + event = f"{event_prefix}_{suffix}" + if page_id: + event = f"{event}_{page_id}" + payload = { + "page_id": page_id, + "task_type": task_type, + **kwargs, + } + frappe.publish_realtime(event, payload, user=user) + + task_tier = classify_task(is_modify=is_modify, task_type=task_type) + params = TASK_PARAMS[task_tier] + + cache_key = f"ai_streaming_content:{page_id}:{user}" if page_id else None + if cache_key: + frappe.cache().set_value(cache_key, {"content": "", "task_type": task_type}, expires_in_sec=600) + + if task_tier == "simple": + model = get_simple_model(model) + + action = "Modifying" if is_modify else "Generating" + model_label = get_model_label(model) + emit( + "progress", + status="generating", + message=f"{action} with {model_label}", + task_tier=task_tier, + model_used=model, + total_length=0, + ) + + original_id = None + stripped_context = None + if is_modify and block_context: + original_id = extract_block_id(block_context) + stripped_context = strip_block_context(block_context, task_tier, task_type=task_type) + + # Image is only applicable for generate/modify-block tasks, not simple text/image tasks + effective_image_url = image_url if task_type not in {"rewrite_text", "replace_image"} else None + + messages = [ + { + "role": "system", + "content": get_system_prompt(is_modify, task_type), + "cache_control": {"type": "ephemeral"}, + }, + { + "role": "user", + "content": build_user_message( + prompt, + is_modify, + block_context=stripped_context, + task_type=task_type, + image_url=effective_image_url, + ), + }, + ] + + content = "" + try: + last_stage = None + for chunk in call_llm(model, messages, params, stream=True, api_key=api_key): + if delta := chunk.choices[0].delta.content: + if not content: + emit("progress", message="Building...") + last_stage = "Building..." + content += delta + if cache_key: + frappe.cache().set_value( + cache_key, {"content": content, "task_type": task_type}, expires_in_sec=600 + ) + + emit("stream", chunk=delta, block_id=original_id, total_length=len(content)) + + stage = get_progress_stage(content) + if stage and stage != last_stage: + last_stage = stage + emit("progress", message=stage, total_length=len(content)) + + except ValueError as e: + if cache_key: + frappe.cache().delete_value(cache_key) + frappe.log_error(f"Parse error: {e}\nContent: {content}", f"{event_prefix} parse") + emit("error", message="Failed to parse AI response. The model returned invalid YAML.") + return + + except Exception as e: + if cache_key: + frappe.cache().delete_value(cache_key) + frappe.log_error(f"LLM job error: {e}", event_prefix) + emit("error", message=str(e)) + return + + if cache_key: + frappe.cache().delete_value(cache_key) + + success_message = "Modified block successfully" if is_modify else "Page generated successfully" + emit( + "complete", + block_id=original_id, + model_used=model, + task_tier=task_tier, + message=success_message, + ) + + +def generate_page_blocks( + prompt: str, + model: str, + api_key: str, + user: str | None = None, + page_id: str | None = None, + image_url: str | None = None, +): + run_llm_job( + prompt, + model, + api_key, + "ai_generation", + is_modify=False, + user=user, + page_id=page_id, + image_url=image_url, + ) + + +def modify_section_blocks( + prompt: str, + block_context: str, + model: str, + api_key: str, + user: str | None = None, + page_id: str | None = None, + task_type: str | None = None, + image_url: str | None = None, +): + run_llm_job( + prompt, + model, + api_key, + "ai_modify", + is_modify=True, + user=user, + page_id=page_id, + block_context=block_context, + task_type=task_type, + image_url=image_url, + ) + + +def enqueue_ai_job(fn, model=None, **kwargs): + if not frappe.has_permission("Builder Page", ptype="write"): + frappe.throw(_("You do not have permission to modify pages")) + settings = frappe.get_single("Builder Settings") + + if not model: + model = "openrouter" + + model = get_default_model(model) + + api_key = settings.get_password("ai_api_key", raise_exception=False) + if not api_key: + frappe.throw(_("Please configure an OpenRouter API key in Settings → AI")) + + frappe.enqueue( + fn, + model=model, + api_key=api_key, + user=frappe.session.user, + now=True, + **kwargs, + ) + capture("builder_ai_used", "builder") + frappe.local.response.http_status_code = 202 + return {"status": "accepted"} + + +AVAILABLE_MODELS = [ + { + "provider": "openrouter", + "models": [ + { + "name": "openrouter/anthropic/claude-sonnet-4.6", + "label": "Claude Sonnet 4.6 (Balanced)", + "max_tokens": 200000, + "vision": True, + }, + { + "name": "openrouter/anthropic/claude-haiku-4-6", + "label": "Claude Haiku 4.6 (Fast)", + "max_tokens": 200000, + "vision": True, + }, + { + "name": "openrouter/google/gemini-3.1-pro", + "label": "Gemini 3.1 Pro (Flagship)", + "max_tokens": 1048576, + "vision": True, + }, + { + "name": "openrouter/google/gemini-3-flash-preview", + "label": "Gemini 3 Flash (Fast)", + "max_tokens": 1048576, + "vision": True, + }, + { + "name": "openrouter/openai/gpt-5.4-mini", + "label": "GPT-5.4 Mini", + "max_tokens": 1000000, + "vision": True, + }, + { + "name": "openrouter/moonshotai/kimi-k2.5", + "label": "Kimi K2.5 (Cheapest)", + "max_tokens": 2000000, + "vision": True, + }, + { + "name": "openrouter/z-ai/glm-5", + "label": "GLM-5 (Balanced)", + "max_tokens": 200000, + "vision": True, + }, + { + "name": "openrouter/moonshotai/kimi-k2", + "label": "Kimi K2 (Generous Free Tier)", + "max_tokens": 131072, + "vision": False, + }, + ], + }, +] + + +def get_model_label(model_name: str) -> str: + for provider in AVAILABLE_MODELS: + for m in provider["models"]: + if m["name"] == model_name: + return m["label"] + # Fallback: clean up the name + return model_name.removeprefix("openrouter/").replace("/", " ").replace("-", " ").title() + + +def get_progress_stage(content: str) -> str | None: + lookback = content[-400:] + major_elements = ["section", "nav", "header", "footer"] + + # Find the most recent major element type + last_pos = -1 + found_el = None + for el in major_elements: + pos = lookback.rfind(f"el: {el}") + if pos > last_pos: + last_pos = pos + found_el = el + + if found_el and last_pos != -1: + part = lookback[last_pos:] + name_match = re.search(r"name:\s*['\"]?([^'\"\n]+)['\"]?", part) + if name_match: + block_name = name_match.group(1).strip() + if block_name.lower() not in {"body", "root", "container"}: + return f"Building {block_name}" + return None + + +PROVIDER_DEFAULT_MODEL: dict[str, str] = { + "openrouter": "openrouter/anthropic/claude-sonnet-4.6", +} + + +PROVIDER_SIMPLE_MODEL: dict[str, str] = { + "openrouter": "openrouter/google/gemini-3-flash-preview", +} + + +def detect_provider(model: str) -> str | None: + if model.lower().startswith("openrouter/"): + return "openrouter" + return None + + +def get_simple_model(model: str) -> str: + provider = detect_provider(model) + if provider is None: + if model in PROVIDER_SIMPLE_MODEL: + return PROVIDER_SIMPLE_MODEL[model] + return model + return PROVIDER_SIMPLE_MODEL.get(provider, model) + + +def get_default_model(model_or_provider: str) -> str: + if model_or_provider in PROVIDER_DEFAULT_MODEL: + return PROVIDER_DEFAULT_MODEL[model_or_provider] + return model_or_provider + + +@frappe.whitelist() +def get_ai_models(): + return AVAILABLE_MODELS + + +@frappe.whitelist() +@has_page_write() +def generate_page_from_prompt( + prompt: str, + page_id: str | None = None, + model: str | None = None, + image_data: str | None = None, +): + image_url = validate_image_data(image_data) if image_data else None + return enqueue_ai_job( + generate_page_blocks, prompt=prompt, page_id=page_id, model=model, image_url=image_url + ) + + +@frappe.whitelist() +@has_page_write() +def modify_section_from_prompt( + prompt: str, + block_context: str, + page_id: str | None = None, + task_type: str | None = None, + model: str | None = None, + image_data: str | None = None, +): + try: + json.loads(block_context) + except json.JSONDecodeError: + frappe.throw(_("Invalid block context JSON")) + image_url = validate_image_data(image_data) if image_data else None + return enqueue_ai_job( + modify_section_blocks, + prompt=prompt, + block_context=block_context, + page_id=page_id, + task_type=task_type, + model=model, + image_url=image_url, + ) + + +@frappe.whitelist() +@has_page_write() +def get_ai_streaming_content(page_id: str): + user = frappe.session.user + cache_key = f"ai_streaming_content:{page_id}:{user}" + return frappe.cache().get_value(cache_key) or {"content": None} + + +@frappe.whitelist() +@has_page_write() +def test_api_key(): + settings = frappe.get_single("Builder Settings") + model = settings.get("ai_model") or "openrouter" + api_key = settings.get_password("ai_api_key", raise_exception=False) + if not api_key: + return {"success": False, "message": _("Please set an OpenRouter API key")} + + actual_model = get_default_model(model) + if actual_model.startswith("gemini-"): + actual_model = f"gemini/{actual_model}" + try: + litellm.completion( + model=actual_model, + messages=[{"role": "user", "content": "Say 'OK' if you can read this"}], + max_tokens=10, + api_key=api_key, + ) + return {"success": True, "message": _("API key is valid")} + except Exception as e: + return {"success": False, "message": str(e)} diff --git a/builder/api.py b/builder/api.py index 6cd6a806f..fec7b87c9 100644 --- a/builder/api.py +++ b/builder/api.py @@ -1,79 +1,45 @@ -import json +import ipaddress import os +import socket from io import BytesIO from types import FunctionType, MethodType, ModuleType -from typing import TYPE_CHECKING, Any -from urllib.parse import unquote +from typing import Any +from urllib.parse import unquote, urlparse import frappe -import frappe.utils import requests from frappe.apps import get_apps as get_permitted_apps from frappe.core.doctype.file.file import get_local_image from frappe.core.doctype.file.utils import delete_file -from frappe.integrations.utils import make_post_request from frappe.model.document import Document from frappe.utils.caching import redis_cache from frappe.utils.safe_exec import NamespaceDict, get_safe_globals -from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD from PIL import Image from werkzeug.wrappers import Response from builder import builder_analytics from builder.builder.doctype.builder_page.builder_page import BuilderPageRenderer +from builder.builder.doctype.builder_snapshot import builder_snapshot +from builder.utils import compact_json, has_page_read, has_page_write, normalize_renamed_doc @frappe.whitelist() -def get_blocks(prompt): - API_KEY = frappe.conf.openai_api_key - if not API_KEY: - frappe.throw("OpenAI API Key not set in site config.") - - messages = [ - { - "role": "system", - "content": "You are a website developer. You respond only with HTML code WITHOUT any EXPLANATION. You use any publicly available images in the webpage. You can use any font from fonts.google.com. Do not use any external css file or font files. DO NOT ADD

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(--)` in page/component blocks. + 3. Normalise legacy type "Spacing" → "Dimension". + + Runs after rename_builder_variable_to_builder_token (pre_model_sync), so the + doctype is always Builder Token by the time this executes. + """ + if not frappe.db.table_exists("Builder Token"): + return + + tokens = frappe.get_all("Builder Token", fields=["name", "token_name"]) + non_uuid = [t for t in tokens if not UUID_RE.match(t.name)] + + if non_uuid: + rename_map, css_rewrite_map = build_maps(non_uuid) + pages_updated = rewrite_doctype_blocks("Builder Page", ["blocks", "draft_blocks"], css_rewrite_map) + components_updated = rewrite_doctype_blocks("Builder Component", ["block"], css_rewrite_map) + renamed = rename_tokens(rename_map) + print( + f"refactor_builder_variables: renamed={renamed} " + f"pages_updated={pages_updated} components_updated={components_updated}" + ) + + normalise_type_spacing() + get_css_variables.clear_cache() + + +def build_maps(tokens): + """Return (rename_map, css_rewrite_map) for the given non-UUID tokens.""" + rename_map = {} + css_rewrite_map = {} + + for token in tokens: + new_id = str(uuid.uuid4()) + rename_map[token.name] = new_id + + # kebab-case of the display label + if token.token_name: + css_rewrite_map[camel_case_to_kebab_case(token.token_name, True)] = new_id + + # snake_case doc names used before this refactor + if "_" in token.name: + css_rewrite_map[token.name.replace("_", "-")] = new_id + + # 10-char hex hashes from an earlier refactor + if re.match(r"^[a-f0-9]{10}$", token.name): + css_rewrite_map[token.name] = new_id + + return rename_map, css_rewrite_map + + +def rename_tokens(rename_map): + renamed = 0 + for old_name, new_id in rename_map.items(): + try: + frappe.rename_doc("Builder Token", old_name, new_id, force=True, merge=False) + renamed += 1 + except Exception as e: + frappe.log_error( + title="refactor_builder_variables: rename failed", + message=f"{old_name} → {new_id}: {e!s}", + ) + return renamed + + +def normalise_type_spacing(): + frappe.db.sql("UPDATE `tabBuilder Token` SET type='Dimension' WHERE type='Spacing'") + + +def rewrite_doctype_blocks(doctype, fields, css_rewrite_map): + if not css_rewrite_map: + return 0 + + # Longer keys first so "brand-primary-light" matches before "brand-primary" + parts = [re.escape(k) for k in sorted(css_rewrite_map, key=len, reverse=True)] + pattern = re.compile(r"var\(--(" + "|".join(parts) + r")(?=[,\s)])") + + def sub(match): + return f"var(--{css_rewrite_map[match.group(1)]}" + + updated = 0 + for rec in frappe.get_all(doctype, fields=["name", *fields]): + dirty = {f: pattern.sub(sub, rec[f]) for f in fields if rec.get(f) and isinstance(rec[f], str)} + dirty = {f: v for f, v in dirty.items() if v != rec[f]} + if dirty: + for f, val in dirty.items(): + frappe.db.set_value(doctype, rec.name, f, val, update_modified=False) + updated += 1 + return updated diff --git a/builder/builder/patches/rename_builder_variable_to_builder_token.py b/builder/builder/patches/rename_builder_variable_to_builder_token.py new file mode 100644 index 000000000..5bbe92b02 --- /dev/null +++ b/builder/builder/patches/rename_builder_variable_to_builder_token.py @@ -0,0 +1,36 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + """Builder Variable → Builder Token. Only the DocType and the label field are + renamed — token doc names (the CSS `--` handles) are untouched, so every + existing page's var(--id) references keep resolving.""" + # guard on the table, not the DocType row: syncing the old model back (a + # downgrade, or migrating on develop) drops the Builder Token DocType but + # leaves tabBuilder Token behind, and rename_doc can't rename onto it + if frappe.db.table_exists("Builder Token"): + merge_stale_builder_variables() + return + if not frappe.db.exists("DocType", "Builder Variable"): + return + frappe.rename_doc("DocType", "Builder Variable", "Builder Token", force=True) + frappe.reload_doc("builder", "doctype", "builder_token") + rename_field("Builder Token", "variable_name", "token_name") + + +def merge_stale_builder_variables(): + """Both doctypes exist when a site ran the rename and later re-synced the old + model. Keep Builder Token, salvage rows only the old table has, drop the rest.""" + if frappe.db.table_exists("Builder Variable"): + frappe.db.sql( + """INSERT INTO `tabBuilder Token` + (name, creation, modified, modified_by, owner, docstatus, + token_name, type, value, dark_value, is_standard, `group`) + SELECT name, creation, modified, modified_by, owner, docstatus, + variable_name, type, value, dark_value, is_standard, `group` + FROM `tabBuilder Variable` bv + WHERE NOT EXISTS (SELECT 1 FROM `tabBuilder Token` bt WHERE bt.name = bv.name)""" + ) + frappe.delete_doc("DocType", "Builder Variable", ignore_missing=True, force=True) + frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabBuilder Variable`") diff --git a/builder/builder/patches/reset_builder_page_clicks.py b/builder/builder/patches/reset_builder_page_clicks.py new file mode 100644 index 000000000..51d41528f --- /dev/null +++ b/builder/builder/patches/reset_builder_page_clicks.py @@ -0,0 +1,14 @@ +import frappe + +from builder.builder_analytics import setup_clicks_table + + +def execute(): + """Click tracking became opt-in: `element` now holds the block id and `tag`/`href` were dropped. + Old rows predate that schema, so reset the table and rebuild the DuckDB snapshot cleanly.""" + frappe.db.truncate("Builder Page Click") + try: + setup_clicks_table() + except Exception: + # DuckDB rebuild is best-effort; the periodic ingestion will recreate the table if needed. + frappe.log_error("Failed to rebuild DuckDB clicks table after reset") diff --git a/builder/builder/test_utils.py b/builder/builder/test_utils.py deleted file mode 100644 index aa917d716..000000000 --- a/builder/builder/test_utils.py +++ /dev/null @@ -1,81 +0,0 @@ -from unittest.mock import patch - -import frappe -from frappe.tests.utils import FrappeTestCase - -from builder.utils import ( - camel_case_to_kebab_case, - escape_single_quotes, - execute_script, - get_builder_page_preview_file_paths, - get_dummy_blocks, - get_template_assets_folder_path, - is_component_used, - remove_unsafe_fields, -) - - -class TestBuilderPage(FrappeTestCase): - def test_camel_case_to_kebab_case(self): - self.assertEqual(camel_case_to_kebab_case("backgroundColor"), "background-color") - self.assertEqual(camel_case_to_kebab_case("Color"), "color") - self.assertEqual(camel_case_to_kebab_case("color"), "color") - self.assertEqual(camel_case_to_kebab_case("NewPage"), "new-page") - self.assertEqual(camel_case_to_kebab_case("new page", remove_spaces=True), "newpage") - - def test_escape_single_quotes(self): - self.assertEqual(escape_single_quotes("Hello 'World'"), "Hello \\'World\\'") - self.assertEqual(escape_single_quotes("Hello World"), "Hello World") - - def test_is_component_used(self): - dummy_blocks = get_dummy_blocks() - self.assertTrue(is_component_used(dummy_blocks, "component-1")) - self.assertTrue(is_component_used(dummy_blocks, "component-2")) - self.assertFalse(is_component_used(dummy_blocks, "component-3")) - - def test_get_builder_page_preview_file_paths(self): - page_doc = frappe._dict( - { - "name": "test-page", - "is_template": False, - } - ) - public_path, local_path = get_builder_page_preview_file_paths(page_doc) - self.assertRegex(public_path, r"/files/test-page-preview.webp\?v=\w{5}") - self.assertEqual(local_path, f"{frappe.local.site_path}/public/files/test-page-preview.webp") - - page_doc.is_template = True - public_path, local_path = get_builder_page_preview_file_paths(page_doc) - self.assertEqual(public_path, "/builder_assets/test-page/preview.webp") - self.assertEqual( - local_path, f"{frappe.get_app_path('builder')}/www/builder_assets/test-page/preview.webp" - ) - - def test_get_template_assets_folder_path(self): - page_doc = frappe._dict({"name": "mypage"}) - path = get_template_assets_folder_path(page_doc) - self.assertEqual(path, f"{frappe.get_app_path('builder')}/www/builder_assets/mypage") - - @patch("builder.utils.is_safe_exec_enabled", return_value=False) - @patch("frappe.utils.safe_exec.is_safe_exec_enabled", return_value=False) - def test_execute_script_with_enabled_server_script(self, *args): - script = "data.test = frappe.get_doc('User', 'Administrator').email" - _locals = dict(data=frappe._dict()) - execute_script(script, _locals, "test.py") - self.assertEqual(_locals["data"]["test"], "admin@example.com") - - @patch("builder.utils.is_safe_exec_enabled", return_value=True) - @patch("frappe.utils.safe_exec.is_safe_exec_enabled", return_value=True) - def test_execute_script_with_disabled_server_script(self, *args): - script = "data.test = frappe.get_doc('User', 'Administrator').email" - _locals = dict(data=frappe._dict()) - execute_script(script, _locals, "test.py") - self.assertEqual(_locals["data"]["test"], "admin@example.com") - - script = "data.users = frappe.db.get_all('User')" - execute_script(script, _locals, "test.py") - self.assertTrue(_locals["data"]["users"]) - - script = "data.users = frappe.db.get_all('User')" - execute_script(script, _locals, "test.py") - self.assertTrue(_locals["data"]["users"]) diff --git a/builder/builder/tests/test_utils.py b/builder/builder/tests/test_utils.py index b5412eb1b..ca28e870c 100644 --- a/builder/builder/tests/test_utils.py +++ b/builder/builder/tests/test_utils.py @@ -1,18 +1,29 @@ import os +from unittest.mock import patch import frappe from frappe.tests.utils import FrappeTestCase from builder.utils import ( + Block, ColonRule, camel_case_to_kebab_case, clean_data, + copy_asset_file, + copy_assets_from_blocks, + copy_img_to_asset_folder, escape_single_quotes, execute_script, + extract_components_from_blocks, get_builder_page_preview_file_paths, get_template_assets_folder_path, is_component_used, + make_safe_get_request, + normalize_legacy_raw_styles, + process_block_assets, remove_unsafe_fields, + sanitize_style_value, + split_styles, ) @@ -23,11 +34,18 @@ def test_camel_case_to_kebab_case(self): "marginTop": "margin-top", "WebsiteHeader": "website-header", "simple": "simple", + "Color": "color", + "color": "color", + "WebkitBackgroundClip": "-webkit-background-clip", + "WebkitTextFillColor": "-webkit-text-fill-color", + "MozTransform": "-moz-transform", } for input_str, expected in test_cases.items(): self.assertEqual(camel_case_to_kebab_case(input_str), expected) + self.assertEqual(camel_case_to_kebab_case("new page", remove_spaces=True), "newpage") + def test_escape_single_quotes(self): test_cases = { "It's working": "It\\'s working", @@ -121,6 +139,26 @@ def test_execute_script(self): execute_script("data.sum = a + b", {"data": data, "a": 2, "b": 2}, "test.py") self.assertEqual(data.sum, 4) + @patch("builder.utils.is_safe_exec_enabled", return_value=False) + @patch("frappe.utils.safe_exec.is_safe_exec_enabled", return_value=False) + def test_execute_script_with_enabled_server_script(self, *args): + script = "data.test = frappe.get_doc('User', 'Administrator').email" + _locals = dict(data=frappe._dict()) + execute_script(script, _locals, "test.py") + self.assertEqual(_locals["data"]["test"], "admin@example.com") + + @patch("builder.utils.is_safe_exec_enabled", return_value=True) + @patch("frappe.utils.safe_exec.is_safe_exec_enabled", return_value=True) + def test_execute_script_with_disabled_server_script(self, *args): + script = "data.test = frappe.get_doc('User', 'Administrator').email" + _locals = dict(data=frappe._dict()) + execute_script(script, _locals, "test.py") + self.assertEqual(_locals["data"]["test"], "admin@example.com") + + script = "data.users = frappe.db.get_all('User')" + execute_script(script, _locals, "test.py") + self.assertTrue(_locals["data"]["users"]) + def test_colon_rule(self): rule = ColonRule("/test/", endpoint="test_endpoint") self.assertEqual(rule.rule, "/test/") @@ -145,3 +183,347 @@ def test_clean_data(self): } cleaned_data = clean_data(data) self.assertEqual(cleaned_data, {"test": "value", "test2": "value2", "test4": None, "test5": {}}) + + def test_make_safe_get_request(self): + # Test with local/private IP addresses (should return None) + self.assertIsNone(make_safe_get_request("http://127.0.0.1/test")) + self.assertIsNone(make_safe_get_request("http://localhost/test")) + + # Test with invalid URL + with self.assertRaises(Exception): + make_safe_get_request("not-a-url") + + def test_split_styles(self): + # Test with None + result = split_styles(None) + self.assertEqual(result, {"regular": {}, "state": {}}) + + # Test with mixed styles + styles = {"color": "red", "margin": "10px", "hover:color": "blue", "focus:border": "1px solid black"} + result = split_styles(styles) + + self.assertEqual(result["regular"], {"color": "red", "margin": "10px"}) + self.assertEqual(result["state"], {"hover:color": "blue", "focus:border": "1px solid black"}) + + def test_normalize_legacy_raw_styles_merges_into_base_styles(self): + blocks = [ + { + "baseStyles": {"color": "red", "hover:color": "green"}, + "rawStyles": {"color": "blue", "hover:background-color": "black", "flex-shrink": "0"}, + "children": [{"rawStyles": {"text-overflow": "ellipsis"}}], + } + ] + + normalize_legacy_raw_styles(blocks) + + self.assertEqual(blocks[0]["baseStyles"]["color"], "blue") + self.assertEqual(blocks[0]["baseStyles"]["hover:backgroundColor"], "black") + self.assertEqual(blocks[0]["baseStyles"]["flexShrink"], "0") + self.assertEqual(blocks[0]["children"][0]["baseStyles"]["textOverflow"], "ellipsis") + self.assertNotIn("rawStyles", blocks[0]) + + def test_copy_assets_from_blocks(self): + # Create a temporary directory for testing + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + # Test with single block + block = Block(element="img", attributes={"src": "/files/test.jpg"}) + copy_assets_from_blocks(block, temp_dir) + + # Test with list of blocks + blocks = [ + { + "element": "div", + "children": [{"element": "img", "attributes": {"src": "/files/test2.jpg"}}], + }, + {"element": "video", "attributes": {"src": "/files/test.mp4"}}, + ] + copy_assets_from_blocks(blocks, temp_dir) + + def test_process_block_assets(self): + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + # Test with img element + block = {"element": "img", "attributes": {"src": "/files/test.jpg"}} + process_block_assets(block, temp_dir) + + # Test with video element + block = {"element": "video", "attributes": {"src": "/files/test.mp4"}} + process_block_assets(block, temp_dir) + + # Test with non-media element + block = {"element": "div", "attributes": {"class": "test"}} + process_block_assets(block, temp_dir) + + def test_copy_asset_file(self): + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + # Test with None/invalid inputs + result = copy_asset_file(None, temp_dir) + self.assertIsNone(result) + + result = copy_asset_file("", temp_dir) + self.assertIsNone(result) + + result = copy_asset_file(123, temp_dir) + self.assertIsNone(result) + + # Test with non-existent file URLs + result = copy_asset_file("/files/nonexistent.jpg", temp_dir) + self.assertIsNone(result) + + result = copy_asset_file("/builder_assets/nonexistent.jpg", temp_dir) + self.assertIsNone(result) + + def test_extract_components_from_blocks(self): + # Test with blocks containing components + blocks = [ + { + "element": "div", + "extendedFromComponent": "TestComponent1", + "children": [{"element": "span", "extendedFromComponent": "TestComponent2"}], + }, + { + "element": "section", + "children": [{"element": "div", "extendedFromComponent": "TestComponent1"}], + }, + ] + + # Mock frappe.get_cached_doc using unittest.mock + + with patch("frappe.get_cached_doc") as mock_get_cached_doc: + mock_get_cached_doc.return_value = frappe._dict(block='{"element": "div"}') + + components = extract_components_from_blocks(blocks) + self.assertIn("TestComponent1", components) + self.assertIn("TestComponent2", components) + + # Test with single block (not a list) + single_block = {"element": "div", "extendedFromComponent": "SingleComponent"} + + with patch("frappe.get_cached_doc") as mock_get_cached_doc: + mock_get_cached_doc.return_value = frappe._dict(block='{"element": "div"}') + + components = extract_components_from_blocks(single_block) + self.assertIn("SingleComponent", components) + + def test_copy_img_to_asset_folder(self): + test_page = frappe._dict(name="test-page") + + # Test with non-img elements (should be ignored) + block = Block() + block.element = "div" + block.children = [] + copy_img_to_asset_folder(block, test_page) # Should not raise error + + # Test with img element but no attributes + block = Block() + block.element = "img" + block.attributes = None + block.children = [] + copy_img_to_asset_folder(block, test_page) # Should not raise error + + # Test with img element but no src attribute + block = Block() + block.element = "img" + block.attributes = frappe._dict() + block.children = [] + copy_img_to_asset_folder(block, test_page) # Should not raise error + + # Test with img element and external src (should be ignored) + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="https://example.com/image.jpg") + block.children = [] + original_src = block.attributes.src + copy_img_to_asset_folder(block, test_page) + self.assertEqual(block.attributes.src, original_src) # Should remain unchanged + + # Test with img element and builder_assets src (should be ignored) + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/builder_assets/local-image.jpg") + block.children = [] + original_src = block.attributes.src + copy_img_to_asset_folder(block, test_page) + self.assertEqual(block.attributes.src, original_src) # Should remain unchanged + + # Test with nested blocks containing img elements + child_block = Block() + child_block.element = "img" + child_block.attributes = frappe._dict(src="https://example.com/nested.jpg") + child_block.children = [] + + parent_block = Block() + parent_block.element = "div" + parent_block.attributes = frappe._dict() + parent_block.children = [child_block] + + copy_img_to_asset_folder(parent_block, test_page) # Should process children recursively + + # Test with local file src that doesn't exist in database + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/files/nonexistent-image.jpg") + block.children = [] + + with patch("frappe.get_all") as mock_get_all: + mock_get_all.return_value = [] # No files found + original_src = block.attributes.src + copy_img_to_asset_folder(block, test_page) + # Should update src even if file not found + self.assertEqual(block.attributes.src, f"/builder_assets/{test_page.name}/nonexistent-image.jpg") + + # Test with valid local file that exists in database + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/files/test-image.jpg") + block.children = [] + + mock_file = frappe._dict() + mock_file.get_full_path = lambda: "/fake/path/test-image.jpg" + + with ( + patch("frappe.get_all") as mock_get_all, + patch("frappe.get_doc") as mock_get_doc, + patch("shutil.copy") as mock_copy, + patch("builder.utils.get_template_assets_folder_path") as mock_get_path, + ): + mock_get_all.return_value = [frappe._dict(name="file-123")] + mock_get_doc.return_value = mock_file + mock_get_path.return_value = "/fake/assets/path" + + copy_img_to_asset_folder(block, test_page) + + # Verify file operations were called correctly + mock_get_all.assert_called_once_with( + "File", filters={"file_url": "/files/test-image.jpg"}, fields=["name"] + ) + mock_get_doc.assert_called_once_with("File", "file-123") + mock_copy.assert_called_once_with("/fake/path/test-image.jpg", "/fake/assets/path") + + # Verify src was updated correctly + self.assertEqual(block.attributes.src, f"/builder_assets/{test_page.name}/test-image.jpg") + + # Test with site URL prefix in src + site_url = frappe.utils.get_url() + block = Block() + block.element = "img" + block.attributes = frappe._dict(src=f"{site_url}/files/prefixed-image.jpg") + block.children = [] + + with patch("frappe.get_all") as mock_get_all: + mock_get_all.return_value = [] + copy_img_to_asset_folder(block, test_page) + # Should strip site URL and update path + self.assertEqual(block.attributes.src, f"/builder_assets/{test_page.name}/prefixed-image.jpg") + + # Test with URL-encoded filename + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/files/image%20with%20spaces.jpg") + block.children = [] + + with patch("frappe.get_all") as mock_get_all: + # Should decode URL and search for decoded filename + mock_get_all.return_value = [] + copy_img_to_asset_folder(block, test_page) + mock_get_all.assert_called_with( + "File", filters={"file_url": "/files/image with spaces.jpg"}, fields=["name"] + ) + self.assertEqual(block.attributes.src, f"/builder_assets/{test_page.name}/image with spaces.jpg") + + # Test error handling when file copy fails + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/files/error-test.jpg") + block.children = [] + + mock_file = frappe._dict() + mock_file.get_full_path = lambda: "/fake/path/error-test.jpg" + + with ( + patch("frappe.get_all") as mock_get_all, + patch("frappe.get_doc") as mock_get_doc, + patch("shutil.copy") as mock_copy, + patch("builder.utils.get_template_assets_folder_path") as mock_get_path, + ): + mock_get_all.return_value = [frappe._dict(name="file-456")] + mock_get_doc.return_value = mock_file + mock_get_path.return_value = "/fake/assets/path" + mock_copy.side_effect = OSError("Permission denied") + + # Function should raise exception when copy fails + with self.assertRaises(OSError): + copy_img_to_asset_folder(block, test_page) + + # Test with empty/None src attribute + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="") + block.children = [] + copy_img_to_asset_folder(block, test_page) # Should not process empty src + + block.attributes = frappe._dict(src=None) + copy_img_to_asset_folder(block, test_page) # Should not process None src + + # Test with malformed URLs + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/uploads/image.jpg") # Not a /files path + block.children = [] + original_src = block.attributes.src + copy_img_to_asset_folder(block, test_page) + self.assertEqual(block.attributes.src, original_src) # Should remain unchanged + + # Test deeply nested structure + grandchild = Block() + grandchild.element = "img" + grandchild.attributes = frappe._dict(src="/files/deep-nested.jpg") + grandchild.children = [] + + child = Block() + child.element = "span" + child.attributes = frappe._dict() + child.children = [grandchild] + + parent = Block() + parent.element = "div" + parent.attributes = frappe._dict() + parent.children = [child] + + with patch("frappe.get_all") as mock_get_all: + mock_get_all.return_value = [] + copy_img_to_asset_folder(parent, test_page) + # Should process grandchild img element + self.assertEqual(grandchild.attributes.src, f"/builder_assets/{test_page.name}/deep-nested.jpg") + + # Test with block that has None children + block = Block() + block.element = "div" + block.attributes = frappe._dict() + block.children = None + copy_img_to_asset_folder(block, test_page) # Should handle None children gracefully + + def test_sanitize_style_value(self): + test_cases = { + # No escaping needed + "center": "center", + "'center'": "'center'", + '"center"': '"center"', + "rgba(0,0,0,0.5)": "rgba(0,0,0,0.5)", + None: None, + 123: 123, + # Unbalanced parentheses + "rgba(0,0,0,0.5": r"rgba\(0,0,0,0.5", + "rgba(0,0,0,0.5))": r"rgba\(0,0,0,0.5\)\)", + # Unbalanced quotes + "'center": r"\'center", + '"center': r"\"center", + } + + for input_val, expected in test_cases.items(): + self.assertEqual(sanitize_style_value(input_val), expected) diff --git a/builder/builder_analytics.py b/builder/builder_analytics.py index 1861e4983..7f0eeb046 100644 --- a/builder/builder_analytics.py +++ b/builder/builder_analytics.py @@ -1,31 +1,46 @@ import os import time -from typing import cast import duckdb import frappe import pandas as pd DUCKDB_TABLE = "web_page_views" +CLICKS_TABLE = "web_page_clicks" class DuckDBConnection: - def __init__(self): + # DuckDB takes a single cross-process file lock: concurrent read-only connections + # coexist, but a read-write one is exclusive. Reads pass read_only=True so dashboard + # requests don't lock each other out; both kinds retry briefly to ride out the lock + # held by the periodic ingestion (or another worker mid-connect). + def __init__(self, read_only=False, retries=8, retry_delay=0.25): self.db = None + self.read_only = read_only + self.retries = retries + self.retry_delay = retry_delay def __enter__(self): duckdb_path = os.path.join(frappe.get_site_path(), "builder_analytics.duckdb") - self.db = duckdb.connect(duckdb_path) - return self.db + for attempt in range(self.retries): + try: + self.db = duckdb.connect(duckdb_path, read_only=self.read_only) + return self.db + except duckdb.IOException as e: + # Only the lock conflict is transient; a missing file etc. should surface + if "lock" not in str(e).lower() or attempt == self.retries - 1: + raise + time.sleep(self.retry_delay) def __exit__(self, exc_type, exc_val, exc_tb): if self.db: self.db.close() -def _get_date_filter(from_date: str | None = None, to_date: str | None = None): +def get_date_filter(from_date: str | None = None, to_date: str | None = None) -> tuple[str, list]: + """Return a parameterized date filter clause and its bind values.""" if not from_date or not to_date: - return "" + return "", [] # Add time component if not present if len(from_date) == 10: # YYYY-MM-DD format @@ -33,59 +48,109 @@ def _get_date_filter(from_date: str | None = None, to_date: str | None = None): if len(to_date) == 10: # YYYY-MM-DD format to_date += " 23:59:59" - return f"creation >= '{from_date}' AND creation <= '{to_date}'" + return "creation >= CAST(? AS TIMESTAMP) AND creation <= CAST(? AS TIMESTAMP)", [from_date, to_date] -def _get_empty_analytics(): +def get_empty_analytics(): return {"total_unique_views": 0, "total_views": 0, "data": [], "top_referrers": []} -def _get_route_filter(route: str | None = None, route_filter_type: str = "wildcard") -> str: - """Get route filter clause for SQL queries""" +def get_empty_ctr(): + return {"total_views": 0, "total_clicks": 0, "ctr": 0, "elements": []} + + +def get_route_filter(route: str | None = None, route_filter_type: str = "wildcard") -> tuple[str, list]: + """Return a parameterized route filter clause and its bind values.""" if not route: - return "" + return "", [] if route_filter_type == "exact": - return f"path = '{route}'" + return "path = ?", [route] else: # wildcard - return f"path LIKE '%{route}%'" + return "path LIKE ?", [f"%{route}%"] -def setup_duckdb_table(table_name=DUCKDB_TABLE): +def build_where_clause( + route: str | None = None, + from_date: str | None = None, + to_date: str | None = None, + route_filter_type: str = "wildcard", +) -> tuple[str, list]: + """Combine the date and route filters into a single WHERE clause and ordered params.""" + conditions = [] + params: list = [] + + date_clause, date_params = get_date_filter(from_date, to_date) + if date_clause: + conditions.append(date_clause) + params += date_params + + route_clause, route_params = get_route_filter(route, route_filter_type) + if route_clause: + conditions.append(route_clause) + params += route_params + + where_clause = " AND ".join(conditions) if conditions else "1=1" + return where_clause, params + + +VIEW_FIELDS = ["creation", "is_unique", "path", "referrer", "time_zone", "user_agent"] +CLICK_FIELDS = ["creation", "is_unique", "path", "element", "text", "visitor_id"] + + +def duckdb_column_cast(field: str) -> str: + """DuckDB cast for a source column when (re)building a table from a DataFrame snapshot.""" + if field == "creation": + return "TRY_CAST(creation AS TIMESTAMP) as creation" + if field == "is_unique": + # is_unique is a string on Web Page View and an int on Builder Page Click; normalize both to 0/1 + return "CAST(COALESCE(NULLIF(CAST(is_unique AS VARCHAR), ''), '0') AS INTEGER) as is_unique" + return f"CAST({field} AS VARCHAR) as {field}" + + +def duckdb_insert_placeholder(field: str) -> str: + """DuckDB VALUES() placeholder matching duckdb_column_cast for incremental inserts.""" + if field == "creation": + return "TRY_CAST(? AS TIMESTAMP)" + if field == "is_unique": + return "CAST(COALESCE(NULLIF(CAST(? AS VARCHAR), ''), '0') AS INTEGER)" + return "?" + + +def setup_table(table_name: str, doctype: str, fields: list[str]): + """(Re)build a DuckDB table as a full snapshot of `doctype`.""" with DuckDBConnection() as db: - sql_connection = frappe.db.get_connection() - df = pd.read_sql( - "SELECT creation, is_unique, path, referrer, time_zone, user_agent FROM `tabWeb Page View`", - sql_connection, # type: ignore - ) + df = pd.read_sql(f"SELECT {', '.join(fields)} FROM `tab{doctype}`", frappe.db.get_connection()) # type: ignore db.register("df", df) - db.execute( - f"CREATE OR REPLACE TABLE {table_name} AS SELECT creation, CAST(CASE WHEN is_unique = '' OR is_unique IS NULL THEN '0' ELSE CAST(is_unique AS VARCHAR) END AS INTEGER) as is_unique, path, referrer, time_zone, user_agent FROM df" - ) - print(f"Successfully ingested {len(df)} records into DuckDB") + select_cols = ", ".join(duckdb_column_cast(f) for f in fields) + db.execute(f"CREATE OR REPLACE TABLE {table_name} AS SELECT {select_cols} FROM df") + frappe.logger().info(f"Ingested {len(df)} {doctype} records into DuckDB ({table_name})") -def ingest_web_page_views_to_duckdb(table_name=DUCKDB_TABLE): +def ingest_to_duckdb(doctype: str, table_name: str, fields: list[str]): + """Incrementally append new `doctype` rows into its DuckDB table, recreating it if missing or stale.""" with DuckDBConnection() as db: table_exists = db.execute( f"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = '{table_name}'" ).fetchone() if table_exists and table_exists[0] == 0: - setup_duckdb_table(table_name) + setup_table(table_name, doctype, fields) return - result = db.execute(f"SELECT MAX(creation) FROM {table_name}").fetchone() - last_record = result[0] if result and result[0] else None - - filters = {"creation": [">", last_record]} if last_record else {} - - total_count = frappe.db.count("Web Page View", filters=filters) - print(f"Starting ingestion of {total_count} records...") + # Recreate table if creation column has a stale/incompatible type (not TIMESTAMP) + col_type = db.execute( + f"SELECT data_type FROM information_schema.columns WHERE table_name = '{table_name}' AND column_name = 'creation'" + ).fetchone() + if col_type and col_type[0].upper() != "TIMESTAMP": + frappe.logger().info( + f"Recreating {table_name}: creation column type is {col_type[0]}, expected TIMESTAMP" + ) + setup_table(table_name, doctype, fields) + return + columns = ", ".join(fields) + placeholders = ", ".join(duckdb_insert_placeholder(f) for f in fields) page_size = 20000 - start = 0 - processed = 0 - db.begin() while True: @@ -94,9 +159,9 @@ def ingest_web_page_views_to_duckdb(table_name=DUCKDB_TABLE): filters = {"creation": [">", last_record]} if last_record else {} records = frappe.get_all( - "Web Page View", + doctype, filters=filters, - fields=["creation", "is_unique", "path", "referrer", "time_zone", "user_agent"], + fields=fields, as_list=True, limit=page_size, order_by="creation asc", @@ -105,25 +170,23 @@ def ingest_web_page_views_to_duckdb(table_name=DUCKDB_TABLE): if not records: break - db.executemany( - f"INSERT INTO {table_name} (creation, is_unique, path, referrer, time_zone, user_agent) VALUES (?, CAST(? AS INTEGER), ?, ?, ?, ?)", - records, - ) - - processed += len(records) - progress = (processed / total_count) * 100 if total_count > 0 else 100 - print(f"Progress: {processed}/{total_count} ({progress:.1f}%) records ingested") + db.executemany(f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})", records) if len(records) < page_size: break - start += page_size - db.commit() - print(f"Successfully ingested {processed} records into DuckDB") -def _get_interval_formats(interval): +def setup_duckdb_table(table_name=DUCKDB_TABLE): + setup_table(table_name, "Web Page View", VIEW_FIELDS) + + +def ingest_web_page_views_to_duckdb(table_name=DUCKDB_TABLE): + ingest_to_duckdb("Web Page View", table_name, VIEW_FIELDS) + + +def get_interval_formats(interval): """Get display and sort formats for time intervals""" display_formats = { "hourly": "%b %d, %I:00 %p", @@ -139,30 +202,34 @@ def _get_interval_formats(interval): "monthly": "%Y-%m", } + # Guard against unknown intervals (these strings end up in SQL format strings) + if interval not in display_formats: + interval = "daily" + return display_formats[interval], sort_formats.get(interval, display_formats[interval]) -def _get_aggregated_views_query(where_clause, table_name=DUCKDB_TABLE): +def get_aggregated_views_query(where_clause, table_name=DUCKDB_TABLE): """Get query for total and unique view counts""" return f"SELECT COUNT(*) as total_views, SUM(is_unique) as unique_views FROM {table_name} WHERE {where_clause}" -def _get_interval_views_query(where_clause, interval, table_name=DUCKDB_TABLE): +def get_interval_views_query(where_clause, interval, table_name=DUCKDB_TABLE): """Get query for views grouped by time interval""" - display_fmt, sort_fmt = _get_interval_formats(interval) + display_fmt, sort_fmt = get_interval_formats(interval) return f""" SELECT strftime('{display_fmt}', creation) as interval, COUNT(*) as total_page_views, SUM(is_unique) as unique_page_views FROM {table_name} - WHERE {where_clause} + WHERE ({where_clause}) AND creation IS NOT NULL GROUP BY interval, strftime('{sort_fmt}', creation) ORDER BY strftime('{sort_fmt}', creation) """ -def _get_referrer_domain_query(where_clause, limit=10, table_name=DUCKDB_TABLE): +def get_referrer_domain_query(where_clause, limit=10, table_name=DUCKDB_TABLE): """Get query for top referrer domains with counts""" return f""" WITH parsed_referrers AS ( @@ -198,38 +265,28 @@ def get_page_analytics( ): """Get analytics data for a specific page route or all pages""" try: - # Get date filter - date_filter = _get_date_filter(from_date, to_date) + # A date range is required for page analytics + date_filter, _ = get_date_filter(from_date, to_date) if not date_filter: - return _get_empty_analytics() - - # Add route filter - route_filter = _get_route_filter(route, route_filter_type) + return get_empty_analytics() - # Build WHERE clause properly - where_conditions = [] - if date_filter: - where_conditions.append(date_filter) - if route_filter: - where_conditions.append(route_filter) - - where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" + where_clause, params = build_where_clause(route, from_date, to_date, route_filter_type) # Use provided interval or default to daily interval = interval or "daily" - with DuckDBConnection() as db: + with DuckDBConnection(read_only=True) as db: # Get interval-based data - interval_query = _get_interval_views_query(where_clause, interval, table_name) - rows = db.execute(interval_query).fetchall() + interval_query = get_interval_views_query(where_clause, interval, table_name) + rows = db.execute(interval_query, params).fetchall() # Get total views - total_query = _get_aggregated_views_query(where_clause, table_name) - total_views, total_unique_views = db.execute(total_query).fetchone() or (0, 0) + total_query = get_aggregated_views_query(where_clause, table_name) + total_views, total_unique_views = db.execute(total_query, params).fetchone() or (0, 0) # Get top referrers for this specific page/route - referrer_query = _get_referrer_domain_query(where_clause, 10, table_name) - referrer_rows = db.execute(referrer_query).fetchall() + referrer_query = get_referrer_domain_query(where_clause, 10, table_name) + referrer_rows = db.execute(referrer_query, params).fetchall() return { "total_unique_views": total_unique_views or 0, @@ -239,7 +296,7 @@ def get_page_analytics( } except Exception as e: frappe.log_error("DuckDB Analytics Error", str(e)) - return _get_empty_analytics() + return get_empty_analytics() def get_top_pages( @@ -249,30 +306,24 @@ def get_top_pages( to_date: str | None = None, route_filter_type: str = "wildcard", ): - # Get date filter - date_filter = _get_date_filter(from_date, to_date) - route_filter = _get_route_filter(route, route_filter_type) - - # Build WHERE clause properly - where_conditions = [] - if date_filter: - where_conditions.append(date_filter) - if route_filter: - where_conditions.append(route_filter) - - where_clause = "WHERE " + " AND ".join(where_conditions) if where_conditions else "" - - with DuckDBConnection() as db: - q = f""" - SELECT path as route, COUNT(*) as view_count, SUM(is_unique) as unique_view_count - FROM {table_name} - {where_clause} - GROUP BY path - ORDER BY view_count DESC - LIMIT 20 - """ - rows = db.execute(q).fetchall() - return [{"route": r[0], "view_count": r[1], "unique_view_count": r[2]} for r in rows] + try: + inner_clause, params = build_where_clause(route, from_date, to_date, route_filter_type) + where_clause = "" if inner_clause == "1=1" else f"WHERE {inner_clause}" + + with DuckDBConnection(read_only=True) as db: + q = f""" + SELECT path as route, COUNT(*) as view_count, SUM(is_unique) as unique_view_count + FROM {table_name} + {where_clause} + GROUP BY path + ORDER BY view_count DESC + LIMIT 20 + """ + rows = db.execute(q, params).fetchall() + return [{"route": r[0], "view_count": r[1], "unique_view_count": r[2]} for r in rows] + except Exception as e: + frappe.log_error("DuckDB Analytics Error in top pages", str(e)) + return [] def get_top_referrers( @@ -284,22 +335,11 @@ def get_top_referrers( ): """Get top referrers from analytics data using SQL for domain extraction""" try: - # Get date filter - date_filter = _get_date_filter(from_date, to_date) - route_filter = _get_route_filter(route, route_filter_type) - - # Build WHERE clause properly - where_conditions = [] - if date_filter: - where_conditions.append(date_filter) - if route_filter: - where_conditions.append(route_filter) - - where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" - - with DuckDBConnection() as db: - referrer_query = _get_referrer_domain_query(where_clause, 20, table_name) - rows = db.execute(referrer_query).fetchall() + where_clause, params = build_where_clause(route, from_date, to_date, route_filter_type) + + with DuckDBConnection(read_only=True) as db: + referrer_query = get_referrer_domain_query(where_clause, 20, table_name) + rows = db.execute(referrer_query, params).fetchall() return [{"domain": r[0], "count": r[1], "unique_count": r[2]} for r in rows] except Exception as e: frappe.log_error("DuckDB Analytics Error in top referrers", str(e)) @@ -345,3 +385,86 @@ def enqueue_web_page_view_ingesion(): "builder.builder_analytics.ingest_web_page_views_to_duckdb", queue="long", ) + + +def setup_clicks_table(table_name=CLICKS_TABLE): + setup_table(table_name, "Builder Page Click", CLICK_FIELDS) + + +def ingest_clicks_to_duckdb(table_name=CLICKS_TABLE): + ingest_to_duckdb("Builder Page Click", table_name, CLICK_FIELDS) + + +def get_page_ctr( + route: str | None = None, + from_date: str | None = None, + to_date: str | None = None, + route_filter_type: str = "wildcard", +): + """Click-through rate per page/element: clicks (from web_page_clicks) over page views + (from web_page_views), joined on the shared `path`.""" + try: + date_filter, _ = get_date_filter(from_date, to_date) + if not date_filter: + return get_empty_ctr() + + where_clause, params = build_where_clause(route, from_date, to_date, route_filter_type) + + # read-only so this SELECT-only query doesn't take the exclusive write lock and + # starve the concurrent get_page_analytics read on the same dashboard load + with DuckDBConnection(read_only=True) as db: + total_views = ( + db.execute(f"SELECT COUNT(*) FROM {DUCKDB_TABLE} WHERE {where_clause}", params).fetchone()[0] + or 0 + ) + total_clicks = ( + db.execute(f"SELECT COUNT(*) FROM {CLICKS_TABLE} WHERE {where_clause}", params).fetchone()[0] + or 0 + ) + + element_rows = db.execute( + f""" + WITH clicks AS ( + SELECT + path, + element, + COALESCE(NULLIF(ANY_VALUE(text), ''), element) AS label, + COUNT(*) AS clicks, + SUM(is_unique) AS unique_clicks + FROM {CLICKS_TABLE} + WHERE {where_clause} + GROUP BY path, element + ), + views AS ( + SELECT path, COUNT(*) AS views FROM {DUCKDB_TABLE} WHERE {where_clause} GROUP BY path + ) + SELECT clicks.label, clicks.element, clicks.path, + clicks.clicks, clicks.unique_clicks, COALESCE(views.views, 0) AS views + FROM clicks + LEFT JOIN views ON clicks.path = views.path + ORDER BY clicks.clicks DESC + LIMIT 50 + """, + params + params, + ).fetchall() + + return { + "total_views": total_views, + "total_clicks": total_clicks, + "ctr": round(total_clicks / total_views * 100, 2) if total_views else 0, + "elements": [ + { + "label": r[0], + "blockId": r[1], + "route": r[2], + "clicks": r[3], + "unique_clicks": r[4], + "views": r[5], + "ctr": round(r[3] / r[5] * 100, 2) if r[5] else 0, + } + for r in element_rows + ], + } + except Exception as e: + frappe.log_error("DuckDB CTR Error", str(e)) + return get_empty_ctr() diff --git a/builder/domain.py b/builder/domain.py new file mode 100644 index 000000000..b69d4d19b --- /dev/null +++ b/builder/domain.py @@ -0,0 +1,85 @@ +import frappe +from frappe.integrations.frappe_providers.frappecloud_billing import get_base_url, get_headers +from frappe.utils.telemetry import capture + + +def fc_call(method: str, **params): + frappe.only_for("System Manager") + + import requests + + response = requests.post( + f"{get_base_url()}/api/method/press.saas.api.domain.{method}", + headers=get_headers(), + json=params or None, + ) + + if response.status_code != 200: + import json as _json + + body = response.json() + server_msgs = body.get("_server_messages") + if server_msgs: + try: + msgs = _json.loads(server_msgs) + error = ( + _json.loads(msgs[0]).get("message") + if isinstance(msgs[0], str) + else msgs[0].get("message") + ) + except Exception: + error = None + else: + error = body.get("exception") or body.get("message") + frappe.throw(error or f"FC API returned {response.status_code}") + + return response.json().get("message") + + +@frappe.whitelist() +def get_server_ip() -> str | None: + return fc_call("get_inbound_ip") + + +@frappe.whitelist() +def get_domains() -> list: + return fc_call("get_domains") + + +@frappe.whitelist() +def check_dns(domain: str) -> dict: + return fc_call("check_dns", domain=domain) + + +@frappe.whitelist() +def add_domain(domain: str) -> str: + result = fc_call("add_domain", domain=domain) + capture("builder_custom_domain_added", "builder") + return result + + +@frappe.whitelist() +def remove_domain(domain: str) -> str: + return fc_call("remove_domain", domain=domain) + + +@frappe.whitelist() +def retry_add_domain(domain: str) -> str: + return fc_call("retry_add_domain", domain=domain) + + +@frappe.whitelist() +def set_host_name(domain: str) -> str: + result = fc_call("set_host_name", domain=domain) + capture("builder_custom_domain_set_primary", "builder") + return result + + +@frappe.whitelist() +def set_redirect(domain: str) -> str: + return fc_call("set_redirect", domain=domain) + + +@frappe.whitelist() +def unset_redirect(domain: str) -> str: + return fc_call("unset_redirect", domain=domain) diff --git a/builder/export_import_standard_page.py b/builder/export_import_standard_page.py new file mode 100644 index 000000000..805884651 --- /dev/null +++ b/builder/export_import_standard_page.py @@ -0,0 +1,347 @@ +import os +import re +import shutil + +import frappe +from frappe.modules.export_file import strip_default_fields + +from builder.utils import ( + copy_asset_file, + copy_assets_from_blocks, + create_export_directories, + export_client_scripts, + export_components, + extract_components_from_blocks, + make_records, + normalize_legacy_raw_styles, +) + + +def export_page_as_standard(page_name, target_app): + """Export a builder page as standard files to the specified app""" + page_doc = frappe.get_doc("Builder Page", page_name) + export_name = frappe.scrub(page_doc.page_name) + + app_path = frappe.get_app_path(target_app) + if not app_path: + frappe.throw(f"App '{target_app}' not found") + + paths = create_export_directories(app_path, export_name) + + page_config = page_doc.as_dict(no_nulls=True) + page_config = strip_default_fields(page_doc, page_config) + + config_file_path = os.path.join(paths["page_path"], f"{export_name}.json") + + blocks = frappe.parse_json(page_config.get("draft_blocks") or page_config["blocks"]) + if blocks: + copy_assets_from_blocks(blocks, paths["assets_path"], target_app) + page_config["blocks"] = blocks + page_config["draft_blocks"] = None + + if page_doc.favicon: + page_config["favicon"] = copy_asset_file(page_doc.favicon, paths["assets_path"], target_app) + if page_doc.meta_image: + page_config["meta_image"] = copy_asset_file(page_doc.meta_image, paths["assets_path"], target_app) + + page_config["project_folder"] = target_app + page_config = frappe.as_json(page_config, ensure_ascii=False) + + with open(config_file_path, "w", encoding="utf-8") as f: + f.write(page_config) + + export_client_scripts(page_doc, paths["client_scripts_path"]) + + if blocks: + components = extract_components_from_blocks(blocks) + export_components(components, paths["components_path"], paths["assets_path"], target_app) + + fonts = extract_fonts_from_blocks(blocks) + variables = extract_variables_from_blocks(blocks) + + for component_id in components: + try: + component_doc = frappe.get_cached_doc("Builder Component", component_id) + component_blocks = frappe.parse_json(component_doc.block or "[]") + copy_assets_from_blocks(component_blocks, paths["assets_path"], target_app) + fonts.update(extract_fonts_from_blocks(component_blocks)) + variables.update(extract_variables_from_blocks(component_blocks)) + except Exception: + pass + + export_fonts(fonts, paths["builder_files_path"], paths["assets_path"], target_app) + export_variables(variables, paths["builder_files_path"]) + + +def sync_standard_builder_pages(app_name=None): + print("Syncing Standard Builder Pages") + + apps_to_sync = [app_name] if app_name else frappe.get_installed_apps() + + for app in apps_to_sync: + app_path = frappe.get_app_path(app) + pages_path = os.path.join(app_path, "builder_files", "pages") + components_path = os.path.join(app_path, "builder_files", "components") + scripts_path = os.path.join(app_path, "builder_files", "client_scripts") + fonts_path = os.path.join(app_path, "builder_files", "fonts") + variables_path = os.path.join(app_path, "builder_files", "variables") + if os.path.exists(components_path): + print(f"Importing components from {components_path}") + make_records(components_path) + if os.path.exists(scripts_path): + print(f"Importing scripts from {scripts_path}") + make_records(scripts_path) + if os.path.exists(fonts_path): + print(f"Importing fonts from {fonts_path}") + import_fonts(fonts_path) + if os.path.exists(variables_path): + print(f"Importing variables from {variables_path}") + make_records(variables_path) + if os.path.exists(pages_path): + frappe.get_doc( + { + "doctype": "Builder Project Folder", + "folder_name": app, + "is_standard": 1, + } + ).insert(ignore_if_duplicate=True) + print(f"Importing page from {pages_path}") + make_records(pages_path) + + +def extract_fonts_from_blocks(blocks): + """Extract font family names from blocks recursively""" + fonts = set() + if not isinstance(blocks, list): + blocks = [blocks] + normalize_legacy_raw_styles(blocks) + + for block in blocks: + if not isinstance(block, dict): + continue + + for style_key in ["baseStyles", "mobileStyles", "tabletStyles"]: + styles = block.get(style_key, {}) + if styles and isinstance(styles, dict): + font = styles.get("fontFamily") + if font and font.strip(): + font = font.replace("\\", "").strip() + if font: + fonts.add(font) + + inner_html = block.get("innerHTML", "") + if inner_html: + inline_fonts = re.findall(r'font-family:\s*([^;"]+)', inner_html) + for font in inline_fonts: + font = font.strip().strip("'\"") + if font: + fonts.add(font) + + children = block.get("children", []) + if children and isinstance(children, list): + fonts.update(extract_fonts_from_blocks(children)) + + return fonts + + +def extract_variables_from_blocks(blocks): + """Extract CSS variable names from blocks recursively""" + variables = set() + if not isinstance(blocks, list): + blocks = [blocks] + normalize_legacy_raw_styles(blocks) + + # Regex to match var(--variable-name, ...) or var(--variable-name) + var_pattern = re.compile(r"var\(--([a-zA-Z0-9_-]+)") + + def extract_vars_from_value(value): + """Extract variable names from a CSS value""" + if not value or not isinstance(value, str): + return + matches = var_pattern.findall(value) + for match in matches: + variables.add(match) + + for block in blocks: + if not isinstance(block, dict): + continue + + for style_key in ["baseStyles", "mobileStyles", "tabletStyles"]: + styles = block.get(style_key, {}) + if styles and isinstance(styles, dict): + for _prop, value in styles.items(): + extract_vars_from_value(value) + + attributes = block.get("attributes", {}) + if attributes and isinstance(attributes, dict): + for _prop, value in attributes.items(): + extract_vars_from_value(value) + + inner_html = block.get("innerHTML", "") + if inner_html: + extract_vars_from_value(inner_html) + + children = block.get("children", []) + if children and isinstance(children, list): + variables.update(extract_variables_from_blocks(children)) + + return variables + + +def export_fonts(fonts, builder_files_path, assets_path, target_app="builder"): + """Export User Font records and their font files""" + if not fonts: + return + + fonts_path = os.path.join(builder_files_path, "fonts") + os.makedirs(fonts_path, exist_ok=True) + + for font_name in fonts: + try: + font_docs = frappe.get_all( + "User Font", filters={"font_name": font_name}, fields=["name", "font_name", "font_file"] + ) + if not font_docs: + continue + + font_doc = font_docs[0] + + # Copy font file to assets + if font_doc.font_file: + new_font_path = copy_font_file(font_doc.font_file, assets_path, target_app) + if new_font_path: + font_doc["font_file"] = new_font_path + + font_config = { + "doctype": "User Font", + "name": font_doc.name, + "font_name": font_doc.font_name, + "font_file": font_doc.get("font_file"), + } + + safe_font_name = frappe.scrub(font_name) + font_dir = os.path.join(fonts_path, safe_font_name) + os.makedirs(font_dir, exist_ok=True) + font_file_path = os.path.join(font_dir, f"{safe_font_name}.json") + + with open(font_file_path, "w", encoding="utf-8") as f: + f.write(frappe.as_json(font_config, ensure_ascii=False)) + + except Exception as e: + frappe.log_error(f"Failed to export font {font_name}: {e!s}") + + +def copy_font_file(file_url, assets_path, target_app="builder"): + """Copy a font file to assets directory""" + if not file_url or not isinstance(file_url, str): + return None + + try: + if file_url.startswith("/files/"): + source_path = os.path.join(frappe.local.site_path, "public", file_url.lstrip("/")) + elif file_url.startswith("/assets/") and "/builder_files/" in file_url: + parts = file_url.split("/") + if len(parts) >= 3: + app_name = parts[2] + source_path = os.path.join(frappe.get_app_path(app_name), "public", "/".join(parts[3:])) + else: + return None + elif file_url.startswith("/builder_assets/"): + source_path = os.path.join(frappe.get_app_path("builder"), "www", file_url.lstrip("/")) + else: + return None + + if os.path.exists(source_path): + filename = os.path.basename(file_url) + dest_path = os.path.join(assets_path, filename) + shutil.copy2(source_path, dest_path) + return f"/assets/{target_app}/builder_assets/{filename}" + except Exception as e: + frappe.log_error(f"Failed to copy font file {file_url}: {e!s}") + + return None + + +def export_variables(variables, builder_files_path): + """Export Builder Token records""" + if not variables: + return + + variables_path = os.path.join(builder_files_path, "variables") + os.makedirs(variables_path, exist_ok=True) + + for var_name in variables: + try: + # Convert CSS variable name (kebab-case) to possible DB name (snake_case) + db_name = var_name.replace("-", "_") + + # Try to find the variable by name + var_docs = frappe.get_all( + "Builder Token", + filters=[ + ["token_name", "in", [var_name, db_name, var_name.replace("-", " ").title()]], + ], + fields=["name", "token_name", "type", "value", "dark_value"], + ) + + if not var_docs: + # Also try searching by the scrubbed name + var_docs = frappe.get_all( + "Builder Token", + filters={"name": db_name}, + fields=["name", "token_name", "type", "value", "dark_value"], + ) + + if not var_docs: + continue + + var_doc = var_docs[0] + + var_config = { + "doctype": "Builder Token", + "name": var_doc.name, + "token_name": var_doc.token_name, + "type": var_doc.type, + "value": var_doc.value, + "dark_value": var_doc.dark_value, + } + + safe_var_name = frappe.scrub(var_doc.token_name) + var_dir = os.path.join(variables_path, safe_var_name) + os.makedirs(var_dir, exist_ok=True) + var_file_path = os.path.join(var_dir, f"{safe_var_name}.json") + + with open(var_file_path, "w", encoding="utf-8") as f: + f.write(frappe.as_json(var_config, ensure_ascii=False)) + + except Exception as e: + frappe.log_error(f"Failed to export variable {var_name}: {e!s}") + + +def import_fonts(fonts_path): + """Import User Font records from exported files""" + if not os.path.isdir(fonts_path): + return + + for fname in os.listdir(fonts_path): + font_dir = os.path.join(fonts_path, fname) + if os.path.isdir(font_dir) and fname != "__pycache__": + font_file = os.path.join(font_dir, f"{fname}.json") + if os.path.exists(font_file): + try: + with open(font_file) as f: + font_config = frappe.parse_json(f.read()) + + if frappe.db.exists("User Font", font_config.get("font_name")): + continue + + font_doc = frappe.get_doc( + { + "doctype": "User Font", + "font_name": font_config.get("font_name"), + "font_file": font_config.get("font_file"), + } + ) + font_doc.insert(ignore_permissions=True) + except Exception as e: + frappe.log_error(f"Failed to import font {fname}: {e!s}") diff --git a/builder/hooks.py b/builder/hooks.py index 7b4231d32..22f86c42f 100644 --- a/builder/hooks.py +++ b/builder/hooks.py @@ -1,13 +1,11 @@ import frappe -from . import __version__ as app_version - app_name = "builder" app_title = "Frappe Builder" app_publisher = "Frappe Technologies Pvt Ltd" app_description = "An easier way to build web pages for your needs!" app_email = "suraj@frappe.io" -app_license = "GNU Affero General Public License v3.0" +app_license = "MIT" # Includes in # ------------------ @@ -16,6 +14,7 @@ app_include_js = "/assets/builder/js/builder.js" export_python_type_annotations = True +require_type_annotated_api_methods = True # include js, css files in header of web template # web_include_css = "/assets/builder/css/builder.css" @@ -55,10 +54,16 @@ # ---------- # add methods and filters to jinja environment -# jinja = { -# "methods": "builder.utils.jinja_methods", -# "filters": "builder.utils.jinja_filters" -# } +jinja = { + "methods": [ + "builder.builder.doctype.builder_component.builder_component.get_component_data", + ], + "filters": [ + "builder.utils.combine", + "builder.utils.hash", + "builder.utils.to_safe_json", + ], +} # Installation # ------------ @@ -66,6 +71,7 @@ # before_install = "builder.install.before_install" after_install = "builder.install.after_install" after_migrate = "builder.install.after_migrate" +after_app_install = "builder.install.after_app_install" # Uninstallation # ------------ @@ -91,6 +97,14 @@ # "Event": "frappe.desk.doctype.event.event.has_permission", # } +user_invitation = { + "allowed_roles": { + "System Manager": ["Website Manager"], + "Website Manager": ["Website Manager"], + }, + "after_accept": ["builder.user_invitation.after_accept"], +} + # DocType Class # --------------- # Override standard doctype classes @@ -103,13 +117,11 @@ # --------------- # Hook on document methods and events -# doc_events = { -# "*": { -# "on_update": "method", -# "on_cancel": "method", -# "on_trash": "method" -# } -# } +doc_events = { + "User Invitation": { + "after_insert": "builder.user_invitation.capture_user_invited", + } +} # Scheduled Tasks # --------------- @@ -118,6 +130,7 @@ "cron": { "*/10 * * * *": [ "builder.builder_analytics.ingest_web_page_views_to_duckdb", + "builder.builder_analytics.ingest_clicks_to_duckdb", ], } } diff --git a/builder/html_preview_image.py b/builder/html_preview_image.py index 675a6560b..b1ccf9d09 100644 --- a/builder/html_preview_image.py +++ b/builder/html_preview_image.py @@ -1,24 +1,38 @@ import html as html_parser import frappe -import requests - -# TODO: Find better alternative -# Note: while working locally, "preview.frappe.cloud" won't be able to generate preview properly since it can't access local server for assets -# So, for local development, better to use local server for preview generation -# (https://github.com/frappe/preview_generator) -PREVIEW_GENERATOR_URL = ( - frappe.conf.preview_generator_url - or "https://preview.frappe.cloud/api/method/preview_generator.api.generate_preview" -) def generate_preview(html, output_path): - escaped_html = html_parser.escape(html) - response = requests.post(PREVIEW_GENERATOR_URL, json={"html": escaped_html, "format": "webp"}) - if response.status_code == 200: - with open(output_path, "wb") as f: - f.write(response.content) - else: - exception = response.json().get("exc") - raise Exception(frappe.parse_json(exception)[0]) + image = render(html) + with open(output_path, "wb") as f: + f.write(image) + + +def render(html: str) -> bytes: + # Newer Frappe versions ship a built-in headless-Chromium screenshot generator, + # so we render previews in-process — no external service, and local assets + # resolve. Older versions don't have this helper; fall back to the + # preview_generator HTTP service there. + try: + from frappe.utils.preview import get_preview_from_html + except ImportError: + return render_via_service(html) + + return get_preview_from_html(html, format="webp") + + +def render_via_service(html: str) -> bytes: + # Note: while working locally, "preview.frappe.cloud" can't reach the local + # server for assets, so set `preview_generator_url` to a local/self-hosted + # preview_generator (https://github.com/frappe/preview_generator). + import requests + + url = ( + frappe.conf.preview_generator_url + or "https://preview.frappe.cloud/api/method/preview_generator.api.generate_preview" + ) + response = requests.post(url, json={"html": html_parser.escape(html), "format": "webp"}) + if response.status_code != 200: + raise Exception(frappe.parse_json(response.json().get("exc"))[0]) + return response.content diff --git a/builder/install.py b/builder/install.py index 7df75eb21..2479384d8 100644 --- a/builder/install.py +++ b/builder/install.py @@ -1,10 +1,10 @@ -import frappe from frappe.core.api.file import create_new_folder +from builder.export_import_standard_page import sync_standard_builder_pages from builder.utils import ( add_composite_index_to_web_page_view, sync_block_templates, - sync_builder_variables, + sync_builder_tokens, sync_page_templates, ) @@ -14,11 +14,17 @@ def after_install(): create_new_folder("Fonts", "Home/Builder Uploads") sync_page_templates() sync_block_templates() - sync_builder_variables() + sync_builder_tokens() add_composite_index_to_web_page_view() + sync_standard_builder_pages() def after_migrate(): sync_page_templates() sync_block_templates() - sync_builder_variables() + sync_builder_tokens() + sync_standard_builder_pages() + + +def after_app_install(app_name=None): + sync_standard_builder_pages(app_name) diff --git a/builder/patches.txt b/builder/patches.txt index 16e7c56fe..d3682d700 100644 --- a/builder/patches.txt +++ b/builder/patches.txt @@ -3,6 +3,7 @@ builder.builder.doctype.builder_page.patches.create_upload_folder_for_builder # builder.builder.patches.rename_web_page_beta_to_builder_page builder.builder.patches.rename_web_page_component_to_builder_component execute:frappe.delete_doc("DocType", "Builder Page Library", ignore_missing=True, force=True) +builder.builder.patches.rename_builder_variable_to_builder_token [post_model_sync] builder.builder.doctype.builder_component.patches.set_component_id @@ -11,5 +12,7 @@ builder.builder.doctype.builder_page.patches.attach_client_script_to_builder_pag builder.builder.doctype.builder_page.patches.enable_auto_convert_to_webp_by_default builder.builder.doctype.builder_client_script.patches.trigger_asset_compression builder.builder.patches.add_composite_index_to_web_page_view +builder.builder.patches.refactor_builder_variables +builder.builder.patches.reset_builder_page_clicks execute:frappe.call("builder.builder_analytics.enqueue_web_page_view_ingesion") -execute:frappe.call("builder.builder_analytics.setup_duckdb_table") \ No newline at end of file +execute:frappe.call("builder.builder_analytics.setup_duckdb_table") diff --git a/builder/public/reset.css b/builder/public/reset.css index f327bcc50..a357f2108 100644 --- a/builder/public/reset.css +++ b/builder/public/reset.css @@ -1 +1 @@ -@font-face{font-family:InterVar;font-weight:100 900;font-display:swap;font-style:normal;src:url(/assets/builder/fonts/Inter/Inter.var.woff2?v=3.19) format("woff2-variations"),url(/assets/builder/fonts/Inter/Inter.var.woff2?v=3.19) format("woff2");src:url(/assets/builder/fonts/Inter/Inter.var.woff2?v=3.19) format("woff2") tech("variations")}@font-face{font-family:InterVar;font-weight:100 900;font-display:swap;font-style:italic;src:url(/assets/builder/fonts/Inter/Inter-Italic.var.woff2?v=3.19) format("woff2-variations"),url(/assets/builder/fonts/Inter/Inter-Italic.var.woff2?v=3.19) format("woff2");src:url(/assets/builder/fonts/Inter/Inter-Italic.var.woff2?v=3.19) format("woff2") tech("variations")}*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#ededed}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#c7c7c7}input::placeholder,textarea::placeholder{opacity:1;color:#c7c7c7}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[type=text],input:where(:not([type])),[type=email],[type=url],[type=password],[type=number],[type=date],[type=datetime-local],[type=month],[type=search],[type=tel],[type=time],[type=week],[multiple],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#999;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow: 0 0 #0000}[type=text]:focus,input:where(:not([type])):focus,[type=email]:focus,[type=url]:focus,[type=password]:focus,[type=number]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=month]:focus,[type=search]:focus,[type=tel]:focus,[type=time]:focus,[type=week]:focus,[multiple]:focus,textarea:focus,select:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset: var(--tw-empty, );--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: #007BE0;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#007be0}input::-moz-placeholder,textarea::-moz-placeholder{color:#999;opacity:1}input::placeholder,textarea::placeholder{color:#999;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23999999' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#007be0;background-color:#fff;border-color:#999;border-width:1px;--tw-shadow: 0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset: var(--tw-empty, );--tw-ring-offset-width: 2px;--tw-ring-offset-color: #fff;--tw-ring-color: #007BE0;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:center;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}@media (forced-colors: active){[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}@media (forced-colors: active){[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:hover,[type=checkbox]:checked:focus,[type=radio]:checked:hover,[type=radio]:checked:focus{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:center;background-repeat:no-repeat}@media (forced-colors: active){[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:hover,[type=checkbox]:indeterminate:focus{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}html{font-family:Inter,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}@supports (font-variation-settings: normal){html{font-family:InterVar,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-optical-sizing:auto}}ul,ol{list-style:revert;padding:revert}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(2 137 247 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(2 137 247 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }p:empty:before{content:"";display:inline-block}.__text_block__{overflow-wrap:break-word}.__text_block__ a{color:var(--link-color);text-decoration:underline;background-color:transparent} \ No newline at end of file +@font-face{font-family:InterVar;font-weight:100 900;font-display:swap;font-style:normal;src:url(/assets/builder/fonts/Inter/Inter.var.woff2?v=3.19) format("woff2-variations"),url(/assets/builder/fonts/Inter/Inter.var.woff2?v=3.19) format("woff2");src:url(/assets/builder/fonts/Inter/Inter.var.woff2?v=3.19) format("woff2") tech("variations")}@font-face{font-family:InterVar;font-weight:100 900;font-display:swap;font-style:italic;src:url(/assets/builder/fonts/Inter/Inter-Italic.var.woff2?v=3.19) format("woff2-variations"),url(/assets/builder/fonts/Inter/Inter-Italic.var.woff2?v=3.19) format("woff2");src:url(/assets/builder/fonts/Inter/Inter-Italic.var.woff2?v=3.19) format("woff2") tech("variations")}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}ol,ul{list-style:revert;padding:revert}html{font-family:InterVar,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-optical-sizing:auto}body,button,div,html,p,span{font-variation-settings:"opsz" 24,"cv11" 1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}p:empty:before{content:"";display:inline-block}p:not(:where(.prose,.ProseMirror) *){line-height:revert}.__text_block__{overflow-wrap:break-word}.__text_block__>*{background-image:inherit;-webkit-background-clip:inherit;background-clip:inherit}.__text_block__ a{color:var(--link-color);text-decoration:underline;background-color:transparent} \ No newline at end of file diff --git a/builder/template_sync.py b/builder/template_sync.py new file mode 100644 index 000000000..0a3c8bdde --- /dev/null +++ b/builder/template_sync.py @@ -0,0 +1,469 @@ +"""Import/export of builder template groups. + +A template group is a set of highly-functional pages (landing, contact, ...) +that share one set of Builder Components and Builder Tokens (the variable +`group` matches the template group). Fixtures live on disk at +builder/builder/builder_templates//: + + / + template.json # UI manifest: title, description, preview, pages order + pages//.json + components//.json + variables//.json + client_scripts//.json + fonts//.json + +Group assets (images, page previews, font files) are committed to +builder/www/builder_assets// and served at /builder_assets//. + +The committed fixtures are the source of truth. They are authored in developer +mode — saving a template page auto-exports its whole group via +`export_template_group` — and imported on every install/migrate via +`sync_builder_templates`. In production, template pages are read-only and only +surface through the template selector. + +A page is part of a shipped template group when both `is_template` and +`template_group` are set. Pages with only `is_template` (saved as template by a +user) are left alone by the sync and the read-only guard. +""" + +import json +import os +import shutil +from urllib.parse import unquote + +import frappe +from frappe.modules.export_file import strip_default_fields +from frappe.modules.import_file import import_file_by_path +from frappe.utils import get_url, now + +from builder.export_import_standard_page import extract_fonts_from_blocks, import_fonts +from builder.utils import ( + export_client_scripts, + extract_components_from_blocks, + make_records, +) + + +def safe_segment(name): + """Make a value safe for use as one filesystem path segment. + + Replaces path separators and blocks empty/traversal values. + """ + segment = str(name).replace("/", "_").replace("\\", "_") + if segment in ("", ".", ".."): + frappe.throw(frappe._("Unsafe template fixture name: {0}").format(name)) + return segment + + +def get_templates_root(app="builder"): + return os.path.join(frappe.get_app_path(app), "builder_templates") + + +def get_group_assets_root(group_folder, app="builder"): + return os.path.join(frappe.get_app_path(app), "www", "builder_assets", group_folder) + + +# --------------------------------------------------------------------------- +# import +# --------------------------------------------------------------------------- +def sync_builder_templates(app="builder", publish=False): + """Import all template group fixtures from disk. Called on install/migrate. + + `app` selects which app's builder_templates/ dir to import from (the hub app + passes "builder_hub"). `publish=True` marks the imported pages published so + their routes resolve (the hub serves them publicly for Preview).""" + templates_root = get_templates_root(app) + if not os.path.isdir(templates_root): + return + + groups_pages = {} + for group in sorted(os.listdir(templates_root)): + group_path = os.path.join(templates_root, group) + if not os.path.isdir(group_path) or group.startswith((".", "_")): + continue + print(f"Syncing builder template group: {group}") + # isolate each group so one group's failure (e.g. a stale document lock + # from clear_page_cache) can't abort the rest of the catalog + try: + make_records(os.path.join(group_path, "variables")) + make_records(os.path.join(group_path, "components")) + make_records(os.path.join(group_path, "client_scripts")) + import_fonts(os.path.join(group_path, "fonts")) + groups_pages[group] = import_template_pages( + os.path.join(group_path, "pages"), group, publish=publish + ) + except Exception: + frappe.log_error(title=f"Failed to sync template group {group}") + print(f" ! skipped template group {group} (see error log)") + + reconcile_deleted_templates(groups_pages) + + +def import_template_pages(pages_path, group, publish=False): + """Import page fixtures of a group and re-stamp template invariants.""" + page_names = [] + if not os.path.isdir(pages_path): + return page_names + + for fname in sorted(os.listdir(pages_path)): + safe_fname = safe_segment(fname) + page_file = os.path.join(pages_path, safe_fname, f"{safe_fname}.json") + if not os.path.isfile(page_file): + continue + with open(page_file, encoding="utf-8") as f: # nosemgrep + fixture = frappe.parse_json(f.read()) + page_name = fixture.get("name") or fname + import_file_by_path(page_file) + if frappe.db.exists("Builder Page", page_name): + # import_file_by_path skips files whose db timestamp is newer, so + # enforce the invariants (and the committed preview) directly. + # Consuming sites keep templates unpublished; the hub publishes them + # so their routes resolve for public Preview. + values = {"is_template": 1, "template_group": group} + if publish: + values.update({"published": 1, "published_at": now()}) + else: + values.update({"published": 0, "published_at": None}) + if fixture.get("preview"): + values["preview"] = fixture["preview"] + frappe.db.set_value("Builder Page", page_name, values, update_modified=False) + page_names.append(page_name) + return page_names + + +def reconcile_deleted_templates(groups_pages): + """Delete template pages whose fixtures were removed from disk. + + Only prunes groups that synced successfully this run (present in + groups_pages) — a group that errored or whose fixtures are absent is left + untouched, so a transient sync failure never wipes a previously-good group. + Skipped in developer mode, where the DB is being authored and fixtures may + not exist yet. Pages without a template_group (user templates) are left alone. + Shared components/variables are not reconciled — orphans are harmless, while + deleting them could break pages that still reference them. + """ + if frappe.conf.developer_mode: + return + + template_pages = frappe.get_all( + "Builder Page", + filters={"is_template": 1, "template_group": ("is", "set")}, + fields=["name", "template_group"], + ) + for page in template_pages: + if page.template_group not in groups_pages: + continue # group didn't sync this run — don't touch its pages + if page.name not in groups_pages[page.template_group]: + frappe.delete_doc("Builder Page", page.name, force=True, ignore_permissions=True) + + +# --------------------------------------------------------------------------- +# export (developer mode) +# --------------------------------------------------------------------------- +def export_template_group(group, target_app="builder"): + """Export every page of a template group — with the shared components, + variables, client scripts, fonts and assets — to its fixture folder. + + `target_app` selects which app's builder_templates/ dir + www/builder_assets/ + to write to (the hub site sets template_target_app=builder_hub).""" + group_folder = safe_segment(frappe.scrub(group)) + group_path = os.path.join(get_templates_root(target_app), group_folder) + paths = { + key: os.path.join(group_path, key) + for key in ("pages", "components", "variables", "client_scripts", "fonts") + } + for path in paths.values(): + os.makedirs(path, exist_ok=True) + + pages = frappe.get_all( + "Builder Page", + filters={"is_template": 1, "template_group": group}, + pluck="name", + order_by="creation", + ) + components = set() + fonts = set() + for page_name in pages: + page_doc = frappe.get_doc("Builder Page", page_name) + blocks = export_template_page(page_doc, paths["pages"], group_folder, target_app=target_app) + components.update(extract_components_from_blocks(blocks)) + fonts.update(extract_fonts_from_blocks(blocks)) + export_client_scripts(page_doc, paths["client_scripts"]) + + for component_id in components: + component_blocks = export_template_component( + component_id, paths["components"], group_folder, target_app=target_app + ) + fonts.update(extract_fonts_from_blocks(component_blocks)) + + export_template_variables(group, paths["variables"]) + export_template_fonts(fonts, paths["fonts"], group_folder, target_app=target_app) + update_template_manifest(group_path, pages, title=group) + + +def export_template_page(page_doc, pages_path, group_folder, target_app="builder"): + """Write one page fixture; returns the exported blocks.""" + preview_url = ensure_template_preview(page_doc, app=target_app) + + page_config = page_doc.as_dict(no_nulls=True) + page_config = strip_default_fields(page_doc, page_config) + + blocks = frappe.parse_json(page_config.get("draft_blocks") or page_config.get("blocks") or "[]") + copy_block_assets(blocks, group_folder, str(page_doc.name), app=target_app) + page_config["blocks"] = blocks + page_config["draft_blocks"] = None + page_config["is_template"] = 1 + page_config["template_group"] = page_doc.template_group + page_config["published"] = 0 + page_config["published_at"] = None + page_config["project_folder"] = None + # point at the committed preview (the db field may still hold the fallback + # if the .webp already existed from a prior export and generation was skipped) + if preview_url: + page_config["preview"] = preview_url + + for field in ("favicon", "meta_image"): + if page_config.get(field): + new_url = copy_file_url_to_group_assets( + page_config[field], group_folder, str(page_doc.name), app=target_app + ) + if new_url: + page_config[field] = new_url + + export_name = safe_segment(frappe.scrub(str(page_doc.name))) + page_dir = os.path.join(pages_path, export_name) + os.makedirs(page_dir, exist_ok=True) + with open(os.path.join(page_dir, f"{export_name}.json"), "w", encoding="utf-8") as f: # nosemgrep + f.write(frappe.as_json(page_config, ensure_ascii=False)) + return blocks + + +def export_template_component(component_id, components_path, group_folder, target_app="builder"): + """Write one component fixture; returns the component's blocks.""" + component_doc = frappe.get_doc("Builder Component", component_id) + component_blocks = frappe.parse_json(component_doc.block or "{}") + copy_block_assets(component_blocks, group_folder, "components", app=target_app) + + component_config = component_doc.as_dict(no_nulls=True) + component_config = strip_default_fields(component_doc, component_config) + component_config["block"] = frappe.as_json(component_blocks, indent=0) + # page references are site-local and meaningless in fixtures + component_config["for_web_page"] = None + + export_name = safe_segment(frappe.scrub(str(component_doc.name))) + component_dir = os.path.join(components_path, export_name) + os.makedirs(component_dir, exist_ok=True) + with open(os.path.join(component_dir, f"{export_name}.json"), "w", encoding="utf-8") as f: # nosemgrep + f.write(frappe.as_json(component_config, ensure_ascii=False)) + return component_blocks + + +def export_template_variables(group, variables_path): + """Write fixtures for all variables of the group, pinning their uuid names + so var(--) references in blocks survive the round-trip.""" + for var in frappe.get_all( + "Builder Token", + filters={"group": group}, + fields=["name", "token_name", "type", "value", "dark_value", "group"], + ): + var_config = { + "doctype": "Builder Token", + "name": var.name, + "token_name": var.token_name, + "type": var.type, + "value": var.value, + "dark_value": var.dark_value, + "group": var.group, + } + safe_name = safe_segment(var.name) + var_dir = os.path.join(variables_path, safe_name) + os.makedirs(var_dir, exist_ok=True) + with open(os.path.join(var_dir, f"{safe_name}.json"), "w", encoding="utf-8") as f: # nosemgrep + f.write(frappe.as_json(var_config, ensure_ascii=False)) + + +def export_template_fonts(fonts, fonts_path, group_folder, target_app="builder"): + """Write User Font fixtures (and their font files) for custom fonts used by + the group. Fonts without a User Font record (e.g. bundled fonts) are skipped.""" + for font_name in fonts: + font = frappe.db.get_value( + "User Font", {"font_name": font_name}, ["name", "font_name", "font_file"], as_dict=True + ) + if not font: + continue + + font_file = font.font_file + if font_file: + source_path = resolve_asset_source_path(font_file, app=target_app) + if source_path: + font_file = copy_file_to_group_assets(source_path, group_folder, "fonts", app=target_app) + + font_config = { + "doctype": "User Font", + "name": font.name, + "font_name": font.font_name, + "font_file": font_file, + } + export_name = safe_segment(frappe.scrub(font.font_name)) + font_dir = os.path.join(fonts_path, export_name) + os.makedirs(font_dir, exist_ok=True) + with open(os.path.join(font_dir, f"{export_name}.json"), "w", encoding="utf-8") as f: # nosemgrep + f.write(frappe.as_json(font_config, ensure_ascii=False)) + + +def ensure_template_preview(page_doc, app="builder"): + """Generate the page preview image if it isn't on disk yet, and return the + public preview url whenever the .webp exists (so the fixture's preview field + is correct even when generation is skipped). Preview generation needs an + external service, so failures are non-fatal.""" + from builder.utils import get_builder_page_preview_file_paths + + public_path, local_path = get_builder_page_preview_file_paths(page_doc, app=app) + if not os.path.exists(local_path): + try: + # generate_page_preview_image renders the draft in preview mode, so it + # works for these unpublished template pages + page_doc.generate_page_preview_image() + except Exception: + frappe.log_error(f"Failed to generate preview for template page {page_doc.name}") + return public_path if os.path.exists(local_path) else None + + +def update_template_manifest(group_path, page_names, title=None): + """Refresh the pages list in template.json, preserving human-authored + title/description/preview and page order.""" + manifest_path = os.path.join(group_path, "template.json") + manifest = {} + if os.path.exists(manifest_path): + with open(manifest_path, encoding="utf-8") as f: # nosemgrep + try: + manifest = json.load(f) + except ValueError: + manifest = {} + + manifest.setdefault("title", (title or "").title() or os.path.basename(group_path)) + manifest.setdefault("description", "") + + pages = [ + page + for page in manifest.get("pages", []) + if isinstance(page, dict) and page.get("name") in page_names + ] + known = {page["name"] for page in pages} + pages += [{"name": name} for name in page_names if name not in known] + manifest["pages"] = pages + + with open(manifest_path, "w", encoding="utf-8") as f: # nosemgrep + f.write(frappe.as_json(manifest, ensure_ascii=False)) + + +def delete_template_page_fixture(page_doc, app="builder"): + """Remove a template page's fixture and assets (dev-mode on_trash). Shared + components/variables of the group are intentionally left in place.""" + group_folder = safe_segment(frappe.scrub(str(page_doc.template_group))) + group_path = os.path.join(get_templates_root(app), group_folder) + + page_segment = safe_segment(frappe.scrub(str(page_doc.name))) + page_dir = os.path.join(group_path, "pages", page_segment) + if os.path.exists(page_dir): + shutil.rmtree(page_dir) + + assets_dir = os.path.join(get_group_assets_root(group_folder, app), safe_segment(str(page_doc.name))) + if os.path.exists(assets_dir): + shutil.rmtree(assets_dir) + + pages_path = os.path.join(group_path, "pages") + if os.path.isdir(pages_path): + remaining = [] + for fname in sorted(os.listdir(pages_path)): + safe = safe_segment(fname) + page_file = os.path.join(pages_path, safe, f"{safe}.json") + if os.path.isfile(page_file): + with open(page_file, encoding="utf-8") as f: # nosemgrep + remaining.append(frappe.parse_json(f.read()).get("name") or fname) + update_template_manifest(group_path, remaining) + + +# --------------------------------------------------------------------------- +# manifest / assets helpers +# --------------------------------------------------------------------------- +def get_group_manifest(group_folder, app="builder"): + manifest_path = os.path.join(get_templates_root(app), safe_segment(group_folder), "template.json") + if not os.path.exists(manifest_path): + return {} + with open(manifest_path, encoding="utf-8") as f: # nosemgrep + try: + return json.load(f) + except ValueError: + return {} + + +def get_all_group_manifests(app="builder"): + """{group_folder: manifest} for all template groups on disk.""" + templates_root = get_templates_root(app) + if not os.path.isdir(templates_root): + return {} + return { + group: get_group_manifest(group, app=app) + for group in sorted(os.listdir(templates_root)) + if os.path.isdir(os.path.join(templates_root, group)) and not group.startswith((".", "_")) + } + + +def copy_block_assets(blocks, group_folder, subpath, app="builder"): + """Copy /files/* assets referenced by a block tree into the group's + committed assets and rewrite srcs to /builder_assets///.""" + if not isinstance(blocks, list): + blocks = [blocks] + for block in blocks: + if not isinstance(block, dict): + continue + if block.get("element") in ("img", "video"): + attributes = block.get("attributes") or {} + new_src = copy_file_url_to_group_assets(attributes.get("src"), group_folder, subpath, app=app) + if new_src: + attributes["src"] = new_src + copy_block_assets(block.get("children") or [], group_folder, subpath, app=app) + + +def copy_file_url_to_group_assets(file_url, group_folder, subpath, app="builder"): + """Copy a site /files/* url into the group assets folder; returns the new + public url, or None if the url doesn't point to a copyable site file.""" + if not file_url or not isinstance(file_url, str): + return None + + site_url = get_url() + if file_url.startswith(f"{site_url}/files"): + file_url = file_url.split(site_url)[1] + if not file_url.startswith("/files/"): + return None + + file_url = unquote(file_url) + file_name = frappe.db.get_value("File", {"file_url": file_url}, "name") + if not file_name: + return None + + source_path = frappe.get_doc("File", file_name).get_full_path() + return copy_file_to_group_assets(source_path, group_folder, subpath, os.path.basename(file_url), app=app) + + +def copy_file_to_group_assets(source_path, group_folder, subpath, filename=None, app="builder"): + filename = filename or os.path.basename(source_path) + dest_dir = os.path.join(get_group_assets_root(group_folder, app), subpath) + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(source_path, os.path.join(dest_dir, filename)) + return f"/builder_assets/{group_folder}/{subpath}/{filename}" + + +def resolve_asset_source_path(file_url, app="builder"): + """Resolve a /files/* or /builder_assets/* url to a local filesystem path.""" + if not file_url or not isinstance(file_url, str): + return None + if file_url.startswith("/files/"): + path = os.path.join(frappe.local.site_path, "public", file_url.lstrip("/")) + elif file_url.startswith("/builder_assets/"): + path = os.path.join(frappe.get_app_path(app), "www", file_url.lstrip("/")) + else: + return None + return path if os.path.exists(path) else None diff --git a/builder/templates/generators/webpage.html b/builder/templates/generators/webpage.html index ea9951f2d..482c81d7a 100644 --- a/builder/templates/generators/webpage.html +++ b/builder/templates/generators/webpage.html @@ -1,16 +1,20 @@ - + {% if base_url %} {% endif %} - + {% if disable_indexing %} {% endif %} + {% if canonical_url %} {% endif %} @@ -18,16 +22,31 @@ {% block meta_block %}{% include "templates/includes/meta_block.html" %}{% endblock %} - + + - {% for (font, options) in fonts.items() %}{% endfor %} + + {% for font_url in font_urls %} + + {% endfor %} {{ style }} - - {% if preview %} - - {% endif %} + {%- if preview -%} + + {%- else -%} + + {%- endif -%} {%- if custom_fonts -%} - + {%- endif -%} {% block style %} {%- if styles -%} @@ -44,4 +63,4 @@ {% block page_content %} {{ __content }} {% endblock %} - \ No newline at end of file + diff --git a/builder/templates/generators/webpage_scripts.html b/builder/templates/generators/webpage_scripts.html index 886439125..d4a06b494 100644 --- a/builder/templates/generators/webpage_scripts.html +++ b/builder/templates/generators/webpage_scripts.html @@ -10,12 +10,17 @@ {% if enable_view_tracking and not preview %} {% endif %} {% if not preview %} +{% endif %} +{% if has_dual_mode_image %} + {% endif %} {% if _body_html %} diff --git a/builder/user_invitation.py b/builder/user_invitation.py new file mode 100644 index 000000000..c3a06e75a --- /dev/null +++ b/builder/user_invitation.py @@ -0,0 +1,13 @@ +import frappe +from frappe.model.document import Document +from frappe.utils.telemetry import capture + + +def capture_user_invited(doc: Document, method: str | None = None) -> None: + if doc.app_name == "builder": + capture("builder_user_invited", "builder") + + +def after_accept(invitation: Document, user: Document, user_inserted: bool) -> None: + if invitation.app_name == "builder": + capture("builder_user_invitation_accepted", "builder") diff --git a/builder/utils.py b/builder/utils.py index 7c5539b3b..de067a0c3 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -1,17 +1,23 @@ -import glob import inspect import os import re import shutil import socket -import subprocess from dataclasses import dataclass +from functools import wraps from os.path import join from urllib.parse import unquote, urlparse import frappe -from frappe.modules.import_file import import_file_by_path -from frappe.utils import get_site_base_path, get_site_path, get_url +import yaml +from frappe.model.document import Document +from frappe.modules.import_file import ( + import_doc, + import_file_by_path, + read_doc_from_file, + update_modified, +) +from frappe.utils import get_datetime, get_url from frappe.utils.safe_exec import ( SERVER_SCRIPT_FILE_PREFIX, FrappeTransformer, @@ -23,25 +29,73 @@ safe_exec_flags, ) from RestrictedPython import compile_restricted +from RestrictedPython import safe_globals as restricted_safe_globals from werkzeug.routing import Rule +def compact_json(obj) -> str: + return frappe.as_json(obj, indent=None, separators=(",", ":")) + + +def has_page_permission(ptype: str = "write", message: str | None = None): + """Decorator to check if user has the given permission on Builder Page. + + Args: + ptype: Permission type — "read" or "write". Defaults to "write". + message: Custom error message to display if permission is denied. + If not provided, a sensible default is used. + """ + + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + if not frappe.has_permission("Builder Page", ptype=ptype): + default_message = ( + frappe._("You do not have permission to modify pages") + if ptype == "write" + else frappe._("You do not have permission to read pages") + ) + frappe.throw(message or default_message) + return fn(*args, **kwargs) + + return wrapper + + return decorator + + +def has_page_write(message: str | None = None): + """Decorator to check if user has write permission on Builder Page.""" + return has_page_permission(ptype="write", message=message) + + +def has_page_read(message: str | None = None): + """Decorator to check if user has read permission on Builder Page.""" + return has_page_permission(ptype="read", message=message) + + @dataclass class BlockDataKey: key: str property: str type: str + comesFrom: str + + +class VisibilityCondition: + key: str + comesFrom: str class Block: blockId: str = "" - children: list["Block"] = None - baseStyles: dict = None - rawStyles: dict = None - mobileStyles: dict = None - tabletStyles: dict = None - attributes: dict = None - classes: list[str] = None + from typing import ClassVar + + children: ClassVar[list["Block"]] = [] + baseStyles: ClassVar[dict] = {} + mobileStyles: ClassVar[dict] = {} + tabletStyles: ClassVar[dict] = {} + attributes: ClassVar[dict] = {} + classes: ClassVar[list[str]] = [] dataKey: BlockDataKey | None = None blockName: str | None = None element: str | None = None @@ -49,27 +103,65 @@ class Block: innerText: str | None = None innerHTML: str | None = None extendedFromComponent: str | None = None + componentVersion: str | None = None originalElement: str | None = None isChildOfComponent: str | None = None referenceBlockId: str | None = None isRepeaterBlock: bool = False - visibilityCondition: str | None = None + visibilityCondition: str | VisibilityCondition | None = None elementBeforeConversion: str | None = None - customAttributes: dict | None = None - dynamicValues: list[BlockDataKey] | None = None + customAttributes: ClassVar[dict] = {} + dynamicValues: ClassVar[list[BlockDataKey]] = [] + props: ClassVar[dict] = {} + clientScript: ClassVar[dict] = {} def __init__(self, **kwargs) -> None: + legacy_client_script = kwargs.pop("blockClientScript", None) + if "clientScript" not in kwargs and legacy_client_script: + kwargs["clientScript"] = {"js": legacy_client_script} + for key, value in kwargs.items(): if key == "children": - value = [Block(**b) if b and isinstance(b, dict) else None for b in (value or [])] + value = [ + b if isinstance(b, Block) else Block(**b) if b and isinstance(b, dict) else None + for b in (value or []) + ] + setattr(self, key, value) + def set_dynamic_value(self, key: str, type: str, property: str, comesFrom: str = "dataScript"): + if not self.dynamicValues: + self.dynamicValues = [] + for i, dv in enumerate(self.dynamicValues): + if dv["property"] == property and dv["type"] == type: + self.dynamicValues[i] = { + "key": key, + "type": type, + "property": property, + "comesFrom": comesFrom, + } + return + self.dynamicValues.append({"key": key, "type": type, "property": property, "comesFrom": comesFrom}) + + def clear_dynamic_values(self): + self.dynamicValues = [] + + def attach_data_key(self, key: str, property: str, type: str = "key", comesFrom: str = "dataScript"): + self.dataKey = {"key": key, "property": property, "type": type, "comesFrom": comesFrom} + + def clear_data_key(self): + self.dataKey = None + + def attach_children(self, *children: "Block"): + if not self.children: + self.children = [] + self.children.extend(children) + def as_dict(self): return { "blockId": self.blockId, "children": [child.as_dict() for child in self.children] if self.children else None, "baseStyles": self.baseStyles, - "rawStyles": self.rawStyles, "mobileStyles": self.mobileStyles, "tabletStyles": self.tabletStyles, "attributes": self.attributes, @@ -81,6 +173,7 @@ def as_dict(self): "innerText": self.innerText, "innerHTML": self.innerHTML, "extendedFromComponent": self.extendedFromComponent, + "componentVersion": self.componentVersion, "originalElement": self.originalElement, "isChildOfComponent": self.isChildOfComponent, "referenceBlockId": self.referenceBlockId, @@ -89,8 +182,13 @@ def as_dict(self): "elementBeforeConversion": self.elementBeforeConversion, "customAttributes": self.customAttributes, "dynamicValues": self.dynamicValues, + "props": self.props, + "clientScript": self.clientScript, } + def as_json(self, wrap_in_array=False): + return frappe.as_json([self.as_dict()]) if wrap_in_array else frappe.as_json(self.as_dict()) + def get_doc_as_dict(doctype, name): assert isinstance(doctype, str) @@ -107,7 +205,7 @@ def get_cached_doc_as_dict(doctype, name): def make_safe_get_request(url, **kwargs): parsed = urlparse(url) parsed_ip = socket.gethostbyname(parsed.hostname) - if parsed_ip.startswith("127", "10", "192", "172"): + if parsed_ip.startswith(("127", "10", "192", "172")): return return frappe.integrations.utils.make_get_request(url, **kwargs) @@ -177,6 +275,7 @@ def get_safer_globals(): out._iter_unpack_sequence_ = safe_globals["_iter_unpack_sequence_"] # add common python builtins + out.update(restricted_safe_globals) out.update(get_python_builtins()) return out @@ -217,10 +316,6 @@ def sync_page_templates(): builder_script_path = frappe.get_module_path("builder", "builder_script") make_records(builder_script_path) - print("Syncing Builder Page Templates") - builder_page_template_path = frappe.get_module_path("builder", "builder_page_template") - make_records(builder_page_template_path) - def sync_block_templates(): print("Syncing Builder Block Templates") @@ -228,10 +323,34 @@ def sync_block_templates(): make_records(builder_block_template_path) -def sync_builder_variables(): - print("Syncing Builder Builder Variables") - builder_variable_path = frappe.get_module_path("builder", "builder_variable") - make_records(builder_variable_path) +def sync_builder_tokens(): + print("Syncing Builder Tokens") + builder_token_path = frappe.get_module_path("builder", "builder_token") + make_records(builder_token_path) + + +# Compat alias, external scripts may still call the old name +sync_builder_variables = sync_builder_tokens + + +# Fixture exports and template bundles made before the Builder Token rename still +# say Builder Variable, and carry the pre-rename fieldname +RENAMED_FIXTURE_DOCTYPES = {"Builder Variable": "Builder Token"} +RENAMED_FIXTURE_FIELDS = {"Builder Token": {"variable_name": "token_name"}} + + +def normalize_renamed_doc(docdict): + """Rewrite a doc exported under a doctype's old name so it can be imported. + + A no-op while the old doctype is still around, i.e. before the rename patch runs.""" + new_doctype = RENAMED_FIXTURE_DOCTYPES.get(docdict.get("doctype")) + if not new_doctype or frappe.db.exists("DocType", docdict["doctype"]): + return docdict + docdict["doctype"] = new_doctype + for old_field, new_field in RENAMED_FIXTURE_FIELDS[new_doctype].items(): + if old_field in docdict: + docdict.setdefault(new_field, docdict.pop(old_field)) + return docdict def make_records(path): @@ -239,72 +358,120 @@ def make_records(path): return for fname in os.listdir(path): if os.path.isdir(join(path, fname)) and fname != "__pycache__": - import_file_by_path(f"{path}/{fname}/{fname}.json") + import_fixture_record(f"{path}/{fname}/{fname}.json") -# def generate_tailwind_css_file_from_html(html): -# # execute tailwindcss cli command to generate css file -# # create temp folder -# temp_folder = os.path.join(get_site_base_path(), "temp") -# if os.path.exists(temp_folder): -# shutil.rmtree(temp_folder) -# os.mkdir(temp_folder) +def import_fixture_record(fpath): + """import_file_by_path, but tolerant of fixtures exported under a doctype's old name.""" + try: + docdict = read_doc_from_file(fpath) + except OSError: + print(f"{fpath} missing") + return + if not isinstance(docdict, dict): + import_file_by_path(fpath) + return + old_doctype = docdict.get("doctype") + normalize_renamed_doc(docdict) + if docdict.get("doctype") == old_doctype: + import_file_by_path(fpath) + return + db_modified = frappe.db.get_value(docdict["doctype"], docdict.get("name"), "modified") + if db_modified and get_datetime(docdict.get("modified")) <= get_datetime(db_modified): + return + import_doc(docdict) + if docdict.get("modified"): + update_modified(docdict["modified"], docdict) -# # create temp html file -# temp_html_file_path = os.path.join(temp_folder, "temp.html") -# with open(temp_html_file_path, "w") as f: -# f.write(html) -# # place tailwind.css file in public folder -# tailwind_css_file_path = os.path.join(get_site_path(), "public", "files", "tailwind.css") +def copy_img_to_asset_folder(block, page_doc, app=None): + def safe_get(obj, attr, default=None): + if isinstance(obj, dict): + return obj.get(attr, default) + else: + return getattr(obj, attr, default) -# # create temp config file -# temp_config_file_path = os.path.join(temp_folder, "tailwind.config.js") -# with open(temp_config_file_path, "w") as f: -# f.write("module.exports = {content: ['./temp.html']}") + if isinstance(block, dict): + block = frappe._dict(block) + children = block.get("children", []) + if children and isinstance(children, list): + block.children = [frappe._dict(child) if isinstance(child, dict) else child for child in children] -# # run tailwindcss cli command in production mode -# subprocess.run( -# ["npx", "tailwindcss", "-o", tailwind_css_file_path, "--config", temp_config_file_path, "--minify"] -# ) + element = safe_get(block, "element") + if element == "img": + attributes = safe_get(block, "attributes") + src = None + + if attributes: + src = safe_get(attributes, "src") -def copy_img_to_asset_folder(block: Block, page_doc): - if block.get("element") == "img": - src = block.get("attributes", {}).get("src") site_url = get_url() if src and (src.startswith(f"{site_url}/files") or src.startswith("/files")): - # find file doc if src.startswith(f"{site_url}/files"): src = src.split(f"{site_url}")[1] - # url decode src = unquote(src) - print(f"src: {src}") files = frappe.get_all("File", filters={"file_url": src}, fields=["name"]) - print(f"files: {files}") if files: _file = frappe.get_doc("File", files[0].name) - # copy physical file to new location - assets_folder_path = get_template_assets_folder_path(page_doc) + assets_folder_path = get_template_assets_folder_path(page_doc, app=app) shutil.copy(_file.get_full_path(), assets_folder_path) - block["attributes"]["src"] = f"/builder_assets/{page_doc.name}/{src.split('/')[-1]}" - for child in block.get("children", []) or []: - copy_img_to_asset_folder(child, page_doc) + new_src = f"{get_template_assets_public_path(page_doc)}/{src.split('/')[-1]}" + if attributes: + if isinstance(attributes, dict): + attributes["src"] = new_src + else: + attributes.src = new_src + + children = safe_get(block, "children", []) + for child in children or []: + copy_img_to_asset_folder(child, page_doc, app=app) + + +def get_template_assets_subfolder(page_doc): + """Relative folder under www/builder_assets for a page's exported assets. + + Template-group pages are namespaced under their group folder so multiple + templates can ship assets without colliding.""" + if getattr(page_doc, "is_template", None) and getattr(page_doc, "template_group", None): + return os.path.join(frappe.scrub(page_doc.template_group), str(page_doc.name)) + return str(page_doc.name) -def get_template_assets_folder_path(page_doc): - path = os.path.join(frappe.get_app_path("builder"), "www", "builder_assets", page_doc.name) + +def get_template_assets_public_path(page_doc): + """Public URL prefix (no trailing slash) for a page's exported assets. + + App-agnostic: whichever app/site serves the assets does so under + /builder_assets/, so only the filesystem write root (below) varies by app.""" + return f"/builder_assets/{get_template_assets_subfolder(page_doc).replace(os.sep, '/')}" + + +def template_target_app(): + """App whose www/builder_assets directory holds exported template assets. + Defaults to builder; the hub site sets template_target_app=builder_hub so + template authoring on the hub writes assets into the hub app.""" + return frappe.conf.get("template_target_app") or "builder" + + +def get_template_assets_folder_path(page_doc, app=None): + path = os.path.join( + frappe.get_app_path(app or template_target_app()), + "www", + "builder_assets", + get_template_assets_subfolder(page_doc), + ) if not os.path.exists(path): os.makedirs(path) return path -def get_builder_page_preview_file_paths(page_doc): - public_path, public_path = None, None +def get_builder_page_preview_file_paths(page_doc, app=None): + public_path, local_path = None, None if page_doc.is_template: - local_path = os.path.join(get_template_assets_folder_path(page_doc), "preview.webp") - public_path = f"/builder_assets/{page_doc.name}/preview.webp" + local_path = os.path.join(get_template_assets_folder_path(page_doc, app=app), "preview.webp") + public_path = f"{get_template_assets_public_path(page_doc)}/preview.webp" else: file_name = f"{page_doc.name}-preview.webp" local_path = os.path.join(frappe.local.site_path, "public", "files", file_name) @@ -323,8 +490,8 @@ def is_component_used(blocks, component_id): continue if block.get("extendedFromComponent") == component_id: return True - elif block.get("children"): - return is_component_used(block.get("children"), component_id) + if block.get("children") and is_component_used(block.get("children"), component_id): + return True return False @@ -334,14 +501,64 @@ def escape_single_quotes(text): def camel_case_to_kebab_case(text, remove_spaces=False): + # Used to convert camelCase css properties to kebab-case, e.g. backgroundColor → background-color if not text: return "" text = re.sub(r"(?= 3: + app_name = parts[2] + source_path = os.path.join(frappe.get_app_path(app_name), "public", "/".join(parts[3:])) + else: + source_path = os.path.join(frappe.get_app_path("builder"), "www", file_url.lstrip("/")) + if os.path.exists(source_path): + return copy_file_to_assets(source_path, file_url, assets_path, target_app) + return None + + +def copy_file_to_assets(source_path, file_url, assets_path, target_app="builder"): + """Copy file to assets directory and return public path""" + filename = os.path.basename(file_url) + dest_path = os.path.join(assets_path, filename) + shutil.copy2(source_path, dest_path) + return f"/assets/{target_app}/builder_assets/{filename}" + + +def extract_components_from_blocks(blocks): + """Extract component IDs from blocks recursively""" + components = set() + if not isinstance(blocks, list): + blocks = [blocks] + + for block in blocks: + if isinstance(block, dict): + if block.get("extendedFromComponent"): + component_doc = frappe.get_cached_doc("Builder Component", block["extendedFromComponent"]) + if component_doc: + components.update( + extract_components_from_blocks(frappe.parse_json(component_doc.block or "{}")) + ) + components.add(block["extendedFromComponent"]) + children = block.get("children") + if children and isinstance(children, list): + components.update(extract_components_from_blocks(children)) + + return components + + +def export_client_scripts(page_doc, client_scripts_path): + """Export client scripts for a page""" + from frappe.modules.export_file import strip_default_fields + + for script_row in page_doc.client_scripts: + script_doc = frappe.get_doc("Builder Client Script", script_row.builder_script) + script_config = script_doc.as_dict(no_nulls=True) + script_config = strip_default_fields(script_doc, script_config) + fname = frappe.scrub(str(script_doc.name)) + # ensure the target directory exists before writing the file + script_dir = os.path.join(client_scripts_path, fname) + os.makedirs(script_dir, exist_ok=True) + script_file_path = os.path.join(script_dir, f"{fname}.json") + + with open(script_file_path, "w", encoding="utf-8") as f: + f.write(frappe.as_json(script_config, ensure_ascii=False)) + + +def export_components(components, components_path, assets_path, target_app="builder"): + """Export components to files""" + for component_id in components: + try: + component_doc = frappe.get_doc("Builder Component", component_id) + # replace assets in component blocks + component_blocks = frappe.parse_json(component_doc.block or "[]") + copy_assets_from_blocks(component_blocks, assets_path, target_app) + component_doc.block = frappe.as_json(component_blocks) + + # Replace forward slashes with underscores to create valid directory names + safe_component_name = frappe.scrub(component_doc.component_name).replace("/", "_") + component_dir = os.path.join(components_path, safe_component_name) + os.makedirs(component_dir, exist_ok=True) + component_file_path = os.path.join(component_dir, f"{safe_component_name}.json") + + with open(component_file_path, "w") as f: + f.write(frappe.as_json(component_doc.as_dict())) + except Exception as e: + print(e) + frappe.log_error(f"Failed to export component {component_id}: {e!s}") + + +def create_export_directories(app_path, export_name): + paths = get_export_paths(app_path, export_name) + for path in paths.values(): + os.makedirs(path, exist_ok=True) + + return paths + + +def get_export_paths(app_path, export_name): + """Get all export directory paths""" + builder_files_path = os.path.join(app_path, "builder_files") + pages_path = os.path.join(builder_files_path, "pages") + public_builder_files_path = os.path.join(app_path, "public", "builder_assets") + + return { + "page_path": os.path.join(pages_path, export_name), + "assets_path": public_builder_files_path, + "client_scripts_path": os.path.join(builder_files_path, "client_scripts"), + "components_path": os.path.join(builder_files_path, "components"), + "builder_files_path": builder_files_path, + "pages_path": pages_path, + } + + +def to_dict_with_fallback(obj): + try: + return frappe._dict(obj) + except TypeError: + if isinstance(obj, Document): + return obj.as_dict() + else: + raise + + +def combine(a, b): + if a is None: + return b + if b is None: + return a + res = to_dict_with_fallback(a) + res.update(to_dict_with_fallback(b)) + return res + + +def hash(s): + return f"{frappe.generate_hash(length=6)}-{s}" + + +def to_safe_json(data): + return frappe.as_json(data or {}).replace("", "'", '"', "\n")): + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'") + return dumper.represent_scalar("tag:yaml.org,2002:str", data) + + +CompactDumper.add_representer(str, _str_representer) + + +def to_compact_yaml(data) -> str: + """Serialize data to minimal YAML for LLM context.""" + return yaml.dump( + data, + Dumper=CompactDumper, + sort_keys=False, + default_flow_style=False, + allow_unicode=True, + width=1000, # prevent wrapping long values mid-token + ) diff --git a/builder/www/404.html b/builder/www/404.html new file mode 100644 index 000000000..54d8e830d --- /dev/null +++ b/builder/www/404.html @@ -0,0 +1,31 @@ +{# Mirrors frappe's default www/404.html. Builder must override it (not just add a + handler) because frappe's NotFoundPage hardcodes the `404` template; 404.py swaps + in a published Builder Page with route `404` when one exists, else this renders. #} +{% extends "templates/web.html" %} + +{%- block title -%}{{ _("Not Found") }}{%- endblock -%} + +{% block navbar %}{% endblock %} +{% block footer %}{% endblock %} + +{% block page_content %} + + + + +
+
+

{{ _("Page not found") }}

+ +
+
+{% endblock %} diff --git a/builder/www/404.py b/builder/www/404.py new file mode 100644 index 000000000..4d28ec70c --- /dev/null +++ b/builder/www/404.py @@ -0,0 +1,21 @@ +import frappe + +from builder.builder.doctype.builder_page.builder_page import find_page_with_path + + +def get_context(context): + """Render a published Builder Page with route `404` as the site's not-found page. + + Frappe's NotFoundPage always renders the `404` template, bypassing custom page + renderers, so this is the seam Builder uses to make the not-found page editable. + Falls back to the default 404.html template when no such page exists. + """ + page_name = find_page_with_path("404") + if not page_name: + return + + doc = frappe.get_cached_doc("Builder Page", page_name) + # BuilderPage.get_context does `del context.favicon`, which assumes the key exists. + context.setdefault("favicon", None) + doc.get_context(context) + context.template = "templates/generators/webpage.html" diff --git a/builder/www/_builder.py b/builder/www/_builder.py index f91d68cf2..ef019e3ba 100644 --- a/builder/www/_builder.py +++ b/builder/www/_builder.py @@ -1,5 +1,6 @@ import frappe from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site +from frappe.pulse.utils import get_app_version from frappe.utils.telemetry import capture from builder.hooks import builder_path @@ -13,8 +14,10 @@ def get_context(context): context.csrf_token = csrf_token context.site_name = frappe.local.site context.builder_path = builder_path + context.builder_version = get_app_version("builder") # developer mode context.is_developer_mode = frappe.conf.developer_mode context.is_fc_site = is_fc_site() + context.is_read_only_mode = bool(frappe.flags.read_only) if frappe.session.user != "Guest": capture("active_site", "builder") diff --git a/builder/www/builder_assets/Theme Switcher/theme-switcher.png b/builder/www/builder_assets/Theme Switcher/theme-switcher.png new file mode 100644 index 000000000..b7bfaada6 Binary files /dev/null and b/builder/www/builder_assets/Theme Switcher/theme-switcher.png differ diff --git a/builder/www/builder_assets/YouTube/youtube.png b/builder/www/builder_assets/YouTube/youtube.png new file mode 100644 index 000000000..6580a139d Binary files /dev/null and b/builder/www/builder_assets/YouTube/youtube.png differ diff --git a/builder/www/builder_assets/color_scheme_variables.css b/builder/www/builder_assets/color_scheme_variables.css deleted file mode 100644 index a85110918..000000000 --- a/builder/www/builder_assets/color_scheme_variables.css +++ /dev/null @@ -1,6 +0,0 @@ -[data-prefers-color-scheme="dark"] { -{%- for key, value in dark_mode_css_variables.items() %}{{ key }}: {{ value }};{%- endfor %}} - -{%- if dark_mode_css_variables -%}[data-prefers-color-scheme="light"] { -{%- for key, value in css_variables.items() %}{{ key }}: {{ value }};{%- endfor %}} -{%- endif -%} \ No newline at end of file diff --git a/builder/www/builder_assets/tokens.css b/builder/www/builder_assets/tokens.css new file mode 100644 index 000000000..792b25cb6 --- /dev/null +++ b/builder/www/builder_assets/tokens.css @@ -0,0 +1,3 @@ +{%- if css_variables -%}:root { +{%- for key, value in css_variables.items() %}{%- set dark_value = (dark_mode_css_variables or {}).get(key) %}{{ key }}: {% if dark_value is not none and dark_value != value %}light-dark({{ value }}, {{ dark_value }}){% else %}{{ value }}{% endif %}; +{%- endfor %}}{%- endif -%} \ No newline at end of file diff --git a/builder/www/builder_assets/color_scheme_variables.py b/builder/www/builder_assets/tokens.py similarity index 68% rename from builder/www/builder_assets/color_scheme_variables.py rename to builder/www/builder_assets/tokens.py index 87bfe9796..eff7554e2 100644 --- a/builder/www/builder_assets/color_scheme_variables.py +++ b/builder/www/builder_assets/tokens.py @@ -1,4 +1,4 @@ -from builder.builder.doctype.builder_variable.builder_variable import get_css_variables +from builder.builder.doctype.builder_token.builder_token import get_css_variables def get_context(context): diff --git a/builder/www/builder_assets/variables.css b/builder/www/builder_assets/variables.css index 53218348d..792b25cb6 100644 --- a/builder/www/builder_assets/variables.css +++ b/builder/www/builder_assets/variables.css @@ -1,7 +1,3 @@ {%- if css_variables -%}:root { -{%- for key, value in css_variables.items() %}{{ key }}: {{ value }}; -{%- endfor %}}{%- endif -%} - -{%- if dark_mode_css_variables -%}@media (prefers-color-scheme: dark) {:root { -{%- for key, value in dark_mode_css_variables.items() %}{{ key }}: {{ value }}; -{%- endfor %}}}{%- endif -%} \ No newline at end of file +{%- for key, value in css_variables.items() %}{%- set dark_value = (dark_mode_css_variables or {}).get(key) %}{{ key }}: {% if dark_value is not none and dark_value != value %}light-dark({{ value }}, {{ dark_value }}){% else %}{{ value }}{% endif %}; +{%- endfor %}}{%- endif -%} \ No newline at end of file diff --git a/builder/www/builder_assets/variables.py b/builder/www/builder_assets/variables.py index 87bfe9796..33137ce77 100644 --- a/builder/www/builder_assets/variables.py +++ b/builder/www/builder_assets/variables.py @@ -1,7 +1,5 @@ -from builder.builder.doctype.builder_variable.builder_variable import get_css_variables +# Compat route: pages published before the Builder Token rename link +# /builder_assets/variables.css. Serves the same CSS as tokens.css. +from builder.www.builder_assets.tokens import get_context - -def get_context(context): - css_variables, dark_mode_css_variables = get_css_variables() - context.css_variables = css_variables - context.dark_mode_css_variables = dark_mode_css_variables +__all__ = ["get_context"] diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 000000000..c95e42df7 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,26 @@ +export default { + parserPreset: "conventional-changelog-conventionalcommits", + rules: { + "subject-empty": [2, "never"], + "type-case": [2, "always", "lower-case"], + "type-empty": [2, "never"], + "type-enum": [ + 2, + "always", + [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", + "deprecate", + ], + ], + }, +}; diff --git a/docker/init.sh b/docker/init.sh index 2b276bcbb..49e6c86ae 100755 --- a/docker/init.sh +++ b/docker/init.sh @@ -1,39 +1,42 @@ -#!bin/bash +#!/bin/bash -if [ -d "/home/frappe/frappe-bench/apps/frappe" ]; then +BENCH_DIR="/home/frappe/frappe-bench" +SITE="builder.localhost" + +# Reuse an existing bench if one is already provisioned. +if [ -d "$BENCH_DIR/apps/frappe" ]; then echo "Bench already exists, skipping init" - cd frappe-bench + cd "$BENCH_DIR" bench start -else - echo "Creating new bench..." + exit 0 fi +echo "Creating new bench..." bench init --skip-redis-config-generation frappe-bench --version version-15 - cd frappe-bench -# Use containers instead of localhost +# Point services at the compose containers instead of localhost. bench set-mariadb-host mariadb -bench set-redis-cache-host redis:6379 -bench set-redis-queue-host redis:6379 -bench set-redis-socketio-host redis:6379 +bench set-redis-cache-host "redis://redis:6379" +bench set-redis-queue-host "redis://redis:6379" +bench set-redis-socketio-host "redis://redis:6379" -# Remove redis, watch from Procfile +# Remove redis/watch entries from the Procfile (handled by compose). sed -i '/redis/d' ./Procfile sed -i '/watch/d' ./Procfile bench get-app builder --branch develop -bench new-site builder.localhost \ ---force \ ---mariadb-root-password 123 \ ---admin-password admin \ ---no-mariadb-socket +bench new-site "$SITE" \ + --force \ + --mariadb-root-password 123 \ + --admin-password admin \ + --no-mariadb-socket -bench --site builder.localhost install-app builder -bench --site builder.localhost set-config developer_mode 1 -bench --site builder.localhost clear-cache -bench --site builder.localhost set-config mute_emails 1 -bench use builder.localhost +bench --site "$SITE" install-app builder +bench --site "$SITE" set-config developer_mode 1 +bench --site "$SITE" set-config mute_emails 1 +bench --site "$SITE" clear-cache +bench use "$SITE" -bench start \ No newline at end of file +bench start diff --git a/frappe-ui b/frappe-ui index 372b85c56..1f237586d 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 372b85c56de6d2d1002c1623b424809031e1da21 +Subproject commit 1f237586d3675f840dba455ae5379a63073d6e42 diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.cjs similarity index 93% rename from frontend/.eslintrc.js rename to frontend/.eslintrc.cjs index 43cfa2f5c..91365e161 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.cjs @@ -1,11 +1,8 @@ -export default { +module.exports = { env: { browser: true, es2021: true, }, - globals: { - convertHTMLToBlocks: true, - }, extends: ["plugin:vue/vue3-recommended", "prettier"], parser: "vue-eslint-parser", parserOptions: { diff --git a/frontend/.gitignore b/frontend/.gitignore index 53f7466ac..d451ff16c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -2,4 +2,4 @@ node_modules .DS_Store dist dist-ssr -*.local \ No newline at end of file +*.local diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 6a3f64dbb..1f9c54463 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -7,6 +7,5 @@ "printWidth": 110, "arrowParens": "always", "trailingComma": "all", - "plugins": ["prettier-plugin-tailwindcss"], "tailwindConfig": "./tailwind.config.js" } diff --git a/frontend/auto-imports.d.ts b/frontend/auto-imports.d.ts new file mode 100644 index 000000000..9d2400790 --- /dev/null +++ b/frontend/auto-imports.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + +} diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 658b0fa9c..f13c75f16 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -1,20 +1,29 @@ /* eslint-disable */ // @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 -// biome-ignore lint: disable + export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { - AlertDialog: typeof import('./src/components/AlertDialog.vue')['default'] + AIPageGeneratorModal: typeof import('./src/components/AIPageGeneratorModal.vue')['default'] + AnalyticsEmptyState: typeof import('./src/components/Settings/AnalyticsEmptyState.vue')['default'] AnalyticsFilters: typeof import('./src/components/Settings/AnalyticsFilters.vue')['default'] AnalyticsOverview: typeof import('./src/components/Settings/AnalyticsOverview.vue')['default'] - AppsMenu: typeof import('./src/components/AppsMenu.vue')['default'] - AuthenticatedUser: typeof import('./src/components/Icons/AuthenticatedUser.vue')['default'] + AnglePicker: typeof import('./src/components/Controls/AnglePicker.vue')['default'] + ArrayEditor: typeof import('./src/components/ArrayEditor.vue')['default'] + ArrayInput: typeof import('./src/components/ArrayInput.vue')['default'] + ArrayOptions: typeof import('./src/components/PropsOptions/ArrayOptions.vue')['default'] + AttributePropertyControl: typeof import('./src/components/Controls/AttributePropertyControl.vue')['default'] Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default'] BackgroundHandler: typeof import('./src/components/BackgroundHandler.vue')['default'] + BasePropertyControl: typeof import('./src/components/Controls/BasePropertyControl.vue')['default'] + BlankPageCard: typeof import('./src/components/Templates/BlankPageCard.vue')['default'] BlockContextMenu: typeof import('./src/components/BlockContextMenu.vue')['default'] BlockEditor: typeof import('./src/components/BlockEditor.vue')['default'] BlockFlexLayoutHandler: typeof import('./src/components/BlockFlexLayoutHandler.vue')['default'] @@ -23,51 +32,60 @@ declare module 'vue' { BlockLayers: typeof import('./src/components/BlockLayers.vue')['default'] BlockPositionHandler: typeof import('./src/components/BlockPositionHandler.vue')['default'] BlockProperties: typeof import('./src/components/BlockProperties.vue')['default'] - Blocks: typeof import('./src/components/Icons/Blocks.vue')['default'] BlockSnapGuides: typeof import('./src/components/BlockSnapGuides.vue')['default'] - BlockStyleManager: typeof import('./src/components/Controls/BlockStyleManager.vue')['default'] + BooleanOptions: typeof import('./src/components/PropsOptions/BooleanOptions.vue')['default'] BorderRadiusHandler: typeof import('./src/components/BorderRadiusHandler.vue')['default'] BoxResizer: typeof import('./src/components/BoxResizer.vue')['default'] BuilderAssets: typeof import('./src/components/BuilderAssets.vue')['default'] BuilderBlock: typeof import('./src/components/BuilderBlock.vue')['default'] BuilderBlockTemplates: typeof import('./src/components/BuilderBlockTemplates.vue')['default'] - BuilderButton: typeof import('./src/components/Controls/BuilderButton.vue')['default'] BuilderCanvas: typeof import('./src/components/BuilderCanvas.vue')['default'] - BuilderInput: typeof import('./src/components/BuilderInput.vue')['default'] + BuilderCommandPalette: typeof import('./src/components/BuilderCommandPalette.vue')['default'] BuilderLeftPanel: typeof import('./src/components/BuilderLeftPanel.vue')['default'] BuilderRightPanel: typeof import('./src/components/BuilderRightPanel.vue')['default'] BuilderSettings: typeof import('./src/components/BuilderSettings.vue')['default'] BuilderToolbar: typeof import('./src/components/BuilderToolbar.vue')['default'] - Chart: typeof import('./src/components/Icons/Chart.vue')['default'] - Code: typeof import('./src/components/Icons/Code.vue')['default'] CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default'] CodeMirrorEditor: typeof import('./src/components/Controls/CodeMirror/CodeMirrorEditor.vue')['default'] CollapsibleSection: typeof import('./src/components/CollapsibleSection.vue')['default'] ColorInput: typeof import('./src/components/Controls/ColorInput.vue')['default'] + ColorOptions: typeof import('./src/components/PropsOptions/ColorOptions.vue')['default'] ColorPicker: typeof import('./src/components/Controls/ColorPicker.vue')['default'] - Component: typeof import('./src/components/Icons/Component.vue')['default'] + ColorPickerContent: typeof import('./src/components/Controls/ColorPickerContent.vue')['default'] + CommandPalette: typeof import('./src/components/CommandPalette.vue')['default'] + CommandPaletteItem: typeof import('./src/components/CommandPaletteItem.vue')['default'] + ComponentUpdates: typeof import('./src/components/ComponentUpdates.vue')['default'] ContextMenu: typeof import('./src/components/ContextMenu.vue')['default'] - Cross: typeof import('./src/components/Icons/Cross.vue')['default'] CSS: typeof import('./src/components/Icons/CSS.vue')['default'] + CursorTooltip: typeof import('./src/components/CursorTooltip.vue')['default'] CustomSearchPanel: typeof import('./src/components/Controls/CodeMirror/CustomSearchPanel.vue')['default'] + DashboardContent: typeof import('./src/components/DashboardContent.vue')['default'] + DashboardHead: typeof import('./src/components/DashboardHead.vue')['default'] DashboardSidebar: typeof import('./src/components/DashboardSidebar.vue')['default'] + DashboardToolbar: typeof import('./src/components/DashboardToolbar.vue')['default'] DataLoaderBlock: typeof import('./src/components/DataLoaderBlock.vue')['default'] Dialog: typeof import('./src/components/Controls/Dialog.vue')['default'] DimensionInput: typeof import('./src/components/DimensionInput.vue')['default'] DraggablePopup: typeof import('./src/components/Controls/DraggablePopup.vue')['default'] + DropIndicator: typeof import('./src/components/DropIndicator.vue')['default'] + DynamicValueDropdown: typeof import('./src/components/DynamicValueDropdown.vue')['default'] DynamicValueHandler: typeof import('./src/components/Controls/DynamicValueHandler.vue')['default'] EditableSpan: typeof import('./src/components/EditableSpan.vue')['default'] EyeDropper: typeof import('./src/components/Icons/EyeDropper.vue')['default'] Files: typeof import('./src/components/Icons/Files.vue')['default'] FitScreen: typeof import('./src/components/Icons/FitScreen.vue')['default'] - Folder: typeof import('./src/components/Icons/Folder.vue')['default'] + FontInput: typeof import('./src/components/Controls/FontInput.vue')['default'] FontUploader: typeof import('./src/components/Controls/FontUploader.vue')['default'] + GlobalAI: typeof import('./src/components/Settings/GlobalAI.vue')['default'] GlobalAnalytics: typeof import('./src/components/Settings/GlobalAnalytics.vue')['default'] GlobalCode: typeof import('./src/components/Settings/GlobalCode.vue')['default'] + GlobalDeveloper: typeof import('./src/components/Settings/GlobalDeveloper.vue')['default'] + GlobalDomains: typeof import('./src/components/Settings/GlobalDomains.vue')['default'] GlobalGeneral: typeof import('./src/components/Settings/GlobalGeneral.vue')['default'] - GlobalMeta: typeof import('./src/components/Settings/GlobalMeta.vue')['default'] GlobalRedirects: typeof import('./src/components/Settings/GlobalRedirects.vue')['default'] - Globe: typeof import('./src/components/Icons/Globe.vue')['default'] + GlobalUsers: typeof import('./src/components/Settings/GlobalUsers.vue')['default'] + GradientEditor: typeof import('./src/components/Controls/GradientEditor.vue')['default'] + ImageOptions: typeof import('./src/components/PropsOptions/ImageOptions.vue')['default'] ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default'] ImageUploadInput: typeof import('./src/components/ImageUploadInput.vue')['default'] InlineInput: typeof import('./src/components/Controls/InlineInput.vue')['default'] @@ -76,17 +94,20 @@ declare module 'vue' { JavaScript: typeof import('./src/components/Icons/JavaScript.vue')['default'] Layers: typeof import('./src/components/Icons/Layers.vue')['default'] Loading: typeof import('./src/components/Icons/Loading.vue')['default'] - LucideCalendar: typeof import('~icons/lucide/calendar')['default'] MainMenu: typeof import('./src/components/MainMenu.vue')['default'] MarginHandler: typeof import('./src/components/MarginHandler.vue')['default'] - Meta: typeof import('./src/components/Icons/Meta.vue')['default'] + MiddleTruncate: typeof import('./src/components/MiddleTruncate.vue')['default'] + MoreStylesPanel: typeof import('./src/components/MoreStylesPanel.vue')['default'] NewBlockTemplate: typeof import('./src/components/Modals/NewBlockTemplate.vue')['default'] - NewBuilderVariable: typeof import('./src/components/Modals/NewBuilderVariable.vue')['default'] - NewComponent: typeof import('./src/components/Modals/NewComponent.vue')['default'] - NewFolder: typeof import('./src/components/Modals/NewFolder.vue')['default'] + NewBuilderToken: typeof import('./src/components/Modals/NewBuilderToken.vue')['default'] + NumberArrows: typeof import('./src/components/Controls/NumberArrows.vue')['default'] + NumberOptions: typeof import('./src/components/PropsOptions/NumberOptions.vue')['default'] ObjectEditor: typeof import('./src/components/ObjectEditor.vue')['default'] + ObjectInput: typeof import('./src/components/ObjectInput.vue')['default'] + ObjectOptions: typeof import('./src/components/PropsOptions/ObjectOptions.vue')['default'] OptionToggle: typeof import('./src/components/Controls/OptionToggle.vue')['default'] PaddingHandler: typeof import('./src/components/PaddingHandler.vue')['default'] + PageActionsDropdown: typeof import('./src/components/PageActionsDropdown.vue')['default'] PageAnalytics: typeof import('./src/components/Settings/PageAnalytics.vue')['default'] PageCard: typeof import('./src/components/PageCard.vue')['default'] PageClientScriptManager: typeof import('./src/components/PageClientScriptManager.vue')['default'] @@ -96,30 +117,46 @@ declare module 'vue' { PageListModal: typeof import('./src/components/Modals/PageListModal.vue')['default'] PageMeta: typeof import('./src/components/Settings/PageMeta.vue')['default'] PageOptions: typeof import('./src/components/PageOptions.vue')['default'] - PagePreviewCard: typeof import('./src/components/PagePreviewCard.vue')['default'] PageRobots: typeof import('./src/components/Settings/PageRobots.vue')['default'] PageScript: typeof import('./src/components/PageScript.vue')['default'] PanelResizer: typeof import('./src/components/PanelResizer.vue')['default'] PlacementControl: typeof import('./src/components/PlacementControl.vue')['default'] Play: typeof import('./src/components/Icons/Play.vue')['default'] - Plus: typeof import('./src/components/Icons/Plus.vue')['default'] - PropertyControl: typeof import('./src/components/Controls/PropertyControl.vue')['default'] + PropertyControlInput: typeof import('./src/components/Controls/PropertyControlInput.vue')['default'] + PropertyLabel: typeof import('./src/components/Controls/PropertyLabel.vue')['default'] + PropsEditor: typeof import('./src/components/PropsEditor.vue')['default'] + PropsPopoverContent: typeof import('./src/components/PropsPopoverContent.vue')['default'] PublishButton: typeof import('./src/components/PublishButton.vue')['default'] RangeInput: typeof import('./src/components/Controls/RangeInput.vue')['default'] - Redirect: typeof import('./src/components/Icons/Redirect.vue')['default'] - Reset: typeof import('./src/components/Reset.vue')['default'] + RotationHandler: typeof import('./src/components/RotationHandler.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + RouteTreeNode: typeof import('./src/components/RouteTreeNode.vue')['default'] + RouteTreeView: typeof import('./src/components/RouteTreeView.vue')['default'] SearchBlock: typeof import('./src/components/Controls/SearchBlock.vue')['default'] - SelectFolder: typeof import('./src/components/Modals/SelectFolder.vue')['default'] - Settings: typeof import('./src/components/Icons/Settings.vue')['default'] + SelectOptions: typeof import('./src/components/PropsOptions/SelectOptions.vue')['default'] SettingsGear: typeof import('./src/components/Icons/SettingsGear.vue')['default'] - StrikeThrough: typeof import('./src/components/Icons/StrikeThrough.vue')['default'] - Switch: typeof import('./src/components/Controls/Switch.vue')['default'] - TabButtons: typeof import('./src/components/Controls/TabButtons.vue')['default'] - TemplatePagePreview: typeof import('./src/components/TemplatePagePreview.vue')['default'] - TemplateSelector: typeof import('./src/components/TemplateSelector.vue')['default'] + ShadowHandler: typeof import('./src/components/ShadowHandler.vue')['default'] + SplitInput: typeof import('./src/components/Controls/SplitInput.vue')['default'] + SplitModeInput: typeof import('./src/components/Controls/SplitModeInput.vue')['default'] + SplitPropertyControl: typeof import('./src/components/Controls/SplitPropertyControl.vue')['default'] + StringOptions: typeof import('./src/components/PropsOptions/StringOptions.vue')['default'] + StylePropertyControl: typeof import('./src/components/Controls/StylePropertyControl.vue')['default'] + TemplateGallery: typeof import('./src/components/Templates/TemplateGallery.vue')['default'] + TemplateGroupCard: typeof import('./src/components/Templates/TemplateGroupCard.vue')['default'] + TemplatePageCard: typeof import('./src/components/Templates/TemplatePageCard.vue')['default'] + TemplatePageGrid: typeof import('./src/components/Templates/TemplatePageGrid.vue')['default'] + TemplatePreview: typeof import('./src/components/Templates/TemplatePreview.vue')['default'] + TemplatesDialog: typeof import('./src/components/Templates/TemplatesDialog.vue')['default'] TextBlock: typeof import('./src/components/TextBlock.vue')['default'] - VariableManager: typeof import('./src/components/Modals/VariableManager.vue')['default'] + TextBlockBubbleMenu: typeof import('./src/components/TextBlockBubbleMenu.vue')['default'] + TokenManager: typeof import('./src/components/Modals/TokenManager.vue')['default'] + TopClicksList: typeof import('./src/components/Settings/TopClicksList.vue')['default'] + TopReferrersList: typeof import('./src/components/Settings/TopReferrersList.vue')['default'] + TrackingDisabledNotice: typeof import('./src/components/Settings/TrackingDisabledNotice.vue')['default'] + VariantControl: typeof import('./src/components/Controls/VariantControl.vue')['default'] + VersionHistory: typeof import('./src/components/VersionHistory.vue')['default'] + VisibilityInput: typeof import('./src/components/VisibilityInput.vue')['default'] + WebPagePresetPicker: typeof import('./src/components/WebPagePresetPicker.vue')['default'] } } diff --git a/frontend/index.html b/frontend/index.html index ff179096f..2d24cb142 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,22 +2,23 @@ - - + Frappe Builder - +
-
diff --git a/frontend/package.json b/frontend/package.json index 604877aa9..b5f13b566 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,9 +5,11 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build --base=/assets/builder/frontend/ && yarn copy-html-entry", - "copy-html-entry": "cp ../builder/public/frontend/index.html ../builder/www/_builder.html", + "build": "vite build", + "build:reset": "tailwindcss -c ./tailwind.reset.config.js -i ./src/reset.css -o ../builder/public/reset.css --minify && node ./scripts/strip-css-comments.js", + "generate:css-metadata": "node ./scripts/generate-css-property-metadata.mjs", "preview": "vite preview", + "lint": "eslint src --ext .js,.ts,.vue", "test-local": "cypress open --e2e --browser chrome", "test": "npx cypress run --record --key fa50a4df-569e-41bc-8600-850331fd1630" }, @@ -25,42 +27,47 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.1", "@replit/codemirror-indentation-markers": "^6.5.3", - "@tiptap/extension-color": "^2.0.4", - "@tiptap/extension-font-family": "^2.0.4", - "@tiptap/extension-link": "^2.1.12", - "@tiptap/extension-text-style": "^2.0.4", - "@tiptap/extension-underline": "^2.22.3", - "@tiptap/pm": "^2.0.4", - "@tiptap/starter-kit": "^2.0.4", - "@tiptap/vue-3": "^2.0.4", + "@tiptap/extension-color": "^3.26.0", + "@tiptap/extension-font-family": "^3.26.0", + "@tiptap/extension-text-style": "^3.26.0", + "@tiptap/extension-underline": "^3.26.0", + "@tiptap/pm": "^3.26.0", + "@tiptap/starter-kit": "^3.26.0", + "@tiptap/vue-3": "^3.26.0", "@vitejs/plugin-vue": "5", "@vueuse/components": "^10.2.1", "@vueuse/core": "^10.2.1", "autoprefixer": "^10.4.2", "codemirror": "^6.0.2", - "frappe-ui": "0.1.152", + "frappe-ui": "1.0.0-beta.21", + "js-yaml": "^4.1.1", "opentype.js": "^1.3.4", "pinia": "^2.0.28", "postcss": "^8.4.5", + "reka-ui": "^2.5.0", + "socket.io-client": "^4.5.1", "tailwindcss": "^3.3.2", "thememirror": "^2.0.1", "vite": "^5.2.6", "vue": "3.5.15", "vue-router": "^4.0.12", - "vue-sonner": "^1.0.2", - "vuedraggable": "^4.1.0", - "webfontloader": "^1.6.28" + "vuedraggable": "^4.1.0" }, "devDependencies": { "@tailwindcss/container-queries": "^0.1.1", + "@types/js-yaml": "^4.0.9", "@types/opentype.js": "^1.3.8", + "@typescript-eslint/parser": "^6.21.0", "cypress": "^13.3.2", "eslint": "^8.38.0", + "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-vue": "^9.11.0", + "mdn-data": "2.27.1", "prettier": "^2.8.8", "prettier-plugin-tailwindcss": "^0.2.8", - "tslib": "^2.5.0" + "tslib": "^2.5.0", + "web-features": "3.34.1" }, "peerDependencies": {} } diff --git a/frontend/scripts/generate-css-property-metadata.mjs b/frontend/scripts/generate-css-property-metadata.mjs new file mode 100644 index 000000000..1705ff3ba --- /dev/null +++ b/frontend/scripts/generate-css-property-metadata.mjs @@ -0,0 +1,113 @@ +/** + * Builds src/data/cssPropertyMetadata.json from mdn-data + web-features so the app ships a + * trimmed lookup instead of parsing MDN syntax grammars at runtime. + * Run it after bumping either dependency: `yarn generate:css-metadata`. + */ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import { features } from "web-features"; + +const require = createRequire(import.meta.url); +const cssProperties = require("mdn-data/css/properties.json"); +const cssSyntaxes = require("mdn-data/css/syntaxes.json"); + +const MAX_KEYWORDS = 40; +const KEYWORD_PATTERN = /^-?[_a-zA-Z][-_a-zA-Z0-9]*$/; + +const isBaseline = (value) => value === "high" || value === "low"; + +const isBaselineProperty = (property) => { + const compatKey = `css.properties.${property}`; + return Object.values(features).some((feature) => { + const compatStatus = feature.status?.by_compat_key?.[compatKey]; + if (compatStatus) return isBaseline(compatStatus.baseline); + if (!feature.compat_features?.includes(compatKey)) return false; + return isBaseline(feature.status?.baseline); + }); +}; + +const getSyntax = (property) => cssProperties[property]?.syntax || ""; + +const getKeywords = (syntax, seen = new Set()) => { + const keywords = new Set(); + if (!syntax || seen.has(syntax)) return keywords; + seen.add(syntax); + + const references = syntax.match(/<[-_a-zA-Z0-9()]+>/g) || []; + references.forEach((reference) => { + const referencedSyntax = cssSyntaxes[reference.slice(1, -1)]?.syntax; + if (referencedSyntax) getKeywords(referencedSyntax, seen).forEach((keyword) => keywords.add(keyword)); + }); + + // shorthands (eg. text-decoration) reference their longhands by quoted property name + const propertyReferences = syntax.match(/<'[^']+'>/g) || []; + propertyReferences.forEach((reference) => { + const property = reference.slice(2, -2); + getKeywords(getSyntax(property), seen).forEach((keyword) => keywords.add(keyword)); + }); + + syntax + .replace(/<[^>]+>/g, " ") + .replace(/[,[\]{}()?*+#/]/g, " ") + .split(/\s*\|\|\s*|\s*\|\s*|\s+/) + .map((part) => part.trim()) + .filter((part) => KEYWORD_PATTERN.test(part) && !part.includes("_separator")) + .forEach((keyword) => keywords.add(keyword)); + + return keywords; +}; + +// components combined with || or && are independent value slots (eg. text-decoration's +// line/style/color/thickness), so the shorthand is only color-like if every slot is +const isColorLike = (syntax, seen = new Set()) => { + const components = syntax.split(/\s*(?:\|\||&&)\s*/).filter(Boolean); + if (components.length > 1) return components.every((component) => isColorLike(component, new Set(seen))); + + if (syntax.includes("")) return true; + const propertyReferences = syntax.match(/<'[^']+'>/g) || []; + return propertyReferences.some((reference) => { + const property = reference.slice(2, -2); + if (seen.has(property)) return false; + seen.add(property); + return isColorLike(getSyntax(property), seen); + }); +}; + +// the value shape a property accepts, which decides the control the editor renders +const getKind = (property) => { + const syntax = getSyntax(property); + if (isColorLike(syntax)) return "color"; + if (//.test(syntax)) return "length"; + if (syntax.includes("")) return "integer"; + if (/||/.test(syntax)) return "number"; + return "keyword"; +}; + +// expanding floods any property that merely accepts one (filter, box-shadow, mask...) +// with 150 named colors, so it is marked as already-seen and left unexpanded +const colorSyntax = cssSyntaxes.color?.syntax; + +const buildEntry = (property) => { + const entry = { kind: getKind(property) }; + if (entry.kind !== "color") { + const keywords = Array.from(getKeywords(getSyntax(property), new Set([colorSyntax]))).slice( + 0, + MAX_KEYWORDS, + ); + if (keywords.length) entry.keywords = keywords; + } + if (isBaselineProperty(property)) entry.baseline = true; + return entry; +}; + +const metadata = Object.entries(cssProperties) + .filter(([property, data]) => data.status === "standard" && !property.startsWith("-")) + .map(([property]) => property) + .sort() + .reduce((accumulator, property) => { + accumulator[property] = buildEntry(property); + return accumulator; + }, {}); + +fs.mkdirSync(new URL("../src/data/", import.meta.url), { recursive: true }); +fs.writeFileSync(new URL("../src/data/cssPropertyMetadata.json", import.meta.url), JSON.stringify(metadata)); diff --git a/frontend/scripts/strip-css-comments.js b/frontend/scripts/strip-css-comments.js new file mode 100644 index 000000000..d26f168fc --- /dev/null +++ b/frontend/scripts/strip-css-comments.js @@ -0,0 +1,9 @@ +import { readFileSync, writeFileSync } from "fs"; + +const file = new URL("../../builder/public/reset.css", import.meta.url).pathname; +const css = readFileSync(file, "utf8") + .replace(/\/\*![\s\S]*?\*\//g, "") // strip license comments + .replace(/\n+/g, "") // collapse blank lines + .trim(); + +writeFileSync(file, css); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 51c661962..c188f9563 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,29 +6,24 @@ - - - +
- +useSiteReadOnlyNotice(); + diff --git a/frontend/src/assets/resize-cursor.svg b/frontend/src/assets/resize-cursor.svg new file mode 100644 index 000000000..9ebca9037 --- /dev/null +++ b/frontend/src/assets/resize-cursor.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/rotation-cursor.svg b/frontend/src/assets/rotation-cursor.svg new file mode 100644 index 000000000..2fc8995f9 --- /dev/null +++ b/frontend/src/assets/rotation-cursor.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/block.ts b/frontend/src/block.ts index bbbf0e247..a45db89d1 100644 --- a/frontend/src/block.ts +++ b/frontend/src/block.ts @@ -1,27 +1,78 @@ import useCanvasStore from "@/stores/canvasStore"; import useComponentStore from "@/stores/componentStore"; +import { + extendWithComponent, + rebuildWithComponent, + resetWithComponent, + syncBlockWithComponent, +} from "@/utils/block/componentInstance"; +import { findBlockInTree, resetBlock } from "@/utils/block/tree"; +import type { SpacingType } from "@/utils/cssUtils"; import { addPxToNumber, + cssUrl, dataURLtoFile, + generateId, getBlockCopy, getBlockInstance, - getBoxSpacing, getNumberFromPx, + getSpacing, getTextContent, + handleBase64Attribute, kebabToCamelCase, parseAndSetBackground, - setBoxSpacing, - uploadImage, + setSpacing, + toStyleProperty, + uploadBuilderAsset, } from "@/utils/helpers"; import { Editor } from "@tiptap/vue-3"; import { clamp } from "@vueuse/core"; -import { computed, nextTick, reactive, toRaw } from "vue"; +import { computed, nextTick, reactive } from "vue"; + +const TEXT_ELEMENTS = new Set([ + "span", + "h1", + "p", + "b", + "h2", + "h3", + "h4", + "h5", + "h6", + "label", + "a", + "cite", + "li", + "strong", + "em", + "i", + "blockquote", +]); + +const CONTAINER_ELEMENTS = new Set(["section", "div"]); + +const HEADER_ELEMENTS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]); + +// editor of the currently editable text block; kept off Block instances to +// avoid Vue reactivity and serialization. Owner tracked by blockId since +// undo/redo swaps instances but keeps ids. +let activeEditor: Editor | null = null; +let activeEditorBlockId: string | null = null; + +// rawStyles were dropped in favour of baseStyles; older blocks still carry them +const mergeLegacyRawStyles = (baseStyles: BlockStyleMap, rawStyles?: BlockStyleMap) => { + if (!rawStyles) return baseStyles; + Object.entries(rawStyles).forEach(([style, value]) => { + if (value === null || value === "" || value === undefined) return; + baseStyles[toStyleProperty(style)] = value; + }); + return baseStyles; +}; class Block implements BlockOptions { blockId: string; children: Array; baseStyles: BlockStyleMap; - rawStyles: BlockStyleMap; mobileStyles: BlockStyleMap; tabletStyles: BlockStyleMap; attributes: BlockAttributeMap; @@ -33,15 +84,19 @@ class Block implements BlockOptions { innerText?: string; innerHTML?: string; extendedFromComponent?: string; + componentVersion?: string; originalElement?: string | undefined; isChildOfComponent?: string; referenceBlockId?: string; isRepeaterBlock?: boolean; - visibilityCondition?: string; + visibilityCondition?: BlockVisibilityCondition; elementBeforeConversion?: string; parentBlock: Block | null; activeState?: string | null = null; dynamicValues: Array; + props?: BlockProps; + editorConfig?: BlockEditorConfig; + clientScript: BlockClientScript; // @ts-expect-error referenceComponent: Block | null; customAttributes: BlockAttributeMap; @@ -50,22 +105,50 @@ class Block implements BlockOptions { this.element = options.element; this.innerHTML = options.innerHTML; this.extendedFromComponent = options.extendedFromComponent; + this.componentVersion = options.componentVersion; this.isRepeaterBlock = options.isRepeaterBlock; this.isChildOfComponent = options.isChildOfComponent; this.referenceBlockId = options.referenceBlockId; - this.visibilityCondition = options.visibilityCondition; this.parentBlock = options.parentBlock || null; if (this.extendedFromComponent) { - componentStore.loadComponent(this.extendedFromComponent); + if (this.componentVersion) { + // restored/pinned instance: load the frozen version from its snapshot + componentStore.loadComponentVersion(this.componentVersion, this.extendedFromComponent); + } else { + componentStore.loadComponent(this.extendedFromComponent); + } + } else if (this.isChildOfComponent && this.componentVersion) { + // a pinned instance's child resolves its component from the frozen version too + componentStore.loadComponentVersion(this.componentVersion, this.isChildOfComponent); + } + if (this.isChildOfComponent && !this.componentVersion) { + let parentBlock = this.getParentBlock(); + while (parentBlock && parentBlock?.extendedFromComponent != this.isChildOfComponent) { + parentBlock = parentBlock?.getParentBlock(); + } + this.componentVersion = parentBlock?.componentVersion; } // to keep this property out of block reactivity Object.defineProperty(this, "referenceComponent", { value: computed(() => { if (this.extendedFromComponent) { + if (this.componentVersion) { + // prefer the pinned version; fall back to live if it was pruned + return ( + componentStore.getComponentVersionBlock(this.componentVersion as string) || + componentStore.getComponentBlock(this.extendedFromComponent as string) || + null + ); + } return componentStore.getComponentBlock(this.extendedFromComponent as string) || null; } else if (this.isChildOfComponent) { - const componentBlock = componentStore.getComponentBlock(this.isChildOfComponent as string); - return findBlock(this.referenceBlockId as string, [componentBlock]); + // honor the pinned version (set on restored instance children) before + // falling back to the live component + const componentBlock = this.componentVersion + ? componentStore.getComponentVersionBlock(this.componentVersion as string) || + componentStore.getComponentBlock(this.isChildOfComponent as string) + : componentStore.getComponentBlock(this.isChildOfComponent as string); + return findBlockInTree(this.referenceBlockId as string, [componentBlock]); } return null; }), @@ -78,10 +161,19 @@ class Block implements BlockOptions { this.innerHTML = options.innerText; } + if (typeof options.visibilityCondition == "string") { + this.visibilityCondition = { + key: options.visibilityCondition, + comesFrom: "dataScript", + }; + } else { + this.visibilityCondition = options.visibilityCondition; + } + this.originalElement = options.originalElement; if (!options.blockId || options.blockId === "root") { - this.blockId = this.generateId(); + this.blockId = generateId(); } else { this.blockId = options.blockId; } @@ -90,13 +182,19 @@ class Block implements BlockOptions { return getBlockInstance(child); }); - this.baseStyles = reactive(options.styles || options.baseStyles || {}); - this.rawStyles = reactive(options.rawStyles || {}); + this.baseStyles = reactive( + mergeLegacyRawStyles({ ...(options.styles || options.baseStyles || {}) }, options.rawStyles), + ); this.customAttributes = reactive(options.customAttributes || {}); this.mobileStyles = reactive(options.mobileStyles || {}); this.tabletStyles = reactive(options.tabletStyles || {}); this.attributes = reactive(options.attributes || {}); this.dynamicValues = reactive(options.dynamicValues || []); + this.props = reactive(options.props || {}); + this.editorConfig = options.editorConfig; + this.clientScript = reactive( + options.clientScript ?? (options.blockClientScript ? { js: options.blockClientScript } : {}), + ); this.blockName = options.blockName; delete this.attributes.style; @@ -113,19 +211,10 @@ class Block implements BlockOptions { parseAndSetBackground(this.tabletStyles); if (this.isImage()) { - // if src is base64, convert it to a file - const src = this.getAttribute("src") as string; - if (src && src.startsWith("data:image")) { - const file = dataURLtoFile(src, "image.png"); - if (file) { - this.setAttribute("src", ""); - options.src = ""; - uploadImage(file, true).then((obj) => { - this.setAttribute("src", obj.fileURL); - }); - } - } + handleBase64Attribute(this, "src", "image.png"); + handleBase64Attribute(this, "darkSrc", "image-dark.png"); } + const bgImage = this.getStyle("backgroundImage") as string; if (bgImage && /^url\(['"]?data:image/.test(bgImage)) { let bgImage = this.getStyle("backgroundImage") as string; @@ -135,8 +224,8 @@ class Block implements BlockOptions { if (file) { this.setStyle("backgroundImage", ""); - uploadImage(file, true).then((obj) => { - this.setStyle("backgroundImage", `url(${obj.fileURL})`); + uploadBuilderAsset(file, true).then((obj) => { + this.setStyle("backgroundImage", cssUrl(obj.fileURL)); }); } } @@ -153,7 +242,6 @@ class Block implements BlockOptions { styleObj = { ...styleObj, ...this.mobileStyles }; } } - styleObj = { ...styleObj, ...this.rawStyles }; // replace variables with values // Object.keys(styleObj).forEach((style) => { // const value = styleObj[style]; @@ -231,14 +319,6 @@ class Block implements BlockOptions { customAttributes = { ...customAttributes, ...this.customAttributes }; return customAttributes; } - getRawStyles() { - let rawStyles = {}; - if (this.isExtendedFromComponent()) { - rawStyles = this.referenceComponent?.rawStyles || {}; - } - rawStyles = { ...rawStyles, ...this.rawStyles }; - return rawStyles; - } getVisibilityCondition() { let visibilityCondition = this.visibilityCondition; if (this.isExtendedFromComponent() && this.referenceComponent?.visibilityCondition) { @@ -267,7 +347,7 @@ class Block implements BlockOptions { } getComponentBlockDescription() { const componentStore = useComponentStore(); - return componentStore.getComponentName(this.extendedFromComponent as string); + return componentStore.getComponentName(this.extendedFromComponent as string, this.componentVersion); } getTextContent() { return getTextContent(this.getInnerHTML() || ""); @@ -290,16 +370,17 @@ class Block implements BlockOptions { isSVG() { return this.getElement() === "svg" || this.getInnerHTML()?.startsWith(" 0) return block; + block = block.getParentBlock(); + } + return null; + } convertToRepeater() { this.setBaseStyle("display", "flex"); this.setBaseStyle("flexDirection", "column"); @@ -688,6 +834,7 @@ class Block implements BlockOptions { key: "", type: this.isImage() || this.isLink() ? "attribute" : "key", property: this.isLink() ? "href" : this.isImage() ? "src" : "innerHTML", + comesFrom: "dataScript", }; } if (!value && key === "key") { @@ -701,7 +848,7 @@ class Block implements BlockOptions { if (!innerHTML && this.isExtendedFromComponent()) { innerHTML = this.referenceComponent?.getInnerHTML() || ""; } - return innerHTML; + return String(innerHTML); } getText(): string { const editor = this.getEditor(); @@ -752,6 +899,9 @@ class Block implements BlockOptions { syncBlockWithComponent(this, this, this.extendedFromComponent as string, component.children); } } + rebuildWithComponent(componentId: string, newComponentChildren: Block[], oldComponentChildren: Block[]) { + rebuildWithComponent(this, componentId, newComponentChildren, oldComponentChildren); + } resetChanges(resetChildren: boolean = false) { resetBlock(this, resetChildren); } @@ -791,6 +941,35 @@ class Block implements BlockOptions { return new Set(componentNames); } + getUsedVariableNames() { + const variableNames = [] as string[]; + const varPattern = /var\(--([a-zA-Z0-9_-]+)/g; + + const extractVarsFromValue = (value: any) => { + if (!value || typeof value !== "string") return; + const matches = value.matchAll(varPattern); + for (const match of matches) { + variableNames.push(match[1]); + } + }; + + const styleObjects = [this.baseStyles, this.mobileStyles, this.tabletStyles]; + styleObjects.forEach((styleObj) => { + if (styleObj) { + Object.values(styleObj).forEach(extractVarsFromValue); + } + }); + + if (this.innerHTML) { + extractVarsFromValue(this.innerHTML); + } + + this.children.forEach((child) => { + variableNames.push(...child.getUsedVariableNames()); + }); + + return new Set(variableNames); + } isFlex() { return this.getStyle("display") === "flex"; } @@ -829,32 +1008,40 @@ class Block implements BlockOptions { nextTick(() => { if (child) { child.selectBlock(); - pauseId && canvasStore.activeCanvas?.history?.resume(pauseId, true); + pauseId && canvasStore.activeCanvas?.history?.resume(pauseId, true, true); } }); } - setPadding(padding: string) { - setBoxSpacing(this, "padding", padding); + setSpacing(type: SpacingType, value: string) { + setSpacing(this, type, value); } - getPadding(opts?: { nativeOnly?: boolean; cascading?: boolean }) { - return getBoxSpacing(this, "padding", opts); + getSpacing(type: SpacingType, opts?: { nativeOnly?: boolean; cascading?: boolean }) { + return getSpacing(this, type, opts); } - setMargin(margin: string) { - setBoxSpacing(this, "margin", margin); - } - getMargin(opts?: { nativeOnly?: boolean; cascading?: boolean }) { - return getBoxSpacing(this, "margin", opts); + getDynamicValues() { + const dynamicValues = [...this.dynamicValues]; + const dynamicValueProperties = dynamicValues.map((v) => v.property); + if (this.isExtendedFromComponent()) { + const componentDynamicValues = this.referenceComponent?.getDynamicValues() || []; + componentDynamicValues.forEach((v) => { + if (!dynamicValueProperties.includes(v.property)) { + dynamicValues.push(v); + } + }); + } + return dynamicValues; } setDynamicValue( property: BlockDataKey["property"], type: BlockDataKeyType, key: BlockDataKey["key"] | null = null, + comesFrom: BlockDataKey["comesFrom"] = "dataScript", ) { const existingKey = this.getDynamicKey(property, type); if (existingKey) { this.dynamicValues = this.dynamicValues.map((v) => { if (v.property === property && v.type === type) { - return { ...v, key: key || "" }; + return { ...v, key: key || "", comesFrom }; } return v; }); @@ -863,6 +1050,7 @@ class Block implements BlockOptions { property, type, key: key || "", + comesFrom, }); } } @@ -889,132 +1077,16 @@ class Block implements BlockOptions { isInsideRepeater(): boolean { return Boolean(this.getRepeaterParent()); } -} - -function extendWithComponent( - block: Block | BlockOptions, - extendedFromComponent: string | undefined, - componentChildren: Block[], - resetOverrides: boolean = true, -) { - resetBlock(block, false, resetOverrides); - block.children?.forEach((child, index) => { - child.isChildOfComponent = extendedFromComponent; - let componentChild = componentChildren[index]; - if (child.extendedFromComponent) { - const component = child.referenceComponent; - child.referenceBlockId = componentChild.blockId; - extendWithComponent(child, child.extendedFromComponent, component.children, false); - } else if (componentChild) { - child.referenceBlockId = componentChild.blockId; - extendWithComponent(child, extendedFromComponent, componentChild.children, resetOverrides); - } - }); -} - -function resetWithComponent( - block: Block | BlockOptions, - extendedWithComponent: string, - componentChildren: Block[], - resetOverrides: boolean = true, -) { - block = toRaw(block); - resetBlock(block, true, resetOverrides); - block.children?.splice(0, block.children.length); - componentChildren.forEach((componentChild) => { - const blockComponent = getBlockCopy(componentChild); - blockComponent.isChildOfComponent = extendedWithComponent; - blockComponent.referenceBlockId = componentChild.blockId; - const childBlock = block.addChild(blockComponent, null, false); - if (componentChild.extendedFromComponent) { - const component = childBlock.referenceComponent; - resetWithComponent(childBlock, componentChild.extendedFromComponent, component.children, false); - } else { - resetWithComponent(childBlock, extendedWithComponent, componentChild.children, resetOverrides); - } - }); -} - -function syncBlockWithComponent( - parentBlock: Block, - block: Block, - componentName: string, - componentChildren: Block[], -) { - componentChildren.forEach((componentChild, index) => { - const blockExists = findComponentBlock(componentChild.blockId, parentBlock.children); - if (!blockExists) { - const blockComponent = getBlockCopy(componentChild); - blockComponent.isChildOfComponent = componentName; - blockComponent.referenceBlockId = componentChild.blockId; - resetBlock(blockComponent); - resetWithComponent(blockComponent, componentName, componentChild.children); - block.addChild(blockComponent, index, false); - } - }); - - block.children.forEach((child) => { - const componentChild = componentChildren.find((c) => c.blockId === child.referenceBlockId); - if (componentChild) { - syncBlockWithComponent(parentBlock, child, componentName, componentChild.children); - } - }); -} - -function findComponentBlock(blockId: string, blocks: Block[]): Block | null { - for (const block of blocks) { - if (block.referenceBlockId === blockId) { - return block; - } - if (block.children) { - const found = findComponentBlock(blockId, block.children); - if (found) { - return found; - } - } + getBlockProps(): BlockProps { + const propsRoot = this.getPropsRoot(); + if (!propsRoot) return { ...(this.props || {}) }; + const referenceProps = propsRoot.extendedFromComponent ? propsRoot.referenceComponent?.props || {} : {}; + return { ...referenceProps, ...(propsRoot.props || {}) }; } - return null; -} - -function resetBlock( - block: Block | BlockOptions, - resetChildren: boolean = true, - resetOverrides: boolean = true, -) { - block.blockId = block.generateId(); - if (resetOverrides) { - delete block.innerHTML; - delete block.element; - block.baseStyles = {}; - block.rawStyles = {}; - block.mobileStyles = {}; - block.tabletStyles = {}; - block.attributes = {}; - block.customAttributes = {}; - block.classes = []; - block.dataKey = null; - } - - if (resetChildren) { - block.children?.forEach((child) => { - resetBlock(child, resetChildren, !Boolean(child.extendedFromComponent)); - }); - } -} - -function findBlock(blockId: string, blocks: Block[]): Block | null { - for (const block of blocks) { - if (block.blockId === blockId) { - return block; - } - if (block.children) { - const found = findBlock(blockId, block.children); - if (found) { - return found; - } - } + setBlockProps(props: BlockProps) { + const propsRoot = this.getPropsRoot() || this; + propsRoot.props = props; } - return null; } export default Block; diff --git a/frontend/src/builder.d.ts b/frontend/src/builder.d.ts index aa7550eda..a8023140e 100644 --- a/frontend/src/builder.d.ts +++ b/frontend/src/builder.d.ts @@ -1,4 +1,4 @@ -declare type StyleValue = string | number | null | undefined; +declare type StyleValue = string | number | boolean | null | undefined; declare type styleProperty = keyof CSSProperties | `__${string}`; @@ -6,10 +6,46 @@ declare interface BlockStyleMap { [key: styleProperty]: StyleValue; } +type BlockPropOptions = { + type: "number" | "string" | "boolean" | "select" | "array" | "object" | "image" | "color"; + isRequired?: boolean; + // defaultValue?: any; + options?: Record; + dependencies?: { [key: string]: any }; +}; + +declare type BlockProps = Record< + string, + { + label?: string; + isDynamic: boolean; + isPassedDown: boolean; + comesFrom: "props" | "dataScript" | "componentData" | null; + value: string?; + isStandard?: boolean; // always true as used only in components + propOptions?: BlockPropOptions; + } +>; + +declare type BlockVisibilityCondition = { + key: string | undefined; + comesFrom: "props" | "dataScript" | "componentData" | undefined; +}; + declare interface BlockAttributeMap { [key: string]: string | number | null | undefined; } +declare interface BlockEditorConfig { + icon?: string; + showChildrenInEditor?: boolean; +} + +declare interface BlockClientScript { + js?: string; + css?: string; +} + declare interface BlockOptions { blockId?: string | undefined; element?: string; @@ -22,6 +58,10 @@ declare interface BlockOptions { children?: Array; dynamicValues?: Array; draggable?: boolean; + editorConfig?: BlockEditorConfig; + componentVersion?: string; + clientScript?: BlockClientScript; + blockClientScript?: string; [key: string]: any; } @@ -71,7 +111,6 @@ declare type HashString = `#${string}`; declare type RGBString = `rgb(${number}, ${number}, ${number})`; declare type LeftSidebarTabOption = "Blocks" | "Layers" | "Assets" | "Code" | "variables"; -declare type RightSidebarTabOption = "Properties" | "Script" | "Options"; declare type BuilderMode = "select" | "text" | "container" | "image" | "repeater" | "move"; @@ -106,6 +145,7 @@ declare type FileDoc = { declare interface BlockDataKey { key?: string; type?: BlockDataKeyType; + comesFrom?: "props" | "dataScript" | "componentData"; property?: string; } diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue new file mode 100644 index 000000000..60376b359 --- /dev/null +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -0,0 +1,616 @@ +