diff --git a/frontend/src/scripts/build.js b/frontend/src/scripts/build.js index 7adf7581..f6d1bfbd 100644 --- a/frontend/src/scripts/build.js +++ b/frontend/src/scripts/build.js @@ -5,6 +5,7 @@ import { build } from "vite" import vue from "@vitejs/plugin-vue" import path from "node:path" import { fileURLToPath } from "node:url" +import { parseArgs } from "node:util" import frappeui from "frappe-ui/vite" const __dirname = fileURLToPath(new URL(".", import.meta.url)) @@ -15,25 +16,31 @@ if (!fs.existsSync(TEMP_DIR)) { fs.mkdirSync(TEMP_DIR, { recursive: true }) } -const args = process.argv.slice(2) -const appName = args[0] -const components = args[2] - -if (!appName) { - console.error("App name is required") +const { values: argv } = parseArgs({ + options: { + app: { type: "string" }, + components: { type: "string" }, + "out-dir": { type: "string" }, + base: { type: "string" }, + }, + strict: false, +}) + +if (!argv.app) { + console.error("--app is required") process.exit(1) } -await generateAppBuild(appName, components) +await generateAppBuild(argv.app, argv.components, argv["out-dir"], argv.base) -export async function generateAppBuild(appName, components) { +export async function generateAppBuild(appName, components, outDir, base) { if (!appName) return const componentList = components ? components.split(",") : [] const componentSources = findComponentSources(componentList) const rendererContent = getRendererContent(componentSources) const tempRendererPath = writeRendererFile(appName, rendererContent) - await buildWithVite(appName, tempRendererPath) + await buildWithVite(appName, tempRendererPath, outDir, base) deleteRendererFile(tempRendererPath) } @@ -111,11 +118,14 @@ function writeRendererFile(appName, content) { return rendererPath } -async function buildWithVite(appName, entryFilePath) { +async function buildWithVite(appName, entryFilePath, outDir, basePath) { + outDir = outDir || path.resolve(__dirname, `../../../studio/public/app_builds/${appName}`) + basePath = basePath || `/assets/studio/app_builds/${appName}/` + console.log(`Building ${appName} with Vite`) await build({ root: path.resolve(__dirname, "../"), - base: "/assets/studio/app_builds/", + base: basePath, server: { // explicitly set origin of generated assets (images, fonts, etc) during development. // Required for the app renderer running on webserver port @@ -145,7 +155,7 @@ async function buildWithVite(appName, entryFilePath) { studioRenderer: path.resolve(__dirname, entryFilePath), }, }, - outDir: path.resolve(__dirname, `../../../studio/public/app_builds/${appName}`), + outDir: outDir, emptyOutDir: true, target: "es2015", sourcemap: true, diff --git a/studio/api.py b/studio/api.py index 43396af4..8f62d272 100644 --- a/studio/api.py +++ b/studio/api.py @@ -100,62 +100,3 @@ def check_app_permission() -> bool: ): return True return False - - -@frappe.whitelist() -def get_app_components(app_name: str, field: Literal["blocks", "draft_blocks"] = "blocks") -> set[str]: - import re - - from studio.constants import DEFAULT_COMPONENTS, NON_VUE_COMPONENTS - - filters = dict(studio_app=app_name, published=1) - filters[field] = ("is", "set") - - pages = frappe.get_all( - "Studio Page", - filters=filters, - pluck=field, - ) - if not pages: - return set() - components = set(DEFAULT_COMPONENTS) - - def add_h_function_components(text: str) -> set[str]: - """Extract component names from h(ComponentName...) function calls""" - pattern = r"\bh\(\s*([A-Z][a-zA-Z0-9_]*)" - - matches = re.findall(pattern, text) - for match in matches: - components.add(match) - - def add_block_components(block: dict): - if block.get("isStudioComponent"): - add_studio_components(block) - elif block.get("componentName") not in NON_VUE_COMPONENTS: - components.add(block.get("componentName")) - for child in block.get("children", []): - add_block_components(child) - - if slots := block.get("componentSlots"): - for slot in slots.values(): - if isinstance(slot.get("slotContent"), str): - continue - for slot_child in slot.get("slotContent"): - add_block_components(slot_child) - - def add_studio_components(block: dict): - component_block = frappe.db.get_value("Studio Component", block.get("componentName"), "block") - if isinstance(component_block, str): - component_block = frappe.parse_json(component_block) - add_block_components(component_block) - - for blocks in pages: - if not blocks: - continue - if isinstance(blocks, str): - add_h_function_components(blocks) - blocks = frappe.parse_json(blocks) - root_block = blocks[0] - add_block_components(root_block) - - return components diff --git a/studio/build.py b/studio/build.py new file mode 100644 index 00000000..274d014d --- /dev/null +++ b/studio/build.py @@ -0,0 +1,268 @@ +# Copyright (c) 2026, Frappe Technologies Pvt Ltd and contributors +# For license information, please see license.txt + +""" +Studio app build orchestration. + +This module provides functions to build studio apps for two scenarios: +1. Standard (exported) apps — built from disk JSON files, output to the + host app's public/ folder +2. Custom (DB) apps — built from database records, output to the site's + public/files/ folder +""" + +import json +import os +import re + +import click +import frappe +from frappe.build import get_node_env +from frappe.commands import popen +from frappe.utils import get_files_path + +from studio.constants import DEFAULT_COMPONENTS, NON_VUE_COMPONENTS + + +class StudioAppBuilder: + def __init__(self, studio_app: str, is_standard: bool, frappe_app: str | None = None): + self.app_name = studio_app + self.is_standard = is_standard + self.frappe_app = frappe_app + self.out_dir = "" + self.base = "" + self.components = set(DEFAULT_COMPONENTS) + self.studio_component_blocks = {} + + def build(self): + if self.is_standard: + """Build a standard (exported) studio app. + Output goes to: apps/{frappe_app}/{frappe_app}/public/app_builds/{app_name}/ + Served at: /assets/{frappe_app}/app_builds/{app_name}/ + """ + self.get_app_components_from_files() + self.out_dir = frappe.get_app_path(self.frappe_app, "public", "app_builds", self.app_name) + self.base = f"/assets/{self.frappe_app}/app_builds/{self.app_name}/" + else: + """Build a custom (DB) studio app for the current site. + Output goes to: sites/{sitename}/public/files/app_builds/{app_name}/ + Served at: /files/app_builds/{app_name}/ + """ + self.get_app_components() + self.out_dir = os.path.abspath(get_files_path("app_builds", self.app_name)) + self.base = f"/files/app_builds/{self.app_name}/" + self._run_vite_build() + + def _run_vite_build(self) -> None: + """Execute the yarn build-studio-app command with the given parameters.""" + if not self.components: + click.echo(f"No components found for {self.app_name}, skipping build") + return + + os.makedirs(self.out_dir, exist_ok=True) + + components_str = ",".join(sorted(self.components)) + command = ( + f"yarn build-studio-app" + f" --app {self.app_name}" + f" --components {components_str}" + f" --out-dir {self.out_dir}" + f" --base {self.base}" + ) + + studio_app_path = frappe.get_app_source_path("studio") + popen(command, cwd=studio_app_path, env=get_node_env(), raise_err=True) + + def get_app_components(self) -> set[str]: + pages = frappe.get_all( + "Studio Page", + filters={"studio_app": self.app_name, "published": 1, "blocks": ("is", "set")}, + pluck="blocks", + ) + if not pages: + return set() + + for blocks in pages: + if not blocks: + continue + if isinstance(blocks, str): + self._add_h_function_components(blocks) + blocks = frappe.parse_json(blocks) + root_block = blocks[0] + self._add_block_components(root_block) + + def get_app_components_from_files(self): + """Extract component names from exported JSON files on disk instead of DB records, + used during `bench build` when there's no DB access. + """ + studio_folder = get_studio_folder(self.frappe_app) + app_folder = os.path.join(studio_folder, self.app_name) + page_folder = os.path.join(app_folder, "studio_page") + if not os.path.exists(page_folder): + return self.components + + self._load_studio_components_from_files(app_folder) + + for page_file in os.listdir(page_folder): + if not page_file.endswith(".json"): + continue + + page_path = os.path.join(page_folder, page_file) + try: + with open(page_path) as f: + page_data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + click.secho(f"Warning: Could not read {page_file}: {e}", fg="yellow") + continue + + blocks = page_data.get("blocks") + if not blocks: + continue + + if isinstance(blocks, str): + self._add_h_function_components(blocks) + blocks = json.loads(blocks) + + if isinstance(blocks, list) and blocks: + self._add_block_components(blocks[0]) + + def _add_h_function_components(self, text: str) -> None: + """Extract component names from h(ComponentName...) function calls""" + pattern = r"\bh\(\s*([A-Z][a-zA-Z0-9_]*)" + + matches = re.findall(pattern, text) + for match in matches: + self.components.add(match) + + def _add_block_components(self, block: dict) -> None: + if block.get("isStudioComponent"): + self._add_studio_components(block) + elif block.get("componentName") not in NON_VUE_COMPONENTS: + self.components.add(block.get("componentName")) + for child in block.get("children", []): + self._add_block_components(child) + + if slots := block.get("componentSlots"): + for slot in slots.values(): + if isinstance(slot.get("slotContent"), str): + continue + for slot_child in slot.get("slotContent"): + self._add_block_components(slot_child) + + def _add_studio_components(self, block: dict): + if self.is_standard: + comp_name = block.get("componentName") + if comp_name and comp_name in self.studio_component_blocks: + self._add_block_components(self.studio_component_blocks[comp_name]) + else: + component_block = frappe.db.get_value("Studio Component", block.get("componentName"), "block") + if isinstance(component_block, str): + component_block = frappe.parse_json(component_block) + self._add_block_components(component_block) + + def _load_studio_components_from_files(self, app_folder: str): + """Load all studio component definitions from disk for recursive component resolution.""" + self.studio_component_blocks = {} + components_folder = os.path.join(app_folder, "studio_components") + + if not os.path.exists(components_folder): + return self.studio_component_blocks + for file in os.listdir(components_folder): + if not file.endswith(".json"): + continue + + component_file_path = os.path.join(components_folder, file) + try: + with open(component_file_path) as f: + component = json.load(f) + + component_name = component.get("name") + block = component.get("block") + + if component_name and block: + if isinstance(block, str): + block = json.loads(block) + self.studio_component_blocks[component_name] = block + except (json.JSONDecodeError, OSError): + continue + + +def build_standard_apps(app: str | None = None) -> None: + """Scan all apps on the bench for studio/ folders and build each exported app. + + This function works without DB access — it reads component data from + exported JSON files on disk. + + Args: + app: Only build studio apps exported to this specific frappe app + """ + apps = [app] if app else frappe.get_all_apps() + + for app in apps: + studio_folder = get_studio_folder(app) + if not os.path.exists(studio_folder): + continue + + if app == "studio": + apps_list_file = frappe.get_app_path("studio", "studio_apps.txt") + if os.path.exists(apps_list_file): + studio_apps = frappe.get_file_items(apps_list_file) + else: + continue + else: + studio_apps = [ + d for d in os.listdir(studio_folder) if os.path.isdir(os.path.join(studio_folder, d)) + ] + + for studio_app in studio_apps: + click.echo(f"\nBuilding Studio App: {studio_app} (from {app})") + + try: + StudioAppBuilder(studio_app, is_standard=True, frappe_app=app).build() + click.secho(f"Successfully built {studio_app}", fg="green") + except Exception as e: + click.secho(f"Failed to build {studio_app}: {e}", fg="red") + + +def build_custom_apps() -> None: + """Build all published custom (DB) studio apps for the current site. + Requires site context (DB access). + """ + custom_apps = get_published_custom_apps() + + for app_name in custom_apps: + click.echo(f"\nBuilding custom Studio App: {app_name}") + try: + StudioAppBuilder(app_name, is_standard=False).build() + except Exception as e: + click.secho(f"Failed to build {app_name}: {e}", fg="red") + + +def get_published_custom_apps() -> list[str]: + StudioApp = frappe.qb.DocType("Studio App") + StudioPage = frappe.qb.DocType("Studio Page") + custom_apps = ( + ( + frappe.qb.from_(StudioApp) + .inner_join(StudioPage) + .on(StudioPage.studio_app == StudioApp.name) + .select(StudioApp.name) + .where(StudioApp.is_standard == 0) + .where(StudioPage.published == 1) + ) + .distinct() + .run(pluck=True) + ) + + return custom_apps + + +def get_studio_folder(frappe_app: str) -> str | None: + return frappe.get_app_source_path(frappe_app, "studio") + + +def after_build() -> None: + """Hook called after `bench build`. Builds all standard studio apps""" + click.echo(click.style("\nBuilding Studio Apps...", fg="cyan")) + build_standard_apps() + click.echo(click.style("Studio Apps built", fg="green")) diff --git a/studio/hooks.py b/studio/hooks.py index 2237aa92..25553631 100644 --- a/studio/hooks.py +++ b/studio/hooks.py @@ -81,6 +81,7 @@ # before_install = "studio.install.before_install" # after_install = "studio.install.after_install" after_migrate = "studio.sync.after_migrate" +after_build = "studio.build.after_build" # Uninstallation # ------------ diff --git a/studio/studio/doctype/studio_app/studio_app.py b/studio/studio/doctype/studio_app/studio_app.py index 01fad075..55fdf4bf 100644 --- a/studio/studio/doctype/studio_app/studio_app.py +++ b/studio/studio/doctype/studio_app/studio_app.py @@ -5,11 +5,10 @@ import frappe from frappe import _ -from frappe.commands import popen +from frappe.utils import get_files_path from frappe.website.page_renderers.document_page import DocumentPage from frappe.website.website_generator import WebsiteGenerator -from studio.api import get_app_components from studio.export import can_export, delete_folder, remove_null_fields, write_document_file @@ -95,13 +94,17 @@ def get_context(self, context): context.app_title = self.app_title context.base_url = frappe.utils.get_url(self.route) context.app_pages = self.get_studio_pages() - context.is_developer_mode = frappe.conf.developer_mode + context.is_developer_mode = frappe.utils.cint(frappe.conf.developer_mode) context.site_name = frappe.local.site def autoname(self): if not self.name: self.name = self.app_name or self.app_title.lower().replace(" ", "-") + @property + def is_published(self): + return frappe.db.exists("Studio Page", dict(studio_app=self.name, published=1)) + def before_insert(self): if not self.app_title: self.app_title = "My App" @@ -147,11 +150,12 @@ def generate_app_build(self): if not frappe.has_permission("Studio App", ptype="write"): frappe.throw(_("You do not have permission to generate the app build"), frappe.PermissionError) + from studio.build import StudioAppBuilder + try: - components = get_app_components(self.name) - command = f"yarn build-studio-app {self.name} --components {','.join(list(components))}" - studio_app_path = frappe.get_app_source_path("studio") - popen(command, cwd=studio_app_path, raise_err=True) + StudioAppBuilder( + studio_app=self.name, is_standard=self.is_standard, frappe_app=self.frappe_app + ).build() except Exception as e: raise Exception(f"Build process failed: {str(e)}") @@ -182,15 +186,24 @@ def get_assets_from_manifest(self): https://vite.dev/guide/backend-integration.html#backend-integration """ try: - manifest_path = os.path.join( - frappe.get_app_source_path("studio"), - "studio", - "public", - "app_builds", - self.name, - ".vite", - "manifest.json", - ) + if self.is_standard: + manifest_path = os.path.join( + frappe.get_app_path(self.frappe_app), + "public", + "app_builds", + self.name, + ".vite", + "manifest.json", + ) + base_path = f"/assets/{self.frappe_app}/app_builds/{self.name}/" + else: + manifest_path = os.path.join( + get_files_path("app_builds", self.name), + ".vite", + "manifest.json", + ) + base_path = f"/files/app_builds/{self.name}/" + if not os.path.exists(manifest_path): return None @@ -202,7 +215,6 @@ def get_assets_from_manifest(self): entry_key = next((key for key in manifest if key.endswith(entry_key)), entry_key) entry = manifest[entry_key] - base_path = f"/assets/studio/app_builds/{self.name}/" result = { "script": f"{base_path}{entry['file']}", "stylesheets": [f"{base_path}{css_file}" for css_file in entry.get("css", [])], diff --git a/studio/sync.py b/studio/sync.py index 718de1cd..9a985dbf 100644 --- a/studio/sync.py +++ b/studio/sync.py @@ -4,11 +4,15 @@ from frappe.modules.import_file import import_file_by_path from frappe.modules.patch_handler import _patch_mode +from studio.build import build_custom_apps + def after_migrate(): _patch_mode(True) sync_studio_apps() _patch_mode(False) + # Rebuild all published custom (DB) apps. + build_custom_apps() def after_app_install(app_name):