diff --git a/frontend/components.d.ts b/frontend/components.d.ts index c168c336..777e749c 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -62,7 +62,6 @@ declare module 'vue' { InputLabel: typeof import('./src/components/InputLabel.vue')['default'] ItemActions: typeof import('./src/components/ItemActions.vue')['default'] ListBox: typeof import('./src/components/ListBox.vue')['default'] - LucideX: typeof import('~icons/lucide/x')['default'] MarginHandler: typeof import('./src/components/MarginHandler.vue')['default'] MarkdownEditor: typeof import('./src/components/AppLayout/MarkdownEditor.vue')['default'] MissingComponent: typeof import('./src/components/MissingComponent.vue')['default'] diff --git a/frontend/src/components/ImportPanel.vue b/frontend/src/components/ImportPanel.vue new file mode 100644 index 00000000..245a6f3b --- /dev/null +++ b/frontend/src/components/ImportPanel.vue @@ -0,0 +1,237 @@ + + + + + Import pages and components from any installed frappe app into Studio. + + + + + + Select App + + + + + + + Scanning app... + + + + + + + + + + + + + + Pages + + {{ allPagesSelected ? "Deselect all" : "Select all" }} + + + + + + {{ page.page_title }} + {{ page.route }} + + + + + + + + Components + + {{ allComponentsSelected ? "Deselect all" : "Select all" }} + + + + + + {{ comp.component_name }} + + + + + + + Warnings + + + {{ w }} + + + + + + + + + + + Import complete + + {{ importResult.pages_imported }} pages · {{ importResult.components_imported }} components imported + + + + + Import failed + {{ importError }} + + + + + diff --git a/frontend/src/components/StudioLeftPanel.vue b/frontend/src/components/StudioLeftPanel.vue index 3d7d60ec..92127c38 100644 --- a/frontend/src/components/StudioLeftPanel.vue +++ b/frontend/src/components/StudioLeftPanel.vue @@ -72,6 +72,7 @@ + @@ -89,6 +90,7 @@ import DataPanel from "@/components/DataPanel.vue" import CodePanel from "@/components/CodePanel.vue" import IconButton from "@/components/IconButton.vue" import AIChatPanel from "@/components/AIChatPanel.vue" +import ImportPanel from "@/components/ImportPanel.vue" import Block from "@/utils/block" import useStudioStore from "@/stores/studioStore" @@ -120,6 +122,10 @@ const sidebarMenu = [ label: "AI Assistant", icon: "zap", }, + { + label: "Import", + icon: "download", + }, ] const store = useStudioStore() const canvasStore = useCanvasStore() diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index dd1f511b..e48dcc58 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -149,7 +149,7 @@ export type Filter = { field: DocTypeField } -export type LeftPanelOptions = "Pages" | "Add Component" | "Layers" | "Data" | "Code" | "AI Assistant" +export type LeftPanelOptions = "Pages" | "Add Component" | "Layers" | "Data" | "Code" | "AI Assistant" | "Import" export type RightPanelOptions = "Properties" | "Styles" | "Events" | "Interface" export type leftPanelComponentTabOptions = "Standard" | "Custom" diff --git a/studio/api.py b/studio/api.py index 6fed1c53..44022f3d 100644 --- a/studio/api.py +++ b/studio/api.py @@ -7,6 +7,7 @@ from frappe.model import display_fieldtypes, no_value_fields, table_fields from studio.constants import STANDARD_COMPONENT_NAMES +import studio.importer as importer @frappe.whitelist() @@ -106,6 +107,28 @@ def check_app_permission() -> bool: return False +@frappe.whitelist() +def get_importable_apps() -> list[dict]: + """Return installed frappe apps that have a detectable Vue frontend.""" + return importer.get_importable_apps() + + +@frappe.whitelist() +def preview_import(frappe_app: str) -> dict: + """Run the Vue parser and return the manifest without writing to the DB.""" + return importer.preview_import(frappe_app) + + +@frappe.whitelist() +def start_import(frappe_app: str, selected_pages: list | str | None = None, selected_components: list | str | None = None) -> str: + """Trigger a full import and return the Studio Import Log name.""" + if isinstance(selected_pages, str): + selected_pages = frappe.parse_json(selected_pages) + if isinstance(selected_components, str): + selected_components = frappe.parse_json(selected_components) + return importer.import_app(frappe_app, selected_pages, selected_components) + + @frappe.whitelist() def get_custom_vue_components(frappe_app: str) -> list[dict]: """Discover custom Vue SFC components""" diff --git a/studio/importer.py b/studio/importer.py new file mode 100644 index 00000000..b4a43ddc --- /dev/null +++ b/studio/importer.py @@ -0,0 +1,263 @@ +# Copyright (c) 2026, Frappe Technologies Pvt Ltd and contributors +# For license information, please see license.txt + +import json +import os +import subprocess +import traceback + +import frappe + + +ROUTER_CANDIDATES = [ + os.path.join("frontend", "src", "router.js"), + os.path.join("frontend", "src", "router", "index.js"), + os.path.join("frontend", "src", "router", "index.ts"), + os.path.join("frontend", "src", "router.ts"), +] + + +def get_importable_apps() -> list[dict]: + """List installed frappe apps that have a detectable Vue frontend.""" + result = [] + for app in frappe.get_installed_apps(): + if app in ("frappe", "studio"): + continue + app_root = frappe.get_app_source_path(app) + router_path = _find_router_file(app_root) + if router_path: + result.append({"app": app, "router_file": router_path}) + return result + + +def preview_import(frappe_app: str) -> dict: + """Run the parser and return the manifest without writing anything to the DB.""" + return _run_parser(frappe_app) + + +def import_app(frappe_app: str, selected_pages: list | None = None, selected_components: list | None = None) -> str: + """Run the full import. Creates Studio records and returns the import log name.""" + log = frappe.get_doc({"doctype": "Studio Import Log", "frappe_app": frappe_app}) + log.insert(ignore_permissions=True) + log.mark_running() + + try: + manifest = _run_parser(frappe_app) + studio_app_name = _upsert_studio_app(frappe_app, manifest) + log.studio_app = studio_app_name + + pages = _filter(manifest.get("pages", []), selected_pages, key="page_name") + components = _filter(manifest.get("components", []), selected_components, key="component_id") + + n_components = _write_components(studio_app_name, components) + frappe.db.commit() + + n_pages = _write_pages(studio_app_name, pages) + frappe.db.commit() + + log.mark_complete(n_pages, n_components, manifest.get("warnings", [])) + frappe.db.commit() + except Exception: + frappe.db.rollback() + log.mark_failed(traceback.format_exc()) + frappe.db.commit() + + return log.name + + +def _find_router_file(app_root: str) -> str | None: + for candidate in ROUTER_CANDIDATES: + path = os.path.join(app_root, candidate) + if os.path.isfile(path): + return path + return None + + +def _run_parser(frappe_app: str) -> dict: + """Invoke the Node.js parser and return the parsed manifest.""" + # frappe.get_app_source_path normalises path segments (hyphens → underscores), + # so build the tool path from the Python package path instead. + studio_pkg = frappe.get_app_path("studio") # …/apps/studio/studio + app_root = os.path.dirname(studio_pkg) # …/apps/studio + tool_dir = os.path.join(app_root, "tools", "vue-importer") + tool_entry = os.path.join(tool_dir, "index.js") + app_src = frappe.get_app_source_path(frappe_app, "frontend", "src") + + _ensure_tool_installed(tool_dir) + + result = subprocess.run( + ["node", tool_entry, "--app-name", frappe_app, "--app-path", app_src], + capture_output=True, + text=True, + timeout=120, + ) + + if result.returncode != 0: + frappe.throw(f"Vue parser failed for '{frappe_app}':\n{result.stderr}") + + return json.loads(result.stdout) + + +def _ensure_tool_installed(tool_dir: str): + modules_dir = os.path.join(tool_dir, "node_modules") + if not os.path.isdir(modules_dir): + subprocess.run(["npm", "install", "--prefix", tool_dir], check=True, capture_output=True) + + +def _upsert_studio_app(frappe_app: str, manifest: dict) -> str: + app_name = manifest.get("app", frappe_app) + app_title = manifest.get("app_title", app_name.title()) + base_route = manifest.get("base_route", f"/{app_name}") + + existing = frappe.db.get_value("Studio App", {"frappe_app": frappe_app}, "name") + if existing: + return existing + + doc = frappe.get_doc({ + "doctype": "Studio App", + "app_name": app_name, + "app_title": app_title, + "route": base_route, + "frappe_app": frappe_app, + }) + doc.insert(ignore_permissions=True) + return doc.name + + +def _write_components(studio_app: str, components: list) -> int: + count = 0 + for comp in components: + existing = frappe.db.get_value("Studio Component", {"component_id": comp["component_id"]}, "name") + if existing: + doc = frappe.get_doc("Studio Component", existing) + else: + doc = frappe.get_doc({"doctype": "Studio Component"}) + + doc.update({ + "component_name": comp["component_name"], + "component_id": comp["component_id"], + "block": frappe.as_json(comp.get("block", {}), indent=None), + "inputs": _build_input_rows(comp.get("inputs", [])), + }) + + if existing: + doc.save(ignore_permissions=True) + else: + doc.insert(ignore_permissions=True) + count += 1 + + return count + + +def _write_pages(studio_app: str, pages: list) -> int: + count = 0 + for page in pages: + route = page.get("route", "") + if not route.startswith("/"): + route = f"/{route}" + existing = frappe.db.get_value( + "Studio Page", {"route": route, "studio_app": studio_app}, "name" + ) + if existing: + doc = frappe.get_doc("Studio Page", existing) + else: + doc = frappe.get_doc({"doctype": "Studio Page"}) + + doc.update({ + "page_name": page["page_name"], + "page_title": page["page_title"], + "studio_app": studio_app, + "route": page.get("route", ""), + "draft_blocks": frappe.as_json(page.get("draft_blocks", []), indent=None), + "resources": _build_resource_rows(page.get("resources", [])), + "variables": _build_variable_rows(page.get("variables", [])), + "watchers": _build_watcher_rows(page.get("watchers", [])), + }) + doc._skip_validate = True + + if existing: + doc.save(ignore_permissions=True) + else: + doc.insert(ignore_permissions=True) + count += 1 + + return count + + +def _build_input_rows(inputs: list) -> list: + return [ + { + "input_name": i.get("input_name", ""), + "type": i.get("type", "String"), + "required": i.get("required", 0), + "default": i.get("default", ""), + "description": i.get("description", ""), + } + for i in inputs + ] + + +def _build_resource_rows(resources: list) -> list: + return [ + { + "resource_name": r.get("resource_name", ""), + "resource_type": r.get("resource_type", "API Resource"), + "url": r.get("url", ""), + "document_type": r.get("document_type", ""), + "document_name": r.get("document_name", ""), + "fields": frappe.as_json(r.get("fields", []), indent=None) if r.get("fields") else None, + "filters": frappe.as_json(r.get("filters", []), indent=None) if r.get("filters") else None, + "auto": r.get("auto", 1), + } + for r in resources + ] + + +def _build_variable_rows(variables: list) -> list: + rows = [] + for v in variables: + vtype = v.get("variable_type", "String") + initial = v.get("initial_value", "") or "" + + # Studio calls JSON.parse(initial_value) for String and Object types, + # so the stored value must be valid JSON. + if vtype == "String": + initial = json.dumps(initial) # "list" → '"list"', "" → '""' + elif vtype == "Object": + initial = initial if _is_valid_json(initial) else "null" + + rows.append({ + "variable_name": v.get("variable_name", ""), + "variable_type": vtype, + "initial_value": initial, + }) + return rows + + +def _is_valid_json(s: str) -> bool: + if not s: + return False + try: + json.loads(s) + return True + except (json.JSONDecodeError, TypeError): + return False + + +def _build_watcher_rows(watchers: list) -> list: + return [ + { + "source": w.get("source", ""), + "script": w.get("script", ""), + "immediate": w.get("immediate", 0), + "deep": w.get("deep", 0), + } + for w in watchers + ] + + +def _filter(items: list, selected: list | None, key: str) -> list: + if selected is None: + return items + selected_set = set(selected) + return [item for item in items if item.get(key) in selected_set] diff --git a/studio/studio/doctype/studio_import_log/__init__.py b/studio/studio/doctype/studio_import_log/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/studio/studio/doctype/studio_import_log/studio_import_log.json b/studio/studio/doctype/studio_import_log/studio_import_log.json new file mode 100644 index 00000000..7c63d68a --- /dev/null +++ b/studio/studio/doctype/studio_import_log/studio_import_log.json @@ -0,0 +1,115 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2026-06-05 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "frappe_app", + "studio_app", + "column_break", + "status", + "pages_imported", + "components_imported", + "section_break", + "warnings", + "error" + ], + "fields": [ + { + "fieldname": "frappe_app", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Frappe App" + }, + { + "fieldname": "studio_app", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Studio App", + "options": "Studio App" + }, + { + "fieldname": "column_break", + "fieldtype": "Column Break" + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Pending\nRunning\nComplete\nFailed" + }, + { + "default": "0", + "fieldname": "pages_imported", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Pages Imported" + }, + { + "default": "0", + "fieldname": "components_imported", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Components Imported" + }, + { + "fieldname": "section_break", + "fieldtype": "Section Break" + }, + { + "fieldname": "warnings", + "fieldtype": "Long Text", + "label": "Warnings" + }, + { + "fieldname": "error", + "fieldtype": "Long Text", + "label": "Error" + } + ], + "index_web_pages_for_search": 0, + "links": [], + "modified": "2026-06-05 00:00:00.000000", + "modified_by": "Administrator", + "module": "Studio", + "name": "Studio Import Log", + "naming_rule": "Random", + "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": "Studio User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "frappe_app", + "track_changes": 0 +} diff --git a/studio/studio/doctype/studio_import_log/studio_import_log.py b/studio/studio/doctype/studio_import_log/studio_import_log.py new file mode 100644 index 00000000..3e917472 --- /dev/null +++ b/studio/studio/doctype/studio_import_log/studio_import_log.py @@ -0,0 +1,40 @@ +# 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 StudioImportLog(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 + + components_imported: DF.Int + error: DF.LongText | None + frappe_app: DF.Data | None + pages_imported: DF.Int + status: DF.Literal["Pending", "Running", "Complete", "Failed"] + studio_app: DF.Link | None + warnings: DF.LongText | None + # end: auto-generated types + + def mark_running(self): + self.status = "Running" + self.save(ignore_permissions=True) + + def mark_complete(self, pages: int, components: int, warnings: list): + self.status = "Complete" + self.pages_imported = pages + self.components_imported = components + self.warnings = frappe.as_json(warnings, indent=None) if warnings else None + self.save(ignore_permissions=True) + + def mark_failed(self, error: str): + self.status = "Failed" + self.error = error + self.save(ignore_permissions=True) diff --git a/tools/vue-importer/index.js b/tools/vue-importer/index.js new file mode 100644 index 00000000..d53387a4 --- /dev/null +++ b/tools/vue-importer/index.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +"use strict" + +const path = require("path") +const fs = require("fs") +const { parseRouter } = require("./phases/router-parser") +const { buildComponentMap, classify } = require("./phases/classifier") +const { parseVueFile } = require("./phases/template-walker") +const { analyzeScript } = require("./phases/script-analyzer") + +function parseArgs(argv) { + const args = {} + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith("--")) { + args[argv[i].slice(2)] = argv[i + 1] + i++ + } + } + return args +} + +async function main() { + const { "app-name": appName, "app-path": appSrcPath } = parseArgs(process.argv.slice(2)) + + if (!appName || !appSrcPath) { + process.stderr.write("Usage: node index.js --app-name --app-path \n") + process.exit(1) + } + + if (!fs.existsSync(appSrcPath)) { + process.stderr.write(`App src path not found: ${appSrcPath}\n`) + process.exit(1) + } + + const warnings = [] + const componentMap = buildComponentMap(appSrcPath) + const processedComponents = new Map() // component_id → component record + + const routes = parseRouter(appSrcPath) + const pages = [] + + for (const route of routes) { + if (shouldSkipPage(route.name)) continue + + const blockTree = parsePage(route.filePath, componentMap, processedComponents, warnings, appSrcPath) + const scriptData = safeAnalyze(route.filePath) + + pages.push({ + page_name: route.pageName, + page_title: route.pageTitle, + route: route.routePath, + source_file: relative(appSrcPath, route.filePath), + draft_blocks: blockTree ? [blockTree] : [], + resources: scriptData.resources, + variables: scriptData.variables, + watchers: scriptData.watchers, + }) + } + + const manifest = { + app: appName, + app_title: toTitle(appName), + base_route: `/${appName}`, + pages, + components: Array.from(processedComponents.values()), + warnings, + } + + process.stdout.write(JSON.stringify(manifest)) +} + +function parsePage(filePath, componentMap, processedComponents, warnings, appSrcPath) { + try { + return parseVueFile(filePath, componentMap, warnings, (tag, filePath) => { + ensureComponent(tag, filePath, componentMap, processedComponents, warnings, appSrcPath) + }) + } catch (err) { + warnings.push(`Failed to parse ${relative(appSrcPath, filePath)}: ${err.message}`) + return null + } +} + +function ensureComponent(tag, filePath, componentMap, processedComponents, warnings, appSrcPath) { + const componentId = toComponentId(tag) + if (processedComponents.has(componentId)) return + + // Reserve the slot before recursing to break cycles + processedComponents.set(componentId, null) + + const scriptData = safeAnalyze(filePath) + // Build localName → studioInputName map for prop rewriting. + // defineProps props: localName === studioInputName. + // defineModel vars: localName (e.g. "viewControls") → model_prop (e.g. "modelValue"). + const propNames = new Map() + for (const p of scriptData.props || []) { + if (!p.input_name) continue + propNames.set(p.input_name, p.model_prop || p.input_name) + } + let block = null + + try { + block = parseVueFile(filePath, componentMap, warnings, (childTag, childPath) => { + ensureComponent(childTag, childPath, componentMap, processedComponents, warnings, appSrcPath) + }, { isComponentContext: true, propNames }) + } catch (err) { + warnings.push(`Failed to parse component ${tag}: ${err.message}`) + } + + processedComponents.set(componentId, { + component_id: componentId, + component_name: tag, + source_file: relative(appSrcPath, filePath), + block: block || {}, + inputs: scriptData.props, + }) +} + +function safeAnalyze(filePath) { + try { + return analyzeScript(filePath) + } catch (_) { + return { resources: [], variables: [], watchers: [], props: [] } + } +} + +function shouldSkipPage(name) { + const skip = new Set(["InvalidPage", "NotPermitted", "NotFound", "Error"]) + return skip.has(name) +} + +function toComponentId(name) { + return name.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "") +} + +function toTitle(str) { + return str.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function relative(base, full) { + return path.relative(base, full) +} + +main().catch((err) => { + process.stderr.write(`Fatal: ${err.message}\n${err.stack}\n`) + process.exit(1) +}) diff --git a/tools/vue-importer/mappings/component-names.js b/tools/vue-importer/mappings/component-names.js new file mode 100644 index 00000000..742fc384 --- /dev/null +++ b/tools/vue-importer/mappings/component-names.js @@ -0,0 +1,59 @@ +"use strict" + +// Maps Vue component names that differ from Studio's registry names. +// Keys are names found in Vue source; values are Studio registry names. +// A null value means skip the component (don't create a block for it). +const COMPONENT_NAME_MAP = { + // frappe-ui aliases that apps commonly use + Input: "TextInput", + FormControl: "TextInput", + ErrorMessage: null, + + // CRM wraps frappe-ui's Autocomplete — map to Combobox (Studio's name) + // If the wrapped component is used directly, honour the standard name + Autocomplete: "Combobox", + + // HTML layout elements → Container + div: "Container", + section: "Container", + main: "Container", + article: "Container", + aside: "Container", + header: "Header", + footer: "Container", + nav: "Container", + ul: "Container", + ol: "Container", + li: "Container", + span: "Container", + p: "TextBlock", + h1: "TextBlock", + h2: "TextBlock", + h3: "TextBlock", + h4: "TextBlock", + h5: "TextBlock", + h6: "TextBlock", + a: "TextBlock", + img: "ImageView", + audio: "Audio", + form: "Container", + label: "TextBlock", + strong: "TextBlock", + em: "TextBlock", + br: null, + hr: "Divider", +} + +/** + * Resolve the Studio component name for a Vue tag. + * Returns null if the element should be skipped. + * Returns the original name if no mapping exists (assumed to be a valid Studio name). + */ +function resolveComponentName(tag) { + if (Object.prototype.hasOwnProperty.call(COMPONENT_NAME_MAP, tag)) { + return COMPONENT_NAME_MAP[tag] + } + return tag +} + +module.exports = { resolveComponentName } diff --git a/tools/vue-importer/mappings/prop-types.js b/tools/vue-importer/mappings/prop-types.js new file mode 100644 index 00000000..7071bb67 --- /dev/null +++ b/tools/vue-importer/mappings/prop-types.js @@ -0,0 +1,20 @@ +"use strict" + +// Maps Vue prop constructor names to Studio Component Input types. +const VUE_PROP_TYPE_MAP = { + String: "String", + Number: "Number", + Boolean: "Boolean", + Object: "Object", + Array: "Object", // Studio has no Array type + Function: "String", + Date: "String", + Symbol: "String", + RegExp: "String", +} + +function resolveInputType(vuePropType) { + return VUE_PROP_TYPE_MAP[vuePropType] || "String" +} + +module.exports = { resolveInputType } diff --git a/tools/vue-importer/package-lock.json b/tools/vue-importer/package-lock.json new file mode 100644 index 00000000..42344641 --- /dev/null +++ b/tools/vue-importer/package-lock.json @@ -0,0 +1,354 @@ +{ + "name": "studio-vue-importer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "studio-vue-importer", + "version": "1.0.0", + "dependencies": { + "@babel/parser": "^7.24.0", + "@babel/traverse": "^7.24.0", + "@vue/compiler-sfc": "^3.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", + "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.35", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", + "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", + "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.35", + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", + "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", + "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/tools/vue-importer/package.json b/tools/vue-importer/package.json new file mode 100644 index 00000000..b8425efa --- /dev/null +++ b/tools/vue-importer/package.json @@ -0,0 +1,14 @@ +{ + "name": "studio-vue-importer", + "version": "1.0.0", + "description": "Parses a frappe Vue SPA and emits a Studio import manifest", + "main": "index.js", + "scripts": { + "parse": "node index.js" + }, + "dependencies": { + "@babel/parser": "^7.24.0", + "@babel/traverse": "^7.24.0", + "@vue/compiler-sfc": "^3.4.0" + } +} diff --git a/tools/vue-importer/phases/classifier.js b/tools/vue-importer/phases/classifier.js new file mode 100644 index 00000000..c2c23945 --- /dev/null +++ b/tools/vue-importer/phases/classifier.js @@ -0,0 +1,87 @@ +"use strict" + +const fs = require("fs") +const path = require("path") +const { resolveComponentName } = require("../mappings/component-names") + +// Standard component names from Studio's constants — kept in sync with studio/constants.py +const STANDARD_COMPONENT_NAMES = new Set([ + // frappe-ui + "Alert", "Autocomplete", "Avatar", "Badge", "Button", "Breadcrumbs", + "Checkbox", "Calendar", "Combobox", "DatePicker", "TimePicker", + "DateTimePicker", "DateRangePicker", "MonthPicker", "Dialog", "Divider", + "Dropdown", "ErrorMessage", "FeatherIcon", "FileUploader", "FormLabel", + "FormControl", "ListView", "MultiSelect", "Progress", "Rating", + "Select", "Sidebar", "Switch", "Tabs", "TabButtons", "Textarea", + "TextInput", "TextEditor", "Tooltip", "Tree", + "AxisChart", "NumberChart", "DonutChart", + // frappe + "Filter", "Link", + // studio built-in + "Container", "FitContainer", "Repeater", "HTML", "Header", "SplitView", + "AvatarCard", "CardList", "Audio", "ImageView", "TextBlock", + "AppHeader", "BottomTabs", "MarkdownEditor", +]) + +/** + * Build a map of component name → source file path by scanning the app's + * components directory. This lets the walker recurse into custom components. + */ +function buildComponentMap(appSrcPath) { + const componentsDir = path.join(appSrcPath, "components") + const map = new Map() + + if (!fs.existsSync(componentsDir)) { + return map + } + + function scan(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + scan(fullPath) + } else if (entry.isFile() && entry.name.endsWith(".vue")) { + const name = entry.name.slice(0, -4) + if (!map.has(name)) { + map.set(name, fullPath) + } + } + } + } + + scan(componentsDir) + return map +} + +/** + * Classify a component name. + * Returns { type: 'standard' | 'custom' | 'skip', studioName, filePath? } + */ +function classify(tag, componentMap) { + const studioName = resolveComponentName(tag) + + if (studioName === null) { + return { type: "skip" } + } + + if (STANDARD_COMPONENT_NAMES.has(studioName)) { + return { type: "standard", studioName } + } + + // Not a standard name — look for a custom Vue file. + // Use component_id (kebab-case) as componentName so Studio's componentStore + // can fetch and cache the Studio Component by its Frappe document name. + const filePath = componentMap.get(tag) + if (filePath) { + return { type: "custom", studioName: toComponentId(tag), filePath } + } + + // Unknown — treat as a generic container so the block tree isn't broken + return { type: "standard", studioName: "Container" } +} + +function toComponentId(name) { + return name.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "") +} + +module.exports = { buildComponentMap, classify } diff --git a/tools/vue-importer/phases/router-parser.js b/tools/vue-importer/phases/router-parser.js new file mode 100644 index 00000000..18cd886e --- /dev/null +++ b/tools/vue-importer/phases/router-parser.js @@ -0,0 +1,145 @@ +"use strict" + +const fs = require("fs") +const path = require("path") +const babelParser = require("@babel/parser") + +const ROUTER_CANDIDATES = [ + path.join("router.js"), + path.join("router", "index.js"), + path.join("router", "index.ts"), + path.join("router.ts"), +] + +/** + * Find and parse the router file in appSrcPath. + * Returns an array of { name, routePath, filePath } page descriptors. + */ +function parseRouter(appSrcPath) { + const routerFile = findRouterFile(appSrcPath) + if (!routerFile) { + return [] + } + + const source = fs.readFileSync(routerFile, "utf8") + const ast = babelParser.parse(source, { + sourceType: "module", + plugins: ["typescript", "jsx"], + errorRecovery: true, + }) + + return extractRoutes(ast, appSrcPath) +} + +function findRouterFile(appSrcPath) { + for (const candidate of ROUTER_CANDIDATES) { + const full = path.join(appSrcPath, candidate) + if (fs.existsSync(full)) { + return full + } + } + return null +} + +function extractRoutes(ast, appSrcPath) { + const routes = [] + + function visit(node) { + if (!node || typeof node !== "object") return + + if (isRouteObject(node)) { + const route = extractRoute(node, appSrcPath) + if (route) routes.push(route) + } + + for (const key of Object.keys(node)) { + if (key === "type" || key === "loc" || key === "start" || key === "end") continue + const child = node[key] + if (Array.isArray(child)) { + child.forEach(visit) + } else if (child && typeof child === "object" && child.type) { + visit(child) + } + } + } + + visit(ast.program) + return routes +} + +function isRouteObject(node) { + if (node.type !== "ObjectExpression") return false + const props = node.properties || [] + return props.some((p) => p.key && (p.key.name === "path" || p.key.value === "path")) +} + +function extractRoute(node, appSrcPath) { + const props = {} + for (const prop of node.properties || []) { + if (!prop.key) continue + const key = prop.key.name || prop.key.value + props[key] = prop.value + } + + const routePath = getStringValue(props.path) + const name = getStringValue(props.name) + if (!routePath || !name) return null + + const filePath = resolveComponentFile(props.component, appSrcPath) + if (!filePath) return null + + return { + name, + routePath, + filePath, + pageName: slugify(name), + pageTitle: titleCase(name), + } +} + +function resolveComponentFile(componentNode, appSrcPath) { + if (!componentNode) return null + + // () => import('@/pages/Foo.vue') + if (componentNode.type === "ArrowFunctionExpression") { + const body = componentNode.body + if (body && body.type === "CallExpression") { + return resolveImport(body, appSrcPath) + } + } + + // import('@/pages/Foo.vue') + if (componentNode.type === "CallExpression") { + return resolveImport(componentNode, appSrcPath) + } + + return null +} + +function resolveImport(callNode, appSrcPath) { + const arg = callNode.arguments && callNode.arguments[0] + if (!arg || arg.type !== "StringLiteral") return null + + const importPath = arg.value.replace(/^@\//, "") + const full = path.join(appSrcPath, importPath) + return fs.existsSync(full) ? full : null +} + +function getStringValue(node) { + if (!node) return null + if (node.type === "StringLiteral") return node.value + if (node.type === "TemplateLiteral" && node.quasis.length === 1) { + return node.quasis[0].value.raw + } + return null +} + +function slugify(str) { + return str.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "").replace(/\s+/g, "-") +} + +function titleCase(str) { + return str.replace(/([A-Z])/g, " $1").trim() +} + +module.exports = { parseRouter } diff --git a/tools/vue-importer/phases/script-analyzer.js b/tools/vue-importer/phases/script-analyzer.js new file mode 100644 index 00000000..f4cbf4c5 --- /dev/null +++ b/tools/vue-importer/phases/script-analyzer.js @@ -0,0 +1,251 @@ +"use strict" + +const babelParser = require("@babel/parser") +const traverse = require("@babel/traverse").default +const { parse: parseSFC } = require("@vue/compiler-sfc") +const { resolveInputType } = require("../mappings/prop-types") +const fs = require("fs") + +const RESOURCE_CREATORS = new Set(["createResource", "createListResource", "createDocumentResource"]) + +/** + * Analyse a .vue file's
+ Import pages and components from any installed frappe app into Studio. +
Warnings
Import complete
+ {{ importResult.pages_imported }} pages · {{ importResult.components_imported }} components imported +
Import failed
{{ importError }}