[
  {
    "path": ".githooks/README.md",
    "content": "# Git Hooks Setup\n\nThis repository uses Git hooks to automatically format code with Prettier before each commit.\n\n## One-time Setup\n\nAfter cloning the repository, run this command to enable the hooks:\n\n```bash\ngit config core.hooksPath .githooks\n```\n\nThat's it! The hooks will now run automatically before each commit.\n\n## What it does\n\nThe `pre-commit` hook will:\n- Automatically run `npx prettier --write .` before each commit\n- Format all supported files (JS, CSS, HTML, JSON, Markdown)\n- Stage the formatted files automatically\n- Continue with the commit\n\n## Skip the hook (if needed)\n\nIf you need to skip the formatting for a particular commit:\n\n```bash\ngit commit --no-verify -m \"your message\"\n```\n\n## Troubleshooting\n\n### Hook not running?\n\nCheck if the hooks path is set correctly:\n\n```bash\ngit config core.hooksPath\n# Should output: .githooks\n```\n\n### npx not found?\n\nMake sure Node.js and npm are installed:\n```bash\nnode --version\nnpm --version\n```\n"
  },
  {
    "path": ".githooks/pre-commit",
    "content": "#!/bin/sh\n# Git pre-commit hook to run Prettier on staged files\n\n# Get the project root directory using Git command (works in all shells)\nPROJECT_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd \"$PROJECT_ROOT\" || exit 1\n\n# Check if npx is available\nif ! command -v npx >/dev/null 2>&1; then\n  echo \"Warning: npx not found. Skipping Prettier formatting.\"\n  echo \"Please install Node.js and npm to use pre-commit formatting.\"\n  exit 0\nfi\n\necho \"Running Prettier on staged files...\"\n\n# Check if .prettierignore exists and run prettier\nif [ -f \"$PROJECT_ROOT/.prettierignore\" ]; then\n  echo \"Found .prettierignore, applying rules...\"\n  npx prettier --write . --log-level=warn --ignore-path=\"$PROJECT_ROOT/.prettierignore\"\nelse\n  echo \"No .prettierignore found, formatting all files...\"\n  npx prettier --write . --log-level=warn\nfi\n\n# Add any newly formatted files to the staging area\ngit add .\n\necho \"Prettier formatting complete.\"\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish to PyPI via uv\n\non:\n  workflow_dispatch:\n  push:\n    tags:\n      - \"v*\"\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n\n    permissions:\n      contents: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0  # 需要完整的 git 历史来获取 tag 信息\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.13\"\n\n      - name: Extract version from tag\n        id: version\n        run: |\n          # 从 tag 中提取版本号（去掉 v 前缀）\n          VERSION=${GITHUB_REF#refs/tags/v}\n          echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n          echo \"Version: $VERSION\"\n\n      - name: Extract release notes from CHANGELOG\n        id: extract_notes\n        run: |\n          VERSION=${{ steps.version.outputs.version }}\n\n          # 从 CHANGELOG.md 提取该版本的说明\n          # 匹配 \"## VERSION\" 或 \"## VERSION,\" 但不匹配 \"## VERSION.X\"\n          awk -v ver=\"$VERSION\" '\n            /^## / {\n              if ($0 ~ \"^## \" ver \"([, \\t]|$)\") {\n                flag=1\n                next\n              }\n              else if ($0 ~ /^## [0-9]/) {\n                flag=0\n              }\n            }\n            flag {print}\n          ' docs/changelog.md > release_notes.md\n\n          # 如果 CHANGELOG 中没有找到，使用 git tag 消息\n          if [ ! -s release_notes.md ]; then\n            echo \"No CHANGELOG entry found, using tag message...\"\n            git tag -l --format='%(contents)' ${{ github.ref_name }} > release_notes.md || echo \"Release $VERSION\" > release_notes.md\n          fi\n\n          # 显示提取的内容（用于调试）\n          echo \"Release notes content:\"\n          head -20 release_notes.md\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Build frontend\n        run: cd src/fastapi_voyager/web && npm install && npm run build\n\n      - name: Build the package\n        run: uv build\n\n      - name: Publish to PyPI\n        run: uv publish --token ${{ secrets.PYPI_PUBLISHER }}\n\n      - name: Create GitHub Release\n        uses: softprops/action-gh-release@v2\n        with:\n          body_path: release_notes.md\n          draft: false\n          prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}\n          files: |\n            dist/*.tar.gz\n            dist/*.whl\n\n      - name: Cleanup\n        if: always()\n        run: rm -f release_notes.md\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[codz]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py.cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# UV\n#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#uv.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n#poetry.toml\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#   pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.\n#   https://pdm-project.org/en/latest/usage/project/#working-with-version-control\n#pdm.lock\n#pdm.toml\n.pdm-python\n.pdm-build/\n\n# pixi\n#   Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.\n#pixi.lock\n#   Pixi creates a virtual environment in the .pixi directory, just like venv module creates one\n#   in the .venv directory. It is recommended not to include this directory in version control.\n.pixi\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.envrc\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n#.idea/\n\n# Abstra\n# Abstra is an AI-powered process automation framework.\n# Ignore directories containing user credentials, local state, and settings.\n# Learn more at https://abstra.io/docs\n.abstra/\n\n# Visual Studio Code\n#  Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore \n#  that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore\n#  and can be added to the global gitignore or merged into this file. However, if you prefer, \n#  you could uncomment the following to ignore the entire vscode folder\n# .vscode/\n\n# Ruff stuff:\n.ruff_cache/\n\n# PyPI configuration file\n.pypirc\n\n# Cursor\n#  Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to\n#  exclude from AI features like autocomplete and code analysis. Recommended for sensitive data\n#  refer to https://docs.cursor.com/context/ignore-files\n.cursorignore\n.cursorindexingignore\n\n# Marimo\nmarimo/_static/\nmarimo/_lsp/\n__marimo__/\n\n*.dot\n\n\nnode_modules/\nsrc/fastapi_voyager/web/node_modules/"
  },
  {
    "path": ".prettierignore",
    "content": "# Dependencies\nnode_modules/\n.venv/\n__pycache__/\n*.pyc\n\n# Build outputs\ndist/\nbuild/\n*.egg-info/\n\n# Static assets\n*.min.js\n*.min.css\n\n# Generated files\npackage-lock.json\nyarn.lock\npnpm-lock.yaml\n\n# Cache\n.ruff_cache/\n.pytest_cache/\n.vscode/\n\n# Git\n.git/\n.github/\n\n# Misc\n*.md\n.env\n.env.*\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": false,\n  \"singleQuote\": false,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"trailingComma\": \"es5\",\n  \"printWidth\": 100,\n  \"arrowParens\": \"always\",\n  \"endOfLine\": \"lf\",\n  \"htmlWhitespaceSensitivity\": \"css\"\n}\n"
  },
  {
    "path": ".python-version",
    "content": "3.12\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md - fastapi-voyager\n\n## 项目概述\n\nFastAPI Voyager 是一个 Python 包，提供 API 路由树和依赖关系的可视化。前端使用 Vue 3 + Naive UI，通过 Vite 构建。\n\n## 前端构建\n\n前端源码位于 `src/fastapi_voyager/web/`，构建产物为 `src/fastapi_voyager/web/dist/`。\n\n```bash\n# 安装依赖（首次或 package.json 变更后）\n. \"$HOME/.nvm/nvm.sh\" && nvm use 20\nnpm --prefix src/fastapi_voyager/web install\n\n# 构建（修改前端代码后执行）\nnpm --prefix src/fastapi_voyager/web run build\n```\n\n构建产物 `dist/` 已在 `.gitignore` 中，通过 `pyproject.toml` 的 `force-include` 在 CI 打包时包含。\n\n## 开发模式\n\n```bash\n# 终端 1：启动 Python 后端（任选一个 demo app）\nuv run uvicorn demo_app:app --port 8000\n# 或\n. .venv/bin/activate && uvicorn demo_app:app --port 8000\n\n# 终端 2（可选）：Vite dev server，支持 HMR\ncd src/fastapi_voyager/web && npm run dev\n# 浏览器打开 http://localhost:5173，API 请求自动代理到 localhost:8000\n```\n\n不启动 Vite dev server 时，直接访问 http://localhost:8000/voyager/ 即可使用构建后的版本。\n\n## 关键文件\n\n| 路径 | 说明 |\n|------|------|\n| `src/fastapi_voyager/web/src/App.vue` | 主组件（Naive UI） |\n| `src/fastapi_voyager/web/src/store.js` | 前端状态管理 |\n| `src/fastapi_voyager/web/src/main.js` | Vue 入口 |\n| `src/fastapi_voyager/web/src/component/*.vue` | 子组件 |\n| `src/fastapi_voyager/web/src/graph-ui.js` | D3 Graphviz 渲染 |\n| `src/fastapi_voyager/web/src/magnifying-glass.js` | 放大镜功能 |\n| `src/fastapi_voyager/web/index.html` | Vite 入口模板（含 Python 占位符） |\n| `src/fastapi_voyager/web/vite.config.js` | Vite 配置 |\n| `src/fastapi_voyager/adapters/common.py` | Python 端读取 dist/index.html 并替换占位符 |\n| `pyproject.toml` | 含 force-include 配置 |\n| `.github/workflows/publish.yml` | CI 含 Node.js 构建步骤 |\n\n## Python 占位符\n\n`dist/index.html` 中的占位符由 Python 在 serve 时替换：\n- `<!-- STATIC_PATH -->` → 静态文件路径\n- `<!-- VERSION_PLACEHOLDER -->` → 版本号\n- `<!-- THEME_COLOR -->` → 框架主题色\n- `<!-- GA_SNIPPET -->` → Google Analytics 代码\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# How to develop & contribute?\n\nfork, clone.\n\ninstall uv.\n\n```shell\nuv venv\nsource .venv/bin/activate\nuv pip install \".[dev]\"\nuvicorn tests.programatic:app  --reload\n```\n\nopen `localhost:8000/voyager`\n\n\nfrontend: \n- `src/web/vue-main.js`: main js\n\nbackend: \n- `voyager.py`: main entry\n- `render.py`: generate dot file\n- `server.py`: serve mode\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 tangkikodo\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "[![pypi](https://img.shields.io/pypi/v/fastapi-voyager.svg)](https://pypi.python.org/pypi/fastapi-voyager)\n![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-voyager)\n[![PyPI Downloads](https://static.pepy.tech/badge/fastapi-voyager/month)](https://pepy.tech/projects/fastapi-voyager)\n\n\n# FastAPI Voyager\n\nVisualize your API endpoints and explore them interactively.\n\nIts vision is to make code easier to read and understand, serving as an ideal documentation tool.\n\n**Now supports multiple frameworks:** FastAPI, Django Ninja, and Litestar.\n\n> This repo is still in early stage, it supports Pydantic v2 only.\n\n> **Breaking Change**: Since v0.19, `fastapi-voyager` depends on `pydantic-resolve>=4.0`. If you use `pydantic-resolve` v3, please pin `fastapi-voyager<=0.18`.\n\n- **Live Demo**: https://www.newsyeah.fun/voyager/\n- **Example Source**: [composition-oriented-development-pattern](https://github.com/allmonday/composition-oriented-development-pattern)\n\n<img width=\"1597\" height=\"933\" alt=\"fastapi-voyager overview\" src=\"https://github.com/user-attachments/assets/020bf5b2-6c69-44bf-ba1f-39389d388d27\" />\n\n## Table of Contents\n\n- [Quick Start](#quick-start)\n- [Installation](#installation)\n- [Supported Frameworks](#supported-frameworks)\n- [Features](#features)\n- [Command Line Usage](#command-line-usage)\n- [About pydantic-resolve](#about-pydantic-resolve)\n- [Development](#development)\n- [Dependencies](#dependencies)\n- [Credits](#credits)\n\n## Quick Start\n\nWith simple configuration, fastapi-voyager can be embedded into your web application:\n\n```python\nfrom fastapi import FastAPI\nfrom fastapi_voyager import create_voyager\n\napp = FastAPI()\n\n# ... define your routes ...\n\napp.mount('/voyager',\n          create_voyager(\n            app,\n            module_color={'src.services': 'tomato'},\n            module_prefix='src.services',\n            swagger_url=\"/docs\",\n            ga_id=\"G-XXXXXXXXVL\",\n            initial_page_policy='first',\n            online_repo_url='https://github.com/your-org/your-repo/blob/master',\n            enable_pydantic_resolve_meta=True))\n```\n\nVisit `http://localhost:8000/voyager` to explore your API visually.\n\nFor framework-specific examples (Django Ninja, Litestar), see [Supported Frameworks](#supported-frameworks).\n\n[View full example](https://github.com/allmonday/composition-oriented-development-pattern/blob/master/src/main.py#L48)\n\n## Installation\n\n### Install via pip\n\n```bash\npip install fastapi-voyager\n```\n\n### Install via uv\n\n```bash\nuv add fastapi-voyager\n```\n\n### Run with CLI\n\n```bash\nvoyager -m path.to.your.app.module --server\n```\n\nFor sub-application scenarios (e.g., `app.mount(\"/api\", api)`), specify the app name:\n\n```bash\nvoyager -m path.to.your.app.module --server --app api\n```\n\n> **Note**: [Sub-Application mounts](https://fastapi.tiangolo.com/advanced/sub-applications/) are not supported yet, but you can specify the name of the FastAPI application with `--app`. Only a single application (default: `app`) can be selected.\n\n## Supported Frameworks\n\nfastapi-voyager automatically detects your framework and provides the appropriate integration. Currently supported frameworks:\n\n### FastAPI\n\n```python\nfrom fastapi import FastAPI\nfrom fastapi_voyager import create_voyager\n\napp = FastAPI()\n\n@app.get(\"/hello\")\ndef hello():\n    return {\"message\": \"Hello World\"}\n\n# Mount voyager\napp.mount(\"/voyager\", create_voyager(app))\n```\n\nStart with:\n```bash\nuvicorn your_app:app --reload\n# Visit http://localhost:8000/voyager\n```\n\n### Django Ninja\n\n```python\nimport os\nimport django\nfrom django.core.asgi import get_asgi_application\nfrom ninja import NinjaAPI\nfrom fastapi_voyager import create_voyager\n\n# Configure Django\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"myapp.settings\")\ndjango.setup()\n\n# Create Django Ninja API\napi = NinjaAPI()\n\n@api.get(\"/hello\")\ndef hello(request):\n    return {\"message\": \"Hello World\"}\n\n# Create voyager ASGI app\nvoyager_app = create_voyager(api)\n\n# Create ASGI application that routes between Django and voyager\nasync def application(scope, receive, send):\n    if scope[\"type\"] == \"http\" and scope[\"path\"].startswith(\"/voyager\"):\n        await voyager_app(scope, receive, send)\n    else:\n        django_app = get_asgi_application()\n        await django_app(scope, receive, send)\n```\n\nStart with:\n```bash\nuvicorn your_app:application --reload\n# Visit http://localhost:8000/voyager\n```\n\n### Litestar\n\nLitestar doesn't support mounting to an existing app like FastAPI. The recommended pattern is to export `ROUTE_HANDLERS` from your main app:\n\n```python\n# In your main app file (e.g., app.py)\nfrom litestar import Litestar, Controller\n\nclass MyController(Controller):\n    # ... your routes ...\n\nROUTE_HANDLERS = [MyController]  # Export for extension\napp = Litestar(route_handlers=ROUTE_HANDLERS)\n```\n\nThen create voyager by reusing `ROUTE_HANDLERS`:\n\n```python\n# In your voyager embedding file\nfrom typing import Any, Awaitable, Callable\nfrom litestar import Litestar, asgi\nfrom fastapi_voyager import create_voyager\nfrom your_app import ROUTE_HANDLERS, app as your_app\n\nvoyager_app = create_voyager(your_app)\n\n@asgi(\"/voyager\", is_mount=True, copy_scope=True)\nasync def voyager_mount(\n    scope: dict[str, Any],\n    receive: Callable[[], Awaitable[dict[str, Any]]],\n    send: Callable[[dict[str, Any]], Awaitable[None]]\n) -> None:\n    await voyager_app(scope, receive, send)\n\napp = Litestar(route_handlers=ROUTE_HANDLERS + [voyager_mount])\n```\n\nStart with:\n```bash\nuvicorn your_app:app --reload\n# Visit http://localhost:8000/voyager\n```\n\n## Features\n\nfastapi-voyager is designed for scenarios using web frameworks with Pydantic models (FastAPI, Django Ninja, Litestar). It helps visualize dependencies and serves as an architecture tool to identify implementation issues such as wrong relationships, overfetching, and more.\n\n**Best Practice**: When building view models following the ER model pattern, fastapi-voyager can fully realize its potential - quickly identifying which APIs use specific entities and vice versa.\n\n### Highlight Nodes and Links\n\nClick a node to highlight its upstream and downstream nodes. Figure out the related models of one page, or how many pages are related with one model.\n\n<img width=\"1100\" height=\"700\" alt=\"highlight nodes and dependencies\" src=\"https://github.com/user-attachments/assets/3e0369ea-5fa4-469a-82c1-ed57d407e53d\" />\n\n### View Source Code\n\nDouble-click a node or route to show source code or open the file in VSCode.\n\n<img width=\"1297\" height=\"940\" alt=\"view source code\" src=\"https://github.com/user-attachments/assets/c8bb2e7d-b727-42a6-8c9e-64dce297d2d8\" />\n\n### Quick Search\n\nSearch schemas by name and display their upstream and downstream dependencies. Use `Shift + Click` on any node to quickly search for it.\n\n<img width=\"1587\" height=\"873\" alt=\"quick search functionality\" src=\"https://github.com/user-attachments/assets/ee4716f3-233d-418f-bc0e-3b214d1498f7\" />\n\n### Display ER Diagram\n\nER diagram is a feature from pydantic-resolve which provides a solid expression for business descriptions. You can visualize application-level entity relationship diagrams.\n\n```python\nfrom pydantic_resolve import ErDiagram, Entity, Relationship\n\ndiagram = ErDiagram(\n    entities=[\n        Entity(\n            kls=Team,\n            relationships=[\n                Relationship(fk='id', name='sprints', target=list[Sprint], loader=sprint_loader.team_to_sprint_loader),\n                Relationship(fk='id', name='users', target=list[User], loader=user_loader.team_to_user_loader)\n            ]\n        ),\n        Entity(\n            kls=Sprint,\n            relationships=[\n                Relationship(fk='id', name='stories', target=list[Story], loader=story_loader.sprint_to_story_loader)\n            ]\n        ),\n        Entity(\n            kls=Story,\n            relationships=[\n                Relationship(fk='id', name='tasks', target=list[Task], loader=task_loader.story_to_task_loader),\n                Relationship(fk='owner_id', name='owner', target=User, loader=user_loader.user_batch_loader)\n            ]\n        ),\n        Entity(\n            kls=Task,\n            relationships=[\n                Relationship(fk='owner_id', name='owner', target=User, loader=user_loader.user_batch_loader)\n            ]\n        )\n    ]\n)\n\n# Display in voyager\napp.mount('/voyager', create_voyager(app, er_diagram=diagram))\n```\n\n<img width=\"1276\" height=\"613\" alt=\"ER diagram visualization\" src=\"https://github.com/user-attachments/assets/ea0091bb-ee11-4f71-8be3-7129d956c910\" />\n\n### Show Pydantic Resolve Meta Info\n\nSet `enable_pydantic_resolve_meta=True` in `create_voyager`, then toggle the \"pydantic resolve meta\" button to visualize resolve/post/expose/collect operations.\n\n<img width=\"1604\" height=\"535\" alt=\"pydantic resolve meta information\" src=\"https://github.com/user-attachments/assets/d1639555-af41-4a08-9970-4b8ef314596a\" />\n\n## Command Line Usage\n\n### Start Server\n\n```bash\n# FastAPI\nvoyager -m tests.demo --server --web fastapi\n\n# Django Ninja\nvoyager -m tests.demo --server --web django-ninja\n\n# Litestar\nvoyager -m tests.demo --server --web litestar\n\n# Custom port\nvoyager -m tests.demo --server --port=8002\n\n# Specify app name\nvoyager -m tests.demo --server --app my_app\n```\n\n> **Note**: Server mode does not support ER diagram or pydantic-resolve metadata configuration. Use `create_voyager()` in your code with `er_diagram` and `enable_pydantic_resolve_meta` parameters to enable these features.\n\n### Generate DOT File\n\n```bash\n# Generate .dot file\nvoyager -m tests.demo\n\n# Specify app\nvoyager -m tests.demo --app my_app\n\n# Filter by schema\nvoyager -m tests.demo --schema Task\n\n# Show all fields\nvoyager -m tests.demo --show_fields all\n\n# Custom module colors\nvoyager -m tests.demo --module_color=tests.demo:red --module_color=tests.service:tomato\n\n# Output to file\nvoyager -m tests.demo -o my_visualization.dot\n\n# Version and help\nvoyager --version\nvoyager --help\n```\n\n## About pydantic-resolve\n\npydantic-resolve is a lightweight tool designed to build complex, nested data in a simple, declarative way. It provides `resolve_*` for loading associated data and `post_*` for computing derived fields, with automatic batch loading to eliminate N+1 queries.\n\nWhen relationship definitions start repeating across multiple models, use ER Diagram with `base_entity()` and `__relationships__` to centralize relationship declarations. `DefineSubset` helps safely pick fields from entity classes while preserving ER diagram references.\n\nDevelopers can use fastapi-voyager without needing to know anything about pydantic-resolve, but I still highly recommend everyone to give it a try.\n\n## Development\n\n### Setup Development Environment\n\n```bash\n# Fork and clone the repository\ngit clone https://github.com/your-username/fastapi-voyager.git\ncd fastapi-voyager\n\n# Install uv\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n\n# Create virtual environment and install dependencies\nuv venv\nsource .venv/bin/activate\nuv pip install \".[dev]\"\n\n# Run development server\nuvicorn tests.programatic:app --reload\n```\n\n### Test Different Frameworks\n\nYou can test the framework-specific examples:\n\n```bash\n# FastAPI example\nuvicorn tests.fastapi.embedding:app --reload\n\n# Django Ninja example\nuvicorn tests.django_ninja.embedding:app --reload\n\n# Litestar example\nuvicorn tests.litestar.embedding:asgi_app --reload\n```\n\nVisit `http://localhost:8000/voyager` to see changes.\n\n### Setup Git Hooks (Optional)\n\nEnable automatic code formatting before commits:\n\n```bash\n./setup-hooks.sh\n# or manually:\ngit config core.hooksPath .githooks\n```\n\nThis will run Prettier automatically before each commit. See [`.githooks/README.md`](./.githooks/README.md) for details.\n\n### Project Structure\n\n**Frontend:**\n- `src/fastapi_voyager/web/vue-main.js` - Main JavaScript entry\n\n**Backend:**\n- `voyager.py` - Main entry point\n- `render.py` - Generate DOT files\n- `server.py` - Server mode\n\n## Roadmap\n\n- [Ideas](./docs/idea.md)\n- [Changelog & Roadmap](./docs/changelog.md)\n\n## Dependencies\n\n- [pydantic-resolve](https://github.com/allmonday/pydantic-resolve)\n- [Quasar Framework](https://quasar.dev/)\n### Dev dependencies\n- [FastAPI](https://fastapi.tiangolo.com/)\n- [Django Ninja](https://django-ninja.rest-framework.com/)\n- [Litestar](https://litestar.dev/)\n\n## Credits\n\n- [graphql-voyager](https://apis.guru/graphql-voyager/) - Thanks for inspiration\n- [vscode-interactive-graphviz](https://github.com/tintinweb/vscode-interactive-graphviz) - Thanks for web visualization\n\n## License\n\nMIT License\n"
  },
  {
    "path": "docs/changelog.md",
    "content": "# Changelog & plan\n\n## <0.9:\n- [x] group schemas by module hierarchy\n- [x] module-based coloring via Analytics(module_color={...})\n- [x] view in web browser\n    - [x] config params\n    - [x] make a explorer dashboard, provide list of routes, schemas, to make it easy to switch and search\n- [x] support programmatic usage\n- [x] better schema /router node appearance\n- [x] hide fields duplicated with parent's (show `parent fields` instead)\n- [x] refactor the frontend to vue, and tweak the build process\n- [x] find dependency based on picked schema and it's field.\n- [x] optimize static resource (cdn -> local)\n- [x] add configuration for highlight (optional)\n- [x] alt+click to show field details\n- [x] display source code of routes (including response_model)\n- [x] handle excluded field \n- [x] add tooltips\n- [x] route\n    - [x] group routes by module hierarchy\n    - [x] add response_model in route\n- [x] fixed left bar show tag/ route\n- [x] export voyager core data into json (for better debugging)\n    - [x] add api to rebuild core data from json, and render it\n- [x] fix Generic case  `test_generic.py`\n- [x] show tips for routes not return pydantic type.\n- [x] fix duplicated link from class and parent class, it also break clicking highlight\n- [x] refactor: abstract render module\n\n## 0.9\n- [x] refactor: server.py\n    - [x] rename create_app_with_fastapi -> create_voyager\n    - [x] add doc for parameters\n- [x] improve initialization time cost\n    - [x] query route / schema info through realtime api\n    - [x] adjust fe\n- 0.9.3\n    - [x] adjust layout \n        - [x] show field detail in right panel\n        - [x] show route info in bottom\n- 0.9.4\n    - [x] close schema sidebar when switch tag/route\n    - [x] schema detail panel show fields by default\n    - [x] adjust schema panel's height\n    - [x] show from base information in subset case\n- 0.9.5\n    - [x] route list should have a max height \n\n## 0.10\n- 0.10.1\n    - [x] refactor voyager.py tag -> route structure\n    - [x] fix missing route (tag has only one route which return primitive value)\n    - [x] make right panel resizable by dragging\n    - [x] allow closing tag expansion item\n    - [x] hide brief mode if not configured\n    - [x] add focus button to only show related nodes under current route/tag graph in dialog\n- 0.10.2\n    - [x] fix graph height\n    - [x] show version in title\n- 0.10.3\n    - [x] fix focus in brief-mode\n    - [x] ui: adjust focus position\n    - [x] refactor naming\n    - [x] fix layout issue when rendering huge graph\n- 0.10.4\n    - [x] fix: when focus is on, should ensure changes from other params not broken.\n- 0.10.5\n    - [x] double click to show details, and highlight as tomato\n    \n\n## 0.11\n- 0.11.1\n    - [x] support opening route in swagger\n        - [x] config docs path\n    - [x] provide option to hide routes in brief mode (auto hide in full graph mode)\n- 0.11.2\n    - [x] enable/disable module cluster  (to save space)\n- 0.11.3\n    - [x] support online repo url\n- 0.11.4\n    - [x] add loading for field detail panel\n- 0.11.5\n    - [x] optimize open in swagger link\n    - [x] change jquery cdn\n- 0.11.6\n    - [x] flag of loading full graph in first render or not\n    - [x] optimize loading static resource \n- 0.11.7\n    - [x] fix swagger link\n- 0.11.8\n    - [x] fix swagger link in another way\n- 0.11.9\n    - [x] replace issubclass with safe_issubclass to prevent exception.\n- 0.11.10\n    - [x] fix bug during updating forward refs\n- 0.11.11\n    - [x] replace print with logging and add `--log-level` in cli, by default info\n    - [x] fill node title color with module color\n    - [x] optimize cluster render logic\n\n## 0.12\n- 0.12.1\n    - [x] sort tag / route names in left panel\n    - [x] display schema name on top of detail panel\n    - [x] optimize dbclick style\n    - [x] persist the tag/ route in url\n- 0.12.2\n    - [x] add google analytics\n- 0.12.3\n    - [x] fix bug in `update_forward_refs`, class should not be skipped if it's parent class has been visited.\n- 0.12.4\n    - [x] fix logger exception \n- 0.12.5\n    - [x] fix nested cluster with same color\n    - [x] refactor fe with store based on reactive\n    - [x] fix duplicated focus toggle\n- 0.12.6\n    - [x] fix overlapped edges\n    - [x] click link(edge) to highlight related nodes\n    - [x] on hover cursor effect\n- 0.12.7\n    - [x] remove search component, integrated into main page\n- 0.12.8\n    - [x] optimize ui elements, change icons, update reset behavior\n- 0.12.9\n    - [x] fix: handle logging exception for forward ref info, preventing crash\n- 0.12.10\n    - [x] fix: double trigger on reset search\n- 0.12.11\n    - [x] better ui for schema select\n    - [x] fix: pick tag and then pick route directly from another tag will render nothing\n    - [x] feat: cancel search schema triggered by shift click will redirect back to previous tag, route selection\n    - [x] optimize the node style\n- 0.12.12\n    - [x] disable `show module cluster` by default\n\n## 0.13\n- 0.13.0\n    - [x] if er diagram is provided, show it first.\n- 0.13.1\n    - [x] show more details in er diagram\n- 0.13.2\n    - [x] show dashed line for link without dataloader\n- 0.13.3\n    - [x] show field description\n\n## 0.14, integration with pydantic-resolve\n- 0.14.0\n    - [x] show hint for resolve (>), post fields (<), post default handler (* at title)\n    - [x] show expose and collect info\n- 0.14.1\n    - [x] minor ui enhancement\n\n## 0.15, internal refactor\n- 0.15.0\n    - [x] refactor render.py\n- 0.15.1\n    - [x] add prettier (npx prettier --write .) and pre-commit hooks\n    - [x] add localstorage for toggle items\n    - [x] refactor er diagram renderer\n    - [x] fix error in search function\n- 0.15.2\n    - [x] fix resetSearch issue: fail to go back previous tag/router after reset.\n    - [x] left panel can be toggled.\n- 0.15.3\n    - [x] refactor vue-main.js, move methods to store\n    - [x] optimize search flow\n- 0.15.4\n    - [x] static files cache buster \n    - [x] store voyager/erd toggle value in url query string\n    - [x] set highlight style\n- 0.15.5\n    - [x] fix loadInitial bug\n- 0.15.6\n    - [x] internal refactor: graph-ui.js\n    - [x] enhance the selected and unselected node & edges\n\n## 0.16\n- 0.16.0alpha-1\n    - [x] support django ninja and litestar\n- 0.16.0alpha-2\n    - [x] fix import error\n- 0.16.0alpha-3\n    - [x] fix voyager cli, add web parameter\n- 0.16.1\n    - [x] improve litestar support\n\n## 0.17, enhance er diagram\n- 0.17.0\n    - [x] 1.different theme color for frameworks\n        - fastapi, keep current\n        - django-ninja, #4cae4f\n        - litestar, rgb(237, 182, 65)\n    - [x] 2.highight entity classes\n        - enable if er diagram is enabled\n        - entities in er diagram should be labeled as \"Entity\" after the title, and title should be bold\n    - [x] 3.click esc to cancel search\n- 0.17.1\n    - [x] add magnification slider to adjust magnifying glass zoom level (2x-5x)\n    - [x] refactor magnifying glass module\n        - fix magnification offset issue when value changes\n        - optimize performance with content caching (reduce 90%+ DOM operations)\n        - add parameter validation and error handling\n        - extract constants and eliminate code redundancy\n        - add configurable debug logging\n    - [x] change double-click highlight color to orange (#FF8C00)\n    - [x] set minimum width for schema nodes (100px) to prevent narrow display\n- 0.17.2\n    - [x] enable PWA\n- 0.17.3\n    - [x] fix unstable size of magnification effect.\n    - [x] 1.show loader name\n\n## 0.18\n- 0.18.0\n    - [x] show query and mutation method info in er diagram.\n\n## 0.19\n- 0.19.0\n    - **Breaking Change**: migrate pydantic-resolve v4.0. If you use pydantic-resolve v3, please pin `fastapi-voyager<=0.18`.\n    - show relationship name on ER diagram edges.\n- 0.19.1\n    - [x] fix: handle value type in diagram relationship.\n\n## 0.20\n- 0.20.0\n  - [x] migrate pydantic resolve from v4 to v5\n\n## 0.21\n- 0.21.0\n  - [x] add dataloader info in side bar\n\n## 0.22\n- 0.22.0\n  - [x] optimize er diagram ineraction and highlight\n\n## 0.23\n- 0.23.0\n  - [x] refactor query and mutation methods to standalone functions and integrate with ER diagram\n  - [x] enhance ER diagram data structure and update highlight modes in GraphUI\n  - [x] add edge length configuration for ER diagram (Small/Middle/Large)\n  - [x] preserve highlight state of nodes and edges after re-render\n  - [x] preserve zoom level after re-render (e.g. adjusting edge length)\n  - [x] add toggle to show/hide query and mutation methods in ER diagram\n\n## 0.24\n- 0.24.0\n  - [x] simplify highlight method by removing tooltip handling in GraphUI and GraphvizSvg\n  - [x] update edge click handling in GraphUI and modify onGenerate action in store\n  - [x] upgrade deps and init db\n- 0.24.1\n  - [x] fix: use `safe_issubclass` to prevent `TypeError: issubclass() arg 1 must be a class` on Python 3.13\n    - Python 3.13 raises TypeError when `issubclass()` receives a `types.GenericAlias` (e.g. `dict[X, set[Y]]`), while Python 3.12 silently returns False\n    - Typical trigger: route with PEP 695 type alias as response_model (e.g. `type ResourceActionDict = dict[K, set[V]]`)\n\n## 0.25\n- 0.25.0\n  - [x] migrate frontend from Vue 3 + Quasar (CDN, ~692KB) to Vue 3 + Naive UI (Vite build, tree-shaken ~120KB)\n  - [x] add Vite build pipeline with dev server + HMR and API proxy\n  - [x] add CI Node.js build step in publish workflow\n  - [x] fix NCollapse tag expansion with v-model and accordion mode\n  - [x] fix NSelect schema/field display (remove render-tag, fix filterable conflict)\n  - [x] fix route item icon vertical alignment (flex layout)\n  - [x] fix drawer close button display (use built-in closable prop)\n  - [x] remove SchemaCodeDisplay outer border\n  - [x] switch toggle style to label + switch separated layout\n  - [x] remove edge :e/:w port anchors in DOT template\n\n## 0.26\n- 0.26.0\n  - [x] replace Material Icons with @vicons/ionicons5 (Naive UI native icon solution)\n  - [x] remove Google Fonts (Roboto + Material Icons) dependency, eliminate external font loading\n  - [x] rename CSS variable `--q-primary` to `--primary-color` (remove Quasar legacy naming)\n  - [x] defer Google Analytics script to post-load to avoid blocking page render\n  - [x] remove PWA manifest and Service Worker registration (not needed for dev-tool usage)\n\n## 0.27\n- 0.27.0\n  - [x] fix: include `web/dist/` in wheel via hatch artifacts config (was missing from PyPI wheel)\n\n## unrelease\n- x.x.x\n    - [ ] 2.show relationship list when double click entity in er diagram\n    - [ ] 3.highlight entity in use case\n    - [ ] 4.change cli -m param, use `path.to.module:app` instead.\n\n## 1.0, release \n    - [ ] add tests\n\n## 1.1 future\n\n\n"
  },
  {
    "path": "docs/claude/0_REFACTORING_RENDER_NOTES.md",
    "content": "# Jinja2 模板引擎重构说明\n\n## 概述\n\n已成功将 `render.py` 从硬编码的模板字符串重构为使用 Jinja2 模板引擎的架构。\n\n## 变更内容\n\n### 1. 新增文件\n\n#### `src/fastapi_voyager/render_style.py`\n- **ColorScheme**: 颜色配置类（节点、链接、文本颜色）\n- **GraphvizStyle**: Graphviz 样式配置类（字体、布局、链接样式）\n- **RenderConfig**: 完整的渲染配置类\n\n#### 模板文件\n```\ntemplates/\n├── dot/                     # DOT 格式模板\n│   ├── digraph.j2          # 主图模板\n│   ├── tag_node.j2         # 标签节点\n│   ├── schema_node.j2      # Schema 节点\n│   ├── route_node.j2       # 路由节点\n│   ├── cluster.j2          # 集群模板\n│   ├── cluster_container.j2 # 容器集群\n│   └── link.j2             # 链接模板\n└── html/                    # HTML 格式模板\n    ├── schema_table.j2     # Schema 表格\n    ├── schema_header.j2    # 表格头部\n    ├── schema_field_row.j2 # 字段行\n    ├── pydantic_meta.j2    # Pydantic 元数据\n    └── colored_text.j2     # 彩色文本\n```\n\n### 2. 重构文件\n\n#### `src/fastapi_voyager/render.py`\n- **新增 TemplateRenderer 类**: Jinja2 环境管理和模板渲染\n- **重构 Renderer 类**:\n  - 使用模板渲染替代字符串拼接\n  - 分离关注点（格式化、渲染、配置）\n  - 保持公共 API 不变，向后兼容\n\n### 3. 依赖更新\n\n#### `pyproject.toml`\n```toml\ndependencies = [\n  \"fastapi>=0.110\",\n  \"pydantic-resolve>=2.4.3\",\n  \"jinja2>=3.0.0\"  # 新增\n]\n```\n\n## 架构优势\n\n### 1. **关注点分离**\n- **逻辑层**: Renderer 类处理业务逻辑\n- **视图层**: Jinja2 模板处理格式化\n- **配置层**: render_style.py 管理样式常量\n\n### 2. **可维护性提升**\n- ✅ 模板集中管理，易于查找和修改\n- ✅ 样式常量集中定义\n- ✅ 代码结构更清晰\n\n### 3. **可扩展性**\n- ✅ 支持主题切换（修改 ColorScheme）\n- ✅ 支持自定义配置（注入 RenderConfig）\n- ✅ 易于添加新的节点类型或样式\n\n### 4. **可测试性**\n- ✅ 模板可独立测试\n- ✅ 样式配置可单独验证\n- ✅ 渲染逻辑更清晰\n\n## 向后兼容性\n\n✅ **完全兼容**: Renderer 类的公共接口保持不变：\n- `__init__()` 参数未变（新增可选的 `config` 参数）\n- `render_dot()` 方法签名未变\n- 所有渲染方法保持原有行为\n\n## 使用示例\n\n### 基础使用（无变化）\n```python\nfrom fastapi_voyager.render import Renderer\n\nrenderer = Renderer(\n    show_fields='all',\n    module_color={'myapp.services': 'tomato'}\n)\ndot_output = renderer.render_dot(tags, routes, nodes, links)\n```\n\n### 高级使用（新功能）\n```python\nfrom fastapi_voyager.render import Renderer\nfrom fastapi_voyager.render_style import RenderConfig, ColorScheme, GraphvizStyle\n\n# 自定义颜色主题\ncustom_colors = ColorScheme(\n    primary='#ff6b6b',\n    highlight='#ffd93d'\n)\n\n# 自定义样式\ncustom_style = GraphvizStyle(\n    font='Arial',\n    node_fontsize='14'\n)\n\n# 使用自定义配置\nconfig = RenderConfig(colors=custom_colors, style=custom_style)\n\nrenderer = Renderer(config=config)\ndot_output = renderer.render_dot(tags, routes, nodes, links)\n```\n\n## 测试验证\n\n✅ 所有现有测试通过 (18/18)\n✅ 模板渲染正确\n✅ 向后兼容性验证通过\n✅ 实际应用场景测试通过\n\n## 未来改进建议\n\n1. **模板继承**: 使用 Jinja2 模板继承减少重复\n2. **主题系统**: 预定义多个主题（深色、浅色、高对比度）\n3. **自定义模板**: 支持用户覆盖默认模板\n4. **模板验证**: 添加模板语法检查\n5. **性能优化**: 缓存编译后的模板\n\n## 迁移指南\n\n### 对于项目维护者\n\n无需修改现有代码，但可选地：\n\n1. **自定义样式**:\n   ```python\n   from fastapi_voyager.render_style import RenderConfig, ColorScheme\n\n   config = RenderConfig(\n       colors=ColorScheme(primary='#custom-color')\n   )\n   renderer = Renderer(config=config)\n   ```\n\n2. **修改模板**:\n   编辑 `templates/dot/*.j2` 或 `templates/html/*.j2` 文件\n\n3. **添加新样式**:\n   在 `render_style.py` 中扩展配置类\n\n## 技术细节\n\n### Jinja2 环境配置\n```python\nEnvironment(\n    loader=FileSystemLoader(template_dir),\n    autoescape=select_autoescape(),\n    trim_blocks=True,      # 移除尾随换行符\n    lstrip_blocks=True     # 移除前导空白\n)\n```\n\n### 模板路径解析\n```python\nTEMPLATE_DIR = Path(__file__).parent / \"templates\"\n```\n自动定位到 `src/fastapi_voyager/templates/`\n\n## 常见问题\n\n**Q: 为什么要引入 Jinja2？**\nA: 将视图模板从业务逻辑中分离，提高代码的可维护性和可扩展性。\n\n**Q: 会影响性能吗？**\nA: Jinja2 会编译并缓存模板，性能影响可忽略不计。\n\n**Q: 如何自定义样式？**\nA: 使用 RenderConfig 注入自定义配置，或直接修改 render_style.py。\n\n**Q: 模板语法错误如何调试？**\nA: Jinja2 会提供详细的错误信息，包括行号和上下文。\n\n## 总结\n\n此次重构成功地将散乱的模板字符串集中管理到 Jinja2 模板文件中，并提取了样式配置到专门的模块。这不仅提高了代码的可维护性，也为未来的功能扩展（如主题系统、自定义模板等）奠定了基础。\n\n✅ **任务完成**: 所有计划任务已完成，测试通过，代码已准备就绪。\n"
  },
  {
    "path": "docs/idea.md",
    "content": "# Idea\n\n## backlog\n- [ ] user can generate nodes/edges manually and connect to generated ones\n    - [ ] eg: add owner\n    - [ ] add extra info for schema\n- [ ] optimize static resource (allow manually config url)\n- [ ] improve search dialog\n    - [ ] add route/tag list\n- [ ] type alias should not be kept as node instead of compiling to original type\n- [ ] how to correctly handle the generic type ?\n    - for example `Page[Student]` of `Page[T]` will be marked in `Page[T]`'s module\n- [ ] sort field name in nodes (only table inside right panel)\n- [ ] set max limit for fields in nodes (? need further thinking)\n- [ ] minimap (good to have)\n    - ref: https://observablehq.com/@rabelais/d3-js-zoom-minimap\n- [ ] ~~debug mode~~\n    - [ ] export dot content, load dot content\n- [ ] abstract voyager-core\n    - [ ] support fastapi-voyager\n    - [ ] support django-ninja-voyager\n\n\n## in analysis\n- [ ] upgrade network algorithm (optional, for example networkx)\n- [ ] click field to highlight links or click link to highlight related nodes\n- [ ] animation effect for edges\n- [ ] display standard ER diagram spec. `hard but important`\n    - [ ] display potential invalid links\n    - [ ] highlight relationship belongs to ER diagram\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"fastapi-voyager\"\ndynamic = [\"version\"]\ndescription = \"Visualize FastAPI application's routing tree and dependencies\"\nauthors = [ { name = \"Tangkikodo\", email = \"allmonday@126.com\" } ]\nlicense = { text = \"MIT\" }\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\nkeywords = [\"fastapi\", \"visualization\", \"routing\", \"openapi\"]\ndependencies = [\n  \"pydantic-resolve>=5.1.0\",\n  \"jinja2>=3.0.0\",\n]\nclassifiers = [\n  \"Programming Language :: Python :: 3\",\n  \"Programming Language :: Python :: 3.10\",\n  \"Programming Language :: Python :: 3.11\",\n  \"Programming Language :: Python :: 3.12\",\n  \"Programming Language :: Python :: 3.13\",\n  \"Programming Language :: Python :: 3.14\",\n  \"Framework :: FastAPI\",\n  \"Intended Audience :: Developers\",\n  \"License :: OSI Approved :: MIT License\"\n]\n\n[project.scripts]\nvoyager = \"fastapi_voyager.cli:main\"\n\n[project.urls]\nHomepage = \"https://github.com/allmonday/fastapi-voyager\"\nSource = \"https://github.com/allmonday/fastapi-voyager\"\n\n[project.optional-dependencies]\ndev = [\"ruff\", \"pytest\", \"pytest-asyncio\", \"httpx\"]\nfastapi = [\"fastapi>=0.110\", \"uvicorn\"]\ndjango-ninja = [\"django>=4.2\", \"django-ninja>=1.5.3\", \"uvicorn\"]\nlitestar = [\"litestar>=2.19.0\", \"pydantic>=2.0\", \"uvicorn\"]\nall = [\"fastapi-voyager[dev,fastapi,django-ninja,litestar]\"]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.version]\npath = \"src/fastapi_voyager/version.py\"\n\n[tool.hatch.build.targets.sdist]\nforce-include.\"src/fastapi_voyager/web/dist\" = \"src/fastapi_voyager/web/dist\"\nartifacts = [\"src/fastapi_voyager/web/dist/\"]\n\n[tool.hatch.build.targets.wheel]\nartifacts = [\"src/fastapi_voyager/web/dist/\"]\n\n[tool.uv]\n# You can pin resolution or indexes here later.\n\n[tool.ruff]\nline-length = 100\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"I\", \"UP\", \"B\"]\n\n[dependency-groups]\ndev = [\n    \"aiosqlite>=0.22.1\",\n    \"greenlet>=3.4.0\",\n    \"httpx>=0.28.1\",\n    \"pytest-asyncio>=1.3.0\",\n    \"pytest>=8.0.0\",\n    \"ruff>=0.9.0\",\n    \"sqlalchemy>=2.0.49\",\n]\nfastapi = [\n    \"fastapi>=0.116.1\",\n    \"uvicorn>=0.34.0\",\n]\ndjango-ninja = [\n    \"django>=4.2\",\n    \"django-ninja>=1.5.3\",\n    \"uvicorn>=0.34.0\",\n]\nlitestar = [\n    \"litestar>=2.19.0\",\n    \"pydantic>=2.0\",\n    \"uvicorn>=0.34.0\",\n]\nall = [\n    \"django>=4.2\",\n    \"django-ninja>=1.5.3\",\n    \"fastapi>=0.116.1\",\n    \"litestar>=2.19.0\",\n    \"pydantic>=2.0\",\n    \"uvicorn>=0.34.0\",\n]\n"
  },
  {
    "path": "release.md",
    "content": "release by pushing the tag\n\n```shell\ngit tag v1.0.0\ngit push origin v1.0.0\n```\n\n "
  },
  {
    "path": "setup-django-ninja.sh",
    "content": "#!/bin/bash\n# Django Ninja Development Setup Script\n# Usage: ./setup-django-ninja.sh [--no-sync]\n\nset -e\n\necho \"🚀 Setting up Django Ninja development environment...\"\necho \"\"\n\n# Parse arguments\nSYNC=true\nfor arg in \"$@\"; do\n    case $arg in\n        --no-sync)\n            SYNC=false\n            shift\n            ;;\n    esac\ndone\n\n# Sync dependencies\nif [ \"$SYNC\" = true ]; then\n    echo \"📦 Syncing dependencies...\"\n    uv sync --group dev --group django-ninja\n    echo \"✅ Dependencies synced\"\n    echo \"\"\nfi\n\n# Check if uvicorn is installed\necho \"🔍 Checking uvicorn installation...\"\nif uv run which uvicorn > /dev/null 2>&1; then\n    UVICORN_PATH=$(uv run which uvicorn)\n    echo \"✅ Uvicorn found at: $UVICORN_PATH\"\nelse\n    echo \"❌ Uvicorn not found in project environment\"\n    exit 1\nfi\necho \"\"\n\n# Start Django Ninja server\necho \"🌟 Starting Django Ninja Voyager server...\"\necho \"   App: tests.django_ninja.embedding:application\"\necho \"   URL: http://127.0.0.1:8000\"\necho \"\"\necho \"Press Ctrl+C to stop the server\"\necho \"\"\n\nuv run uvicorn tests.django_ninja.embedding:application --reload --host 127.0.0.1 --port 8000\n"
  },
  {
    "path": "setup-fastapi.sh",
    "content": "#!/bin/bash\n# FastAPI Development Setup Script\n# Usage: ./setup-fastapi.sh [--no-sync]\n\nset -e\n\necho \"🚀 Setting up FastAPI development environment...\"\necho \"\"\n\n# Parse arguments\nSYNC=true\nfor arg in \"$@\"; do\n    case $arg in\n        --no-sync)\n            SYNC=false\n            shift\n            ;;\n    esac\ndone\n\n# Sync dependencies\nif [ \"$SYNC\" = true ]; then\n    echo \"📦 Syncing dependencies...\"\n    uv sync --group dev --group fastapi\n    echo \"✅ Dependencies synced\"\n    echo \"\"\nfi\n\n# Check if uvicorn is installed\necho \"🔍 Checking uvicorn installation...\"\nif uv run which uvicorn > /dev/null 2>&1; then\n    UVICORN_PATH=$(uv run which uvicorn)\n    echo \"✅ Uvicorn found at: $UVICORN_PATH\"\nelse\n    echo \"❌ Uvicorn not found in project environment\"\n    exit 1\nfi\necho \"\"\n\n# Start FastAPI server\necho \"🌟 Starting FastAPI Voyager server...\"\necho \"   App: tests.fastapi.embedding:app\"\necho \"   URL: http://127.0.0.1:8000\"\necho \"\"\necho \"Press Ctrl+C to stop the server\"\necho \"\"\n\nuv run uvicorn tests.fastapi.embedding:app --reload --host 127.0.0.1 --port 8000\n"
  },
  {
    "path": "setup-hooks.sh",
    "content": "#!/bin/bash\n# Setup script for Git hooks\n\necho \"Setting up Git hooks...\"\n\n# Check if we're in a git repository\nif ! git rev-parse --git-dir > /dev/null 2>&1; then\n    echo \"Error: Not a Git repository\"\n    exit 1\nfi\n\n# Set the hooks path\ngit config core.hooksPath .githooks\n\n# Make hooks executable\nchmod +x .githooks/*\n\necho \"✓ Git hooks configured successfully!\"\necho \"\"\necho \"Hooks are now enabled. Prettier will run automatically before each commit.\"\necho \"\"\necho \"To verify:\"\necho \"  git config core.hooksPath\"\n"
  },
  {
    "path": "setup-litestar.sh",
    "content": "#!/bin/bash\n# Litestar Development Setup Script\n# Usage: ./setup-litestar.sh [--no-sync]\n\nset -e\n\necho \"🚀 Setting up Litestar development environment...\"\necho \"\"\n\n# Parse arguments\nSYNC=true\nfor arg in \"$@\"; do\n    case $arg in\n        --no-sync)\n            SYNC=false\n            shift\n            ;;\n    esac\ndone\n\n# Sync dependencies\nif [ \"$SYNC\" = true ]; then\n    echo \"📦 Syncing dependencies...\"\n    uv sync --group dev --group litestar\n    echo \"✅ Dependencies synced\"\n    echo \"\"\nfi\n\n# Check if uvicorn is installed\necho \"🔍 Checking uvicorn installation...\"\nif uv run which uvicorn > /dev/null 2>&1; then\n    UVICORN_PATH=$(uv run which uvicorn)\n    echo \"✅ Uvicorn found at: $UVICORN_PATH\"\nelse\n    echo \"❌ Uvicorn not found in project environment\"\n    exit 1\nfi\necho \"\"\n\n# Start Litestar server\necho \"🌟 Starting Litestar Voyager server...\"\necho \"   App: tests.litestar.embedding:app\"\necho \"   URL: http://127.0.0.1:8000\"\necho \"\"\necho \"Press Ctrl+C to stop the server\"\necho \"\"\n\nuv run uvicorn tests.litestar.embedding:app --reload --host 127.0.0.1 --port 8000\n"
  },
  {
    "path": "src/fastapi_voyager/__init__.py",
    "content": "\"\"\"fastapi_voyager\n\nUtilities to introspect web applications and visualize their routing tree.\n\"\"\"\nfrom .server import create_voyager\nfrom .version import __version__  # noqa: F401\n\n__all__ = [ \"__version__\", \"create_voyager\" ]\n"
  },
  {
    "path": "src/fastapi_voyager/adapters/__init__.py",
    "content": "\"\"\"\nFramework adapters for fastapi-voyager.\n\nThis module provides adapters that allow voyager to work with different web frameworks.\n\"\"\"\nfrom fastapi_voyager.adapters.base import VoyagerAdapter\nfrom fastapi_voyager.adapters.django_ninja_adapter import DjangoNinjaAdapter\nfrom fastapi_voyager.adapters.fastapi_adapter import FastAPIAdapter\nfrom fastapi_voyager.adapters.litestar_adapter import LitestarAdapter\n\n__all__ = [\n    \"VoyagerAdapter\",\n    \"FastAPIAdapter\",\n    \"DjangoNinjaAdapter\",\n    \"LitestarAdapter\",\n]\n"
  },
  {
    "path": "src/fastapi_voyager/adapters/base.py",
    "content": "\"\"\"\nBase adapter interface for framework-agnostic voyager server.\n\nThis module defines the abstract interface that all framework adapters must implement.\n\"\"\"\nfrom abc import ABC, abstractmethod\nfrom typing import Any\n\n\nclass VoyagerAdapter(ABC):\n    \"\"\"\n    Abstract base class for framework-specific voyager adapters.\n\n    Each adapter is responsible for:\n    1. Creating routes/endpoints for the voyager UI\n    2. Handling HTTP requests and responses in a framework-specific way\n    3. Returning an object that can be mounted/integrated with the target app\n    \"\"\"\n\n    @abstractmethod\n    def create_app(self) -> Any:\n        \"\"\"\n        Create and return a framework-specific application object.\n\n        The returned object should be mountable/integrable with the target framework.\n        For example:\n        - FastAPI: returns a FastAPI app\n        - Django Ninja: returns an ASGI application\n        - Litestar: returns a Litestar app\n\n        Returns:\n            A framework-specific application object\n        \"\"\"\n        pass\n"
  },
  {
    "path": "src/fastapi_voyager/adapters/common.py",
    "content": "\"\"\"\nShared business logic for voyager endpoints.\n\nThis module contains the core logic that is reused across all framework adapters.\n\"\"\"\nfrom pathlib import Path\nfrom typing import Any\n\nfrom pydantic_resolve import ErDiagram\n\nfrom fastapi_voyager.er_diagram import VoyagerErDiagram\nfrom fastapi_voyager.introspectors.detector import FrameworkType, detect_framework\nfrom fastapi_voyager.render import Renderer\nfrom fastapi_voyager.render_style import RenderConfig\nfrom fastapi_voyager.type import CoreData, SchemaNode, Tag\nfrom fastapi_voyager.type_helper import get_source, get_vscode_link\nfrom fastapi_voyager.version import __version__\nfrom fastapi_voyager.voyager import Voyager\n\nWEB_DIR = Path(__file__).parent.parent / \"web\"\nWEB_DIR.mkdir(exist_ok=True)\n\nSTATIC_FILES_PATH = \"/fastapi-voyager-static\"\n\nGA_PLACEHOLDER = \"<!-- GA_SNIPPET -->\"\nVERSION_PLACEHOLDER = \"<!-- VERSION_PLACEHOLDER -->\"\nSTATIC_PATH_PLACEHOLDER = \"<!-- STATIC_PATH -->\"\nTHEME_COLOR_PLACEHOLDER = \"<!-- THEME_COLOR -->\"\nVOYAGER_PATH_PLACEHOLDER = \"<!-- VOYAGER_PATH -->\"\n\n\ndef build_ga_snippet(ga_id: str | None) -> str:\n    \"\"\"Build Google Analytics snippet.\"\"\"\n    if not ga_id:\n        return \"\"\n\n    return f\"\"\"    <script>\n      window.addEventListener('load', function() {{\n        var s = document.createElement('script');\n        s.src = 'https://www.googletagmanager.com/gtag/js?id={ga_id}';\n        s.async = true;\n        document.head.appendChild(s);\n        window.dataLayer = window.dataLayer || [];\n        function gtag(){{dataLayer.push(arguments);}}\n        gtag('js', new Date());\n        gtag('config', '{ga_id}');\n      }});\n    </script>\n\"\"\"\n\n\nclass VoyagerContext:\n    \"\"\"\n    Context object that holds configuration and provides business logic methods.\n\n    This is shared across all framework adapters to avoid code duplication.\n    \"\"\"\n\n    def __init__(\n        self,\n        target_app: Any,\n        module_color: dict[str, str] | None = None,\n        module_prefix: str | None = None,\n        swagger_url: str | None = None,\n        online_repo_url: str | None = None,\n        initial_page_policy: str = 'first',\n        ga_id: str | None = None,\n        er_diagram: ErDiagram | None = None,\n        enable_pydantic_resolve_meta: bool = False,\n        framework_name: str | None = None,\n    ):\n        self.target_app = target_app\n        self.module_color = module_color or {}\n        self.module_prefix = module_prefix\n        self.swagger_url = swagger_url\n        self.online_repo_url = online_repo_url\n        self.initial_page_policy = initial_page_policy\n        self.ga_id = ga_id\n        self.er_diagram = er_diagram\n        self.enable_pydantic_resolve_meta = enable_pydantic_resolve_meta\n\n        # Detect and store framework type (single source of truth)\n        self._framework_type = detect_framework(target_app)\n        # Display name for frontend (backward compatible)\n        self.framework_name = framework_name or self._get_display_name()\n\n    def _get_display_name(self) -> str:\n        \"\"\"Get display name for the detected framework type.\"\"\"\n        display_names = {\n            FrameworkType.FASTAPI: \"FastAPI\",\n            FrameworkType.DJANGO_NINJA: \"Django Ninja\",\n            FrameworkType.LITESTAR: \"Litestar\",\n        }\n        return display_names.get(self._framework_type, \"API\")\n\n    def _get_theme_color(self) -> str:\n        \"\"\"Get theme color for the current framework.\"\"\"\n        config = RenderConfig()\n        return config.colors.get_framework_color(self._framework_type)\n\n    def _get_entity_class_names(self) -> set[str] | None:\n        \"\"\"Extract entity class names from er_diagram.\"\"\"\n        if not self.er_diagram:\n            return None\n\n        from fastapi_voyager.type_helper import full_class_name\n\n        return {\n            full_class_name(entity.kls)\n            for entity in self.er_diagram.entities\n        }\n\n    def get_voyager(self, **kwargs) -> Voyager:\n        \"\"\"Create a Voyager instance with common configuration.\"\"\"\n        config = {\n            \"module_color\": self.module_color,\n            \"show_pydantic_resolve_meta\": self.enable_pydantic_resolve_meta,\n            \"theme_color\": self._get_theme_color(),\n            \"entity_class_names\": self._get_entity_class_names(),\n        }\n        config.update(kwargs)\n        return Voyager(**config)\n\n    def analyze_and_get_dot(self) -> tuple[str, list[Tag], list[SchemaNode]]:\n        \"\"\"\n        Analyze the target app and return dot graph, tags, and schemas.\n\n        Returns:\n            Tuple of (dot_graph, tags, schemas)\n        \"\"\"\n        voyager = self.get_voyager()\n        voyager.analysis(self.target_app)\n        dot = voyager.render_dot()\n\n        # include tags and their routes\n        tags = voyager.tags\n        for t in tags:\n            t.routes.sort(key=lambda r: r.name)\n        tags.sort(key=lambda t: t.name)\n\n        schemas = voyager.nodes[:]\n        schemas.sort(key=lambda s: s.name)\n\n        return dot, tags, schemas\n\n    def get_option_param(self) -> dict:\n        \"\"\"Get the option parameter for the voyager UI.\"\"\"\n        dot, tags, schemas = self.analyze_and_get_dot()\n\n        return {\n            \"tags\": tags,\n            \"schemas\": schemas,\n            \"dot\": dot,\n            \"enable_brief_mode\": bool(self.module_prefix),\n            \"version\": __version__,\n            \"swagger_url\": self.swagger_url,\n            \"initial_page_policy\": self.initial_page_policy,\n            \"has_er_diagram\": self.er_diagram is not None,\n            \"enable_pydantic_resolve_meta\": self.enable_pydantic_resolve_meta,\n            \"framework_name\": self.framework_name,\n        }\n\n    def get_search_dot(self, payload: dict) -> list[Tag]:\n        \"\"\"Get filtered tags for search.\"\"\"\n        voyager = self.get_voyager(\n            schema=payload.get(\"schema_name\"),\n            schema_field=payload.get(\"schema_field\"),\n            show_fields=payload.get(\"show_fields\", \"object\"),\n            hide_primitive_route=payload.get(\"hide_primitive_route\", False),\n            show_module=payload.get(\"show_module\", True),\n            show_pydantic_resolve_meta=payload.get(\"show_pydantic_resolve_meta\", False),\n        )\n        voyager.analysis(self.target_app)\n        tags = voyager.calculate_filtered_tag_and_route()\n\n        for t in tags:\n            t.routes.sort(key=lambda r: r.name)\n        tags.sort(key=lambda t: t.name)\n\n        return tags\n\n    def get_filtered_dot(self, payload: dict) -> str:\n        \"\"\"Get filtered dot graph.\"\"\"\n        voyager = self.get_voyager(\n            include_tags=payload.get(\"tags\"),\n            schema=payload.get(\"schema_name\"),\n            schema_field=payload.get(\"schema_field\"),\n            show_fields=payload.get(\"show_fields\", \"object\"),\n            route_name=payload.get(\"route_name\"),\n            hide_primitive_route=payload.get(\"hide_primitive_route\", False),\n            show_module=payload.get(\"show_module\", True),\n            show_pydantic_resolve_meta=payload.get(\"show_pydantic_resolve_meta\", False),\n        )\n        voyager.analysis(self.target_app)\n\n        if payload.get(\"brief\"):\n            if payload.get(\"tags\"):\n                return voyager.render_tag_level_brief_dot(module_prefix=self.module_prefix)\n            else:\n                return voyager.render_overall_brief_dot(module_prefix=self.module_prefix)\n        else:\n            return voyager.render_dot()\n\n    def get_core_data(self, payload: dict) -> CoreData:\n        \"\"\"Get core data for the graph.\"\"\"\n        voyager = self.get_voyager(\n            include_tags=payload.get(\"tags\"),\n            schema=payload.get(\"schema_name\"),\n            schema_field=payload.get(\"schema_field\"),\n            show_fields=payload.get(\"show_fields\", \"object\"),\n            route_name=payload.get(\"route_name\"),\n        )\n        voyager.analysis(self.target_app)\n        return voyager.dump_core_data()\n\n    def render_dot_from_core_data(self, core_data: CoreData) -> str:\n        \"\"\"Render dot graph from core data.\"\"\"\n        renderer = Renderer(\n            show_fields=core_data.show_fields,\n            module_color=core_data.module_color,\n            schema=core_data.schema,\n            theme_color=self._get_theme_color(),\n        )\n        return renderer.render_dot(\n            core_data.tags, core_data.routes, core_data.nodes, core_data.links\n        )\n\n    def get_er_diagram_dot(self, payload: dict) -> str:\n        \"\"\"Get ER diagram dot graph.\"\"\"\n        if self.er_diagram:\n            return VoyagerErDiagram(\n                self.er_diagram,\n                show_fields=payload.get(\"show_fields\", \"object\"),\n                show_module=payload.get(\"show_module\", True),\n                theme_color=self._get_theme_color(),\n            ).render_dot()\n        return \"\"\n\n    def get_er_diagram_data(self, payload: dict) -> dict:\n        \"\"\"Get ER diagram dot graph and link metadata.\"\"\"\n        if not self.er_diagram:\n            return {\"dot\": \"\", \"links\": [], \"schemas\": []}\n        edge_minlen = max(3, min(10, payload.get(\"edge_minlen\", 3)))\n        diagram = VoyagerErDiagram(\n            self.er_diagram,\n            show_fields=payload.get(\"show_fields\", \"object\"),\n            show_module=payload.get(\"show_module\", True),\n            theme_color=self._get_theme_color(),\n            edge_minlen=edge_minlen,\n            show_methods=payload.get(\"show_methods\", True),\n        )\n        dot = diagram.render_dot()\n        links_meta = [\n            {\n                \"source_origin\": link.source_origin,\n                \"target_origin\": link.target_origin,\n                \"label\": link.label,\n                \"loader_fullname\": link.loader_fullname,\n            }\n            for link in diagram.links\n        ]\n        schemas_meta = [\n            {\n                \"id\": node.id,\n                \"name\": node.name,\n                \"module\": node.module,\n                \"fields\": [\n                    {\n                        \"name\": f.name,\n                        \"type_name\": f.type_name,\n                        \"from_base\": f.from_base,\n                        \"is_object\": f.is_object,\n                        \"is_exclude\": f.is_exclude,\n                        \"desc\": f.desc,\n                    }\n                    for f in node.fields\n                ],\n            }\n            for node in diagram.node_set.values()\n        ]\n        return {\"dot\": dot, \"links\": links_meta, \"schemas\": schemas_meta}\n\n    def get_index_html(self) -> str:\n        \"\"\"Get the index HTML content.\"\"\"\n        # Prefer built (dist) version, fall back to source index.html\n        index_file = WEB_DIR / \"dist\" / \"index.html\"\n        if not index_file.exists():\n            index_file = WEB_DIR / \"index.html\"\n        if index_file.exists():\n            content = index_file.read_text(encoding=\"utf-8\")\n            content = content.replace(GA_PLACEHOLDER, build_ga_snippet(self.ga_id))\n            content = content.replace(VERSION_PLACEHOLDER, f\"?v={__version__}\")\n            # Replace static files path placeholder with actual path (without leading slash)\n            content = content.replace(STATIC_PATH_PLACEHOLDER, STATIC_FILES_PATH.lstrip(\"/\"))\n            # Fix Vite absolute asset paths to be relative (for sub-app mounting)\n            content = content.replace(f\"{STATIC_FILES_PATH}/dist/\", f\"{STATIC_FILES_PATH.lstrip('/')}/dist/\")\n            # Replace theme color placeholder with framework-specific color\n            content = content.replace(THEME_COLOR_PLACEHOLDER, self._get_theme_color())\n            return content\n        # fallback simple page if index.html missing\n        return \"\"\"\n        <!doctype html>\n        <html>\n        <head><meta charset=\"utf-8\"><title>Graphviz Preview</title></head>\n        <body>\n          <p>index.html not found. Create one under src/fastapi_voyager/web/index.html</p>\n        </body>\n        </html>\n        \"\"\"\n\n    def get_source_code(self, schema_name: str) -> dict:\n        \"\"\"Get source code for a schema.\"\"\"\n        try:\n            components = schema_name.split(\".\")\n            if len(components) < 2:\n                return {\"error\": \"Invalid schema name format. Expected format: module.ClassName\"}\n\n            module_name = \".\".join(components[:-1])\n            class_name = components[-1]\n\n            mod = __import__(module_name, fromlist=[class_name])\n            obj = getattr(mod, class_name)\n            source_code = get_source(obj)\n\n            return {\"source_code\": source_code}\n        except ImportError as e:\n            return {\"error\": f\"Module not found: {e}\"}\n        except AttributeError as e:\n            return {\"error\": f\"Class not found: {e}\"}\n        except Exception as e:\n            return {\"error\": f\"Internal error: {str(e)}\"}\n\n    def get_vscode_link(self, schema_name: str) -> dict:\n        \"\"\"Get VSCode link for a schema.\"\"\"\n        try:\n            components = schema_name.split(\".\")\n            if len(components) < 2:\n                return {\"error\": \"Invalid schema name format. Expected format: module.ClassName\"}\n\n            module_name = \".\".join(components[:-1])\n            class_name = components[-1]\n\n            mod = __import__(module_name, fromlist=[class_name])\n            obj = getattr(mod, class_name)\n            link = get_vscode_link(obj, online_repo_url=self.online_repo_url)\n\n            return {\"link\": link}\n        except ImportError as e:\n            return {\"error\": f\"Module not found: {e}\"}\n        except AttributeError as e:\n            return {\"error\": f\"Class not found: {e}\"}\n        except Exception as e:\n            return {\"error\": f\"Internal error: {str(e)}\"}\n\n    def get_service_worker(self) -> str:\n        \"\"\"Get the Service Worker JavaScript content with placeholders replaced.\"\"\"\n        sw_file = WEB_DIR / \"sw.js\"\n        if sw_file.exists():\n            content = sw_file.read_text(encoding=\"utf-8\")\n            content = content.replace(VERSION_PLACEHOLDER, __version__)\n            content = content.replace(STATIC_PATH_PLACEHOLDER, STATIC_FILES_PATH.lstrip(\"/\"))\n            return content\n        return \"\"\n\n    def get_manifest(self) -> str:\n        \"\"\"Get the PWA manifest JSON content with placeholders replaced.\"\"\"\n        manifest_file = WEB_DIR / \"icon\" / \"site.webmanifest\"\n        if manifest_file.exists():\n            content = manifest_file.read_text(encoding=\"utf-8\")\n            # VOYAGER_PATH will be replaced with the voyager mount path (e.g., \"/voyager/\")\n            # This is set by adapters based on how they are mounted\n            content = content.replace(THEME_COLOR_PLACEHOLDER, self._get_theme_color())\n            return content\n        return \"{}\"\n"
  },
  {
    "path": "src/fastapi_voyager/adapters/django_ninja_adapter.py",
    "content": "\"\"\"\nDjango Ninja adapter for fastapi-voyager.\n\nThis module provides the Django Ninja-specific implementation of the voyager server.\nIt creates an ASGI application that can be integrated with Django.\n\"\"\"\nimport json\nimport mimetypes\nfrom typing import Any\n\nfrom fastapi_voyager.adapters.base import VoyagerAdapter\nfrom fastapi_voyager.adapters.common import STATIC_FILES_PATH, VOYAGER_PATH_PLACEHOLDER, WEB_DIR, VoyagerContext\nfrom fastapi_voyager.type import CoreData, SchemaNode, Tag\n\n\nclass DjangoNinjaAdapter(VoyagerAdapter):\n    \"\"\"\n    Django Ninja-specific implementation of VoyagerAdapter.\n\n    Creates an ASGI application with voyager endpoints that can be integrated with Django.\n    \"\"\"\n\n    def __init__(\n        self,\n        target_app: Any,\n        module_color: dict[str, str] | None = None,\n        gzip_minimum_size: int | None = 500,\n        module_prefix: str | None = None,\n        swagger_url: str | None = None,\n        online_repo_url: str | None = None,\n        initial_page_policy: str = \"first\",\n        ga_id: str | None = None,\n        er_diagram: Any = None,\n        enable_pydantic_resolve_meta: bool = False,\n        server_mode: bool = False,\n    ):\n        self.ctx = VoyagerContext(\n            target_app=target_app,\n            module_color=module_color,\n            module_prefix=module_prefix,\n            swagger_url=swagger_url,\n            online_repo_url=online_repo_url,\n            initial_page_policy=initial_page_policy,\n            ga_id=ga_id,\n            er_diagram=er_diagram,\n            enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,\n            framework_name=\"Django Ninja\",\n        )\n        self.server_mode = server_mode\n        # Note: gzip should be handled by Django's middleware, not here\n\n    async def _handle_request(self, scope, receive, send):\n        \"\"\"ASGI request handler.\"\"\"\n        if scope[\"type\"] != \"http\":\n            return\n\n        # Parse the request\n        method = scope[\"method\"]\n        path = scope[\"path\"]\n        # Remove /voyager prefix for internal routing (unless in server_mode)\n        if not self.server_mode and path.startswith(\"/voyager\"):\n            path = path[8:]  # Remove '/voyager'\n            if path == \"\":\n                path = \"/\"\n\n        # Handle static files\n        if method == \"GET\" and path.startswith(f\"{STATIC_FILES_PATH}/\"):\n            await self._handle_static_file(path, send)\n            return\n\n        # Route the request\n        if method == \"GET\" and path == \"/\":\n            await self._handle_index(send)\n        elif method == \"GET\" and path == \"/sw.js\":\n            await self._handle_service_worker(send)\n        elif method == \"GET\" and path == \"/manifest.webmanifest\":\n            await self._handle_manifest(send)\n        elif method == \"GET\" and path == \"/dot\":\n            await self._handle_get_dot(send)\n        elif method == \"POST\" and path == \"/er-diagram\":\n            await self._handle_post_request(receive, send, self._handle_er_diagram)\n        elif method == \"POST\" and path == \"/dot-search\":\n            await self._handle_post_request(receive, send, self._handle_search_dot)\n        elif method == \"POST\" and path == \"/dot\":\n            await self._handle_post_request(receive, send, self._handle_filtered_dot)\n        elif method == \"POST\" and path == \"/dot-core-data\":\n            await self._handle_post_request(receive, send, self._handle_core_data)\n        elif method == \"POST\" and path == \"/dot-render-core-data\":\n            await self._handle_post_request(receive, send, self._handle_render_core_data)\n        elif method == \"POST\" and path == \"/source\":\n            await self._handle_post_request(receive, send, self._handle_source)\n        elif method == \"POST\" and path == \"/vscode-link\":\n            await self._handle_post_request(receive, send, self._handle_vscode_link)\n        else:\n            await self._send_404(send)\n\n    async def _handle_post_request(self, receive, send, handler):\n        \"\"\"Helper to handle POST requests with JSON body.\"\"\"\n        body = b\"\"\n        more_body = True\n\n        while more_body:\n            message = await receive()\n            if message[\"type\"] == \"http.request\":\n                body += message.get(\"body\", b\"\")\n                more_body = message.get(\"more_body\", False)\n\n        try:\n            payload = json.loads(body.decode())\n            await handler(payload, send)\n        except Exception as e:\n            await self._send_json({\"error\": str(e)}, send, status_code=400)\n\n    async def _handle_static_file(self, path: str, send):\n        \"\"\"Handle GET {STATIC_FILES_PATH}/* - serve static files.\"\"\"\n        # Remove /fastapi-voyager-static/ prefix\n        prefix = f\"{STATIC_FILES_PATH}/\"\n        file_path = path[len(prefix):]\n        full_path = WEB_DIR / file_path\n\n        # Security check: ensure the path is within WEB_DIR\n        try:\n            full_path = full_path.resolve()\n            web_dir_resolved = WEB_DIR.resolve()\n            if not str(full_path).startswith(str(web_dir_resolved)):\n                await self._send_404(send)\n                return\n        except Exception:\n            await self._send_404(send)\n            return\n\n        if not full_path.exists() or not full_path.is_file():\n            await self._send_404(send)\n            return\n\n        # Read file content\n        try:\n            with open(full_path, \"rb\") as f:\n                content = f.read()\n\n            # Determine content type\n            content_type, _ = mimetypes.guess_type(str(full_path))\n            if content_type is None:\n                content_type = \"application/octet-stream\"\n\n            await self._send_response(content_type, content, send)\n        except Exception:\n            await self._send_404(send)\n\n    async def _handle_index(self, send):\n        \"\"\"Handle GET / - return the index HTML.\"\"\"\n        html = self.ctx.get_index_html()\n        await self._send_html(html, send)\n\n    async def _handle_service_worker(self, send):\n        \"\"\"Handle GET /sw.js - return the Service Worker.\"\"\"\n        sw_content = self.ctx.get_service_worker()\n        await self._send_response(\n            \"application/javascript\",\n            sw_content.encode(\"utf-8\"),\n            send,\n        )\n\n    async def _handle_manifest(self, send):\n        \"\"\"Handle GET /manifest.webmanifest - return the PWA manifest.\"\"\"\n        content = self.ctx.get_manifest()\n        content = content.replace(VOYAGER_PATH_PLACEHOLDER, \"./\")\n        await self._send_response(\n            \"application/manifest+json\",\n            content.encode(\"utf-8\"),\n            send,\n        )\n\n    async def _handle_get_dot(self, send):\n        \"\"\"Handle GET /dot - return options and initial dot graph.\"\"\"\n        data = self.ctx.get_option_param()\n        # Convert tags and schemas to dicts for JSON serialization\n        response_data = {\n            \"tags\": [self._tag_to_dict(t) for t in data[\"tags\"]],\n            \"schemas\": [self._schema_to_dict(s) for s in data[\"schemas\"]],\n            \"dot\": data[\"dot\"],\n            \"enable_brief_mode\": data[\"enable_brief_mode\"],\n            \"version\": data[\"version\"],\n            \"initial_page_policy\": data[\"initial_page_policy\"],\n            \"swagger_url\": data[\"swagger_url\"],\n            \"has_er_diagram\": data[\"has_er_diagram\"],\n            \"enable_pydantic_resolve_meta\": data[\"enable_pydantic_resolve_meta\"],\n            \"framework_name\": data[\"framework_name\"],\n        }\n        await self._send_json(response_data, send)\n\n    async def _handle_er_diagram(self, payload, send):\n        \"\"\"Handle POST /er-diagram.\"\"\"\n        data = self.ctx.get_er_diagram_data(payload)\n        await self._send_json(data, send)\n\n    async def _handle_search_dot(self, payload, send):\n        \"\"\"Handle POST /dot-search.\"\"\"\n        tags = self.ctx.get_search_dot(payload)\n        response_data = {\"tags\": [self._tag_to_dict(t) for t in tags]}\n        await self._send_json(response_data, send)\n\n    async def _handle_filtered_dot(self, payload, send):\n        \"\"\"Handle POST /dot.\"\"\"\n        dot = self.ctx.get_filtered_dot(payload)\n        await self._send_text(dot, send)\n\n    async def _handle_core_data(self, payload, send):\n        \"\"\"Handle POST /dot-core-data.\"\"\"\n        core_data = self.ctx.get_core_data(payload)\n        await self._send_json(core_data.model_dump(), send)\n\n    async def _handle_render_core_data(self, payload, send):\n        \"\"\"Handle POST /dot-render-core-data.\"\"\"\n        core_data = CoreData(**payload)\n        dot = self.ctx.render_dot_from_core_data(core_data)\n        await self._send_text(dot, send)\n\n    async def _handle_source(self, payload, send):\n        \"\"\"Handle POST /source.\"\"\"\n        result = self.ctx.get_source_code(payload.get(\"schema_name\", \"\"))\n        status_code = 200 if \"error\" not in result else 400\n        if \"error\" in result and \"not found\" in result[\"error\"]:\n            status_code = 404\n        await self._send_json(result, send, status_code=status_code)\n\n    async def _handle_vscode_link(self, payload, send):\n        \"\"\"Handle POST /vscode-link.\"\"\"\n        result = self.ctx.get_vscode_link(payload.get(\"schema_name\", \"\"))\n        status_code = 200 if \"error\" not in result else 400\n        if \"error\" in result and \"not found\" in result[\"error\"]:\n            status_code = 404\n        await self._send_json(result, send, status_code=status_code)\n\n    async def _send_html(self, html: str, send):\n        \"\"\"Send HTML response.\"\"\"\n        await self._send_response(\n            \"text/html; charset=utf-8\",\n            html.encode(\"utf-8\"),\n            send,\n            status_code=200,\n        )\n\n    async def _send_json(self, data: dict, send, status_code: int = 200):\n        \"\"\"Send JSON response.\"\"\"\n        body = json.dumps(data).encode(\"utf-8\")\n        await self._send_response(\"application/json\", body, send, status_code=status_code)\n\n    async def _send_text(self, text: str, send):\n        \"\"\"Send plain text response.\"\"\"\n        await self._send_response(\"text/plain; charset=utf-8\", text.encode(\"utf-8\"), send)\n\n    async def _send_404(self, send):\n        \"\"\"Send 404 response.\"\"\"\n        await self._send_response(\"text/plain\", b\"Not Found\", send, status_code=404)\n\n    async def _send_response(\n        self, content_type: str, body: bytes, send, status_code: int = 200\n    ):\n        \"\"\"Send ASGI response.\"\"\"\n        await send(\n            {\n                \"type\": \"http.response.start\",\n                \"status\": status_code,\n                \"headers\": [\n                    [b\"content-type\", content_type.encode()],\n                    [b\"content-length\", str(len(body)).encode()],\n                ],\n            }\n        )\n        await send({\"type\": \"http.response.body\", \"body\": body})\n\n    def _tag_to_dict(self, tag: Tag) -> dict:\n        \"\"\"Convert Tag object to dict.\"\"\"\n        return {\n            \"id\": tag.id,\n            \"name\": tag.name,\n            \"routes\": [\n                {\n                    \"id\": r.id,\n                    \"name\": r.name,\n                    \"module\": r.module,\n                    \"unique_id\": r.unique_id,\n                    \"response_schema\": r.response_schema,\n                    \"is_primitive\": r.is_primitive,\n                }\n                for r in tag.routes\n            ],\n        }\n\n    def _schema_to_dict(self, schema: SchemaNode) -> dict:\n        \"\"\"Convert SchemaNode to dict.\"\"\"\n        return {\n            \"id\": schema.id,\n            \"module\": schema.module,\n            \"name\": schema.name,\n            \"fields\": [\n                {\n                    \"name\": f.name,\n                    \"type_name\": f.type_name,\n                    \"is_object\": f.is_object,\n                    \"is_exclude\": f.is_exclude,\n                }\n                for f in schema.fields\n            ],\n        }\n\n    def create_app(self):\n        \"\"\"Create and return an ASGI application.\"\"\"\n\n        async def asgi_app(scope, receive, send):\n            # In server_mode, handle all paths; otherwise only handle /voyager/*\n            if scope[\"type\"] == \"http\":\n                if self.server_mode or scope[\"path\"].startswith(\"/voyager\"):\n                    await self._handle_request(scope, receive, send)\n                else:\n                    # Return 404 for non-voyager paths\n                    # (Django should handle these before they reach here)\n                    await self._send_404(send)\n            else:\n                await self._send_404(send)\n\n        return asgi_app\n"
  },
  {
    "path": "src/fastapi_voyager/adapters/fastapi_adapter.py",
    "content": "\"\"\"\nFastAPI adapter for fastapi-voyager.\n\nThis module provides the FastAPI-specific implementation of the voyager server.\n\"\"\"\nfrom typing import Any, Literal\n\nfrom pydantic import BaseModel\n\nfrom fastapi_voyager.adapters.base import VoyagerAdapter\nfrom fastapi_voyager.adapters.common import STATIC_FILES_PATH, VOYAGER_PATH_PLACEHOLDER, VoyagerContext\nfrom fastapi_voyager.type import CoreData, SchemaNode, Tag\n\n\nclass OptionParam(BaseModel):\n    tags: list[Tag]\n    schemas: list[SchemaNode]\n    dot: str\n    enable_brief_mode: bool\n    version: str\n    initial_page_policy: Literal[\"first\", \"full\", \"empty\"]\n    swagger_url: str | None = None\n    has_er_diagram: bool = False\n    enable_pydantic_resolve_meta: bool = False\n    framework_name: str = \"API\"\n\n\nclass Payload(BaseModel):\n    tags: list[str] | None = None\n    schema_name: str | None = None\n    schema_field: str | None = None\n    route_name: str | None = None\n    show_fields: str = \"object\"\n    brief: bool = False\n    hide_primitive_route: bool = False\n    show_module: bool = True\n    show_pydantic_resolve_meta: bool = False\n\n\nclass SearchResultOptionParam(BaseModel):\n    tags: list[Tag]\n\n\nclass SchemaSearchPayload(BaseModel):\n    schema_name: str | None = None\n    schema_field: str | None = None\n    show_fields: str = \"object\"\n    brief: bool = False\n    hide_primitive_route: bool = False\n    show_module: bool = True\n    show_pydantic_resolve_meta: bool = False\n\n\nclass ErDiagramPayload(BaseModel):\n    show_fields: str = \"object\"\n    show_module: bool = True\n    edge_minlen: int = 3\n    show_methods: bool = True\n\n\nclass SourcePayload(BaseModel):\n    schema_name: str\n\n\nclass FastAPIAdapter(VoyagerAdapter):\n    \"\"\"\n    FastAPI-specific implementation of VoyagerAdapter.\n\n    Creates a FastAPI application with voyager endpoints.\n    \"\"\"\n\n    def __init__(\n        self,\n        target_app: Any,\n        module_color: dict[str, str] | None = None,\n        gzip_minimum_size: int | None = 500,\n        module_prefix: str | None = None,\n        swagger_url: str | None = None,\n        online_repo_url: str | None = None,\n        initial_page_policy: str = \"first\",\n        ga_id: str | None = None,\n        er_diagram: Any = None,\n        enable_pydantic_resolve_meta: bool = False,\n        server_mode: bool = False,\n    ):\n        self.ctx = VoyagerContext(\n            target_app=target_app,\n            module_color=module_color,\n            module_prefix=module_prefix,\n            swagger_url=swagger_url,\n            online_repo_url=online_repo_url,\n            initial_page_policy=initial_page_policy,\n            ga_id=ga_id,\n            er_diagram=er_diagram,\n            enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,\n            framework_name=\"FastAPI\",\n        )\n        self.gzip_minimum_size = gzip_minimum_size\n        # Note: server_mode is accepted for API consistency but not used\n        # since FastAPI apps are always standalone with routes at /\n\n    def create_app(self) -> Any:\n        \"\"\"Create and return a FastAPI application with voyager endpoints.\"\"\"\n        # Lazy import FastAPI to avoid import errors when framework is not installed\n        from fastapi import APIRouter, FastAPI\n        from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse\n        from fastapi.staticfiles import StaticFiles\n        from starlette.middleware.gzip import GZipMiddleware\n\n        router = APIRouter(tags=[\"fastapi-voyager\"])\n\n        @router.post(\"/er-diagram\")\n        def get_er_diagram(payload: ErDiagramPayload):\n            return self.ctx.get_er_diagram_data(payload.model_dump())\n\n        @router.get(\"/dot\", response_model=OptionParam)\n        def get_dot() -> OptionParam:\n            data = self.ctx.get_option_param()\n            return OptionParam(**data)\n\n        @router.post(\"/dot-search\", response_model=SearchResultOptionParam)\n        def get_search_dot(payload: SchemaSearchPayload) -> SearchResultOptionParam:\n            tags = self.ctx.get_search_dot(payload.model_dump())\n            return SearchResultOptionParam(tags=tags)\n\n        @router.post(\"/dot\", response_class=PlainTextResponse)\n        def get_filtered_dot(payload: Payload) -> str:\n            return self.ctx.get_filtered_dot(payload.model_dump())\n\n        @router.post(\"/dot-core-data\", response_model=CoreData)\n        def get_filtered_dot_core_data(payload: Payload) -> CoreData:\n            return self.ctx.get_core_data(payload.model_dump())\n\n        @router.post(\"/dot-render-core-data\", response_class=PlainTextResponse)\n        def render_dot_from_core_data(core_data: CoreData) -> str:\n            return self.ctx.render_dot_from_core_data(core_data)\n\n        @router.get(\"/\", response_class=HTMLResponse)\n        def index() -> str:\n            return self.ctx.get_index_html()\n\n        @router.get(\"/sw.js\")\n        def get_service_worker():\n            \"\"\"Serve the Service Worker with correct content type.\"\"\"\n            from fastapi.responses import PlainTextResponse\n            return PlainTextResponse(\n                content=self.ctx.get_service_worker(),\n                media_type=\"application/javascript\"\n            )\n\n        @router.get(\"/manifest.webmanifest\")\n        def get_manifest():\n            \"\"\"Serve the PWA manifest with correct content type.\"\"\"\n            from fastapi.responses import PlainTextResponse\n            content = self.ctx.get_manifest()\n            # Replace VOYAGER_PATH with root-relative path (works for any mount point)\n            content = content.replace(VOYAGER_PATH_PLACEHOLDER, \"./\")\n            return PlainTextResponse(\n                content=content,\n                media_type=\"application/manifest+json\"\n            )\n\n        @router.post(\"/source\")\n        def get_object_by_module_name(payload: SourcePayload) -> JSONResponse:\n            result = self.ctx.get_source_code(payload.schema_name)\n            status_code = 200 if \"error\" not in result else 400\n            if \"error\" in result and \"not found\" in result[\"error\"]:\n                status_code = 404\n            return JSONResponse(content=result, status_code=status_code)\n\n        @router.post(\"/vscode-link\")\n        def get_vscode_link_by_module_name(payload: SourcePayload) -> JSONResponse:\n            result = self.ctx.get_vscode_link(payload.schema_name)\n            status_code = 200 if \"error\" not in result else 400\n            if \"error\" in result and \"not found\" in result[\"error\"]:\n                status_code = 404\n            return JSONResponse(content=result, status_code=status_code)\n\n        app = FastAPI(title=\"fastapi-voyager demo server\")\n\n        if self.gzip_minimum_size is not None and self.gzip_minimum_size >= 0:\n            app.add_middleware(GZipMiddleware, minimum_size=self.gzip_minimum_size)\n\n        from fastapi_voyager.adapters.common import WEB_DIR\n\n        app.mount(STATIC_FILES_PATH, StaticFiles(directory=str(WEB_DIR)), name=\"static\")\n        app.include_router(router)\n\n        return app\n"
  },
  {
    "path": "src/fastapi_voyager/adapters/litestar_adapter.py",
    "content": "\"\"\"\nLitestar adapter for fastapi-voyager.\n\nThis module provides the Litestar-specific implementation of the voyager server.\n\"\"\"\nfrom typing import Any\n\nfrom fastapi_voyager.adapters.base import VoyagerAdapter\nfrom fastapi_voyager.adapters.common import STATIC_FILES_PATH, VOYAGER_PATH_PLACEHOLDER, WEB_DIR, VoyagerContext\nfrom fastapi_voyager.type import CoreData, SchemaNode, Tag\n\n\nclass LitestarAdapter(VoyagerAdapter):\n    \"\"\"\n    Litestar-specific implementation of VoyagerAdapter.\n\n    Creates a Litestar application with voyager endpoints.\n    \"\"\"\n\n    def __init__(\n        self,\n        target_app: Any,\n        module_color: dict[str, str] | None = None,\n        gzip_minimum_size: int | None = 500,\n        module_prefix: str | None = None,\n        swagger_url: str | None = None,\n        online_repo_url: str | None = None,\n        initial_page_policy: str = \"first\",\n        ga_id: str | None = None,\n        er_diagram: Any = None,\n        enable_pydantic_resolve_meta: bool = False,\n        server_mode: bool = False,\n    ):\n        self.ctx = VoyagerContext(\n            target_app=target_app,\n            module_color=module_color,\n            module_prefix=module_prefix,\n            swagger_url=swagger_url,\n            online_repo_url=online_repo_url,\n            initial_page_policy=initial_page_policy,\n            ga_id=ga_id,\n            er_diagram=er_diagram,\n            enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,\n            framework_name=\"Litestar\",\n        )\n        self.gzip_minimum_size = gzip_minimum_size\n        # Note: server_mode is accepted for API consistency but not used\n        # since Litestar apps are always standalone with routes at /\n\n    def create_app(self) -> Any:\n        \"\"\"Create and return a Litestar application with voyager endpoints.\"\"\"\n        # Lazy import Litestar to avoid import errors when framework is not installed\n        from litestar import Litestar, MediaType, Request, Response, get, post\n        from litestar.static_files import create_static_files_router\n\n        @post(\"/er-diagram\")\n        async def get_er_diagram(request: Request) -> dict:\n            payload = await request.json()\n            return self.ctx.get_er_diagram_data(payload)\n\n        @get(\"/dot\")\n        async def get_dot(request: Request) -> dict:\n            data = self.ctx.get_option_param()\n            # Convert tags and schemas to dicts for JSON serialization\n            return {\n                \"tags\": [self._tag_to_dict(t) for t in data[\"tags\"]],\n                \"schemas\": [self._schema_to_dict(s) for s in data[\"schemas\"]],\n                \"dot\": data[\"dot\"],\n                \"enable_brief_mode\": data[\"enable_brief_mode\"],\n                \"version\": data[\"version\"],\n                \"initial_page_policy\": data[\"initial_page_policy\"],\n                \"swagger_url\": data[\"swagger_url\"],\n                \"has_er_diagram\": data[\"has_er_diagram\"],\n                \"enable_pydantic_resolve_meta\": data[\"enable_pydantic_resolve_meta\"],\n                \"framework_name\": data[\"framework_name\"],\n            }\n\n        @post(\"/dot-search\")\n        async def get_search_dot(request: Request) -> dict:\n            payload = await request.json()\n            tags = self.ctx.get_search_dot(payload)\n            return {\"tags\": [self._tag_to_dict(t) for t in tags]}\n\n        @post(\"/dot\")\n        async def get_filtered_dot(request: Request) -> str:\n            payload = await request.json()\n            return self.ctx.get_filtered_dot(payload)\n\n        @post(\"/dot-core-data\")\n        async def get_filtered_dot_core_data(request: Request) -> CoreData:\n            payload = await request.json()\n            return self.ctx.get_core_data(payload)\n\n        @post(\"/dot-render-core-data\")\n        async def render_dot_from_core_data(request: Request) -> str:\n            payload = await request.json()\n            core_data = CoreData(**payload)\n            return self.ctx.render_dot_from_core_data(core_data)\n\n        @get(\"/\", media_type=MediaType.HTML)\n        async def index() -> str:\n            return self.ctx.get_index_html()\n\n        @get(\"/sw.js\", media_type=\"application/javascript\")\n        async def get_service_worker() -> str:\n            \"\"\"Serve the Service Worker.\"\"\"\n            return self.ctx.get_service_worker()\n\n        @get(\"/manifest.webmanifest\", media_type=\"application/manifest+json\")\n        async def get_manifest() -> str:\n            \"\"\"Serve the PWA manifest.\"\"\"\n            content = self.ctx.get_manifest()\n            return content.replace(VOYAGER_PATH_PLACEHOLDER, \"./\")\n\n        @post(\"/source\")\n        async def get_object_by_module_name(request: Request) -> dict:\n            payload = await request.json()\n            result = self.ctx.get_source_code(payload.get(\"schema_name\", \"\"))\n            status_code = 200 if \"error\" not in result else 400\n            if \"error\" in result and \"not found\" in result[\"error\"]:\n                status_code = 404\n            return Response(\n                content=result,\n                status_code=status_code,\n                media_type=MediaType.JSON,\n            )\n\n        @post(\"/vscode-link\")\n        async def get_vscode_link_by_module_name(request: Request) -> dict:\n            payload = await request.json()\n            result = self.ctx.get_vscode_link(payload.get(\"schema_name\", \"\"))\n            status_code = 200 if \"error\" not in result else 400\n            if \"error\" in result and \"not found\" in result[\"error\"]:\n                status_code = 404\n            return Response(\n                content=result,\n                status_code=status_code,\n                media_type=MediaType.JSON,\n            )\n\n        # Create static files router using the new API (replaces deprecated StaticFilesConfig)\n        static_files_router = create_static_files_router(\n            path=STATIC_FILES_PATH,\n            directories=[str(WEB_DIR)],\n        )\n\n        # Create Litestar app\n        app = Litestar(\n            route_handlers=[\n                get_er_diagram,\n                get_dot,\n                get_search_dot,\n                get_filtered_dot,\n                get_filtered_dot_core_data,\n                render_dot_from_core_data,\n                index,\n                get_service_worker,\n                get_manifest,\n                get_object_by_module_name,\n                get_vscode_link_by_module_name,\n                static_files_router,\n            ],\n        )\n\n        return app\n\n    def _tag_to_dict(self, tag: Tag) -> dict:\n        \"\"\"Convert Tag object to dict.\"\"\"\n        return {\n            \"id\": tag.id,\n            \"name\": tag.name,\n            \"routes\": [\n                {\n                    \"id\": r.id,\n                    \"name\": r.name,\n                    \"module\": r.module,\n                    \"unique_id\": r.unique_id,\n                    \"response_schema\": r.response_schema,\n                    \"is_primitive\": r.is_primitive,\n                }\n                for r in tag.routes\n            ],\n        }\n\n    def _schema_to_dict(self, schema: SchemaNode) -> dict:\n        \"\"\"Convert SchemaNode to dict.\"\"\"\n        return {\n            \"id\": schema.id,\n            \"module\": schema.module,\n            \"name\": schema.name,\n            \"fields\": [\n                {\n                    \"name\": f.name,\n                    \"type_name\": f.type_name,\n                    \"is_object\": f.is_object,\n                    \"is_exclude\": f.is_exclude,\n                }\n                for f in schema.fields\n            ],\n        }\n"
  },
  {
    "path": "src/fastapi_voyager/cli.py",
    "content": "\"\"\"Command line interface for fastapi-voyager.\"\"\"\nimport argparse\nimport importlib\nimport importlib.util\nimport logging\nimport os\nimport sys\nfrom typing import Any\n\nfrom fastapi_voyager import server as viz_server\nfrom fastapi_voyager.version import __version__\nfrom fastapi_voyager.voyager import Voyager\n\nlogger = logging.getLogger(__name__)\n\n# Framework type constants\nSUPPORTED_FRAMEWORKS = [\"fastapi\", \"litestar\", \"django-ninja\"]\n\n\ndef load_app_from_file(module_path: str, app_name: str = \"app\", framework: str | None = None) -> Any:\n    \"\"\"Load web framework app from a Python module file.\"\"\"\n    try:\n        # Convert relative path to absolute path\n        if not os.path.isabs(module_path):\n            module_path = os.path.abspath(module_path)\n\n        # Load the module\n        spec = importlib.util.spec_from_file_location(\"app_module\", module_path)\n        if spec is None or spec.loader is None:\n            logger.error(f\"Could not load module from {module_path}\")\n            return None\n\n        module = importlib.util.module_from_spec(spec)\n        sys.modules[\"app_module\"] = module\n        spec.loader.exec_module(module)\n\n        # Get the app instance\n        if not hasattr(module, app_name):\n            logger.error(f\"No attribute '{app_name}' found in the module\")\n            return None\n\n        app = getattr(module, app_name)\n\n        # Verify app type if framework is specified\n        if framework is not None:\n            if not _validate_app_framework(app, framework):\n                logger.error(f\"'{app_name}' is not a {framework} instance\")\n                return None\n\n        return app\n\n    except Exception as e:\n        logger.error(f\"Error loading app: {e}\")\n        return None\n\n\ndef load_app_from_module(module_name: str, app_name: str = \"app\", framework: str | None = None) -> Any:\n    \"\"\"Load web framework app from a Python module name.\"\"\"\n    try:\n        # Temporarily add the current working directory to sys.path\n        current_dir = os.getcwd()\n        if current_dir not in sys.path:\n            sys.path.insert(0, current_dir)\n            path_added = True\n        else:\n            path_added = False\n\n        try:\n            # Import the module by name\n            module = importlib.import_module(module_name)\n\n            # Get the app instance\n            if not hasattr(module, app_name):\n                logger.error(f\"No attribute '{app_name}' found in module '{module_name}'\")\n                return None\n\n            app = getattr(module, app_name)\n\n            # Verify app type if framework is specified\n            if framework is not None:\n                if not _validate_app_framework(app, framework):\n                    logger.error(f\"'{app_name}' is not a {framework} instance\")\n                    return None\n\n            return app\n        finally:\n            # Cleanup: if we added the path, remove it\n            if path_added and current_dir in sys.path:\n                sys.path.remove(current_dir)\n\n    except ImportError as e:\n        logger.error(f\"Could not import module '{module_name}': {e}\")\n        return None\n    except Exception as e:\n        logger.error(f\"Error loading app from module '{module_name}': {e}\")\n        return None\n\n\ndef _validate_app_framework(app: Any, framework: str) -> bool:\n    \"\"\"Validate that the app matches the expected framework type.\"\"\"\n    try:\n        if framework == \"fastapi\":\n            from fastapi import FastAPI\n            return isinstance(app, FastAPI)\n        elif framework == \"litestar\":\n            from litestar import Litestar\n            return isinstance(app, Litestar)\n        elif framework == \"django-ninja\":\n            from ninja import NinjaAPI\n            return isinstance(app, NinjaAPI)\n        return False\n    except ImportError as e:\n        logger.error(\n            f\"The {framework} package is not installed. \"\n            f\"Install it with: uv add fastapi-voyager[{framework}]\"\n        )\n        logger.debug(f\"Import error details: {e}\")\n        return False\n\n\ndef generate_visualization(\n    app: Any,\n    output_file: str = \"router_viz.dot\", tags: list[str] | None = None,\n    schema: str | None = None,\n    show_fields: bool = False,\n    module_color: dict[str, str] | None = None,\n    route_name: str | None = None,\n):\n\n    \"\"\"Generate DOT file for API router visualization.\"\"\"\n    analytics = Voyager(\n        include_tags=tags,\n        schema=schema,\n        show_fields=show_fields,\n        module_color=module_color,\n        route_name=route_name,\n    )\n\n    analytics.analysis(app)\n\n    dot_content = analytics.render_dot()\n    \n    # Optionally write to file\n    with open(output_file, 'w', encoding='utf-8') as f:\n        f.write(dot_content)\n    logger.info(f\"DOT file generated: {output_file}\")\n    logger.info(\"To render the graph, use: dot -Tpng router_viz.dot -o router_viz.png\")\n    logger.info(\"Or view online: https://dreampuf.github.io/GraphvizOnline/\")\n\n\ndef main():\n    \"\"\"Main CLI entry point.\"\"\"\n    parser = argparse.ArgumentParser(\n        description=\"Visualize web application's routing tree and dependencies (supports FastAPI, Litestar, Django-Ninja)\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\nExamples:\n  voyager app.py --web fastapi                                                       # Load 'app' from app.py (FastAPI)\n  voyager app.py --web litestar                                                      # Load 'app' from app.py (Litestar)\n  voyager -m tests.demo --web django-ninja                                             # Load 'app' from demo module (Django-Ninja)\n  voyager -m tests.demo --app=api --web fastapi                                       # Load 'api' from tests.demo\n  voyager -m tests.demo --web fastapi --schema=NodeA                                    # filter nodes by schema name\n  voyager -m tests.demo --web fastapi --tags=page restful                               # filter routes by tags\n  voyager -m tests.demo --web fastapi --module_color=tests.demo:red --module_color=tests.service:yellow\n  voyager -m tests.demo --web fastapi -o my_graph.dot                                   # Output to my_graph.dot\n  voyager -m tests.demo --web fastapi --server                                          # start a local server to preview\n  voyager -m tests.demo --web fastapi --server --port=8001                              # start a local server to preview\n\"\"\"\n    )\n\n    # Create mutually exclusive group for module loading options\n    group = parser.add_mutually_exclusive_group(required=False)\n    group.add_argument(\n        \"module\",\n        nargs=\"?\",\n        help=\"Python file containing the web application\"\n    )\n    group.add_argument(\n        \"-m\", \"--module\",\n        dest=\"module_name\",\n        help=\"Python module name containing the web application (like python -m)\"\n    )\n\n    parser.add_argument(\n        \"--web\",\n        choices=SUPPORTED_FRAMEWORKS,\n        help=\"Web framework type (required when using --server): fastapi, litestar, django-ninja\"\n    )\n\n    parser.add_argument(\n        \"--app\", \"-a\",\n        default=\"app\",\n        help=\"Name of the app variable (default: app)\"\n    )\n    \n    parser.add_argument(\n        \"--output\", \"-o\",\n        default=\"router_viz.dot\",\n        help=\"Output DOT file name (default: router_viz.dot)\"\n    )\n    parser.add_argument(\n        \"--server\",\n        action=\"store_true\",\n        help=\"Start a local server to preview the generated DOT graph\"\n    )\n    parser.add_argument(\n        \"--port\",\n        type=int,\n        default=8000,\n        help=\"Port for the preview server when --server is used (default: 8000)\"\n    )\n    parser.add_argument(\n        \"--host\",\n        type=str,\n        default=\"127.0.0.1\",\n        help=\"Host/IP for the preview server when --server is used (default: 127.0.0.1). Use 0.0.0.0 to listen on all interfaces.\"\n    )\n    parser.add_argument(\n        \"--module_prefix\",\n        type=str,\n        default=None,\n        help=\"Prefix routes with module name when rendering brief view (only valid with --server)\"\n    )\n    \n    parser.add_argument(\n        \"--version\", \"-v\",\n        action=\"version\",\n        version=f\"fastapi-voyager {__version__}\"\n    )\n    parser.add_argument(\n        \"--tags\",\n        nargs=\"+\",\n        help=\"Only include routes whose first tag is in the provided list\"\n    )\n    parser.add_argument(\n        \"--module_color\",\n        action=\"append\",\n        metavar=\"KEY:VALUE\",\n        help=\"Module color mapping as key1:value1 key2:value2 (module name to Graphviz color)\"\n    )\n    # removed service_prefixes option\n    parser.add_argument(\n        \"--schema\",\n        default=None,\n        help=\"Filter schemas by name\"\n    )\n    parser.add_argument(\n        \"--show_fields\",\n        choices=[\"single\", \"object\", \"all\"],\n        default=\"object\",\n        help=\"Field display mode: single (no fields), object (only object-like fields), all (all fields). Default: object\"\n    )\n    parser.add_argument(\n        \"--route_name\",\n        type=str,\n        default=None,\n        help=\"Filter by route id (format: <endpoint>_<path with _>)\"\n    )\n    parser.add_argument(\n        \"--log-level\",\n        dest=\"log_level\",\n        default=\"INFO\",\n        help=\"Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)\"\n    )\n    \n    args = parser.parse_args()\n\n    # Validate arguments\n    if args.module_prefix and not args.server:\n        parser.error(\"--module_prefix can only be used together with --server\")\n\n    if not (args.module_name or args.module):\n        parser.error(\"You must provide a module file or -m module name\")\n\n    # When --server is used, --web is required\n    if args.server and not args.web:\n        parser.error(\"--web is required when using --server. Please specify: fastapi, litestar, or django-ninja\")\n\n    # Determine the framework (default to the one specified, or None for non-server mode)\n    framework = args.web if args.server else None\n\n    # Configure logging based on --log-level\n    level_name = (args.log_level or \"INFO\").upper()\n    logging.basicConfig(level=level_name)\n\n    # Load app based on the input method (module_name takes precedence)\n    if args.module_name:\n        app = load_app_from_module(args.module_name, args.app, framework)\n    else:\n        if not os.path.exists(args.module):\n            logger.error(f\"File '{args.module}' not found\")\n            sys.exit(1)\n        app = load_app_from_file(args.module, args.app, framework)\n\n    if app is None:\n        sys.exit(1)\n    \n    # helper: parse KEY:VALUE pairs into dict\n    def parse_kv_pairs(pairs: list[str] | None) -> dict[str, str] | None:\n        if not pairs:\n            return None\n        result: dict[str, str] = {}\n        for item in pairs:\n            if \":\" in item:\n                k, v = item.split(\":\", 1)\n                k = k.strip()\n                v = v.strip()\n                if k:\n                    result[k] = v\n        return result or None\n\n    try:\n        module_color = parse_kv_pairs(args.module_color)\n        if args.server:\n            # Build a preview server using the appropriate framework\n            try:\n                import uvicorn\n            except ImportError:\n                logger.info(\"uvicorn is required to run the server. Install via 'pip install uvicorn' or 'uv add uvicorn'.\")\n                sys.exit(1)\n\n            # Create voyager app - it auto-detects framework and returns appropriate app type\n            app_server = viz_server.create_voyager(\n                app,\n                module_color=module_color,\n                module_prefix=args.module_prefix,\n                server_mode=True,  # Enable server mode to serve at root path\n            )\n            logger.info(f\"Starting {args.web} preview server at http://{args.host}:{args.port} ... (Ctrl+C to stop)\")\n            uvicorn.run(app_server, host=args.host, port=args.port, log_level=level_name.lower())\n        else:\n            # Generate and write dot file locally\n            generate_visualization(\n                app, \n                args.output, \n                tags=args.tags, \n                schema=args.schema,\n                show_fields=args.show_fields,\n                module_color=module_color,\n                route_name=args.route_name,\n            )\n    except Exception as e:\n        logger.info(f\"Error generating visualization: {e}\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/fastapi_voyager/er_diagram.py",
    "content": "from __future__ import annotations\n\nfrom logging import getLogger\n\nfrom pydantic import BaseModel\nfrom pydantic_resolve import Entity, ErDiagram, Relationship\n\nfrom fastapi_voyager.pydantic_resolve_util import extract_query_mutation_methods\nfrom fastapi_voyager.render import Renderer\nfrom fastapi_voyager.render_style import RenderConfig\nfrom fastapi_voyager.type import (\n    FieldInfo,\n    FieldType,\n    Link,\n    LinkType,\n    MethodInfo,\n    PK,\n    SchemaNode,\n)\nfrom fastapi_voyager.type_helper import (\n    full_class_name,\n    get_core_types,\n    get_type_name,\n    is_list,\n    safe_issubclass,\n    update_forward_refs,\n)\n\nARROR = \"=>\"\nlogger = getLogger(__name__)\n\n\ndef _get_loader_name(loader) -> str | None:\n    \"\"\"Extract loader function name (without module path).\"\"\"\n    if loader is None:\n        return None\n    # loader is a callable, get its __name__ or __qualname__\n    name = getattr(loader, '__name__', None) or getattr(loader, '__qualname__', None)\n    if name and '.' in name:\n        # Return only the function name, not the full path\n        return name.split('.')[-1]\n    return name\n\n\nclass DiagramRenderer(Renderer):\n    \"\"\"\n    Renderer for Entity-Relationship diagrams.\n\n    Inherits from Renderer to reuse template system and styling.\n    ER diagrams have simpler structure (no tags/routes), so we only\n    need to customize the top-level DOT structure.\n    \"\"\"\n\n    def __init__(\n        self,\n        *,\n        show_fields: FieldType = 'single',\n        show_module: bool = True,\n        theme_color: str | None = None,\n        edge_minlen: int = 3,\n        show_methods: bool = True,\n    ) -> None:\n        # Initialize parent Renderer with shared config\n        super().__init__(\n            show_fields=show_fields,\n            show_module=show_module,\n            config=RenderConfig(),  # Use unified style configuration\n            theme_color=theme_color,\n            show_methods=show_methods,\n        )\n        self.edge_minlen = edge_minlen\n        logger.info(f'show_module: {self.show_module}')\n\n    def render_link(self, link: Link) -> str:\n        \"\"\"Override link rendering for ER diagrams.\"\"\"\n        source = self._handle_schema_anchor(link.source)\n        target = self._handle_schema_anchor(link.target)\n\n        # Build link attributes\n        if link.style is not None:\n            attrs = {'style': link.style}\n            if link.label:\n                attrs['label'] = link.label\n            attrs['minlen'] = self.edge_minlen\n        else:\n            attrs = self.style.get_link_attributes(link.type)\n            if link.label:\n                attrs['label'] = link.label\n\n        return self.template_renderer.render_template(\n            'dot/link.j2',\n            source=source,\n            target=target,\n            attributes=self._format_link_attributes(attrs)\n        )\n\n    def render_dot(self, nodes: list[SchemaNode], links: list[Link], spline_line=False) -> str:\n        \"\"\"\n        Render ER diagram as DOT format.\n\n        Reuses parent's render_module_schema_content and render_link methods.\n        Only customizes the top-level digraph structure.\n        \"\"\"\n        # Reuse parent's module schema rendering\n        module_schemas_str = self.render_module_schema_content(nodes)\n\n        # Reuse parent's link rendering\n        link_str = '\\n'.join(self.render_link(link) for link in links)\n\n        # Render using ER diagram template\n        return self.template_renderer.render_template(\n            'dot/er_diagram.j2',\n            pad=self.style.pad,\n            nodesep=self.style.nodesep,\n            font=self.style.font,\n            node_fontsize=self.style.node_fontsize,\n            spline='line' if spline_line else None,\n            er_cluster=module_schemas_str,\n            links=link_str\n        )\n\n\nclass VoyagerErDiagram:\n    def __init__(self,\n                 er_diagram: ErDiagram,\n                 show_fields: FieldType = 'single',\n                 show_module: bool = False,\n                 theme_color: str | None = None,\n                 edge_minlen: int = 3,\n                 show_methods: bool = True):\n\n        self.er_diagram = er_diagram\n        self.nodes: list[SchemaNode] = []\n        self.node_set: dict[str, SchemaNode] = {}\n\n        self.links: list[Link] = []\n        self.link_set: set[tuple[str, str]] = set()\n\n        self.fk_set: dict[str, set[str]] = {}\n\n        self.show_field = show_fields\n        self.show_module = show_module\n        self.theme_color = theme_color\n        self.edge_minlen = edge_minlen\n        self.show_methods = show_methods\n    \n    def generate_node_head(self, link_name: str):\n        return f'{link_name}::{PK}'\n\n    def analysis_entity(self, entity: Entity):\n        schema = entity.kls\n        update_forward_refs(schema)\n        self.add_to_node_set(\n            schema,\n            fk_set=self.fk_set.get(full_class_name(schema)),\n            entity_queries=entity.queries,\n            entity_mutations=entity.mutations,\n        )\n\n        for relationship in entity.relationships:\n            annos = get_core_types(relationship.target)\n            for anno in annos:\n                if not isinstance(anno, type) or not safe_issubclass(anno, BaseModel):\n                    continue\n                self.add_to_node_set(anno, fk_set=self.fk_set.get(full_class_name(anno)))\n                source_name = f'{full_class_name(schema)}::f{relationship.fk}'\n                # Build label with cardinality and loader name\n                cardinality = f'1 {ARROR} N' if is_list(relationship.target) else f'1 {ARROR} 1'\n                loader_name = _get_loader_name(relationship.loader)\n                loader_fullname = (\n                    f\"{relationship.loader.__module__}.{loader_name}\"\n                    if relationship.loader and loader_name\n                    else None\n                )\n                label = cardinality\n                if relationship.name:\n                    label = f'{relationship.name}\\n{label}'\n                self.add_to_link_set(\n                    source=source_name,\n                    source_origin=full_class_name(schema),\n                    target=self.generate_node_head(full_class_name(anno)),\n                    target_origin=full_class_name(anno),\n                    type='schema',\n                    label=label,\n                    style='solid' if relationship.loader else 'solid, dashed',\n                    loader_fullname=loader_fullname\n                    )\n\n    def add_to_node_set(\n        self,\n        schema,\n        fk_set: set[str] | None = None,\n        entity_queries: list | None = None,\n        entity_mutations: list | None = None,\n    ) -> str:\n        \"\"\"\n        1. calc full_path, add to node_set\n        2. if duplicated, do nothing, else insert\n        2. return the full_path\n        \"\"\"\n        full_name = full_class_name(schema)\n\n        if full_name not in self.node_set:\n            # Extract queries and mutations: prefer Entity-level configs, fallback to class decorators\n            queries, mutations = get_queries_and_mutations(\n                schema,\n                entity_queries=entity_queries,\n                entity_mutations=entity_mutations,\n            )\n\n            # skip meta info for normal queries\n            self.node_set[full_name] = SchemaNode(\n                id=full_name,\n                module=schema.__module__,\n                name=schema.__name__,\n                fields=get_fields(schema, fk_set),\n                is_entity=False,  # Don't mark in ER diagram\n                queries=queries,\n                mutations=mutations\n            )\n        return full_name\n\n    def add_to_link_set(\n            self,\n            source: str,\n            source_origin: str,\n            target: str,\n            target_origin: str,\n            type: LinkType,\n            label: str,\n            style: str,\n            biz: str | None = None,\n            loader_fullname: str | None = None\n        ) -> bool:\n        \"\"\"\n        1. add link to link_set\n        2. if duplicated, do nothing, else insert\n        \"\"\"\n        pair = (source, target, biz)\n        if result := pair not in self.link_set:\n            self.link_set.add(pair)\n            self.links.append(Link(\n                source=source,\n                source_origin=source_origin,\n                target=target,\n                target_origin=target_origin,\n                type=type,\n                label=label,\n                style=style,\n                loader_fullname=loader_fullname\n            ))\n        return result\n\n\n    def render_dot(self):\n        self.fk_set = {\n            full_class_name(entity.kls): set([rel.fk for rel in entity.relationships])\n                for entity in self.er_diagram.entities\n        }\n\n        for entity in self.er_diagram.entities:\n            self.analysis_entity(entity)\n        renderer = DiagramRenderer(\n            show_fields=self.show_field,\n            show_module=self.show_module,\n            theme_color=self.theme_color,\n            edge_minlen=self.edge_minlen,\n            show_methods=self.show_methods,\n        )\n        return renderer.render_dot(list(self.node_set.values()), self.links)\n\n\ndef get_fields(schema: type[BaseModel], fk_set: set[str] | None = None) -> list[FieldInfo]:\n\n    fields: list[FieldInfo] = []\n    for k, v in schema.model_fields.items():\n        anno = v.annotation\n        fields.append(FieldInfo(\n            is_object=k in fk_set if fk_set is not None else False,\n            name=k,\n            from_base=False,\n            type_name=get_type_name(anno),\n            is_exclude=bool(v.exclude)\n        ))\n    return fields\n\n\ndef get_queries_and_mutations(\n    schema: type[BaseModel],\n    entity_queries: list | None = None,\n    entity_mutations: list | None = None,\n) -> tuple[list[MethodInfo], list[MethodInfo]]:\n    \"\"\"Extract @query and @mutation methods from an entity.\n\n    Prefers Entity-level QueryConfig/MutationConfig when available,\n    falls back to @query/@mutation decorators on the class.\n    \"\"\"\n    queries: list[MethodInfo] = []\n    mutations: list[MethodInfo] = []\n\n    if entity_queries:\n        for qc in entity_queries:\n            method = qc.method\n            name = qc.name or method.__name__\n            return_type = _get_return_type_str(method)\n            queries.append(MethodInfo(name=name, return_type=return_type))\n    elif entity_mutations is not None:\n        # No queries configured at entity level, skip decorator extraction\n        pass\n    else:\n        # Fallback: extract from class decorators\n        query_dicts, _ = extract_query_mutation_methods(schema)\n        queries = [MethodInfo(name=q['name'], return_type=q['return_type']) for q in query_dicts]\n\n    if entity_mutations:\n        for mc in entity_mutations:\n            method = mc.method\n            name = mc.name or method.__name__\n            return_type = _get_return_type_str(method)\n            mutations.append(MethodInfo(name=name, return_type=return_type))\n    elif entity_queries is not None:\n        # No mutations configured at entity level, skip decorator extraction\n        pass\n    else:\n        # Fallback: extract from class decorators\n        _, mutation_dicts = extract_query_mutation_methods(schema)\n        mutations = [MethodInfo(name=m['name'], return_type=m['return_type']) for m in mutation_dicts]\n\n    return queries, mutations\n\n\ndef _get_return_type_str(method) -> str:\n    \"\"\"Extract return type annotation string from a method.\"\"\"\n    import inspect\n    sig = inspect.signature(method)\n    if sig.return_annotation != inspect.Parameter.empty:\n        ann = sig.return_annotation\n        if isinstance(ann, str):\n            return ann\n        if hasattr(ann, '__origin__'):\n            import typing\n            return str(ann).replace('typing.', '')\n        return getattr(ann, '__name__', str(ann))\n    return ''\n"
  },
  {
    "path": "src/fastapi_voyager/filter.py",
    "content": "from __future__ import annotations\n\nfrom collections import deque\n\nfrom fastapi_voyager.type import PK, Link, Route, SchemaNode, Tag\n\n\ndef filter_graph(\n    *,\n    schema: str | None,\n    schema_field: str | None,\n    tags: list[Tag],\n    routes: list[Route],\n    nodes: list[SchemaNode],\n    links: list[Link],\n    node_set: dict[str, SchemaNode],\n) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:\n    \"\"\"Filter tags, routes, schema nodes and links based on a target schema and optional field.\n\n    Behaviour summary (mirrors previous Analytics.filter_nodes_and_schemas_based_on_schemas):\n      1. If `schema` is None, return inputs unmodified.\n      2. Seed with the schema node id (full id match). If not found, return inputs.\n      3. If `schema_field` provided, prune parent/subset links so that only those whose *source* schema\n         contains that field and whose *target* is already accepted remain, recursively propagating upward.\n      4. Perform two traversals on the (possibly pruned) links set:\n         - Upstream: reverse walk (collect nodes that point to current frontier) -> brings in children & entry chain.\n         - Downstream: forward walk (collect targets from current frontier) -> brings in ancestors.\n      5. Keep only objects (tags, routes, nodes, links) whose origin ids are in the collected set.\n    \"\"\"\n    if schema is None:\n        return tags, routes, nodes, links\n\n    seed_node_ids = {n.id for n in nodes if n.id == schema}\n    if not seed_node_ids:\n        return tags, routes, nodes, links\n\n    # Step 1: schema_field pruning logic for parent/subset links\n    if schema_field:\n        current_targets = set(seed_node_ids)\n        accepted_targets = set(seed_node_ids)\n        accepted_links: list[Link] = []\n        parent_subset_links = [lk for lk in links if lk.type in (\"parent\", \"subset\")]\n        other_links = [lk for lk in links if lk.type not in (\"parent\", \"subset\")]\n\n        while current_targets:\n            next_targets: set[str] = set()\n            for lk in parent_subset_links:\n                if (\n                    lk.target_origin in current_targets\n                    and lk.source_origin not in accepted_targets\n                    and lk.source_origin in node_set\n                    and lk.target_origin in node_set\n                ):\n                    src_node = node_set.get(lk.source_origin)\n                    if src_node and any(f.name == schema_field for f in src_node.fields):\n                        accepted_links.append(lk)\n                        next_targets.add(lk.source_origin)\n                        accepted_targets.add(lk.source_origin)\n                elif lk.target_origin in current_targets and lk.source_origin in accepted_targets:\n                    src_node = node_set.get(lk.source_origin)\n                    if src_node and any(f.name == schema_field for f in src_node.fields):\n                        if lk not in accepted_links:\n                            accepted_links.append(lk)\n            current_targets = next_targets\n        filtered_links = other_links + accepted_links\n    else:\n        filtered_links = links\n\n    # Step 2: build adjacency maps\n    fwd: dict[str, set[str]] = {}\n    rev: dict[str, set[str]] = {}\n    for lk in filtered_links:\n        fwd.setdefault(lk.source_origin, set()).add(lk.target_origin)\n        rev.setdefault(lk.target_origin, set()).add(lk.source_origin)\n\n    # Upstream (reverse) traversal\n    upstream: set[str] = set()\n    frontier = set(seed_node_ids)\n    while frontier:\n        new_layer: set[str] = set()\n        for nid in frontier:\n            for src in rev.get(nid, ()):  # src points to nid\n                if src not in upstream and src not in seed_node_ids:\n                    new_layer.add(src)\n        upstream.update(new_layer)\n        frontier = new_layer\n\n    # Downstream (forward) traversal\n    downstream: set[str] = set()\n    frontier = set(seed_node_ids)\n    while frontier:\n        new_layer: set[str] = set()\n        for nid in frontier:\n            for tgt in fwd.get(nid, ()):  # nid points to tgt\n                if tgt not in downstream and tgt not in seed_node_ids:\n                    new_layer.add(tgt)\n        downstream.update(new_layer)\n        frontier = new_layer\n\n    included_ids: set[str] = set(seed_node_ids) | upstream | downstream\n\n    _nodes = [n for n in nodes if n.id in included_ids]\n    _links = [l for l in filtered_links if l.source_origin in included_ids and l.target_origin in included_ids]\n    _tags = [t for t in tags if t.id in included_ids]\n    _routes = [r for r in routes if r.id in included_ids]\n\n    return _tags, _routes, _nodes, _links\n\n\ndef filter_subgraph_by_module_prefix(\n    *,\n    tags: list[Tag],\n    routes: list[Route],\n    links: list[Link],\n    nodes: list[SchemaNode],\n    module_prefix: str\n) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:\n    \"\"\"Collapse schema graph so routes link directly to nodes whose module matches ``module_prefix``.\n\n    The routine keeps tag→route links untouched, prunes schema nodes whose module does not start\n    with ``module_prefix``, and merges the remaining schema relationships so each route connects\n    directly to the surviving schema nodes. Traversal stops once a qualifying node is reached and\n    guards against cycles in the schema graph.\n    \"\"\"\n\n    if not module_prefix:\n        # empty prefix keeps existing graph structure, so simply reuse incoming data\n        return tags, routes, nodes, [lk for lk in links if lk.type in (\"tag_route\", \"route_to_schema\")]\n\n    route_links = [lk for lk in links if lk.type == \"route_to_schema\"]\n    schema_links = [lk for lk in links if lk.type in {\"schema\", \"parent\", \"subset\"}]\n    tag_route_links = [lk for lk in links if lk.type == \"tag_route\"]\n\n    node_lookup: dict[str, SchemaNode] = {node.id: node for node in nodes}\n\n    filtered_nodes = [node for node in nodes if node_lookup[node.id].module.startswith(module_prefix)]\n    filtered_node_ids = {node.id for node in filtered_nodes}\n\n    adjacency: dict[str, list[str]] = {}\n    for link in schema_links:\n        if link.source_origin not in node_lookup or link.target_origin not in node_lookup:\n            continue\n        adjacency.setdefault(link.source_origin, [])\n        if link.target_origin not in adjacency[link.source_origin]:\n            adjacency[link.source_origin].append(link.target_origin)\n\n    merged_links: list[Link] = []\n    seen_pairs: set[tuple[str, str]] = set()\n\n    for link in route_links:\n        route_id = link.source_origin\n        start_node_id = link.target_origin\n        if route_id is None or start_node_id is None:\n            continue\n        if start_node_id not in node_lookup:\n            continue\n\n        visited: set[str] = set()\n        queue: deque[str] = deque([start_node_id])\n\n        while queue:\n            current = queue.popleft()\n            if current in visited:\n                continue\n            visited.add(current)\n\n            if current in filtered_node_ids:\n                key = (route_id, current)\n                if key not in seen_pairs:\n                    seen_pairs.add(key)\n                    merged_links.append(\n                        Link(\n                            source=link.source,\n                            source_origin=route_id,\n                            target=f\"{current}::{PK}\",\n                            target_origin=current,\n                            type=\"route_to_schema\",\n                        )\n                    )\n                # stop traversing past a qualifying node\n                continue\n\n            for next_node in adjacency.get(current, () ):\n                if next_node not in visited:\n                    queue.append(next_node)\n\n    module_prefix_links = [\n        lk\n        for lk in links\n        if (lk.source_origin or \"\").startswith(module_prefix)\n        and (lk.target_origin or \"\").startswith(module_prefix)\n    ]\n\n    filtered_links = tag_route_links + merged_links + module_prefix_links\n\n    return tags, routes, filtered_nodes, filtered_links\n\n\ndef filter_subgraph_from_tag_to_schema_by_module_prefix(\n    *,\n    tags: list[Tag],\n    routes: list[Route],\n    links: list[Link],\n    nodes: list[SchemaNode],\n    module_prefix: str\n) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:\n    \"\"\"Collapse schema graph so routes link directly to nodes whose module matches ``module_prefix``.\n\n    The routine keeps tag→route links untouched, prunes schema nodes whose module does not start\n    with ``module_prefix``, and merges the remaining schema relationships so each route connects\n    directly to the surviving schema nodes. Traversal stops once a qualifying node is reached and\n    guards against cycles in the schema graph.\n    \"\"\"\n\n    if not module_prefix:\n        # empty prefix keeps existing graph structure, so simply reuse incoming data\n        return tags, routes, nodes, [lk for lk in links if lk.type in (\"tag_route\", \"route_to_schema\")]\n\n    route_links = [lk for lk in links if lk.type == \"route_to_schema\"]\n    schema_links = [lk for lk in links if lk.type in {\"schema\", \"parent\", \"subset\"}]\n    tag_route_links = [lk for lk in links if lk.type == \"tag_route\"]\n\n    node_lookup: dict[str, SchemaNode] = {node.id: node for node in (nodes + routes)}\n\n    filtered_nodes = [node for node in nodes if node_lookup[node.id].module.startswith(module_prefix)]\n    filtered_node_ids = {node.id for node in filtered_nodes}\n\n    adjacency: dict[str, list[str]] = {}\n    for link in (schema_links + route_links):\n        if link.source_origin not in node_lookup or link.target_origin not in node_lookup:\n            continue\n        adjacency.setdefault(link.source_origin, [])\n        if link.target_origin not in adjacency[link.source_origin]:\n            adjacency[link.source_origin].append(link.target_origin)\n\n    merged_links: list[Link] = []\n    seen_pairs: set[tuple[str, str]] = set()\n\n    for link in tag_route_links:\n        tag_id = link.source_origin\n        start_node_id = link.target_origin\n        if tag_id is None or start_node_id is None:\n            continue\n        if start_node_id not in node_lookup:\n            continue\n\n        visited: set[str] = set()\n        queue: deque[str] = deque([start_node_id])\n\n        while queue:\n            current = queue.popleft()\n            if current in visited:\n                continue\n            visited.add(current)\n\n            if current in filtered_node_ids:\n                key = (tag_id, current)\n                if key not in seen_pairs:\n                    seen_pairs.add(key)\n                    merged_links.append(\n                        Link(\n                            source=link.source,\n                            source_origin=tag_id,\n                            target=f\"{current}::{PK}\",\n                            target_origin=current,\n                            type=\"tag_to_schema\",\n                        )\n                    )\n                # stop traversing past a qualifying node\n                continue\n\n            for next_node in adjacency.get(current, () ):\n                if next_node not in visited:\n                    queue.append(next_node)\n\n    module_prefix_links = [\n        lk\n        for lk in links\n        if (lk.source_origin or \"\").startswith(module_prefix)\n        and (lk.target_origin or \"\").startswith(module_prefix)\n    ]\n\n    filtered_links =  merged_links + module_prefix_links\n\n    return tags, [], filtered_nodes, filtered_links  # route is skipped"
  },
  {
    "path": "src/fastapi_voyager/introspectors/__init__.py",
    "content": "\"\"\"\nIntrospectors for different web frameworks.\n\nThis package contains built-in introspector implementations for various frameworks.\n\"\"\"\nfrom .base import AppIntrospector, RouteInfo\nfrom .detector import FrameworkType, detect_framework, get_introspector\n\n# Try to import each introspector, but don't fail if the framework isn't installed\ntry:\n    from .fastapi import FastAPIIntrospector\nexcept ImportError:\n    FastAPIIntrospector = None  # type: ignore\n\ntry:\n    from .django_ninja import DjangoNinjaIntrospector\nexcept ImportError:\n    DjangoNinjaIntrospector = None  # type: ignore\n\ntry:\n    from .litestar import LitestarIntrospector\nexcept ImportError:\n    LitestarIntrospector = None  # type: ignore\n\n__all__ = [\n    \"AppIntrospector\",\n    \"RouteInfo\",\n    \"FastAPIIntrospector\",\n    \"DjangoNinjaIntrospector\",\n    \"LitestarIntrospector\",\n    \"FrameworkType\",\n    \"detect_framework\",\n    \"get_introspector\",\n]\n"
  },
  {
    "path": "src/fastapi_voyager/introspectors/base.py",
    "content": "\"\"\"\nIntrospection abstraction layer for framework-agnostic route analysis.\n\nThis module provides the abstraction that allows fastapi-voyager to work with\ndifferent web frameworks that support OpenAPI and Pydantic, such as:\n- FastAPI\n- Django Ninja\n- Litestar\n- Flask-OpenAPI\n\"\"\"\nfrom abc import ABC, abstractmethod\nfrom collections.abc import Callable, Iterator\nfrom dataclasses import dataclass\nfrom typing import Any\n\n\n@dataclass\nclass RouteInfo:\n    \"\"\"\n    Standardized route information that works across different frameworks.\n\n    This data class encapsulates the essential information needed by voyager\n    to analyze and visualize routes, independent of the underlying framework.\n    \"\"\"\n    # Unique identifier for the route (function path)\n    id: str\n\n    # Human-readable name (function name)\n    name: str\n\n    # Module where the route handler is defined\n    module: str\n\n    # Operation ID from OpenAPI spec\n    operation_id: str | None\n\n    # List of tags associated with this route\n    tags: list[str]\n\n    # The route handler function/endpoint\n    endpoint: Callable\n\n    # Response model (should be a Pydantic BaseModel)\n    response_model: type[Any]\n\n    # Any additional framework-specific data\n    extra: dict[str, Any] | None = None\n\n\nclass AppIntrospector(ABC):\n    \"\"\"\n    Abstract base class for app introspection.\n\n    Implement this class to add support for different web frameworks.\n    The introspector is responsible for extracting route information\n    from the framework's internal structure.\n    \"\"\"\n\n    @abstractmethod\n    def get_routes(self) -> Iterator[RouteInfo]:\n        \"\"\"\n        Iterate over all available routes in the application.\n\n        Yields:\n            RouteInfo: Standardized route information\n\n        Example:\n            >>> for route in introspector.get_routes():\n            ...     print(f\"{route.id}: {route.tags}\")\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_swagger_url(self) -> str | None:\n        \"\"\"\n        Get the URL to the Swagger/OpenAPI documentation.\n\n        Returns:\n            The URL path or None if not available\n        \"\"\"\n        pass\n"
  },
  {
    "path": "src/fastapi_voyager/introspectors/detector.py",
    "content": "\"\"\"\nFramework detection utility for fastapi-voyager.\n\nThis module provides a centralized framework detection mechanism that is used\nby both introspectors and adapters to avoid code duplication.\n\"\"\"\nfrom enum import Enum\nfrom typing import Any\n\nfrom fastapi_voyager.introspectors.base import AppIntrospector\n\n\nclass FrameworkType(Enum):\n    \"\"\"Supported framework types.\"\"\"\n    FASTAPI = \"fastapi\"\n    DJANGO_NINJA = \"django_ninja\"\n    LITESTAR = \"litestar\"\n    UNKNOWN = \"unknown\"\n\n\ndef detect_framework(app: Any) -> FrameworkType:\n    \"\"\"\n    Detect the framework type of the given application.\n\n    This function uses the same detection logic as the introspector system,\n    ensuring consistency across the codebase.\n\n    Args:\n        app: A web application instance\n\n    Returns:\n        FrameworkType: The detected framework type\n\n    Note:\n        The detection order matters: Litestar is checked before Django Ninja\n        to avoid Django import issues.\n    \"\"\"\n    # If it's already an introspector, try to determine framework from it\n    if isinstance(app, AppIntrospector):\n        app_class_name = type(app).__name__\n        if \"FastAPI\" in app_class_name:\n            return FrameworkType.FASTAPI\n        elif \"DjangoNinja\" in app_class_name or \"Ninja\" in app_class_name:\n            return FrameworkType.DJANGO_NINJA\n        elif \"Litestar\" in app_class_name:\n            return FrameworkType.LITESTAR\n        return FrameworkType.UNKNOWN\n\n    # Get the class name for type checking\n    app_class_name = type(app).__name__\n\n    # Try FastAPI\n    try:\n        from fastapi import FastAPI\n        if isinstance(app, FastAPI):\n            return FrameworkType.FASTAPI\n    except ImportError:\n        pass\n\n    # Try Litestar (check before Django Ninja to avoid Django import issues)\n    try:\n        from litestar import Litestar\n        if isinstance(app, Litestar):\n            return FrameworkType.LITESTAR\n    except ImportError:\n        pass\n\n    # Try Django Ninja (check by class name first to avoid import if not needed)\n    try:\n        if app_class_name == \"NinjaAPI\":\n            from ninja import NinjaAPI\n            if isinstance(app, NinjaAPI):\n                return FrameworkType.DJANGO_NINJA\n    except ImportError:\n        pass\n\n    return FrameworkType.UNKNOWN\n\n\ndef get_introspector(app: Any) -> AppIntrospector | None:\n    \"\"\"\n    Get the appropriate introspector for the given app.\n\n    This is a centralized function that uses the framework detection logic\n    to return the correct introspector instance.\n\n    Args:\n        app: A web application instance or AppIntrospector\n\n    Returns:\n        An AppIntrospector instance, or None if framework not supported\n\n    Raises:\n        TypeError: If the app type is not supported\n    \"\"\"\n    # If it's already an introspector, return it\n    if isinstance(app, AppIntrospector):\n        return app\n\n    framework = detect_framework(app)\n\n    if framework == FrameworkType.FASTAPI:\n        from fastapi_voyager.introspectors import FastAPIIntrospector\n        if FastAPIIntrospector:\n            return FastAPIIntrospector(app)\n\n    elif framework == FrameworkType.LITESTAR:\n        from fastapi_voyager.introspectors import LitestarIntrospector\n        if LitestarIntrospector:\n            return LitestarIntrospector(app)\n\n    elif framework == FrameworkType.DJANGO_NINJA:\n        from fastapi_voyager.introspectors import DjangoNinjaIntrospector\n        if DjangoNinjaIntrospector:\n            return DjangoNinjaIntrospector(app)\n\n    # If we get here, the app type is not supported\n    raise TypeError(\n        f\"Unsupported app type: {type(app).__name__}. \"\n        f\"Supported types: FastAPI, Django Ninja API, Litestar, or any AppIntrospector implementation. \"\n        f\"If you're using a different framework, please implement AppIntrospector for that framework. \"\n        f\"See ADAPTER_EXAMPLE.md for instructions.\"\n    )\n"
  },
  {
    "path": "src/fastapi_voyager/introspectors/django_ninja.py",
    "content": "\"\"\"\nDjango Ninja implementation of the AppIntrospector interface.\n\nThis module provides the adapter that allows fastapi-voyager to work with Django Ninja applications.\n\"\"\"\nfrom collections.abc import Iterator\n\nfrom fastapi_voyager.introspectors.base import AppIntrospector, RouteInfo\n\n\nclass DjangoNinjaIntrospector(AppIntrospector):\n    \"\"\"\n    Django Ninja-specific implementation of AppIntrospector.\n\n    This class extracts route information from Django Ninja's internal structure\n    and converts it to the framework-agnostic RouteInfo format.\n    \"\"\"\n\n    def __init__(self, ninja_api, swagger_url: str | None = None):\n        \"\"\"\n        Initialize the Django Ninja introspector.\n\n        Args:\n            ninja_api: The Django Ninja API instance\n            swagger_url: Optional custom URL to Swagger documentation\n        \"\"\"\n        self.api = ninja_api\n        self.swagger_url = swagger_url or \"/api/docs\"\n\n    def get_routes(self) -> Iterator[RouteInfo]:\n        \"\"\"\n        Iterate over all API routes in the Django Ninja application.\n\n        Yields:\n            RouteInfo: Standardized route information for each API route\n        \"\"\"\n        # Access the internal router structure\n        if not hasattr(self.api, \"default_router\"):\n            return\n\n        router = self.api.default_router\n\n        # Iterate through all path operations registered in the router\n        if not hasattr(router, \"path_operations\"):\n            return\n\n        for path, path_view in router.path_operations.items():\n            # path_view is a PathView object with a list of operations\n            if not hasattr(path_view, \"operations\"):\n                continue\n\n            for operation in path_view.operations:\n                try:\n                    yield RouteInfo(\n                        id=self._get_route_id(operation),\n                        name=operation.view_func.__name__,\n                        module=operation.view_func.__module__,\n                        operation_id=operation.operation_id or operation.view_func.__name__,\n                        tags=operation.tags or [],\n                        endpoint=operation.view_func,\n                        response_model=self._get_response_model(operation),\n                        extra={\n                            \"methods\": operation.methods,  # This is a list\n                            \"path\": path,\n                        },\n                    )\n                except (AttributeError, TypeError):\n                    # Skip routes that don't have the expected structure\n                    continue\n\n    def get_swagger_url(self) -> str | None:\n        \"\"\"\n        Get the URL to the Swagger UI documentation.\n\n        Returns:\n            The URL path to Swagger UI\n        \"\"\"\n        return self.swagger_url\n\n    def _get_route_id(self, operation) -> str:\n        \"\"\"\n        Generate a unique identifier for the route.\n\n        Uses the full class path of the view function.\n\n        Args:\n            operation: The Django Ninja operation object\n\n        Returns:\n            A unique identifier string\n        \"\"\"\n        # Import here to avoid circular dependency\n        from fastapi_voyager.type_helper import full_class_name\n        return full_class_name(operation.view_func)\n\n    def _get_response_model(self, operation) -> type:\n        \"\"\"\n        Extract the response model from the operation.\n\n        Django Ninja infers response model from function's return type annotation.\n\n        Args:\n            operation: The Django Ninja operation object\n\n        Returns:\n            The response model class, or type(None) if not found\n        \"\"\"\n        # Django Ninja uses type hints for response models\n        # The response_models field is always NOT_SET_TYPE, so we only check __annotations__\n        if hasattr(operation.view_func, \"__annotations__\") and \"return\" in operation.view_func.__annotations__:\n            return operation.view_func.__annotations__[\"return\"]\n\n        # No response model found\n        return type(None)  # type: ignore\n"
  },
  {
    "path": "src/fastapi_voyager/introspectors/fastapi.py",
    "content": "\"\"\"\nFastAPI implementation of the AppIntrospector interface.\n\nThis module provides the adapter that allows fastapi-voyager to work with FastAPI applications.\n\"\"\"\nfrom collections.abc import Iterator\nfrom typing import TYPE_CHECKING, Any\n\nfrom fastapi_voyager.introspectors.base import AppIntrospector, RouteInfo\n\nif TYPE_CHECKING:\n    from fastapi import FastAPI\n\n\nclass FastAPIIntrospector(AppIntrospector):\n    \"\"\"\n    FastAPI-specific implementation of AppIntrospector.\n\n    This class extracts route information from FastAPI's internal route structure\n    and converts it to the framework-agnostic RouteInfo format.\n    \"\"\"\n\n    def __init__(self, app: \"FastAPI\", swagger_url: str | None = None):\n        \"\"\"\n        Initialize the FastAPI introspector.\n\n        Args:\n            app: The FastAPI application instance\n            swagger_url: Optional custom URL to Swagger documentation\n        \"\"\"\n        # Lazy import to avoid import errors when FastAPI is not installed\n        from fastapi import FastAPI\n\n        if not isinstance(app, FastAPI):\n            raise TypeError(f\"Expected FastAPI instance, got {type(app)}\")\n\n        self.app = app\n        self.swagger_url = swagger_url or \"/docs\"\n\n    def get_routes(self) -> Iterator[RouteInfo]:\n        \"\"\"\n        Iterate over all API routes in the FastAPI application.\n\n        Yields:\n            RouteInfo: Standardized route information for each API route\n        \"\"\"\n        # Lazy import routing to avoid import errors when FastAPI is not installed\n        from fastapi import routing\n\n        for route in self.app.routes:\n            # Only process APIRoute instances (not static files, etc.)\n            if isinstance(route, routing.APIRoute):\n                # Extract tags from the route\n                tags = getattr(route, 'tags', None) or []\n\n                yield RouteInfo(\n                    id=self._get_route_id(route),\n                    name=route.endpoint.__name__,\n                    module=route.endpoint.__module__,\n                    operation_id=route.operation_id,\n                    tags=tags,\n                    endpoint=route.endpoint,\n                    response_model=route.response_model,\n                    extra={\n                        'unique_id': route.unique_id,\n                        'methods': route.methods,\n                        'path': route.path,\n                    }\n                )\n\n    def get_swagger_url(self) -> str | None:\n        \"\"\"\n        Get the URL to the Swagger UI documentation.\n\n        Returns:\n            The URL path to Swagger UI\n        \"\"\"\n        return self.swagger_url\n\n    def _get_route_id(self, route: Any) -> str:\n        \"\"\"\n        Generate a unique identifier for the route.\n\n        Uses the full class path of the endpoint function.\n\n        Args:\n            route: The FastAPI route object\n\n        Returns:\n            A unique identifier string\n        \"\"\"\n        # Import here to avoid circular dependency\n        from fastapi_voyager.type_helper import full_class_name\n        return full_class_name(route.endpoint)\n"
  },
  {
    "path": "src/fastapi_voyager/introspectors/litestar.py",
    "content": "\"\"\"\nLitestar implementation of the AppIntrospector interface.\n\nThis module provides the adapter that allows fastapi-voyager to work with Litestar applications.\n\"\"\"\nfrom collections.abc import Iterator\n\nfrom fastapi_voyager.introspectors.base import AppIntrospector, RouteInfo\n\n\nclass LitestarIntrospector(AppIntrospector):\n    \"\"\"\n    Litestar-specific implementation of AppIntrospector.\n\n    This class extracts route information from Litestar's internal structure\n    and converts it to the framework-agnostic RouteInfo format.\n    \"\"\"\n\n    def __init__(self, app, swagger_url: str | None = None):\n        \"\"\"\n        Initialize the Litestar introspector.\n\n        Args:\n            app: The Litestar application instance\n            swagger_url: Optional custom URL to Swagger/OpenAPI documentation\n        \"\"\"\n        self.app = app\n        self.swagger_url = swagger_url or \"/schema/swagger\"\n\n    def get_routes(self) -> Iterator[RouteInfo]:\n        \"\"\"\n        Iterate over all routes in the Litestar application.\n\n        Yields:\n            RouteInfo: Standardized route information for each route\n        \"\"\"\n        for route in self.app.routes:\n            try:\n                # Skip routes without path or methods\n                if not hasattr(route, \"path\") or not hasattr(route, \"methods\"):\n                    continue\n\n                # Skip Litestar's auto-generated schema routes\n                if hasattr(route, \"path\") and route.path.startswith(\"/schema\"):\n                    continue\n\n                # Get the handler function from route_handlers\n                handler = None\n                handler_obj = None\n                if hasattr(route, \"route_handlers\") and route.route_handlers:\n                    # Find the GET handler (or any non-OPTIONS handler)\n                    for route_handler in route.route_handlers:\n                        if hasattr(route_handler, \"fn\") and hasattr(route_handler.fn, \"__name__\"):\n                            # Store the route handler object for tags\n                            if hasattr(route_handler, \"http_methods\") and \"GET\" in route_handler.http_methods:\n                                handler_obj = route_handler\n                            handler = route_handler.fn\n                            if handler_obj:\n                                break\n\n                if not handler:\n                    continue\n\n                # Skip handlers with names starting with _ (internal/private)\n                if hasattr(handler, \"__name__\") and handler.__name__.startswith(\"_\"):\n                    continue\n\n                # Extract tags from the route handler object\n                tags = []\n                if handler_obj and hasattr(handler_obj, \"tags\") and handler_obj.tags:\n                    tags = list(handler_obj.tags)\n\n                # Get return type from handler's annotations\n                return_model = type(None)\n                if hasattr(handler, \"__annotations__\") and \"return\" in handler.__annotations__:\n                    return_model = handler.__annotations__[\"return\"]\n\n                yield RouteInfo(\n                    id=self._get_route_id(handler),\n                    name=handler.__name__,\n                    module=handler.__module__,\n                    operation_id=self._get_operation_id(route, handler),\n                    tags=tags,\n                    endpoint=handler,\n                    response_model=return_model,\n                    extra={\n                        \"methods\": list(route.methods) if hasattr(route, \"methods\") else [],\n                        \"path\": route.path,\n                    },\n                )\n            except (AttributeError, TypeError):\n                # Skip routes that don't have the expected structure\n                continue\n\n    def get_swagger_url(self) -> str | None:\n        \"\"\"\n        Get the URL to the Swagger/OpenAPI documentation.\n\n        Returns:\n            The URL path to Swagger UI\n        \"\"\"\n        return self.swagger_url\n\n    def _get_route_id(self, handler) -> str:\n        \"\"\"\n        Generate a unique identifier for the route.\n\n        Uses the full module path of the handler function.\n\n        Args:\n            handler: The route handler function\n\n        Returns:\n            A unique identifier string\n        \"\"\"\n        # Import here to avoid circular dependency\n        from fastapi_voyager.type_helper import full_class_name\n        return full_class_name(handler)\n\n    def _get_operation_id(self, route, handler) -> str:\n        \"\"\"\n        Extract or generate the operation ID for the route.\n\n        Args:\n            route: The Litestar route object\n            handler: The handler function\n\n        Returns:\n            An operation ID string\n        \"\"\"\n        # Litestar might not have operation_id, so we generate one\n        if hasattr(route, \"operation_id\"):\n            return route.operation_id\n        # Fallback to using the handler function name\n        if hasattr(handler, \"__name__\"):\n            return handler.__name__\n        # Fallback to using the path\n        if hasattr(route, \"path\"):\n            return route.path\n        return \"\"\n\n    def _get_response_model(self, route) -> type:\n        \"\"\"\n        Extract the response model from the route.\n\n        Args:\n            route: The Litestar route object\n\n        Returns:\n            The response model class\n        \"\"\"\n        # Try to get response model from route\n        if hasattr(route, \"responses\"):\n            responses = route.responses\n            if responses and \"200\" in responses:\n                response_200 = responses[\"200\"]\n                if hasattr(response_200, \"model\"):\n                    return response_200.model\n\n        # Fallback: check if handler has return annotation\n        handler = route.handler if hasattr(route, \"handler\") else None\n        if handler and hasattr(handler, \"__annotations__\") and \"return\" in handler.__annotations__:\n            return handler.__annotations__[\"return\"]\n\n        # Return None if no response model found\n        return type(None)  # type: ignore\n"
  },
  {
    "path": "src/fastapi_voyager/module.py",
    "content": "from collections.abc import Callable\nfrom typing import Any, TypeVar\n\nfrom fastapi_voyager.type import ModuleNode, ModuleRoute, Route, SchemaNode\n\nN = TypeVar('N')  # Node type: ModuleNode or ModuleRoute\nI = TypeVar('I')  # Item type: SchemaNode or Route\n\n\ndef _build_module_tree(\n    items: list[I],\n    *,\n    get_module_path: Callable[[I], str | None],\n    NodeClass: type[N],\n    item_list_attr: str,\n) -> list[N]:\n    \"\"\"\n    Generic builder that groups items by dotted module path into a tree of NodeClass.\n\n    NodeClass must accept kwargs: name, fullname, modules(list), and an item list via\n    item_list_attr (e.g., 'schema_nodes' or 'routes').\n    \"\"\"\n    # Map from top-level module name to node\n    top_modules: dict[str, N] = {}\n    # Items without module path\n    root_level_items: list[I] = []\n\n    def make_node(name: str, fullname: str) -> N:\n        kwargs: dict[str, Any] = {\n            'name': name,\n            'fullname': fullname,\n            'modules': [],\n            item_list_attr: [],\n        }\n        return NodeClass(**kwargs)  # type: ignore[arg-type]\n\n    def get_or_create(child_name: str, parent: N) -> N:\n        for m in parent.modules:\n            if m.name == child_name:\n                return m\n        parent_full = parent.fullname\n        fullname = child_name if not parent_full or parent_full == \"__root__\" else f\"{parent_full}.{child_name}\"\n        new_node = make_node(child_name, fullname)\n        parent.modules.append(new_node)\n        return new_node\n\n    # Build the tree\n    for it in items:\n        module_path = get_module_path(it) or \"\"\n        if not module_path:\n            root_level_items.append(it)\n            continue\n        parts = module_path.split('.')\n        top_name = parts[0]\n        if top_name not in top_modules:\n            top_modules[top_name] = make_node(top_name, top_name)\n        current = top_modules[top_name]\n        for part in parts[1:]:\n            current = get_or_create(part, current)\n        getattr(current, item_list_attr).append(it)\n\n    result: list[N] = list(top_modules.values())\n    if root_level_items:\n        result.append(make_node(\"__root__\", \"__root__\"))\n        setattr(result[-1], item_list_attr, root_level_items)\n\n    # Collapse linear chains: no items on node and exactly one child module\n    def collapse(node: N) -> None:\n        while len(node.modules) == 1 and len(getattr(node, item_list_attr)) == 0:\n            child = node.modules[0]\n            node.name = f\"{node.name}.{child.name}\"\n            node.fullname = child.fullname\n            setattr(node, item_list_attr, getattr(child, item_list_attr))\n            node.modules = child.modules\n        for m in node.modules:\n            collapse(m)\n\n    for top in result:\n        collapse(top)\n\n    return result\n\ndef build_module_schema_tree(schema_nodes: list[SchemaNode]) -> list[ModuleNode]:\n    \"\"\"Build a module tree for schema nodes, grouped by their module path.\"\"\"\n    return _build_module_tree(\n        schema_nodes,\n        get_module_path=lambda sn: sn.module,\n        NodeClass=ModuleNode,\n        item_list_attr='schema_nodes',\n    )\n\n\ndef build_module_route_tree(routes: list[Route]) -> list[ModuleRoute]:\n    \"\"\"Build a module tree for routes, grouped by their module path.\"\"\"\n    return _build_module_tree(\n        routes,\n        get_module_path=lambda r: r.module,\n        NodeClass=ModuleRoute,\n        item_list_attr='routes',\n    )"
  },
  {
    "path": "src/fastapi_voyager/pydantic_resolve_util.py",
    "content": "import inspect\n\nimport pydantic_resolve.constant as const\nfrom pydantic import BaseModel\nfrom pydantic.fields import FieldInfo\nfrom pydantic_resolve.utils.collector import ICollector, SendToInfo\nfrom pydantic_resolve.utils.er_diagram import LoaderInfo\nfrom pydantic_resolve.utils.expose import ExposeInfo\n\n\ndef analysis_pydantic_resolve_fields(schema: type[BaseModel], field: str):\n    \"\"\"\n    get information for pydantic resolve specific info\n    in future, this function will be provide by pydantic-resolve package\n\n    is_resolve: bool = False\n    - check existence of def resolve_{field} method\n    - check existence of LoaderInfo in field.metadata\n\n    is_post: bool = False\n    - check existence of def post_{field} method\n\n    expose_as_info: str | None = None\n    - check ExposeInfo in field.metadata\n    - check field in schema.__pydantic_resolve_expose__ (const.EXPOSE_TO_DESCENDANT)\n\n    send_to_info: list[str] | None = None\n    - check SendToInfo in field.metadata\n    - check field in schema.__pydantic_resolve_collect__ (const.COLLECTOR_CONFIGURATION)\n\n    collect_info: list[str] | None = None\n    - 1. check existence of def post_{field} method\n    - 2. get the signature of this method\n    - 3. extrace the collector names from the parameters with ICollector metadata\n    \n\n\n    return dict in form of \n    {\n        \"is_resolve\": True,\n        ...\n    }\n    \"\"\"\n    has_meta = False\n    field_info: FieldInfo = schema.model_fields.get(field)\n    \n    is_resolve = hasattr(schema, f'{const.RESOLVE_PREFIX}{field}')\n    is_post = hasattr(schema, f'{const.POST_PREFIX}{field}')\n    expose_as_info = None\n    send_to_info = None\n    post_collector = []\n\n    send_to_info_list = []\n\n    if field_info:\n        # Check metadata\n        for meta in field_info.metadata:\n            if isinstance(meta, LoaderInfo):\n                is_resolve = True\n            if isinstance(meta, ExposeInfo):\n                expose_as_info = meta.alias\n            if isinstance(meta, SendToInfo):\n                if isinstance(meta.collector_name, str):\n                    send_to_info_list.append(meta.collector_name)\n                else:\n                    send_to_info_list.extend(list(meta.collector_name))\n\n    # Check class attributes\n    expose_dict = getattr(schema, const.EXPOSE_TO_DESCENDANT, {})\n    if field in expose_dict:\n        expose_as_info = expose_dict[field]\n\n    collect_dict = getattr(schema, const.COLLECTOR_CONFIGURATION, {})\n\n    for keys, collectors in collect_dict.items():\n        target_keys = [keys] if isinstance(keys, str) else list(keys)\n        if field in target_keys:\n            if isinstance(collectors, str):\n                send_to_info_list.append(collectors)\n            else:\n                send_to_info_list.extend(list(collectors))\n    \n    if send_to_info_list:\n        send_to_info = list(set(send_to_info_list))  # unique collectors\n    \n    if is_post:\n        post_method = getattr(schema, f'{const.POST_PREFIX}{field}')\n        for _, param in inspect.signature(post_method).parameters.items():\n            if isinstance(param.default, ICollector):\n                post_collector.append(param.default.alias)\n    \n    has_meta = any([is_resolve, is_post, expose_as_info, send_to_info])\n\n    return {\n        \"has_pydantic_resolve_meta\": has_meta,\n        \"is_resolve\": is_resolve,\n        \"is_post\": is_post,\n        \"expose_as_info\": expose_as_info,\n        \"send_to_info\": send_to_info,\n        \"collect_info\": None if len(post_collector) == 0 else post_collector\n    }\n\n\ndef extract_query_mutation_methods(entity: type) -> tuple[list[dict], list[dict]]:\n    \"\"\"\n    Extract all @query and @mutation decorated methods from an Entity.\n\n    Returns:\n        A tuple of (queries, mutations), each is a list of dicts:\n        - name: GraphQL name (from decorator or method name)\n        - return_type: Return type annotation as string\n\n    Each list is sorted alphabetically by name.\n    \"\"\"\n    # Lazy import to avoid circular dependency\n    from fastapi_voyager.type_helper import get_type_name\n\n    queries = []\n    mutations = []\n\n    for name, method in entity.__dict__.items():\n        # Handle classmethod - access underlying function\n        actual_method = method\n        if isinstance(method, classmethod):\n            actual_method = method.__func__\n\n        is_query = hasattr(actual_method, '_pydantic_resolve_query')\n        is_mutation = hasattr(actual_method, '_pydantic_resolve_mutation')\n\n        if is_query or is_mutation:\n            # Get GraphQL name\n            if is_query:\n                gql_name = getattr(actual_method, '_pydantic_resolve_query_name', None)\n            else:\n                gql_name = getattr(actual_method, '_pydantic_resolve_mutation_name', None)\n\n            # Use method name if no GraphQL name specified\n            display_name = gql_name or name\n\n            # Get return type from signature\n            return_type = 'Unknown'\n            try:\n                sig = inspect.signature(actual_method)\n                if sig.return_annotation != inspect.Signature.empty:\n                    return_type = get_type_name(sig.return_annotation)\n            except Exception:\n                pass\n\n            method_info = {\n                'name': display_name,\n                'return_type': return_type\n            }\n\n            if is_query:\n                queries.append(method_info)\n            else:\n                mutations.append(method_info)\n\n    # Sort each list alphabetically by name\n    queries.sort(key=lambda m: m['name'])\n    mutations.sort(key=lambda m: m['name'])\n\n    return queries, mutations\n"
  },
  {
    "path": "src/fastapi_voyager/render.py",
    "content": "\"\"\"\nRender FastAPI application structure to DOT format using Jinja2 templates.\n\"\"\"\nfrom logging import getLogger\nfrom pathlib import Path\n\nfrom jinja2 import Environment, FileSystemLoader, select_autoescape\n\nfrom fastapi_voyager.module import build_module_route_tree, build_module_schema_tree\nfrom fastapi_voyager.render_style import RenderConfig\nfrom fastapi_voyager.type import (\n    FieldInfo,\n    FieldType,\n    Link,\n    MethodInfo,\n    ModuleNode,\n    ModuleRoute,\n    PK,\n    Route,\n    SchemaNode,\n    Tag,\n)\nfrom typing import Literal\n\nlogger = getLogger(__name__)\n\n# Get the template directory relative to this file\nTEMPLATE_DIR = Path(__file__).parent / \"templates\"\n\n\nclass TemplateRenderer:\n    \"\"\"\n    Jinja2-based template renderer for DOT and HTML templates.\n    \"\"\"\n\n    def __init__(self, template_dir: Path = TEMPLATE_DIR):\n        # Initialize Jinja2 environment\n        self.env = Environment(\n            loader=FileSystemLoader(template_dir),\n            autoescape=select_autoescape(),\n            trim_blocks=True,\n            lstrip_blocks=True,\n        )\n\n    def render_template(self, template_name: str, **context) -> str:\n        \"\"\"Render a template with the given context.\"\"\"\n        template = self.env.get_template(template_name)\n        return template.render(**context)\n\n\nclass Renderer:\n    \"\"\"\n    Render FastAPI application structure to DOT format.\n\n    This class handles the conversion of tags, routes, schemas, and links\n    into Graphviz DOT format, with support for custom styling and filtering.\n    \"\"\"\n\n    def __init__(\n        self,\n        *,\n        show_fields: FieldType = 'single',\n        module_color: dict[str, str] | None = None,\n        schema: str | None = None,\n        show_module: bool = True,\n        show_pydantic_resolve_meta: bool = False,\n        config: RenderConfig | None = None,\n        theme_color: str | None = None,\n        show_methods: bool = True,\n    ) -> None:\n        self.show_fields = show_fields if show_fields in ('single', 'object', 'all') else 'single'\n        self.module_color = module_color or {}\n        self.schema = schema\n        self.show_module = show_module\n        self.show_pydantic_resolve_meta = show_pydantic_resolve_meta\n        self.show_methods = show_methods\n\n        # Use provided config or create default\n        self.config = config or RenderConfig()\n        self.colors = self.config.colors\n        self.style = self.config.style\n\n        # Framework theme color (overrides default primary color)\n        self.theme_color = theme_color or self.colors.primary\n\n        # Initialize template renderer\n        self.template_renderer = TemplateRenderer()\n\n        logger.info(f'show_module: {self.show_module}')\n        logger.info(f'module_color: {self.module_color}')\n\n    def _render_pydantic_meta_parts(self, field: FieldInfo) -> list[str]:\n        \"\"\"Render pydantic-resolve metadata as HTML parts.\"\"\"\n        if not self.show_pydantic_resolve_meta:\n            return []\n\n        parts = []\n        if field.is_resolve:\n            parts.append(\n                self.template_renderer.render_template(\n                    'html/colored_text.j2',\n                    text='● resolve',\n                    color=self.colors.resolve\n                )\n            )\n        if field.is_post:\n            parts.append(\n                self.template_renderer.render_template(\n                    'html/colored_text.j2',\n                    text='● post',\n                    color=self.colors.post\n                )\n            )\n        if field.expose_as_info:\n            parts.append(\n                self.template_renderer.render_template(\n                    'html/colored_text.j2',\n                    text=f'● expose as: {field.expose_as_info}',\n                    color=self.colors.expose_as\n                )\n            )\n        if field.send_to_info:\n            to_collectors = ', '.join(field.send_to_info)\n            parts.append(\n                self.template_renderer.render_template(\n                    'html/colored_text.j2',\n                    text=f'● send to: {to_collectors}',\n                    color=self.colors.send_to\n                )\n            )\n        if field.collect_info:\n            defined_collectors = ', '.join(field.collect_info)\n            parts.append(\n                self.template_renderer.render_template(\n                    'html/colored_text.j2',\n                    text=f'● collectors: {defined_collectors}',\n                    color=self.colors.collector\n                )\n            )\n\n        return parts\n\n    def _render_schema_field(\n        self,\n        field: FieldInfo,\n        max_type_length: int | None = None\n    ) -> str:\n        \"\"\"Render a single schema field.\"\"\"\n        max_len = max_type_length or self.config.max_type_length\n\n        # Truncate type name if too long\n        type_name = field.type_name\n        if len(type_name) > max_len:\n            type_name = type_name[:max_len] + self.config.type_suffix\n\n        # Format field display\n        field_text = f'{field.name}: {type_name}'\n\n        # Render pydantic metadata\n        meta_parts = self._render_pydantic_meta_parts(field)\n        meta_html = self.template_renderer.render_template(\n            'html/pydantic_meta.j2',\n            meta_parts=meta_parts\n        )\n\n        # Render field text (with strikethrough if excluded)\n        text_html = self.template_renderer.render_template(\n            'html/colored_text.j2',\n            text=field_text,\n            color='#000',  # Default color\n            strikethrough=field.is_exclude\n        )\n\n        # Combine field text and metadata\n        content = f'<font>  {text_html}  </font> {meta_html}'\n\n        # Render the table row\n        return self.template_renderer.render_template(\n            'html/schema_field_row.j2',\n            port=field.name,\n            align='left',\n            content=content\n        )\n\n    def _render_schema_method(self, method: MethodInfo, type: Literal['query', 'mutation']) -> str:\n        \"\"\"Render a single method row for @query or @mutation.\"\"\"\n        # Format: [Q] name: type or [M] name: type\n        prefix = '[Q]' if type == 'query' else '[M]'\n        color = self.colors.query if type == 'query' else self.colors.mutation\n\n        # Truncate return type if too long\n        return_type = method.return_type\n        if len(return_type) > self.config.max_type_length:\n            return_type = return_type[:self.config.max_type_length] + self.config.type_suffix\n\n        method_text = f'{prefix} {method.name}: {return_type}'\n\n        # Render method text with color\n        text_html = self.template_renderer.render_template(\n            'html/colored_text.j2',\n            text=method_text,\n            color=color\n        )\n\n        content = f'<font>  {text_html}  </font>'\n\n        return self.template_renderer.render_template(\n            'html/schema_field_row.j2',\n            port=None,  # No port needed for methods\n            align='left',\n            content=content\n        )\n\n    def _get_filtered_fields(self, node: SchemaNode) -> list[FieldInfo]:\n        \"\"\"Get fields filtered by show_fields and show_pydantic_resolve_meta settings.\"\"\"\n\n        # Filter fields based on pydantic-resolve meta setting\n        if self.show_pydantic_resolve_meta:\n            fields = [n for n in node.fields if n.has_pydantic_resolve_meta or not n.from_base]\n        else:\n            fields = [n for n in node.fields if not n.from_base]\n\n        # Further filter by show_fields setting\n        if self.show_fields == 'all':\n            return fields\n        elif self.show_fields == 'object':\n            if self.show_pydantic_resolve_meta:\n                # Show object fields or fields with pydantic-resolve metadata\n                return [f for f in fields if f.is_object or f.has_pydantic_resolve_meta]\n            else:\n                # Show only object fields\n                return [f for f in fields if f.is_object]\n        else:  # 'single'\n            return []\n\n    def render_schema_label(self, node: SchemaNode, color: str | None = None) -> str:\n        \"\"\"\n        Render a schema node's label as an HTML table.\n\n        TODO: Improve logic with show_pydantic_resolve_meta\n        \"\"\"\n        fields = self._get_filtered_fields(node)\n\n        # Render field rows\n        rows = []\n        has_base_fields = any(f.from_base for f in node.fields)\n\n        # Add inherited fields notice if needed\n        if self.show_fields == 'all' and has_base_fields:\n            notice = self.template_renderer.render_template(\n                'html/colored_text.j2',\n                text='  Inherited Fields ... ',\n                color=self.colors.text_gray\n            )\n            rows.append(\n                self.template_renderer.render_template(\n                    'html/schema_field_row.j2',\n                    content=notice,\n                    align='left'\n                )\n            )\n\n        # Render each field\n        for field in fields:\n            rows.append(self._render_schema_field(field))\n\n        # Add methods if present (in all show_fields modes)\n        if self.show_methods and (node.queries or node.mutations):\n            # Render queries\n            for method in node.queries:\n                rows.append(self._render_schema_method(method, type='query'))\n\n            # Render mutations\n            for method in node.mutations:\n                rows.append(self._render_schema_method(method, type='mutation'))\n\n        # Determine header color\n        default_color = self.theme_color if color is None else color\n        header_color = self.colors.highlight if node.id == self.schema else default_color\n\n        # Render header\n        header = self.template_renderer.render_template(\n            'html/schema_header.j2',\n            text=node.name,\n            bg_color=header_color,\n            port=PK,\n            is_entity=node.is_entity\n        )\n\n        # Render complete table\n        return self.template_renderer.render_template(\n            'html/schema_table.j2',\n            header=header,\n            rows=''.join(rows)\n        )\n\n    def _handle_schema_anchor(self, source: str) -> str:\n        \"\"\"Handle schema anchor for DOT links.\"\"\"\n        if '::' in source:\n            a, b = source.split('::', 1)\n            return f'\"{a}\":{b}'\n        return f'\"{source}\"'\n\n    def _format_link_attributes(self, attrs: dict) -> str:\n        \"\"\"Format link attributes for DOT format.\"\"\"\n        return ', '.join(f'{k}=\"{v}\"' for k, v in attrs.items())\n\n    def render_link(self, link: Link) -> str:\n        \"\"\"Render a link in DOT format.\"\"\"\n        source = self._handle_schema_anchor(link.source)\n        target = self._handle_schema_anchor(link.target)\n\n        # Build link attributes\n        # If link.style is explicitly set (e.g., 'solid, dashed' for ER diagrams), use it\n        # Otherwise, get default style from configuration based on link.type\n        if link.style is not None:\n            attrs = {'style': link.style}\n            if link.label:\n                attrs['label'] = link.label\n            # attrs['minlen'] = 3\n        else:\n            attrs = self.style.get_link_attributes(link.type)\n            if link.label:\n                attrs['label'] = link.label\n\n        return self.template_renderer.render_template(\n            'dot/link.j2',\n            source=source,\n            target=target,\n            attributes=self._format_link_attributes(attrs)\n        )\n\n    def render_schema_node(self, node: SchemaNode, color: str | None = None) -> str:\n        \"\"\"Render a schema node in DOT format.\"\"\"\n        label = self.render_schema_label(node, color)\n\n        return self.template_renderer.render_template(\n            'dot/schema_node.j2',\n            id=node.id,\n            label=label,\n            margin=self.style.node_margin\n        )\n\n    def render_tag_node(self, tag: Tag) -> str:\n        \"\"\"Render a tag node in DOT format.\"\"\"\n        return self.template_renderer.render_template(\n            'dot/tag_node.j2',\n            id=tag.id,\n            name=tag.name,\n            margin=self.style.node_margin\n        )\n\n    def render_route_node(self, route: Route) -> str:\n        \"\"\"Render a route node in DOT format.\"\"\"\n        # Truncate response schema if too long\n        response_schema = route.response_schema\n        if len(response_schema) > self.config.max_type_length:\n            response_schema = response_schema[:self.config.max_type_length] + self.config.type_suffix\n\n        return self.template_renderer.render_template(\n            'dot/route_node.j2',\n            id=route.id,\n            name=route.name,\n            response_schema=response_schema,\n            margin=self.style.node_margin\n        )\n\n    def _render_module_schema(\n        self,\n        mod: ModuleNode,\n        module_color_flag: set[str],\n        inherit_color: str | None = None,\n        show_cluster: bool = True\n    ) -> str:\n        \"\"\"Render a module schema tree.\"\"\"\n        color = inherit_color\n        cluster_color: str | None = None\n\n        # Check if this module has a custom color\n        for k in module_color_flag:\n            if mod.fullname.startswith(k):\n                module_color_flag.remove(k)\n                color = self.module_color[k]\n                cluster_color = color if color != inherit_color else None\n                break\n\n        # Render inner schema nodes\n        inner_nodes = [\n            self.render_schema_node(node, color)\n            for node in mod.schema_nodes\n        ]\n        inner_nodes_str = '\\n'.join(inner_nodes)\n\n        # Recursively render child modules\n        child_str = '\\n'.join(\n            self._render_module_schema(\n                m,\n                module_color_flag=module_color_flag,\n                inherit_color=color,\n                show_cluster=show_cluster\n            )\n            for m in mod.modules\n        )\n\n        if show_cluster:\n            # Render as a cluster\n            cluster_id = f'module_{mod.fullname.replace(\".\", \"_\")}'\n            pen_style = ''\n\n            if cluster_color:\n                pen_style = f'pencolor = \"{cluster_color}\"'\n                pen_style += '\\n' + 'penwidth = 3' if color else ''\n            else:\n                pen_style = 'pencolor=\"#ccc\"'\n\n            return self.template_renderer.render_template(\n                'dot/cluster.j2',\n                cluster_id=cluster_id,\n                label=mod.name,\n                tooltip=mod.fullname,\n                border_color=self.colors.border,\n                pen_color=cluster_color,\n                pen_width=3 if color and not cluster_color else None,\n                content=f'{inner_nodes_str}\\n{child_str}'\n            )\n        else:\n            # Render without cluster\n            return f'{inner_nodes_str}\\n{child_str}'\n\n    def render_module_schema_content(self, nodes: list[SchemaNode]) -> str:\n        \"\"\"Render all module schemas.\"\"\"\n        module_schemas = build_module_schema_tree(nodes)\n        module_color_flag = set(self.module_color.keys())\n\n        return '\\n'.join(\n            self._render_module_schema(\n                m,\n                module_color_flag=module_color_flag,\n                show_cluster=self.show_module\n            )\n            for m in module_schemas\n        )\n\n    def _render_module_route(self, mod: ModuleRoute, show_cluster: bool = True) -> str:\n        \"\"\"Render a module route tree.\"\"\"\n        # Render inner route nodes\n        inner_nodes = [self.render_route_node(r) for r in mod.routes]\n        inner_nodes_str = '\\n'.join(inner_nodes)\n\n        # Recursively render child modules\n        child_str = '\\n'.join(\n            self._render_module_route(m, show_cluster=show_cluster)\n            for m in mod.modules\n        )\n\n        if show_cluster:\n            cluster_id = f'route_module_{mod.fullname.replace(\".\", \"_\")}'\n\n            return self.template_renderer.render_template(\n                'dot/cluster.j2',\n                cluster_id=cluster_id,\n                label=mod.name,\n                tooltip=mod.fullname,\n                border_color=self.colors.border,\n                pen_color=None,\n                pen_width=None,\n                content=f'{inner_nodes_str}\\n{child_str}'\n            )\n        else:\n            return f'{inner_nodes_str}\\n{child_str}'\n\n    def render_module_route_content(self, routes: list[Route]) -> str:\n        \"\"\"Render all module routes.\"\"\"\n        module_routes = build_module_route_tree(routes)\n\n        return '\\n'.join(\n            self._render_module_route(m, show_cluster=self.show_module)\n            for m in module_routes\n        )\n\n    def _render_cluster_container(\n        self,\n        name: str,\n        label: str,\n        content: str,\n        fontsize: str | None = None\n    ) -> str:\n        \"\"\"Render a cluster container (for tags, routes, schemas).\"\"\"\n        return self.template_renderer.render_template(\n            'dot/cluster_container.j2',\n            name=name,\n            label=label,\n            content=content,\n            border_color=self.colors.border,\n            margin=self.style.cluster_margin,\n            fontsize=fontsize or self.style.cluster_fontsize\n        )\n\n    def render_dot(\n        self,\n        tags: list[Tag],\n        routes: list[Route],\n        nodes: list[SchemaNode],\n        links: list[Link],\n        spline_line: bool = False\n    ) -> str:\n        \"\"\"\n        Render the complete DOT graph.\n\n        Args:\n            tags: List of tags\n            routes: List of routes\n            nodes: List of schema nodes\n            links: List of links\n            spline_line: Whether to use spline lines\n\n        Returns:\n            Complete DOT graph as a string\n        \"\"\"\n        # Render tag nodes\n        tag_str = '\\n'.join(self.render_tag_node(t) for t in tags)\n\n        # Render tags cluster\n        tags_cluster = self._render_cluster_container(\n            name='tags',\n            label='Tags',\n            content=tag_str\n        )\n\n        # Render routes cluster\n        module_routes_str = self.render_module_route_content(routes)\n        routes_cluster = self._render_cluster_container(\n            name='router',\n            label='Routes',\n            content=module_routes_str\n        )\n\n        # Render schemas cluster\n        module_schemas_str = self.render_module_schema_content(nodes)\n        schemas_cluster = self._render_cluster_container(\n            name='schema',\n            label='Schema',\n            content=module_schemas_str\n        )\n\n        # Render links\n        link_str = '\\n'.join(self.render_link(link) for link in links)\n\n        # Render complete digraph\n        return self.template_renderer.render_template(\n            'dot/digraph.j2',\n            pad=self.style.pad,\n            nodesep=self.style.nodesep,\n            spline='line' if spline_line else '',\n            font=self.style.font,\n            node_fontsize=self.style.node_fontsize,\n            tags_cluster=tags_cluster,\n            routes_cluster=routes_cluster,\n            schemas_cluster=schemas_cluster,\n            links=link_str\n        )\n"
  },
  {
    "path": "src/fastapi_voyager/render_style.py",
    "content": "\"\"\"\nStyle constants and configuration for rendering DOT graphs and HTML tables.\n\"\"\"\nfrom dataclasses import dataclass, field\n\nfrom fastapi_voyager.introspectors.detector import FrameworkType\n\n\n@dataclass\nclass ColorScheme:\n    \"\"\"Color scheme for graph visualization.\"\"\"\n\n    # Framework-specific theme colors (single source of truth)\n    FRAMEWORK_COLORS: dict[FrameworkType, str] = field(default_factory=lambda: {\n        FrameworkType.FASTAPI: '#009485',\n        FrameworkType.DJANGO_NINJA: '#4cae4f',\n        FrameworkType.LITESTAR: '#edb641',\n    })\n\n    # Node colors\n    primary: str = '#009485'\n    highlight: str = 'tomato'\n\n    # Pydantic-resolve metadata colors\n    resolve: str = '#47a80f'\n    post: str = '#427fa4'\n    expose_as: str = '#895cb9'\n    send_to: str = '#ca6d6d'\n    collector: str = '#777'\n\n    # GraphQL method colors\n    query: str = '#47a80f'      # Green for @query methods\n    mutation: str = '#ca6d6d'   # Red/coral for @mutation methods\n\n    # Link colors\n    inherit: str = 'purple'\n    subset: str = 'orange'\n\n    # Border colors\n    border: str = '#666'\n    cluster_border: str = '#ccc'\n\n    # Text colors\n    text_gray: str = '#999'\n\n    def get_framework_color(self, framework_type: FrameworkType) -> str:\n        \"\"\"Get theme color for a specific framework type.\"\"\"\n        return self.FRAMEWORK_COLORS.get(framework_type, self.primary)\n\n\n@dataclass\nclass GraphvizStyle:\n    \"\"\"Graphviz DOT style configuration.\"\"\"\n\n    # Font settings\n    font: str = 'Helvetica,Arial,sans-serif'\n    node_fontsize: str = '16'\n    cluster_fontsize: str = '20'\n\n    # Layout settings\n    nodesep: str = '0.8'\n    pad: str = '0.5'\n    node_margin: str = '0.5,0.1'\n    cluster_margin: str = '18'\n\n    # Link styles configuration\n    LINK_STYLES: dict[str, dict] = field(default_factory=lambda: {\n        'tag_route': {\n            'style': 'solid',\n            'minlen': 3,\n        },\n        'route_to_schema': {\n            'style': 'solid',\n            'dir': 'back',\n            'arrowtail': 'odot',\n            'minlen': 3,\n        },\n        'schema': {\n            'style': 'solid',\n            'label': '',\n            'dir': 'back',\n            'minlen': 3,\n            'arrowtail': 'odot',\n        },\n        'parent': {\n            'style': 'solid,dashed',\n            'dir': 'back',\n            'minlen': 3,\n            'taillabel': '< inherit >',\n            'color': 'purple',\n            'tailport': 'n',\n        },\n        'subset': {\n            'style': 'solid,dashed',\n            'dir': 'back',\n            'minlen': 3,\n            'taillabel': '< subset >',\n            'color': 'orange',\n            'tailport': 'n',\n        },\n        'tag_to_schema': {\n            'style': 'solid',\n            'minlen': 3,\n        },\n    })\n\n    def get_link_attributes(self, link_type: str) -> dict:\n        \"\"\"Get link style attributes for a given link type.\"\"\"\n        return self.LINK_STYLES.get(link_type, {})\n\n\n@dataclass\nclass RenderConfig:\n    \"\"\"Complete rendering configuration.\"\"\"\n\n    colors: ColorScheme = field(default_factory=ColorScheme)\n    style: GraphvizStyle = field(default_factory=GraphvizStyle)\n\n    # Field display settings\n    max_type_length: int = 25\n    type_suffix: str = '..'\n"
  },
  {
    "path": "src/fastapi_voyager/server.py",
    "content": "\"\"\"\nFastAPI-voyager server module with framework adapter support.\n\nThis module provides the main `create_voyager` function that automatically\ndetects the framework type and returns an appropriately configured voyager UI.\n\"\"\"\nfrom typing import Any, Literal\n\nfrom pydantic_resolve import ErDiagram\n\nfrom fastapi_voyager.adapters import DjangoNinjaAdapter, FastAPIAdapter, LitestarAdapter\nfrom fastapi_voyager.introspectors import FrameworkType, detect_framework\n\nINITIAL_PAGE_POLICY = Literal[\"first\", \"full\", \"empty\"]\n\n\ndef _get_adapter(\n    target_app: Any,\n    module_color: dict[str, str] | None = None,\n    gzip_minimum_size: int | None = 500,\n    module_prefix: str | None = None,\n    swagger_url: str | None = None,\n    online_repo_url: str | None = None,\n    initial_page_policy: INITIAL_PAGE_POLICY = \"first\",\n    ga_id: str | None = None,\n    er_diagram: ErDiagram | None = None,\n    enable_pydantic_resolve_meta: bool = False,\n    server_mode: bool = False,\n) -> Any:\n    \"\"\"\n    Get the appropriate adapter for the given target app.\n\n    Automatically detects the framework type and returns the matching adapter.\n\n    Args:\n        target_app: The web application instance to introspect\n        module_color: Optional color mapping for modules\n        gzip_minimum_size: Minimum size for gzip compression\n        module_prefix: Optional module prefix for filtering\n        swagger_url: Optional custom URL to Swagger documentation\n        online_repo_url: Optional online repository URL for source links\n        initial_page_policy: Initial page display policy\n        ga_id: Optional Google Analytics ID\n        er_diagram: Optional ER diagram from pydantic-resolve\n        enable_pydantic_resolve_meta: Enable pydantic-resolve metadata display\n\n    Returns:\n        An adapter instance for the detected framework\n\n    Raises:\n        TypeError: If the app type is not supported\n    \"\"\"\n    # Use centralized framework detection from introspectors\n    framework = detect_framework(target_app)\n\n    if framework == FrameworkType.FASTAPI:\n        return FastAPIAdapter(\n            target_app=target_app,\n            module_color=module_color,\n            gzip_minimum_size=gzip_minimum_size,\n            module_prefix=module_prefix,\n            swagger_url=swagger_url,\n            online_repo_url=online_repo_url,\n            initial_page_policy=initial_page_policy,\n            ga_id=ga_id,\n            er_diagram=er_diagram,\n            enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,\n            server_mode=server_mode,\n        )\n\n    elif framework == FrameworkType.LITESTAR:\n        return LitestarAdapter(\n            target_app=target_app,\n            module_color=module_color,\n            gzip_minimum_size=gzip_minimum_size,\n            module_prefix=module_prefix,\n            swagger_url=swagger_url,\n            online_repo_url=online_repo_url,\n            initial_page_policy=initial_page_policy,\n            ga_id=ga_id,\n            er_diagram=er_diagram,\n            enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,\n            server_mode=server_mode,\n        )\n\n    elif framework == FrameworkType.DJANGO_NINJA:\n        return DjangoNinjaAdapter(\n            target_app=target_app,\n            module_color=module_color,\n            gzip_minimum_size=gzip_minimum_size,  # Note: ignored for Django\n            module_prefix=module_prefix,\n            swagger_url=swagger_url,\n            online_repo_url=online_repo_url,\n            initial_page_policy=initial_page_policy,\n            ga_id=ga_id,\n            er_diagram=er_diagram,\n            enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,\n            server_mode=server_mode,\n        )\n\n    # If we get here, the app type is not supported\n    raise TypeError(\n        f\"Unsupported app type: {type(target_app).__name__}. \"\n        f\"Supported types: FastAPI, Django Ninja API, Litestar. \"\n        f\"If you're using a different framework, please implement a VoyagerAdapter for that framework. \"\n        f\"See fastapi_voyager/adapters/ for examples.\"\n    )\n\n\ndef create_voyager(\n    target_app: Any,\n    module_color: dict[str, str] | None = None,\n    gzip_minimum_size: int | None = 500,\n    module_prefix: str | None = None,\n    swagger_url: str | None = None,\n    online_repo_url: str | None = None,\n    initial_page_policy: INITIAL_PAGE_POLICY = \"first\",\n    ga_id: str | None = None,\n    er_diagram: ErDiagram | None = None,\n    enable_pydantic_resolve_meta: bool = False,\n    server_mode: bool = False,\n) -> Any:\n    \"\"\"\n    Create a voyager UI application for the given target app.\n\n    This function automatically detects the framework type (FastAPI, Django Ninja, or Litestar)\n    and returns an appropriately configured voyager UI application.\n\n    For FastAPI: Returns a FastAPI app that can be mounted\n    For Django Ninja: Returns an ASGI application\n    For Litestar: Returns a Litestar app\n\n    Args:\n        target_app: The web application to visualize\n        module_color: Optional color mapping for modules (e.g., {\"myapp\": \"blue\"})\n        gzip_minimum_size: Minimum response size for gzip compression (set to <0 to disable)\n        module_prefix: Optional module prefix for filtering/organization\n        swagger_url: Optional custom URL to Swagger/OpenAPI documentation\n        online_repo_url: Optional base URL for online repository source links\n        initial_page_policy: Initial page display policy ('first', 'full', or 'empty')\n        ga_id: Optional Google Analytics tracking ID\n        er_diagram: Optional ER diagram from pydantic-resolve\n        enable_pydantic_resolve_meta: Enable display of pydantic-resolve metadata\n        server_mode: If True, serve voyager UI at root path (for standalone preview mode)\n\n    Returns:\n        A framework-specific application object that provides the voyager UI\n\n    Example:\n        # FastAPI\n        from fastapi import FastAPI\n        from fastapi_voyager import create_voyager\n\n        app = FastAPI()\n        voyager_app = create_voyager(app)\n        app.mount(\"/voyager\", voyager_app)\n\n        # Django Ninja\n        from ninja import NinjaAPI\n        from fastapi_voyager import create_voyager\n\n        api = NinjaAPI()\n        voyager_asgi_app = create_voyager(api)\n        # See django_ninja tests for integration examples\n\n        # Litestar\n        from litestar import Litestar\n        from fastapi_voyager import create_voyager\n\n        app = Litestar()\n        voyager_app = create_voyager(app)\n        # Mount or integrate as needed\n    \"\"\"\n    adapter = _get_adapter(\n        target_app=target_app,\n        module_color=module_color,\n        gzip_minimum_size=gzip_minimum_size,\n        module_prefix=module_prefix,\n        swagger_url=swagger_url,\n        online_repo_url=online_repo_url,\n        initial_page_policy=initial_page_policy,\n        ga_id=ga_id,\n        er_diagram=er_diagram,\n        enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,\n        server_mode=server_mode,\n    )\n\n    return adapter.create_app()\n"
  },
  {
    "path": "src/fastapi_voyager/templates/dot/cluster.j2",
    "content": "subgraph cluster_{{ cluster_id }} {\n    tooltip=\"{{ tooltip }}\"\n    color = \"{{ border_color }}\"\n    style=\"rounded\"\n    label = \"  {{ label }}\"\n    labeljust = \"l\"\n    {% if pen_color %}pencolor = \"{{ pen_color }}\"{% endif %}\n    {% if pen_width %}penwidth = {{ pen_width }}{% endif %}\n    {{ content }}\n}\n"
  },
  {
    "path": "src/fastapi_voyager/templates/dot/cluster_container.j2",
    "content": "subgraph cluster_{{ name }} {\n    color = \"{{ border_color }}\"\n    margin={{ margin }}\n    style=\"dashed\"\n    label = \"  {{ label }}\"\n    labeljust = \"l\"\n    fontsize = {{ fontsize }}\n    {{ content }}\n}\n"
  },
  {
    "path": "src/fastapi_voyager/templates/dot/digraph.j2",
    "content": "digraph world {\n    pad=\"{{ pad }}\"\n    nodesep={{ nodesep }}\n    {% if spline %}splines={{ spline }}{% endif %}\n    fontname=\"{{ font }}\"\n    node [fontname=\"{{ font }}\"]\n    edge [\n        fontname=\"{{ font }}\"\n        color=\"gray\"\n    ]\n    graph [\n        rankdir = \"LR\"\n    ];\n    node [\n        fontsize = {{ node_fontsize }}\n    ];\n\n    {{ tags_cluster }}\n\n    {{ routes_cluster }}\n\n    {{ schemas_cluster }}\n\n    {{ links }}\n}\n"
  },
  {
    "path": "src/fastapi_voyager/templates/dot/er_diagram.j2",
    "content": "digraph world {\n    pad=\"{{ pad }}\"\n    nodesep={{ nodesep }}\n    {% if spline %}splines={{ spline }}{% endif %}\n    fontname=\"{{ font }}\"\n    node [fontname=\"{{ font }}\"]\n    edge [\n        fontname=\"{{ font }}\"\n        color=\"gray\"\n    ]\n    graph [\n        rankdir = \"LR\"\n    ];\n    node [\n        fontsize = {{ node_fontsize }}\n    ];\n\n    subgraph cluster_schema {\n        color = \"#aaa\"\n        margin=18\n        style=\"dashed\"\n        label=\"  ER Diagram\"\n        labeljust=\"l\"\n        fontsize=\"20\"\n        {{ er_cluster }}\n    }\n\n    {{ links }}\n}\n"
  },
  {
    "path": "src/fastapi_voyager/templates/dot/link.j2",
    "content": "{{ source }} -> {{ target }} [{{ attributes }}];\n"
  },
  {
    "path": "src/fastapi_voyager/templates/dot/route_node.j2",
    "content": "\"{{ id }}\" [\n    label = \"    {{ name }} | {{ response_schema }}    \"\n    margin=\"{{ margin }}\"\n    shape = \"record\"\n];\n"
  },
  {
    "path": "src/fastapi_voyager/templates/dot/schema_node.j2",
    "content": "\"{{ id }}\" [\n    label = {{ label }}\n    shape = \"plain\"\n    margin=\"{{ margin }}\"\n];\n"
  },
  {
    "path": "src/fastapi_voyager/templates/dot/tag_node.j2",
    "content": "\"{{ id }}\" [\n    label = \"    {{ name }}    \"\n    shape = \"record\"\n    margin=\"{{ margin }}\"\n];\n"
  },
  {
    "path": "src/fastapi_voyager/templates/html/colored_text.j2",
    "content": "<font color=\"{{ color }}\">{% if strikethrough %}<s>{{ text }}</s>{% else %}{{ text }}{% endif %}</font>\n"
  },
  {
    "path": "src/fastapi_voyager/templates/html/pydantic_meta.j2",
    "content": "{% if meta_parts %}<br align=\"left\"/><br align=\"left\"/>{{ meta_parts | join('<br align=\"left\"/>') }}<br align=\"left\"/>{% endif %}\n"
  },
  {
    "path": "src/fastapi_voyager/templates/html/schema_field_row.j2",
    "content": "<tr><td align=\"{{ align }}\" {% if port %}port=\"f{{ port }}\"{% endif %} cellpadding=\"8\">{{ content }}</td></tr>\n"
  },
  {
    "path": "src/fastapi_voyager/templates/html/schema_header.j2",
    "content": "<tr><td cellpadding=\"6\" bgcolor=\"{{ bg_color }}\" align=\"center\" colspan=\"1\" width=\"75\" {% if port %}port=\"{{ port }}\"{% endif %}><font color=\"white\">{% if is_entity %}<b>{{ text }} (E)</b>{% else %}{{ text }}{% endif %}</font></td></tr>\n"
  },
  {
    "path": "src/fastapi_voyager/templates/html/schema_table.j2",
    "content": "<<table border=\"0\" cellborder=\"1\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"white\" width=\"75\">\n{{ header }}\n{{ rows }}\n</table>>\n"
  },
  {
    "path": "src/fastapi_voyager/type.py",
    "content": "from dataclasses import field\nfrom typing import Literal\n\nfrom pydantic.dataclasses import dataclass\n\n\n@dataclass\nclass NodeBase:\n    id: str\n    name: str\n\n@dataclass\nclass FieldInfo:\n    name: str\n    type_name: str\n    from_base: bool = False\n    is_object: bool = False\n    is_exclude: bool = False\n    desc: str = ''\n\n    # pydantic resolve specific fields\n    has_pydantic_resolve_meta: bool = False  # overall flag\n    is_resolve: bool = False\n    is_post: bool = False\n    expose_as_info: str | None = None\n    send_to_info: list[str] | None = None\n    collect_info: list[str] | None = None\n\n\n@dataclass\nclass MethodInfo:\n    \"\"\"@query 或 @mutation 方法信息\"\"\"\n    name: str              # GraphQL 名称（来自装饰器或方法名）\n    return_type: str       # 返回类型字符串\n\n@dataclass\nclass Tag(NodeBase):\n    routes: list['Route']  # route.id\n\n@dataclass\nclass Route(NodeBase):\n    module: str\n    unique_id: str = ''\n    response_schema: str = ''\n    is_primitive: bool = True\n\n@dataclass\nclass ModuleRoute:\n    name: str\n    fullname: str\n    routes: list[Route]\n    modules: list['ModuleRoute']\n\n@dataclass\nclass SchemaNode(NodeBase):\n    module: str\n    fields: list[FieldInfo] = field(default_factory=list)\n    is_entity: bool = False  # Mark if this is an ER diagram entity\n    queries: list[MethodInfo] = field(default_factory=list)   # @query methods\n    mutations: list[MethodInfo] = field(default_factory=list) # @mutation methods\n\n@dataclass\nclass ModuleNode:\n    name: str\n    fullname: str\n    schema_nodes: list[SchemaNode]\n    modules: list['ModuleNode']\n\n\n# type: \n#    - tag_route: tag -> route\n#    - route_to_schema: route -> response model\n#    - subset: schema -> schema (subset)\n#    - parent: schema -> schema (inheritance)\n#    - schema: schema -> schema (field reference)\n#    - tag_to_schema: tag -> schema (only happens in module prefix filtering, aka brief mode)\nLinkType = Literal['schema', 'parent', 'tag_route', 'subset', 'route_to_schema', 'tag_to_schema']\n\n@dataclass\nclass Link:\n    # node + field level links\n    source: str\n    target: str\n\n    # node level links\n    source_origin: str\n    target_origin: str\n    type: LinkType\n    label: str | None = None\n    style: str | None = None\n    loader_fullname: str | None = None\n\nFieldType = Literal['single', 'object', 'all']\nPK = \"PK\"\n\n@dataclass\nclass CoreData:\n    tags: list[Tag]\n    routes: list[Route]\n    nodes: list[SchemaNode]\n    links: list[Link]\n    show_fields: FieldType\n    module_color: dict[str, str] | None = None\n    schema: str | None = None"
  },
  {
    "path": "src/fastapi_voyager/type_helper.py",
    "content": "import inspect\nimport logging\nimport os\nfrom types import UnionType\nfrom typing import Annotated, Any, ForwardRef, Generic, Union, get_args, get_origin\n\nimport pydantic_resolve.constant as const\nfrom pydantic import BaseModel\n\nfrom fastapi_voyager.pydantic_resolve_util import analysis_pydantic_resolve_fields\nfrom fastapi_voyager.type import FieldInfo\n\nlogger = logging.getLogger(__name__)\n\n# Python <3.12 compatibility: TypeAliasType exists only from 3.12 (PEP 695)\ntry:  # pragma: no cover - import guard\n    from typing import TypeAliasType  # type: ignore\nexcept Exception:  # pragma: no cover\n    class _DummyTypeAliasType:  # minimal sentinel so isinstance checks are safe\n        pass\n    TypeAliasType = _DummyTypeAliasType  # type: ignore\n\n\ndef is_list(annotation):\n    return getattr(annotation, \"__origin__\", None) == list\n\n\ndef full_class_name(cls):\n    return f\"{cls.__module__}.{cls.__qualname__}\"\n\n\ndef is_base_entity_subclass(schema, entity_class_names: set[str] | None = None) -> bool:\n    \"\"\"\n    Check if a schema is a pydantic-resolve BaseEntity entity.\n\n    Checks if the class's full name is in the entity_class_names set.\n\n    Args:\n        schema: The schema class to check\n        entity_class_names: Optional set of full class names from er_diagram.entities\n\n    Returns:\n        True if the schema is an entity, False otherwise\n    \"\"\"\n    if not entity_class_names:\n        return False\n\n    schema_full_name = full_class_name(schema)\n    return schema_full_name in entity_class_names\n\n\ndef get_core_types(tp):\n    \"\"\"\n    - get the core type\n    - always return a tuple of core types\n    \"\"\"\n    # Helpers\n    def _unwrap_alias(t):\n        \"\"\"Unwrap PEP 695 type aliases by following __value__ repeatedly.\"\"\"\n        while isinstance(t, TypeAliasType) or (\n            t.__class__.__name__ == 'TypeAliasType' and hasattr(t, '__value__')\n        ):\n            try:\n                t = t.__value__\n            except Exception:  # pragma: no cover - defensive\n                break\n        return t\n\n    def _enqueue(items, q):\n        for it in items:\n            if it is not type(None):  # skip None in unions\n                q.append(it)\n\n    # Queue-based shelling to reach concrete core types\n    queue: list[object] = [tp]\n    result: list[object] = []\n\n    while queue:\n        cur = queue.pop(0)\n        if cur is type(None):\n            continue\n\n        cur = _unwrap_alias(cur)\n\n        # Handle Annotated[T, ...] as a shell\n        if get_origin(cur) is Annotated:\n            args = get_args(cur)\n            if args:\n                queue.append(args[0])\n            continue\n\n        # Handle Union / Optional / PEP 604 UnionType\n        orig = get_origin(cur)\n        if orig in (Union, UnionType):\n            args = get_args(cur)\n            # push all non-None members back for further shelling\n            _enqueue(args, queue)\n            continue\n\n        # Handle list shells\n        if is_list(cur):\n            args = getattr(cur, \"__args__\", ())\n            if args:\n                queue.append(args[0])\n            continue\n\n        # If still an alias-like wrapper, unwrap again and re-process\n        _cur2 = _unwrap_alias(cur)\n        if _cur2 is not cur:\n            queue.append(_cur2)\n            continue\n\n        # Otherwise treat as a concrete core type (could be a class, typing.Final, etc.)\n        result.append(cur)\n\n    return tuple(result)\n\n\ndef get_type_name(anno):\n    def name_of(tp):\n        origin = get_origin(tp)\n        args = get_args(tp)\n\n        # Annotated[T, ...] -> T\n        if origin is Annotated:\n            return name_of(args[0]) if args else 'Annotated'\n\n        # Union / Optional\n        if origin is Union:\n            non_none = [a for a in args if a is not type(None)]\n            if len(non_none) == 1 and len(args) == 2:\n                return f\"Optional[{name_of(non_none[0])}]\"\n            return f\"Union[{', '.join(name_of(a) for a in args)}]\"\n\n        # Parametrized generics\n        if origin is not None:\n            origin_name_map = {\n                list: 'List',\n                dict: 'Dict',\n                set: 'Set',\n                tuple: 'Tuple',\n                frozenset: 'FrozenSet',\n            }\n            origin_name = origin_name_map.get(origin)\n            if origin_name is None:\n                origin_name = getattr(origin, '__name__', None) or str(origin).replace('typing.', '')\n            if args:\n                return f\"{origin_name}[{', '.join(name_of(a) for a in args)}]\"\n            return origin_name\n\n        # Non-generic leaf types\n        if tp is Any:\n            return 'Any'\n        if tp is None or tp is type(None):\n            return 'None'\n        if isinstance(tp, type):\n            return tp.__name__\n\n        # ForwardRef\n        fwd = getattr(tp, '__forward_arg__', None) or getattr(tp, 'arg', None)\n        if fwd:\n            return str(fwd)\n\n        # Fallback clean string\n        return str(tp).replace('typing.', '').replace('<class ', '').replace('>', '').replace(\"'\", '')\n\n    return name_of(anno)\n\n\ndef is_inheritance_of_pydantic_base(cls):\n    return safe_issubclass(cls, BaseModel) and cls is not BaseModel and not is_generic_container(cls)\n\n\ndef get_bases_fields(schemas: list[type[BaseModel]]) -> set[str]:\n    \"\"\"Collect field names from a list of BaseModel subclasses (their model_fields keys).\"\"\"\n    fields: set[str] = set()\n    for schema in schemas:\n        for k, _ in getattr(schema, 'model_fields', {}).items():\n            fields.add(k)\n    return fields\n\n\ndef get_pydantic_fields(schema: type[BaseModel], bases_fields: set[str]) -> list[FieldInfo]:\n    \"\"\"Extract pydantic model fields with metadata.\n\n    Parameters:\n        schema: The pydantic BaseModel subclass to inspect.\n        bases_fields: Set of field names that come from base classes (for from_base marking).\n\n    Returns:\n        A list of FieldInfo objects describing the schema's direct fields.\n    \"\"\"\n\n    def _is_object(anno):  # internal helper, previously a method on Analytics\n        _types = get_core_types(anno)\n        return any(is_inheritance_of_pydantic_base(t) for t in _types if t)\n\n    fields: list[FieldInfo] = []\n    for k, v in schema.model_fields.items():\n        anno = v.annotation\n        pydantic_resolve_specific_params = analysis_pydantic_resolve_fields(schema, k)\n        fields.append(FieldInfo(\n            is_object=_is_object(anno),\n            name=k,\n            from_base=k in bases_fields,\n            type_name=get_type_name(anno),\n            is_exclude=bool(v.exclude),\n            desc=v.description or '',\n            **pydantic_resolve_specific_params\n        ))\n    return fields\n\n\ndef get_vscode_link(kls, online_repo_url: str | None = None) -> str:\n    \"\"\"Build a VSCode deep link to the class definition.\n\n    Priority:\n      1. If running inside WSL and WSL_DISTRO_NAME is present, return a remote link:\n         vscode://vscode-remote/wsl+<distro>/<absolute/path>:<line>\n         (This opens directly in the VSCode WSL remote window.)\n      2. Else, if path is /mnt/<drive>/..., translate to Windows drive and return vscode://file/C:\\\\...:line\n      3. Else, fallback to vscode://file/<unix-absolute-path>:line\n    \"\"\"\n    try:\n        source_file = inspect.getfile(kls)\n        _lines, start_line = inspect.getsourcelines(kls)\n\n        distro = os.environ.get(\"WSL_DISTRO_NAME\")\n        if online_repo_url:\n            cwd = os.getcwd()\n            relative_path = os.path.relpath(source_file, cwd)\n            return f\"{online_repo_url}/{relative_path}#L{start_line}\"\n        if distro:\n            # Ensure absolute path (it should already be under /) and build remote link\n            return f\"vscode://vscode-remote/wsl+{distro}{source_file}:{start_line}\"\n\n        # Non-remote scenario: maybe user wants to open via translated Windows path\n        if source_file.startswith('/mnt/') and len(source_file) > 6:\n            parts = source_file.split('/')\n            if len(parts) >= 4 and len(parts[2]) == 1:  # drive letter\n                drive = parts[2].upper()\n                rest = parts[3:]\n                win_path = drive + ':\\\\' + '\\\\'.join(rest)\n                return f\"vscode://file/{win_path}:{start_line}\"\n\n        # Fallback plain unix path\n        return f\"vscode://file/{source_file}:{start_line}\"\n    except Exception:\n        return \"\"\n\n\ndef get_source(kls):\n    try:\n        source = inspect.getsource(kls)\n        return source\n    except Exception:\n        return \"failed to get source\"\n\n\ndef safe_issubclass(kls, target_kls):\n    try:\n        return issubclass(kls, target_kls)\n    except TypeError:\n        # if kls is ForwardRef, log it\n        if isinstance(kls, ForwardRef):\n            logger.error(f'{str(kls)} is a ForwardRef, not a subclass of {target_kls.__module__}:{target_kls.__qualname__}')\n        elif isinstance(kls, type):\n            logger.debug(f'{kls.__module__}:{kls.__qualname__} is not subclass of {target_kls.__module__}:{target_kls.__qualname__}')\n        return False\n\n\ndef update_forward_refs(kls):\n    # TODO: refactor\n    def update_pydantic_forward_refs(pydantic_kls: type[BaseModel]):\n        \"\"\"\n        recursively update refs.\n        \"\"\"\n\n        pydantic_kls.model_rebuild()\n        setattr(pydantic_kls, const.PYDANTIC_FORWARD_REF_UPDATED, True)\n\n        values = pydantic_kls.model_fields.values()\n        for field in values:\n            update_forward_refs(field.annotation)\n        \n    for shelled_type in get_core_types(kls):\n        # Only treat as updated if the flag is set on the class itself, not via inheritance\n\n        local_attrs = getattr(shelled_type, '__dict__', {})\n        if local_attrs.get(const.PYDANTIC_FORWARD_REF_UPDATED, False):\n            logger.debug(\"%s visited\", shelled_type.__qualname__)\n            continue\n        if safe_issubclass(shelled_type, BaseModel):\n            update_pydantic_forward_refs(shelled_type)\n\n\ndef is_generic_container(cls):\n    \"\"\"\n    T = TypeVar('T')\n    class DataModel(BaseModel, Generic[T]):\n        data: T\n        id: int\n\n    type DataModelPageStory = DataModel[PageStory]\n\n    is_generic_container(DataModel) -> True\n    is_generic_container(DataModel[PageStory]) -> False\n\n    DataModel.__parameters__ == (T,)\n    DataModelPageStory.__parameters__ == (,)\n    \"\"\"\n    try:\n        return (hasattr(cls, '__bases__') and Generic in cls.__bases__ and (hasattr(cls, '__parameters__') and bool(cls.__parameters__)))\n    except (TypeError, AttributeError):\n        return False\n    \ndef is_non_pydantic_type(tp):\n    for schema in get_core_types(tp):\n        if schema and safe_issubclass(schema, BaseModel):\n            return False\n    return True\n\nif __name__ == \"__main__\":\n    from tests.demo_anno import PageOverall, PageSprint\n\n    update_forward_refs(PageOverall)\n    update_forward_refs(PageSprint)"
  },
  {
    "path": "src/fastapi_voyager/version.py",
    "content": "__all__ = [\"__version__\"]\n__version__ = \"0.27.0\"\n"
  },
  {
    "path": "src/fastapi_voyager/voyager.py",
    "content": "\nimport pydantic_resolve.constant as const\nfrom pydantic import BaseModel\n\nfrom fastapi_voyager.filter import (\n    filter_graph,\n    filter_subgraph_by_module_prefix,\n    filter_subgraph_from_tag_to_schema_by_module_prefix,\n)\nfrom fastapi_voyager.introspectors import AppIntrospector, RouteInfo\nfrom fastapi_voyager.render import Renderer\nfrom fastapi_voyager.type import PK, CoreData, FieldType, Link, LinkType, Route, SchemaNode, Tag\nfrom fastapi_voyager.type_helper import (\n    full_class_name,\n    get_bases_fields,\n    get_core_types,\n    get_pydantic_fields,\n    get_type_name,\n    is_base_entity_subclass,\n    is_inheritance_of_pydantic_base,\n    is_non_pydantic_type,\n    safe_issubclass,\n    update_forward_refs,\n)\n\n\nclass Voyager:\n    def __init__(\n            self,\n            schema: str | None = None,\n            schema_field: str | None = None,\n            show_fields: FieldType = 'single',\n            include_tags: list[str] | None = None,\n            module_color: dict[str, str] | None = None,\n            route_name: str | None = None,\n            hide_primitive_route: bool = False,\n            show_module: bool = True,\n            show_pydantic_resolve_meta: bool = False,\n            theme_color: str | None = None,\n            entity_class_names: set[str] | None = None,\n        ):\n\n        self.routes: list[Route] = []\n\n        self.nodes: list[SchemaNode] = []\n        self.node_set: dict[str, SchemaNode] = {}\n\n        self.link_set: set[tuple[str, str]] = set()\n        self.links: list[Link] = []\n\n        # store Tag by id, and also keep a list for rendering order\n        self.tag_set: dict[str, Tag] = {}\n        self.tags: list[Tag] = []\n\n        self.include_tags = include_tags\n        self.schema = schema\n        self.schema_field = schema_field\n        self.show_fields = show_fields if show_fields in ('single','object','all') else 'object'\n        self.module_color = module_color or {}\n        self.route_name = route_name\n        self.hide_primitive_route = hide_primitive_route\n        self.show_module = show_module\n        self.show_pydantic_resolve_meta = show_pydantic_resolve_meta\n        self.theme_color = theme_color\n        self.entity_class_names = entity_class_names\n\n    def _get_introspector(self, app) -> AppIntrospector:\n        \"\"\"\n        Get the appropriate introspector for the given app.\n\n        Automatically detects the framework type and returns the matching introspector.\n\n        Args:\n            app: A web application instance or AppIntrospector\n\n        Returns:\n            An AppIntrospector instance\n\n        Raises:\n            TypeError: If the app type is not supported\n        \"\"\"\n        from fastapi_voyager.introspectors import get_introspector\n\n        return get_introspector(app)\n\n    def analysis(self, app):\n        \"\"\"\n        Analyze routes and schemas from a web application.\n\n        This method automatically detects the framework type and uses the appropriate\n        introspector. Supported frameworks:\n        - FastAPI (built-in)\n        - Any framework with a custom AppIntrospector implementation\n\n        Args:\n            app: A web application instance (FastAPI, Django Ninja API, etc.)\n                  or an AppIntrospector instance for custom frameworks.\n\n        1. get routes which return pydantic schema\n            1.1 collect tags and routes, add links tag-> route\n            1.2 collect response_model and links route -> response_model\n\n        2. iterate schemas, construct the schema/model nodes and their links\n        \"\"\"\n        introspector = self._get_introspector(app)\n        schemas: list[type[BaseModel]] = []\n\n        # First, group all routes by tag\n        routes_by_tag: dict[str, list[RouteInfo]] = {}\n        for route_info in introspector.get_routes():\n            # using multiple tags is harmful, it's not recommended and will not be supported\n            route_tag = route_info.tags[0] if route_info.tags else '__default__'\n            routes_by_tag.setdefault(route_tag, []).append(route_info)\n\n        # Then filter by include_tags if provided\n        if self.include_tags:\n            filtered_routes_by_tag = {\n                tag: routes\n                for tag, routes in routes_by_tag.items()\n                if tag in self.include_tags\n            }\n        else:\n            filtered_routes_by_tag = routes_by_tag\n\n        # Process filtered routes\n        for route_tag, route_infos in filtered_routes_by_tag.items():\n            tag_id = f'tag__{route_tag}'\n            tag_obj = Tag(id=tag_id, name=route_tag, routes=[])\n            self.tags.append(tag_obj)\n\n            for route_info in route_infos:\n                # filter by route_name (route.id) if provided\n                if self.route_name is not None and route_info.id != self.route_name:\n                    continue\n\n                is_primitive_response = is_non_pydantic_type(route_info.response_model)\n                # filter primitive route if needed\n                if self.hide_primitive_route and is_primitive_response:\n                    continue\n\n                self.links.append(\n                    Link(\n                        source=tag_id,\n                        source_origin=tag_id,\n                        target=route_info.id,\n                        target_origin=route_info.id,\n                        type='tag_route',\n                    )\n                )\n\n                # Get unique_id from extra data if available\n                unique_id = route_info.operation_id\n                if route_info.extra and 'unique_id' in route_info.extra:\n                    unique_id = unique_id or route_info.extra['unique_id']\n\n                route_obj = Route(\n                    id=route_info.id,\n                    name=route_info.name,\n                    module=route_info.module,\n                    unique_id=unique_id,\n                    response_schema=get_type_name(route_info.response_model),\n                    is_primitive=is_primitive_response,\n                )\n                self.routes.append(route_obj)\n                tag_obj.routes.append(route_obj)\n\n                # add response_models and create links from route -> response_model\n                for schema in get_core_types(route_info.response_model):\n                    if schema and safe_issubclass(schema, BaseModel):\n                        is_primitive_response = False\n                        target_name = full_class_name(schema)\n                        self.links.append(\n                            Link(\n                                source=route_info.id,\n                                source_origin=route_info.id,\n                                target=self.generate_node_head(target_name),\n                                target_origin=target_name,\n                                type='route_to_schema',\n                            )\n                        )\n\n                        schemas.append(schema)\n\n        for s in schemas:\n            self.analysis_schemas(s)\n\n        self.nodes = list(self.node_set.values())\n\n\n    def add_to_node_set(self, schema):\n        \"\"\"\n        1. calc full_path, add to node_set\n        2. if duplicated, do nothing, else insert\n        2. return the full_path\n        \"\"\"\n        full_name = full_class_name(schema)\n        bases_fields = get_bases_fields([s for s in schema.__bases__ if is_inheritance_of_pydantic_base(s)])\n\n        subset_reference = getattr(schema, const.ENSURE_SUBSET_REFERENCE, None)\n        if subset_reference and is_inheritance_of_pydantic_base(subset_reference):\n            bases_fields.update(get_bases_fields([subset_reference]))\n\n        if full_name not in self.node_set:\n            # skip meta info for normal queries\n            self.node_set[full_name] = SchemaNode(\n                id=full_name,\n                module=schema.__module__,\n                name=schema.__name__,\n                fields=get_pydantic_fields(schema, bases_fields),\n                is_entity=is_base_entity_subclass(schema, self.entity_class_names)\n            )\n        return full_name\n\n\n    def add_to_link_set(\n            self, \n            source: str, \n            source_origin: str,\n            target: str, \n            target_origin: str,\n            type: LinkType\n        ) -> bool:\n        \"\"\"\n        1. add link to link_set\n        2. if duplicated, do nothing, else insert\n        \"\"\"\n        pair = (source, target)\n        if result := pair not in self.link_set:\n            self.link_set.add(pair)\n            self.links.append(Link(\n                source=source,\n                source_origin=source_origin,\n                target=target,\n                target_origin=target_origin,\n                type=type\n            ))\n        return result\n\n\n    def analysis_schemas(self, schema: type[BaseModel]):\n        \"\"\"\n        1. cls is the source, add schema\n        2. pydantic fields are targets, if annotation is subclass of BaseMode, add fields and add links\n        3. recursively run walk_schema\n        \"\"\"\n        \n        update_forward_refs(schema)\n        self.add_to_node_set(schema)\n\n        base_fields = set()\n\n        # handle schema inside ensure_subset(schema)\n        if subset_reference := getattr(schema,  const.ENSURE_SUBSET_REFERENCE, None):\n            if is_inheritance_of_pydantic_base(subset_reference):\n\n                self.add_to_node_set(subset_reference)\n                self.add_to_link_set(\n                    source=self.generate_node_head(full_class_name(schema)),\n                    source_origin=full_class_name(schema),\n                    target= self.generate_node_head(full_class_name(subset_reference)), \n                    target_origin=full_class_name(subset_reference),\n                    type='subset')\n                self.analysis_schemas(subset_reference)\n\n        # handle bases\n        for base_class in schema.__bases__:\n            if is_inheritance_of_pydantic_base(base_class):\n                # collect base class field names to avoid duplicating inherited fields\n                try:\n                    base_fields.update(getattr(base_class, 'model_fields', {}).keys())\n                except Exception:\n                    # be defensive in case of unconventional BaseModel subclasses\n                    pass\n                self.add_to_node_set(base_class)\n                self.add_to_link_set(\n                    source=self.generate_node_head(full_class_name(schema)),\n                    source_origin=full_class_name(schema),\n                    target=self.generate_node_head(full_class_name(base_class)),\n                    target_origin=full_class_name(base_class),\n                    type='parent')\n                self.analysis_schemas(base_class)\n\n        # handle fields\n        for k, v in schema.model_fields.items():\n            # skip fields inherited from base classes\n            if k in base_fields:\n                continue\n            annos = get_core_types(v.annotation)\n            for anno in annos:\n                if anno and is_inheritance_of_pydantic_base(anno):\n                    self.add_to_node_set(anno)\n                    # add f prefix to fix highlight issue in vsc graphviz interactive previewer\n                    source_name = f'{full_class_name(schema)}::f{k}'\n                    if self.add_to_link_set(\n                        source=source_name,\n                        source_origin=full_class_name(schema),\n                        target=self.generate_node_head(full_class_name(anno)),\n                        target_origin=full_class_name(anno),\n                        type='schema'):\n                        self.analysis_schemas(anno)\n\n\n    def generate_node_head(self, link_name: str):\n        return f'{link_name}::{PK}'\n\n    def dump_core_data(self):\n        _tags, _routes, _nodes, _links = filter_graph(\n            schema=self.schema,\n            schema_field=self.schema_field,\n            tags=self.tags,\n            routes=self.routes,\n            nodes=self.nodes,\n            links=self.links,\n            node_set=self.node_set,\n        )\n        return CoreData(\n            tags=_tags,\n            routes=_routes,\n            nodes=_nodes,\n            links=_links,\n            show_fields=self.show_fields,\n            module_color=self.module_color,\n            schema=self.schema\n        )\n\n    def handle_hide(self, tags, routes, links):\n        if self.include_tags:\n            return [], routes, [lk for lk in links if lk.type != 'tag_route']\n        else:\n            return tags, routes, links\n    \n    def calculate_filtered_tag_and_route(self):\n        _tags, _routes, _, _ = filter_graph(\n            schema=self.schema,\n            schema_field=self.schema_field,\n            tags=self.tags,\n            routes=self.routes,\n            nodes=self.nodes,\n            links=self.links,\n            node_set=self.node_set,\n        )\n        # filter tag.routes based by _routes\n        route_ids = {r.id for r in _routes}\n        for t in _tags:\n            t.routes = [r for r in t.routes if r.id in route_ids]\n        return _tags\n\n    def render_dot(self):\n        _tags, _routes, _nodes, _links = filter_graph(\n            schema=self.schema,\n            schema_field=self.schema_field,\n            tags=self.tags,\n            routes=self.routes,\n            nodes=self.nodes,\n            links=self.links,\n            node_set=self.node_set,\n        )\n\n        renderer = Renderer(\n            show_fields=self.show_fields,\n            module_color=self.module_color,\n            schema=self.schema,\n            show_module=self.show_module,\n            show_pydantic_resolve_meta=self.show_pydantic_resolve_meta,\n            theme_color=self.theme_color)\n\n        _tags, _routes, _links = self.handle_hide(_tags, _routes, _links)\n        return renderer.render_dot(_tags, _routes, _nodes, _links)\n\n\n    def render_tag_level_brief_dot(self, module_prefix: str | None = None):\n        _tags, _routes, _nodes, _links = filter_graph(\n            schema=self.schema,\n            schema_field=self.schema_field,\n            tags=self.tags,\n            routes=self.routes,\n            nodes=self.nodes,\n            links=self.links,\n            node_set=self.node_set,\n        )\n\n        _tags, _routes, _nodes, _links = filter_subgraph_by_module_prefix(\n            module_prefix=module_prefix,\n            tags=_tags,\n            routes=_routes,\n            nodes=_nodes,\n            links=_links,\n        )\n\n        renderer = Renderer(\n            show_fields=self.show_fields,\n            module_color=self.module_color,\n            schema=self.schema,\n            show_module=self.show_module,\n            theme_color=self.theme_color)\n\n        _tags, _routes, _links = self.handle_hide(_tags, _routes, _links)\n        return renderer.render_dot(_tags, _routes, _nodes, _links, True)\n\n    def render_overall_brief_dot(self, module_prefix: str | None = None):\n        _tags, _routes, _nodes, _links = filter_graph(\n            schema=self.schema,\n            schema_field=self.schema_field,\n            tags=self.tags,\n            routes=self.routes,\n            nodes=self.nodes,\n            links=self.links,\n            node_set=self.node_set,\n        )\n\n        _tags, _routes, _nodes, _links = filter_subgraph_from_tag_to_schema_by_module_prefix(\n            module_prefix=module_prefix,\n            tags=_tags,\n            routes=_routes,\n            nodes=_nodes,\n            links=_links,\n        )\n\n        renderer = Renderer(\n            show_fields=self.show_fields,\n            module_color=self.module_color,\n            schema=self.schema,\n            show_module=self.show_module,\n            theme_color=self.theme_color)\n\n        _tags, _routes, _links = self.handle_hide(_tags, _routes, _links)\n        return renderer.render_dot(_tags, _routes, _nodes, _links, True)"
  },
  {
    "path": "src/fastapi_voyager/web/component/demo.js",
    "content": "const { defineComponent, computed } = window.Vue\n\nimport { store } from \"../store.js\"\n\nexport default defineComponent({\n  name: \"Demo\",\n  emits: [\"close\"],\n  setup() {\n    return { store }\n  },\n  template: `\n    <div>\n      <p>Count: {{ store.state.item.count }}</p>\n      <button @click=\"store.mutations.increment()\">Add</button>\n    </div>\n  `,\n})\n"
  },
  {
    "path": "src/fastapi_voyager/web/component/loader-code-display.js",
    "content": "const { defineComponent, ref, watch, onMounted } = window.Vue\n\nexport default defineComponent({\n  name: \"LoaderCodeDisplay\",\n  props: {\n    loaderFullname: { type: String, default: null },\n    sourceEntity: { type: String, default: null },\n    targetEntity: { type: String, default: null },\n    label: { type: String, default: null },\n  },\n  setup(props) {\n    const code = ref(\"\")\n    const link = ref(\"\")\n    const error = ref(\"\")\n    const loading = ref(false)\n\n    async function highlightLater() {\n      requestAnimationFrame(() => {\n        try {\n          if (window.hljs) {\n            const block = document.querySelector(\".frv-loader-display pre code.language-python\")\n            if (block) {\n              if (block.dataset && block.dataset.highlighted) {\n                block.removeAttribute(\"data-highlighted\")\n              }\n              window.hljs.highlightElement(block)\n            }\n          }\n        } catch (e) {\n          console.warn(\"highlight failed\", e)\n        }\n      })\n    }\n\n    function resetState() {\n      code.value = \"\"\n      link.value = \"\"\n      error.value = null\n      loading.value = true\n    }\n\n    async function loadSource() {\n      if (!props.loaderFullname) return\n\n      resetState()\n\n      const payload = { schema_name: props.loaderFullname }\n      try {\n        const resp = await fetch(`source`, {\n          method: \"POST\",\n          headers: {\n            Accept: \"application/json\",\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(payload),\n        })\n        const data = await resp.json().catch(() => ({}))\n        if (resp.ok) {\n          code.value = data.source_code || \"# no source code available\"\n        } else {\n          error.value = (data && data.error) || \"Failed to load source\"\n        }\n\n        const resp2 = await fetch(`vscode-link`, {\n          method: \"POST\",\n          headers: {\n            Accept: \"application/json\",\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(payload),\n        })\n        const data2 = await resp2.json().catch(() => ({}))\n        if (resp2.ok) {\n          link.value = data2.link || \"\"\n        }\n      } catch (e) {\n        error.value = \"Failed to load source\"\n      } finally {\n        loading.value = false\n        highlightLater()\n      }\n    }\n\n    watch(\n      () => props.loaderFullname,\n      () => {\n        if (props.loaderFullname) {\n          loadSource()\n        }\n      }\n    )\n\n    onMounted(() => {\n      if (props.loaderFullname) {\n        loadSource()\n      }\n    })\n\n    function shortName(fullname) {\n      if (!fullname) return \"\"\n      const parts = fullname.split(\".\")\n      return parts[parts.length - 1]\n    }\n\n    return { code, link, error, loading, shortName }\n  },\n  template: `\n  <div class=\"frv-loader-display\" style=\"border: 1px solid #ccc; border-left: none; position:relative; height:100%; background:#fff;\">\n      <div v-show=\"loading\" style=\"position:absolute; top:0; left:0; right:0; z-index:10;\">\n        <q-linear-progress indeterminate color=\"primary\" size=\"2px\"/>\n      </div>\n      <div class=\"q-ml-lg q-mt-md\">\n        <p style=\"font-size: 14px; font-weight: bold;\">\n          {{ shortName(sourceEntity) }} → {{ shortName(targetEntity) }}\n        </p>\n        <p v-if=\"label\" style=\"font-size: 12px; color: #666; margin-top: 2px; white-space: pre-line;\">\n          {{ label }}\n        </p>\n        <p style=\"font-size: 12px; color: #888; margin-top: 4px;\">\n          {{ loaderFullname }}\n        </p>\n        <a v-if=\"link\" :href=\"link\" target=\"_blank\" rel=\"noopener\" style=\"font-size:12px; color:#3b82f6;\">\n          Open in VSCode\n        </a>\n      </div>\n      <q-separator class=\"q-mt-sm\" />\n      <div style=\"padding:8px 16px 16px 16px; box-sizing:border-box; overflow:auto;\">\n        <div v-if=\"error\" style=\"color:#c10015; font-family:Menlo, monospace; font-size:12px;\">{{ error }}</div>\n        <div v-else>\n          <pre style=\"margin:0;\"><code class=\"language-python\">{{ code }}</code></pre>\n        </div>\n      </div>\n  </div>\n  `,\n})\n"
  },
  {
    "path": "src/fastapi_voyager/web/component/render-graph.js",
    "content": "import { GraphUI } from \"../graph-ui.js\"\nconst { defineComponent, ref, onMounted, nextTick } = window.Vue\n\nexport default defineComponent({\n  name: \"RenderGraph\",\n  props: {\n    coreData: { type: [Object, Array], required: false, default: null },\n  },\n  emits: [\"close\"],\n  setup(props, { emit }) {\n    const containerId = `graph-render-${Math.random().toString(36).slice(2, 9)}`\n    const hasRendered = ref(false)\n    const loading = ref(false)\n    let graphInstance = null\n\n    async function ensureGraph() {\n      await nextTick()\n      if (!graphInstance) {\n        graphInstance = new GraphUI(`#${containerId}`)\n      }\n    }\n\n    async function renderFromDot(dotText) {\n      if (!dotText) return\n      await ensureGraph()\n      await graphInstance.render(dotText)\n      hasRendered.value = true\n    }\n\n    async function renderFromCoreData() {\n      if (!props.coreData) return\n      loading.value = true\n      try {\n        const res = await fetch(\"dot-render-core-data\", {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify(props.coreData),\n        })\n        const dotText = await res.text()\n        await renderFromDot(dotText)\n        if (window.Quasar?.Notify) {\n          window.Quasar.Notify.create({ type: \"positive\", message: \"Rendered\" })\n        }\n      } catch (e) {\n        console.error(\"Render from core data failed\", e)\n        if (window.Quasar?.Notify) {\n          window.Quasar.Notify.create({ type: \"negative\", message: \"Render failed\" })\n        }\n      } finally {\n        loading.value = false\n      }\n    }\n\n    async function reload() {\n      await renderFromCoreData()\n    }\n\n    onMounted(async () => {\n      await reload()\n    })\n\n    function close() {\n      emit(\"close\")\n    }\n\n    return { containerId, close, hasRendered, reload, loading }\n  },\n  template: `\n\t\t<div style=\"height:100%; position:relative; background:#fff;\">\n\t\t\t<q-btn\n\t\t\t\tflat dense round icon=\"close\"\n\t\t\t\taria-label=\"Close\"\n\t\t\t\t@click=\"close\"\n\t\t\t\tstyle=\"position:absolute; top:6px; right:6px; z-index:11; background:rgba(255,255,255,0.85);\"\n\t\t\t/>\n\t\t\t<q-btn\n\t\t\t\tflat dense round icon=\"refresh\"\n\t\t\t\taria-label=\"Reload\"\n\t\t\t\t:loading=\"loading\"\n\t\t\t\t@click=\"reload\"\n\t\t\t\tstyle=\"position:absolute; top:6px; right:46px; z-index:11; background:rgba(255,255,255,0.85);\"\n\t\t\t/>\n\t\t\t<div :id=\"containerId\" style=\"width:100%; height:100%; overflow:auto; background:#fafafa\"></div>\n\t\t</div>\n\t`,\n})\n"
  },
  {
    "path": "src/fastapi_voyager/web/component/route-code-display.js",
    "content": "const { defineComponent, ref, watch, onMounted } = window.Vue\n\n// Component: RouteCodeDisplay\n// Props:\n//   routeId: route id key in routeItems\nexport default defineComponent({\n  name: \"RouteCodeDisplay\",\n  props: {\n    routeId: { type: String, required: true },\n  },\n  emits: [\"close\"],\n  setup(props, { emit }) {\n    const loading = ref(false)\n    const code = ref(\"\")\n    const error = ref(\"\")\n    const link = ref(\"\")\n\n    function close() {\n      emit(\"close\")\n    }\n\n    function highlightLater() {\n      requestAnimationFrame(() => {\n        try {\n          if (window.hljs) {\n            const block = document.querySelector(\".frv-route-code-display pre code.language-python\")\n            if (block) {\n              window.hljs.highlightElement(block)\n            }\n          }\n        } catch (e) {\n          console.warn(\"highlight failed\", e)\n        }\n      })\n    }\n\n    async function load() {\n      if (!props.routeId) {\n        code.value = \"\"\n        return\n      }\n\n      loading.value = true\n      error.value = null\n      code.value = \"\"\n      link.value = \"\"\n\n      // try to fetch from server: POST /source with { schema_name: routeId }\n      const payload = { schema_name: props.routeId }\n      try {\n        const resp = await fetch(`source`, {\n          method: \"POST\",\n          headers: {\n            Accept: \"application/json\",\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(payload),\n        })\n\n        const data = await resp.json().catch(() => ({}))\n        if (resp.ok) {\n          code.value = data.source_code || \"// no source code available\"\n        } else {\n          error.value = (data && data.error) || \"Failed to load source\"\n        }\n      } catch (e) {\n        error.value = e && e.message ? e.message : \"Failed to load source\"\n      } finally {\n        loading.value = false\n      }\n\n      try {\n        const resp = await fetch(`vscode-link`, {\n          method: \"POST\",\n          headers: {\n            Accept: \"application/json\",\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(payload),\n        })\n\n        const data = await resp.json().catch(() => ({}))\n        if (resp.ok) {\n          link.value = data.link || \"// no source code available\"\n        } else {\n          error.value += (data && data.error) || \"Failed to load vscode link\"\n        }\n      } catch (e) {\n      } finally {\n        loading.value = false\n      }\n\n      if (!error.value) {\n        highlightLater()\n      }\n    }\n\n    watch(\n      () => props.routeId,\n      () => {\n        load()\n      }\n    )\n\n    onMounted(() => {\n      load()\n    })\n\n    return { loading, code, error, close, link }\n  },\n  template: `\n  <div class=\"frv-route-code-display\" style=\"border:1px solid #ccc; position:relative; background:#fff;\">\n    <q-btn dense flat round icon=\"close\" @click=\"close\" aria-label=\"Close\" style=\"position:absolute; top:6px; right:6px; z-index:10; background:rgba(255,255,255,0.85)\" />\n    <div v-if=\"link\" class=\"q-ml-md q-mt-md\" style=\"padding-top:4px;\">\n      <a :href=\"link\" target=\"_blank\" rel=\"noopener\" style=\"font-size:12px; color:#3b82f6;\">Open in VSCode</a>\n    </div>\n    <div style=\"padding:40px 16px 16px 16px; box-sizing:border-box; overflow:auto;\">\n      <div v-if=\"loading\" style=\"font-family:Menlo, monospace; font-size:12px;\">Loading source...</div>\n      <div v-else-if=\"error\" style=\"color:#c10015; font-family:Menlo, monospace; font-size:12px;\">{{ error }}</div>\n      <pre v-else style=\"margin:0;\"><code class=\"language-python\">{{ code }}</code></pre>\n    </div>\n  </div>`,\n})\n"
  },
  {
    "path": "src/fastapi_voyager/web/component/schema-code-display.js",
    "content": "const { defineComponent, ref, watch, onMounted } = window.Vue\n\n// Component: SchemaCodeDisplay\n// Props:\n//   schemaName: full qualified schema id (module.Class)\n//   modelValue: boolean (dialog visibility from parent)\n//   source: optional direct source code (if already resolved client side)\n//   schemas: list of schema meta objects (each containing fullname & source_code)\n// Behavior:\n//   - When dialog opens and schemaName changes, search schemas prop and display its source_code.\n//   - No network / global cache side effects.\nexport default defineComponent({\n  name: \"SchemaCodeDisplay\",\n  props: {\n    schemaName: { type: String, required: true },\n    schemas: { type: Object, default: () => ({}) },\n    // visibility from parent (e.g., dialog v-model)\n    modelValue: { type: Boolean, default: true },\n  },\n  setup(props, { emit }) {\n    const code = ref(\"\")\n    const link = ref(\"\")\n    const error = ref(\"\")\n    const fields = ref([]) // schema fields list\n    const tab = ref(\"fields\")\n    const loading = ref(false)\n\n    async function highlightLater() {\n      // wait a tick for DOM update\n      requestAnimationFrame(() => {\n        try {\n          if (window.hljs) {\n            const block = document.querySelector(\".frv-code-display pre code.language-python\")\n            if (block) {\n              // If already highlighted by highlight.js, remove the flag so it can be highlighted again\n              if (block.dataset && block.dataset.highlighted) {\n                block.removeAttribute(\"data-highlighted\")\n              }\n              window.hljs.highlightElement(block)\n            }\n          }\n        } catch (e) {\n          console.warn(\"highlight failed\", e)\n        }\n      })\n    }\n\n    function resetState() {\n      code.value = \"\"\n      link.value = \"\"\n      error.value = null\n      fields.value = []\n      // tab.value = \"fields\";\n      loading.value = true\n    }\n\n    async function loadSource() {\n      if (!props.schemaName) return\n\n      error.value = null\n      code.value = \"\"\n      link.value = \"\"\n      loading.value = true\n\n      // try to fetch from server: /source/{schema_name}\n      const payload = { schema_name: props.schemaName }\n      try {\n        // validate input: ensure we have a non-empty schemaName\n        const resp = await fetch(`source`, {\n          method: \"POST\",\n          headers: {\n            Accept: \"application/json\",\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(payload),\n        })\n        // surface server-side validation message for bad request\n        const data = await resp.json().catch(() => ({}))\n        if (resp.ok) {\n          code.value = data.source_code || \"// no source code available\"\n        } else {\n          error.value = (data && data.error) || \"Failed to load source\"\n        }\n\n        const resp2 = await fetch(`vscode-link`, {\n          method: \"POST\",\n          headers: {\n            Accept: \"application/json\",\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(payload),\n        })\n        const data2 = await resp2.json().catch(() => ({}))\n        if (resp2.ok) {\n          link.value = data2.link || \"// no vscode link available\"\n        } else {\n          error.value = (error.value || \"\") + ((data2 && data2.error) || \"Failed to load source\")\n        }\n      } catch (e) {\n        error.value = \"Failed to load source\"\n      } finally {\n        loading.value = false\n      }\n\n      const schema = props.schemas && props.schemas[props.schemaName]\n      fields.value = Array.isArray(schema?.fields) ? schema.fields : []\n\n      if (tab.value === \"source\") {\n        highlightLater()\n      }\n    }\n\n    // re-highlight when switching back to source tab\n    watch(\n      () => tab.value,\n      (val) => {\n        if (val === \"source\") {\n          highlightLater()\n        }\n      }\n    )\n\n    watch(\n      () => props.schemaName,\n      () => {\n        resetState()\n        loadSource()\n      }\n    )\n\n    // respond to visibility changes: when shown, clear old data and reload\n    watch(\n      () => props.modelValue,\n      (val) => {\n        if (val) {\n          resetState()\n          loadSource()\n        }\n      }\n    )\n\n    onMounted(() => {\n      if (props.modelValue) {\n        resetState()\n        loadSource()\n      }\n    })\n\n    return { link, code, error, fields, tab, loading }\n  },\n  template: `\n  <div class=\"frv-code-display\" style=\"border: 1px solid #ccc; border-left: none; position:relative; height:100%; background:#fff;\">\n      <div v-show=\"loading\" style=\"position:absolute; top:0; left:0; right:0; z-index:10;\">\n        <q-linear-progress indeterminate color=\"primary\" size=\"2px\"/>\n      </div>\n      <div class=\"q-ml-lg q-mt-md\">\n        <p style=\"font-size: 16px;\"> {{ schemaName }} </p>\n        <a :href=\"link\" target=\"_blank\" rel=\"noopener\" style=\"font-size:12px; color:#3b82f6;\">\n          Open in VSCode\n        </a>\n      </div>\n\n      <div style=\"padding:8px 12px 0 12px; box-sizing:border-box;\">\n        <q-tabs v-model=\"tab\" align=\"left\" dense active-color=\"primary\" indicator-color=\"primary\" class=\"text-grey-8\">\n          <q-tab name=\"fields\" label=\"Fields\" />\n          <q-tab name=\"source\" label=\"Source Code\" />\n        </q-tabs>\n      </div>\n      <q-separator />\n      <div style=\"padding:8px 16px 16px 16px; box-sizing:border-box; overflow:auto;\">\n        <div v-if=\"error\" style=\"color:#c10015; font-family:Menlo, monospace; font-size:12px;\">{{ error }}</div>\n        <template v-else>\n          <div v-show=\"tab === 'fields'\">\n            <table style=\"border-collapse:collapse; width:100%; font-size:12px; font-family:Menlo, monospace;\">\n              <thead>\n                <tr>\n                  <th style=\"text-align:left; border-bottom:1px solid #ddd; padding:4px 6px;\">Field</th>\n                  <th style=\"text-align:left; border-bottom:1px solid #ddd; padding:4px 6px;\">Type</th>\n                  <th style=\"text-align:left; border-bottom:1px solid #ddd; padding:4px 6px;\">Description</th>\n                  <th style=\"text-align:left; border-bottom:1px solid #ddd; padding:4px 6px;\">Inherited</th>\n                </tr>\n              </thead>\n              <tbody>\n                <tr v-for=\"f in fields\" :key=\"f.name\">\n                  <td style=\"padding:4px 6px; border-bottom:1px solid #f0f0f0;\">{{ f.name }}</td>\n                  <td style=\"padding:4px 6px; border-bottom:1px solid #f0f0f0; white-space:nowrap;\">{{ f.type_name }}</td>\n                  <td style=\"padding:4px 6px; border-bottom:1px solid #f0f0f0; max-width: 200px;\">{{ f.desc }}</td>\n                  <td style=\"padding:4px 6px; border-bottom:1px solid #f0f0f0; text-align:left;\">{{ f.from_base ? '✔︎' : '' }}</td>\n                </tr>\n                <tr v-if=\"!fields.length\">\n                  <td colspan=\"3\" style=\"padding:8px 6px; color:#666; font-style:italic;\">No fields</td>\n                </tr>\n              </tbody>\n            </table>\n          </div>\n          <div v-show=\"tab === 'source'\">\n            <pre style=\"margin:0;\"><code class=\"language-python\">{{ code }}</code></pre>\n          </div>\n        </template>\n      </div>\n\t</div>\n\t`,\n})\n"
  },
  {
    "path": "src/fastapi_voyager/web/graph-ui.js",
    "content": "export class GraphUI {\n  // ====================\n  // Constants\n  // ====================\n\n  static HIGHLIGHT_COLOR = \"#FF8C00\"\n  static HIGHLIGHT_STROKE_WIDTH = \"3.0\"\n\n  // ====================\n  // Constructor\n  // ====================\n\n  constructor(selector = \"#graph\", options = {}) {\n    this.selector = selector\n    this.options = options // e.g. { onSchemaClick: (name) => {} }\n    this.graphviz = d3.select(this.selector).graphviz().zoom(false)\n\n    this.gv = null\n    this.currentSelection = []\n    this.magnifyingGlass = null\n    this.highlightMode = options.highlightMode || \"deep\"\n\n    // Magnifying glass magnification setting (radius is percentage of viewBox width)\n    this._magnification = options.magnifyingGlassMagnification || 3.0\n\n    // Highlight state snapshot for restoring after re-render\n    this._lastHighlight = null // { type: 'node', name } or { type: 'edge', source, target }\n\n    this._init()\n  }\n\n  // ====================\n  // Highlight Methods\n  // ====================\n\n  _highlight(mode = \"bidirectional\") {\n    let highlightedNodes = $()\n    for (const selection of this.currentSelection) {\n      const nodes = this._getAffectedNodes(selection.set, mode)\n      highlightedNodes = highlightedNodes.add(nodes)\n    }\n    if (this.gv) {\n      this.gv.highlight(highlightedNodes)\n      this.gv.bringToFront(highlightedNodes)\n    }\n  }\n\n  _highlightEdgeNodes() {\n    let highlightedNodes = $()\n    const [up, down, edge] = this.currentSelection\n    highlightedNodes = highlightedNodes.add(this._getAffectedNodes(up.set, up.direction))\n    highlightedNodes = highlightedNodes.add(this._getAffectedNodes(down.set, down.direction))\n    highlightedNodes = highlightedNodes.add(edge.set)\n    if (this.gv) {\n      this.gv.highlight(highlightedNodes)\n      this.gv.bringToFront(highlightedNodes)\n    }\n  }\n\n  _highlightEdgeOnly(edgeEl, sourceNodeName, targetNodeName) {\n    const nodes = this.gv.nodesByName()\n    let $set = $()\n    $set = $set.add(edgeEl)\n    if (nodes[sourceNodeName]) {\n      $set = $set.add(nodes[sourceNodeName])\n    }\n    if (nodes[targetNodeName]) {\n      $set = $set.add(nodes[targetNodeName])\n    }\n    if (this.gv) {\n      this.gv.highlight($set)\n      this.gv.bringToFront($set)\n    }\n    // Highlight node banners\n    if (nodes[sourceNodeName]) {\n      this.highlightSchemaBanner(nodes[sourceNodeName])\n    }\n    if (nodes[targetNodeName]) {\n      this.highlightSchemaBanner(nodes[targetNodeName])\n    }\n  }\n\n  _getAffectedNodes($set, mode = \"bidirectional\") {\n    let $result = $().add($set)\n    if (mode === \"bidirectional\" || mode === \"downstream\") {\n      $set.each((i, el) => {\n        if (el.className.baseVal === \"edge\") {\n          const edge = $(el).data(\"name\")\n          const nodes = this.gv.nodesByName()\n          const downStreamNode = edge.split(\"->\")[1]\n          if (downStreamNode) {\n            $result.push(nodes[downStreamNode])\n            $result = $result.add(this.gv.linkedFrom(nodes[downStreamNode], true))\n          }\n        } else {\n          $result = $result.add(this.gv.linkedFrom(el, true))\n        }\n      })\n    }\n    if (mode === \"bidirectional\" || mode === \"upstream\") {\n      $set.each((i, el) => {\n        if (el.className.baseVal === \"edge\") {\n          const edge = $(el).data(\"name\")\n          const nodes = this.gv.nodesByName()\n          const upStreamNode = edge.split(\"->\")[0]\n          if (upStreamNode) {\n            $result.push(nodes[upStreamNode])\n            $result = $result.add(this.gv.linkedTo(nodes[upStreamNode], true))\n          }\n        } else {\n          $result = $result.add(this.gv.linkedTo(el, true))\n        }\n      })\n    }\n    return $result\n  }\n\n  // ====================\n  // Schema Banner Methods\n  // ====================\n\n  highlightSchemaBanner(node) {\n    const polygons = node.querySelectorAll(\"polygon\")\n    const outerFrame = polygons[0]\n    const titleBg = polygons[1]\n\n    if (outerFrame) {\n      this._saveOriginalAttributes(outerFrame)\n      outerFrame.setAttribute(\"stroke\", GraphUI.HIGHLIGHT_COLOR)\n      outerFrame.setAttribute(\"stroke-width\", GraphUI.HIGHLIGHT_STROKE_WIDTH)\n    }\n\n    if (titleBg) {\n      this._saveOriginalAttributes(titleBg)\n      titleBg.setAttribute(\"fill\", GraphUI.HIGHLIGHT_COLOR)\n      titleBg.setAttribute(\"stroke\", GraphUI.HIGHLIGHT_COLOR)\n    }\n  }\n\n  clearSchemaBanners() {\n    if (this.gv) {\n      this.gv.highlight()\n    }\n    this._lastHighlight = null\n\n    const allPolygons = document.querySelectorAll(\"polygon[data-original-stroke]\")\n    allPolygons.forEach((polygon) => {\n      polygon.removeAttribute(\"data-original-stroke\")\n      polygon.removeAttribute(\"data-original-stroke-width\")\n      polygon.removeAttribute(\"data-original-fill\")\n    })\n  }\n\n  _saveOriginalAttributes(element) {\n    if (!element.hasAttribute(\"data-original-stroke\")) {\n      element.setAttribute(\"data-original-stroke\", element.getAttribute(\"stroke\") || \"\")\n      element.setAttribute(\n        \"data-original-stroke-width\",\n        element.getAttribute(\"stroke-width\") || \"1\"\n      )\n      element.setAttribute(\"data-original-fill\", element.getAttribute(\"fill\") || \"\")\n    }\n  }\n\n  _highlightNodeShallow(node) {\n    const nodeName = $(node).attr(\"data-name\")\n    const nodesByName = this.gv.nodesByName()\n    let $set = $().add(node)\n\n    // Find directly connected edges and their neighbor nodes (no recursion)\n    for (const edgeName in this.gv._edgesByName) {\n      const parts = edgeName.split(\"->\")\n      const srcNode = parts[0].split(\":\")[0]\n      const tgtNode = parts[1] ? parts[1].split(\":\")[0] : null\n\n      if (srcNode === nodeName || tgtNode === nodeName) {\n        this.gv._edgesByName[edgeName].forEach((edge) => {\n          $set = $set.add(edge)\n        })\n        if (srcNode === nodeName && tgtNode && nodesByName[tgtNode]) {\n          $set = $set.add(nodesByName[tgtNode])\n        }\n        if (tgtNode === nodeName && nodesByName[srcNode]) {\n          $set = $set.add(nodesByName[srcNode])\n        }\n      }\n    }\n\n    this.gv.highlight($set)\n    this.gv.bringToFront($set)\n    this.highlightSchemaBanner(node)\n    this._lastHighlight = { type: \"node\", name: nodeName }\n  }\n\n  _applyNodeHighlight(node) {\n    const set = $()\n    set.push(node)\n    const obj = { set, direction: \"bidirectional\" }\n\n    this.clearSchemaBanners()\n    this.currentSelection = [obj]\n    this._highlight()\n\n    this._lastHighlight = { type: \"node\", name: $(node).attr(\"data-name\") }\n\n    return obj\n  }\n\n  setHighlightMode(mode) {\n    this.highlightMode = mode\n  }\n\n  _restoreHighlight() {\n    if (!this._lastHighlight || !this.gv) return\n\n    if (this._lastHighlight.type === \"node\") {\n      const nodes = this.gv.nodesByName()\n      const node = nodes[this._lastHighlight.name]\n      if (node) {\n        if (this.highlightMode === \"shallow\") {\n          this._highlightNodeShallow(node)\n        } else {\n          this._applyNodeHighlight(node)\n          try {\n            this.highlightSchemaBanner(node)\n          } catch (e) {\n            console.warn(\"[restore-highlight] banner error:\", e)\n          }\n        }\n      }\n    } else if (this._lastHighlight.type === \"edge\") {\n      const { source, target } = this._lastHighlight\n      const edgeName = Object.keys(this.gv._edgesByName).find((name) => {\n        const [s, t] = name.split(\"->\")\n        return s.split(\":\")[0] === source && t.split(\":\")[0] === target\n      })\n      if (edgeName && this.gv._edgesByName[edgeName]?.[0]) {\n        if (this.highlightMode === \"shallow\") {\n          this._highlightEdgeOnly(this.gv._edgesByName[edgeName][0], source, target)\n        } else {\n          const nodes = this.gv.nodesByName()\n          const up = $()\n          const down = $()\n          const edge = $()\n          if (nodes[source]) up.push(nodes[source])\n          if (nodes[target]) down.push(nodes[target])\n          edge.push(this.gv._edgesByName[edgeName][0])\n          this.currentSelection = [\n            { set: up, direction: \"upstream\" },\n            { set: down, direction: \"downstream\" },\n            { set: edge, direction: \"single\" },\n          ]\n          this._highlightEdgeNodes()\n        }\n      }\n    }\n  }\n\n  _triggerCallback(callbackName, ...args) {\n    const callback = this.options[callbackName]\n    if (callback) {\n      try {\n        callback(...args)\n      } catch (e) {\n        console.warn(`${callbackName} callback failed`, e)\n      }\n    }\n  }\n\n  // ====================\n  // Magnifying Glass Methods\n  // ====================\n\n  _initMagnifyingGlass() {\n    // Destroy existing magnifier if any\n    if (this.magnifyingGlass) {\n      this.magnifyingGlass.destroy()\n      this.magnifyingGlass = null\n    }\n\n    // Only initialize if enabled in options (default: true)\n    if (this.options.enableMagnifyingGlass !== false) {\n      const svgElement = document.querySelector(`${this.selector} svg`)\n      if (svgElement) {\n        import(\"./magnifying-glass.js\")\n          .then((module) => {\n            const { MagnifyingGlass } = module\n            this.magnifyingGlass = new MagnifyingGlass(svgElement, {\n              magnification: this._magnification,\n            })\n          })\n          .catch((err) => {\n            console.warn(\"Failed to load magnifying glass module:\", err)\n          })\n      }\n    }\n  }\n\n  // ====================\n  // Initialization & Events\n  // ====================\n\n  _init() {\n    const self = this\n    $(this.selector).graphviz({\n      shrink: null,\n      zoom: false,\n      ready: function () {\n        self.gv = this\n\n        const nodes = self.gv.nodes()\n        const edges = self.gv.edges()\n\n        nodes.off(\".graphui\")\n        edges.off(\".graphui\")\n\n        nodes.on(\"dblclick.graphui\", function (event) {\n          event.stopPropagation()\n\n          if (self.highlightMode === \"shallow\") {\n            self.clearSchemaBanners()\n            self._highlightNodeShallow(this)\n          } else {\n            self._applyNodeHighlight(this)\n            try {\n              self.highlightSchemaBanner(this)\n            } catch (e) {\n              console.log(e)\n            }\n          }\n\n          self._triggerCallback(\"onSchemaClick\", event.currentTarget.dataset.name)\n        })\n\n        edges.on(\"click.graphui\", function (event) {\n          event.stopPropagation()\n          const [upStreamNodeRaw, downStreamNodeRaw] = event.currentTarget.dataset.name.split(\"->\")\n          // Strip port info (e.g. \"ClassA:f.owner_id\" -> \"ClassA\")\n          const upStreamNode = upStreamNodeRaw.split(\":\")[0]\n          const downStreamNode = downStreamNodeRaw.split(\":\")[0]\n\n          if (self.highlightMode === \"shallow\") {\n            self.clearSchemaBanners()\n            try {\n              self._highlightEdgeOnly(this, upStreamNode, downStreamNode)\n            } catch (e) {\n              console.warn(\"[edge-click] highlight error:\", e)\n            }\n            self._lastHighlight = { type: \"edge\", source: upStreamNode, target: downStreamNode }\n          } else {\n            const nodes = self.gv.nodesByName()\n            const up = $()\n            const down = $()\n            const edge = $()\n            if (nodes[upStreamNode]) up.push(nodes[upStreamNode])\n            if (nodes[downStreamNode]) down.push(nodes[downStreamNode])\n            edge.push(this)\n            self.currentSelection = [\n              { set: up, direction: \"upstream\" },\n              { set: down, direction: \"downstream\" },\n              { set: edge, direction: \"single\" },\n            ]\n            try {\n              self._highlightEdgeNodes()\n            } catch (e) {\n              console.warn(\"[edge-click] highlight error:\", e)\n            }\n            self._lastHighlight = { type: \"edge\", source: upStreamNode, target: downStreamNode }\n          }\n        })\n\n        edges.on(\"dblclick.graphui\", function (event) {\n          event.stopPropagation()\n          self._triggerCallback(\"onEdgeClick\", event.currentTarget.dataset.name)\n        })\n\n        nodes.on(\"click.graphui\", function (event) {\n          if (event.shiftKey) {\n            self._triggerCallback(\"onSchemaShiftClick\", event.currentTarget.dataset.name)\n          } else if (self.highlightMode === \"shallow\") {\n            self.clearSchemaBanners()\n            self._highlightNodeShallow(this)\n          } else {\n            self._applyNodeHighlight(this)\n          }\n        })\n\n        $(document)\n          .off(\"click.graphui\")\n          .on(\"click.graphui\", function (evt) {\n            const graphContainer = $(self.selector)[0]\n            if (!graphContainer || !evt.target || !graphContainer.contains(evt.target)) {\n              return\n            }\n\n            const $everything = self.gv.$nodes.add(self.gv.$edges).add(self.gv.$clusters)\n            // Walk up from click target to find if it's inside a node/edge/cluster\n            let el = evt.target\n            let isNode = false\n            while (el && el !== graphContainer) {\n              if ($everything.is(el)) {\n                isNode = true\n                break\n              }\n              el = el.parentNode\n            }\n\n            if (!isNode && self.gv) {\n              self.clearSchemaBanners()\n\n              if (self.options.resetCb) {\n                self.options.resetCb()\n              }\n            }\n          })\n      },\n    })\n  }\n\n  // ====================\n  // Render Method\n  // ====================\n\n  async render(dotSrc, resetZoom = true) {\n    const height = this.options.height || \"100%\"\n    // Save current zoom transform before re-render\n    let savedTransform = null\n    if (!resetZoom) {\n      const svgEl = document.querySelector(`${this.selector} svg`)\n      if (svgEl) {\n        savedTransform = d3.zoomTransform(svgEl)\n      }\n    }\n    return new Promise((resolve, reject) => {\n      try {\n        this.graphviz\n          .engine(\"dot\")\n          .tweenPaths(false)\n          .tweenShapes(false)\n          .zoomScaleExtent([0, Infinity])\n          .zoom(true)\n          .width(\"100%\")\n          .height(height)\n          .fit(true)\n          .renderDot(dotSrc)\n          .on(\"end\", () => {\n            $(this.selector).data(\"graphviz.svg\").setup()\n            this._restoreHighlight()\n            if (resetZoom) {\n              this.graphviz.resetZoom()\n            } else if (savedTransform) {\n              this.graphviz\n                .zoomSelection()\n                .call(this.graphviz.zoomBehavior().transform, savedTransform)\n            }\n\n            // Initialize magnifying glass after render\n            this._initMagnifyingGlass()\n\n            resolve()\n          })\n      } catch (err) {\n        reject(err)\n      }\n    })\n  }\n}\n"
  },
  {
    "path": "src/fastapi_voyager/web/graphviz.svg.css",
    "content": "/*\n * Copyright (c) 2015 Mountainstorm\n * \n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n * \n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n * \n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\n/* this element needs tooltip positioning to work */\n.graphviz-svg {\n  position: relative;\n}\n\n/* stop tooltips wrapping */\n.graphviz-svg .tooltip-inner {\n  white-space: nowrap;\n}\n\n/* stop people selecting text on nodes */\n.graphviz-svg text {\n  -webkit-touch-callout: none;\n  -webkit-user-select: none;\n  -khtml-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n  cursor: default;\n}\n\n/* ==================== */\n/* Magnifying Glass Styles */\n/* ==================== */\n\n.magnifying-lens {\n  pointer-events: none;\n  z-index: 9999;\n}\n\n.magnifying-lens .lens-border {\n  filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));\n}\n\n.magnifying-lens .lens-content {\n  shape-rendering: geometricPrecision;\n  text-rendering: geometricPrecision;\n}\n\nsvg.magnifier-active {\n  cursor: none;\n}\n"
  },
  {
    "path": "src/fastapi_voyager/web/graphviz.svg.js",
    "content": ";+(function ($) {\n  \"use strict\"\n\n  // GRAPHVIZSVG PUBLIC CLASS DEFINITION\n  // ===================================\n\n  var GraphvizSvg = function (element, options) {\n    this.type = null\n    this.options = null\n    this.enabled = null\n    this.$element = null\n\n    this.init(\"graphviz.svg\", element, options)\n  }\n\n  GraphvizSvg.VERSION = \"1.0.1\"\n\n  GraphvizSvg.GVPT_2_PX = 32.5 // used to ease removal of extra space\n\n  // SVG element selectors for color manipulation\n  // NOTE: If you need to add more element types for highlighting/dimming,\n  // update SHAPE_ELEMENTS and the code will automatically handle them\n  GraphvizSvg.SHAPE_ELEMENTS = \"polygon, ellipse, path, polyline\"\n  GraphvizSvg.TEXT_ELEMENTS = \"text\"\n  GraphvizSvg.ALL_COLOR_ELEMENTS = GraphvizSvg.SHAPE_ELEMENTS + \", \" + GraphvizSvg.TEXT_ELEMENTS\n\n  GraphvizSvg.DEFAULTS = {\n    url: null,\n    svg: null,\n    shrink: \"0.125pt\",\n    edgeHitPadding: 12,\n    pointerCursor: true,\n    zoom: true,\n    highlight: {\n      selected: function (col, bg) {\n        return col\n      },\n      unselected: function (col, bg) {\n        return jQuery.Color(col).transition(bg, 0.9)\n      },\n    },\n    ready: null,\n  }\n\n  GraphvizSvg.prototype.init = function (type, element, options) {\n    this.enabled = true\n    this.type = type\n    this.$element = $(element)\n    this.options = this.getOptions(options)\n\n    if (options.url) {\n      var that = this\n      $.get(\n        options.url,\n        null,\n        function (data) {\n          var svg = $(\"svg\", data)\n          that.$element.html(document.adoptNode(svg[0]))\n          that.setup()\n        },\n        \"xml\"\n      )\n    } else {\n      if (options.svg) {\n        this.$element.html(options.svg)\n      }\n      this.setup()\n    }\n  }\n\n  GraphvizSvg.prototype.getDefaults = function () {\n    return GraphvizSvg.DEFAULTS\n  }\n\n  GraphvizSvg.prototype.getOptions = function (options) {\n    options = $.extend({}, this.getDefaults(), this.$element.data(), options)\n\n    if (options.shrink) {\n      if (typeof options.shrink != \"object\") {\n        options.shrink = {\n          x: options.shrink,\n          y: options.shrink,\n        }\n      }\n      options.shrink.x = this.convertToPx(options.shrink.x)\n      options.shrink.y = this.convertToPx(options.shrink.y)\n    }\n    return options\n  }\n\n  GraphvizSvg.prototype.setup = function () {\n    var options = this.options\n\n    // save key elements in the graph for easy access\n    var $svg = $(this.$element.children(\"svg\"))\n    var $graph = $svg.children(\"g:first\")\n    this.$svg = $svg\n    this.$graph = $graph\n    this.$background = $graph.children(\"polygon:first\") // might not exist\n    this.$nodes = $graph.children(\".node\")\n    this.$edges = $graph.children(\".edge\")\n    this.$clusters = $graph.children(\".cluster\")\n    this._nodesByName = {}\n    this._edgesByName = {}\n    this._clustersByName = {}\n\n    // add top level class and copy background color to element\n    this.$element.addClass(\"graphviz-svg\")\n    if (this.$background.length) {\n      this.$element.css(\"background\", this.$background.attr(\"fill\"))\n    }\n\n    // setup all the nodes and edges\n    var that = this\n    this.$nodes.each(function () {\n      $(this).attr({\n        \"pointer-events\": \"visible\",\n      })\n      that.setupNodesEdges($(this), \"node\")\n    })\n    this.$edges.each(function () {\n      that.setupNodesEdges($(this), \"edge\")\n    })\n    this.$clusters.each(function () {\n      that.setupNodesEdges($(this), \"cluster\")\n    })\n\n    // remove the graph title element\n    var $title = this.$graph.children(\"title\")\n    this.$graph.attr(\"data-name\", $title.text())\n    $title.remove()\n\n    if (options.zoom) {\n      this.setupZoom()\n    }\n\n    // tell people we're done\n    if (options.ready) {\n      options.ready.call(this)\n    }\n  }\n\n  GraphvizSvg.prototype.setupNodesEdges = function ($el, type) {\n    var that = this\n    var options = this.options\n\n    if (type === \"edge\" && options.edgeHitPadding) {\n      this.ensureEdgeHitArea($el, options.edgeHitPadding)\n    }\n\n    if (options.pointerCursor && (type === \"edge\" || type === \"node\")) {\n      this.setInteractiveCursor($el, type === \"edge\")\n    }\n\n    // Save the colors of shape elements (polygon, ellipse, path, polyline)\n    $el.find(GraphvizSvg.SHAPE_ELEMENTS).each(function () {\n      var $this = $(this)\n      if ($this.attr(\"data-graphviz-hitbox\") === \"true\") {\n        return\n      }\n      // save original colors\n      $this.data(\"graphviz.svg.color\", {\n        fill: $this.attr(\"fill\"),\n        stroke: $this.attr(\"stroke\"),\n      })\n\n      // shrink it if it's a node\n      if (type === \"node\" && options.shrink) {\n        that.scaleNode($this)\n      }\n    })\n\n    // Save the colors of text elements\n    $el.find(GraphvizSvg.TEXT_ELEMENTS).each(function () {\n      var $this = $(this)\n      // text elements might not have explicit fill attribute, use black as default\n      var fill = $this.attr(\"fill\")\n      if (!fill || fill === \"none\") {\n        fill = \"#000000\" // default black color for text\n      }\n      $this.data(\"graphviz.svg.color\", {\n        fill: fill,\n        stroke: $this.attr(\"stroke\"),\n      })\n    })\n\n    // save the node name and check if theres a comment above; save it\n    var $title = $el.children(\"title\")\n    if ($title[0]) {\n      // remove any compass points:\n      var title = $title.text().replace(/:[snew][ew]?/g, \"\")\n      $el.attr(\"data-name\", title)\n      $title.remove()\n      if (type === \"node\") {\n        this._nodesByName[title] = $el[0]\n      } else if (type === \"edge\") {\n        if (!this._edgesByName[title]) {\n          this._edgesByName[title] = []\n        }\n        this._edgesByName[title].push($el[0])\n      } else if (type === \"cluster\") {\n        this._clustersByName[title] = $el[0]\n      }\n      // without a title we can't tell if its a user comment or not\n      var previousSibling = $el[0].previousSibling\n      while (previousSibling && previousSibling.nodeType != 8) {\n        previousSibling = previousSibling.previousSibling\n      }\n      if (previousSibling != null && previousSibling.nodeType == 8) {\n        var htmlDecode = function (input) {\n          var e = document.createElement(\"div\")\n          e.innerHTML = input\n          return e.childNodes[0].nodeValue\n        }\n        var value = htmlDecode(previousSibling.nodeValue.trim())\n        if (value != title) {\n          // user added comment\n          $el.attr(\"data-comment\", value)\n        }\n      }\n    }\n\n    // remove namespace from a[xlink:title]\n    $el\n      .find(\"a\")\n      .filter(function () {\n        return $(this).attr(\"xlink:title\")\n      })\n      .each(function () {\n        var $a = $(this)\n        $a.attr(\"title\", $a.attr(\"xlink:title\"))\n        $a.removeAttr(\"xlink:title\")\n      })\n  }\n\n  GraphvizSvg.prototype.setupZoom = function () {\n    var that = this\n    var $element = this.$element\n    var $svg = this.$svg\n    this.zoom = {\n      width: $svg.attr(\"width\"),\n      height: $svg.attr(\"height\"),\n      percentage: null,\n    }\n    this.scaleView(100.0)\n    $element.mousewheel(function (evt) {\n      if (evt.shiftKey) {\n        var percentage = that.zoom.percentage\n        percentage -= evt.deltaY * evt.deltaFactor\n        if (percentage < 100.0) {\n          percentage = 100.0\n        }\n        // get pointer offset in view\n        // ratio offset within svg\n        var dx = evt.pageX - $svg.offset().left\n        var dy = evt.pageY - $svg.offset().top\n        var rx = dx / $svg.width()\n        var ry = dy / $svg.height()\n\n        // offset within frame ($element)\n        var px = evt.pageX - $element.offset().left\n        var py = evt.pageY - $element.offset().top\n\n        that.scaleView(percentage)\n        // scroll so pointer is still in same place\n        $element.scrollLeft(rx * $svg.width() + 0.5 - px)\n        $element.scrollTop(ry * $svg.height() + 0.5 - py)\n        return false // stop propogation\n      }\n    })\n  }\n\n  GraphvizSvg.prototype.scaleView = function (percentage) {\n    var $svg = this.$svg\n    $svg.attr(\"width\", percentage + \"%\")\n    $svg.attr(\"height\", percentage + \"%\")\n    this.zoom.percentage = percentage\n  }\n\n  GraphvizSvg.prototype.scaleNode = function ($node) {\n    var dx = this.options.shrink.x\n    var dy = this.options.shrink.y\n    var tagName = $node.prop(\"tagName\")\n    if (tagName == \"ellipse\") {\n      $node.attr(\"rx\", parseFloat($node.attr(\"rx\")) - dx)\n      $node.attr(\"ry\", parseFloat($node.attr(\"ry\")) - dy)\n    } else if (tagName == \"polygon\") {\n      // this is more complex - we need to scale it manually\n      var bbox = $node[0].getBBox()\n      var cx = bbox.x + bbox.width / 2\n      var cy = bbox.y + bbox.height / 2\n      var pts = $node.attr(\"points\").split(\" \")\n      var points = \"\" // new value\n      for (var i in pts) {\n        var xy = pts[i].split(\",\")\n        var ox = parseFloat(xy[0])\n        var oy = parseFloat(xy[1])\n        points +=\n          ((cx - ox) / (bbox.width / 2)) * dx +\n          ox +\n          \",\" +\n          (((cy - oy) / (bbox.height / 2)) * dy + oy) +\n          \" \"\n      }\n      $node.attr(\"points\", points)\n    }\n  }\n\n  GraphvizSvg.prototype.ensureEdgeHitArea = function ($edge, padding) {\n    var width = parseFloat(padding)\n    if (!isFinite(width) || width <= 0) {\n      return\n    }\n    var $paths = $edge.children(\"path\").filter(function () {\n      return $(this).attr(\"data-graphviz-hitbox\") !== \"true\"\n    })\n    if (!$paths.length) {\n      return\n    }\n    $paths.each(function () {\n      var $path = $(this)\n      var $existing = $path.prev('[data-graphviz-hitbox=\"true\"]')\n      if ($existing.length) {\n        $existing.attr(\"stroke-width\", width)\n        return\n      }\n      var clone = this.cloneNode(false)\n\n      /**\n       * gtp-5-codex:\n       * Cloning the edge paths without copying D3’s data binding caused those Cannot\n       * read properties of undefined (reading 'key') errors when d3-graphviz re-rendered.\n       * I now copy the original path’s bound datum (__data__) onto the transparent hitbox\n       * clone inside ensureEdgeHitArea, so D3 still finds the expected metadata.\n       */\n      if (this.__data__) {\n        clone.__data__ = this.__data__\n      }\n\n      var $clone = $(clone)\n      $clone.attr({\n        \"data-graphviz-hitbox\": \"true\",\n        stroke: \"transparent\",\n        fill: \"none\",\n        \"stroke-width\": width,\n      })\n      $clone.attr(\"pointer-events\", \"stroke\")\n      $clone.css(\"pointer-events\", \"stroke\")\n      if (!$clone.attr(\"stroke-linecap\")) {\n        $clone.attr(\"stroke-linecap\", $path.attr(\"stroke-linecap\") || \"round\")\n      }\n      $clone.insertBefore($path)\n    })\n  }\n\n  GraphvizSvg.prototype.setInteractiveCursor = function ($el, isEdge) {\n    $el.css(\"cursor\", \"pointer\")\n    var selectors = \"path, polygon, ellipse, rect, text\"\n    $el.find(selectors).each(function () {\n      $(this).css(\"cursor\", \"pointer\")\n    })\n    if (isEdge) {\n      $el.children('[data-graphviz-hitbox=\"true\"]').css(\"cursor\", \"pointer\")\n    }\n    $el.find(\"a\").each(function () {\n      $(this).css(\"cursor\", \"pointer\")\n    })\n  }\n\n  GraphvizSvg.prototype.convertToPx = function (val) {\n    var retval = val\n    if (typeof val == \"string\") {\n      var end = val.length\n      var factor = 1.0\n      if (val.endsWith(\"px\")) {\n        end -= 2\n      } else if (val.endsWith(\"pt\")) {\n        end -= 2\n        factor = GraphvizSvg.GVPT_2_PX\n      }\n      retval = parseFloat(val.substring(0, end)) * factor\n    }\n    return retval\n  }\n\n  // Helper function to apply color transformation to elements\n  GraphvizSvg.prototype._applyColorToElements = function (\n    $elements,\n    colorTransformer,\n    bgColor,\n    setStrokeWidth\n  ) {\n    var that = this\n    $elements.each(function () {\n      var $this = $(this)\n      if ($this.attr(\"data-graphviz-hitbox\") === \"true\") {\n        return\n      }\n      var color = $this.data(\"graphviz.svg.color\")\n      if (color) {\n        if (color.fill && color.fill != \"none\") {\n          $this.attr(\"fill\", colorTransformer(color.fill, bgColor))\n        }\n        if (color.stroke && color.stroke != \"none\") {\n          $this.attr(\"stroke\", colorTransformer(color.stroke, bgColor))\n        }\n        if (setStrokeWidth !== undefined) {\n          $this.attr(\"stroke-width\", setStrokeWidth)\n        }\n      }\n    })\n  }\n\n  // Helper function to restore original colors\n  GraphvizSvg.prototype._restoreElementColors = function ($elements, setStrokeWidth) {\n    var that = this\n    $elements.each(function () {\n      var $this = $(this)\n      if ($this.attr(\"data-graphviz-hitbox\") === \"true\") {\n        return\n      }\n      var color = $this.data(\"graphviz.svg.color\")\n      if (color) {\n        if (color.fill && color.fill != \"none\") {\n          $this.attr(\"fill\", color.fill)\n        }\n        if (color.stroke && color.stroke != \"none\") {\n          $this.attr(\"stroke\", color.stroke)\n        }\n        if (setStrokeWidth !== undefined) {\n          $this.attr(\"stroke-width\", setStrokeWidth)\n        }\n      }\n    })\n  }\n\n  GraphvizSvg.prototype.findEdge = function (nodeName, testEdge, $retval) {\n    var retval = []\n    for (var name in this._edgesByName) {\n      var match = testEdge(nodeName, name)\n      if (match) {\n        if ($retval) {\n          this._edgesByName[name].forEach((edge) => {\n            $retval.push(edge)\n          })\n        }\n        retval.push(match)\n      }\n    }\n    return retval\n  }\n\n  GraphvizSvg.prototype.findLinked = function (node, includeEdges, testEdge, $retval) {\n    var that = this\n    var $node = $(node)\n    var $edges = null\n    if (includeEdges) {\n      $edges = $retval\n    }\n    var names = this.findEdge($node.attr(\"data-name\"), testEdge, $edges)\n    for (var i in names) {\n      var n = this._nodesByName[names[i]]\n      if (!$retval.is(n)) {\n        $retval.push(n)\n        that.findLinked(n, includeEdges, testEdge, $retval)\n      }\n    }\n  }\n\n  GraphvizSvg.prototype.colorElement = function ($el, getColor) {\n    var bg = this.$element.css(\"background\")\n\n    // Apply color transformation to all elements (shapes + text)\n    this._applyColorToElements($el.find(GraphvizSvg.ALL_COLOR_ELEMENTS), getColor, bg)\n  }\n\n  GraphvizSvg.prototype.restoreElement = function ($el) {\n    // Restore original colors for all elements (shapes + text)\n    this._restoreElementColors($el.find(GraphvizSvg.ALL_COLOR_ELEMENTS), 1)\n  }\n\n  // methods users can actually call\n  GraphvizSvg.prototype.nodes = function () {\n    return this.$nodes\n  }\n\n  GraphvizSvg.prototype.edges = function () {\n    return this.$edges\n  }\n\n  GraphvizSvg.prototype.clusters = function () {\n    return this.$clusters\n  }\n\n  GraphvizSvg.prototype.nodesByName = function () {\n    return this._nodesByName\n  }\n\n  GraphvizSvg.prototype.edgesByName = function () {\n    return this._edgesByName\n  }\n\n  GraphvizSvg.prototype.clustersByName = function () {\n    return this._clustersByName\n  }\n\n  GraphvizSvg.prototype.linkedTo = function (node, includeEdges) {\n    var $retval = $()\n    this.findLinked(\n      node,\n      includeEdges,\n      function (nodeName, edgeName) {\n        var other = null\n\n        const connection = edgeName.split(\"->\")\n        if (\n          connection.length > 1 &&\n          (connection[1] === nodeName || connection[1].startsWith(nodeName + \":\"))\n        ) {\n          return connection[0].split(\":\")[0]\n        }\n\n        return other\n      },\n      $retval\n    )\n    return $retval\n  }\n\n  GraphvizSvg.prototype.linkedFrom = function (node, includeEdges) {\n    var $retval = $()\n    this.findLinked(\n      node,\n      includeEdges,\n      function (nodeName, edgeName) {\n        var other = null\n\n        const connection = edgeName.split(\"->\")\n        if (\n          connection.length > 1 &&\n          (connection[0] === nodeName || connection[0].startsWith(nodeName + \":\"))\n        ) {\n          return connection[1].split(\":\")[0]\n        }\n        return other\n      },\n      $retval\n    )\n    return $retval\n  }\n\n  GraphvizSvg.prototype.linked = function (node, includeEdges) {\n    var $retval = $()\n    this.findLinked(\n      node,\n      includeEdges,\n      function (nodeName, edgeName) {\n        return \"^\" + name + \"--(.*)$\"\n      },\n      $retval\n    )\n    this.findLinked(\n      node,\n      includeEdges,\n      function (nodeName, edgeName) {\n        return \"^(.*)--\" + name + \"$\"\n      },\n      $retval\n    )\n    return $retval\n  }\n\n  GraphvizSvg.prototype.bringToFront = function ($elements) {\n    $elements.detach().appendTo(this.$graph)\n  }\n\n  GraphvizSvg.prototype.sendToBack = function ($elements) {\n    if (this.$background.length) {\n      $element.insertAfter(this.$background)\n    } else {\n      $elements.detach().prependTo(this.$graph)\n    }\n  }\n\n  GraphvizSvg.prototype.highlight = function ($nodesEdges) {\n    var that = this\n    var options = this.options\n    var $everything = this.$nodes.add(this.$edges).add(this.$clusters)\n    if ($nodesEdges && $nodesEdges.length > 0) {\n      // create set of all other elements and dim them\n      $everything.not($nodesEdges).each(function () {\n        that.colorElement($(this), options.highlight.unselected)\n      })\n      $nodesEdges.each(function () {\n        that.colorElement($(this), options.highlight.selected)\n      })\n    } else {\n      $everything.each(function () {\n        that.restoreElement($(this))\n      })\n    }\n  }\n\n  GraphvizSvg.prototype.destroy = function () {\n    var that = this\n    this.hide(function () {\n      that.$element.off(\".\" + that.type).removeData(that.type)\n    })\n  }\n\n  // GRAPHVIZSVG PLUGIN DEFINITION\n  // =============================\n\n  function Plugin(option) {\n    return this.each(function () {\n      var $this = $(this)\n      var data = $this.data(\"graphviz.svg\")\n      var options = typeof option == \"object\" && option\n\n      if (!data && /destroy/.test(option)) return\n      if (!data) $this.data(\"graphviz.svg\", (data = new GraphvizSvg(this, options)))\n      if (typeof option == \"string\") data[option]()\n    })\n  }\n\n  var old = $.fn.graphviz\n\n  $.fn.graphviz = Plugin\n  $.fn.graphviz.Constructor = GraphvizSvg\n\n  // GRAPHVIZ NO CONFLICT\n  // ====================\n\n  $.fn.graphviz.noConflict = function () {\n    $.fn.graphviz = old\n    return this\n  }\n})(jQuery)\n"
  },
  {
    "path": "src/fastapi_voyager/web/icon/site.webmanifest",
    "content": "{\n  \"name\": \"FastAPI Voyager\",\n  \"short_name\": \"Voyager\",\n  \"description\": \"Visualize API routing tree and dependencies\",\n  \"start_url\": \"<!-- VOYAGER_PATH -->\",\n  \"scope\": \"<!-- VOYAGER_PATH -->\",\n  \"icons\": [\n    { \"src\": \"android-chrome-192x192.png\", \"sizes\": \"192x192\", \"type\": \"image/png\" },\n    { \"src\": \"android-chrome-512x512.png\", \"sizes\": \"512x512\", \"type\": \"image/png\" }\n  ],\n  \"theme_color\": \"#009485\",\n  \"background_color\": \"#ffffff\",\n  \"display\": \"standalone\",\n  \"orientation\": \"landscape-primary\",\n  \"categories\": [\"developer tools\", \"utilities\"]\n}\n"
  },
  {
    "path": "src/fastapi_voyager/web/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <title>FastAPI Voyager</title>\n    <meta name=\"description\" content=\"Visualize API routing tree and dependencies\" />\n    <meta name=\"theme-color\" content=\"#ffffff\" />\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\" />\n    <meta name=\"apple-mobile-web-app-title\" content=\"Voyager\" />\n    <link\n      rel=\"stylesheet\"\n      href=\"<!-- STATIC_PATH -->/graphviz.svg.css<!-- VERSION_PLACEHOLDER -->\"\n    />\n    <!-- App Icons / Favicons -->\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"180x180\"\n      href=\"<!-- STATIC_PATH -->/icon/apple-touch-icon.png\"\n    />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      sizes=\"32x32\"\n      href=\"<!-- STATIC_PATH -->/icon/favicon-32x32.png\"\n    />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      sizes=\"16x16\"\n      href=\"<!-- STATIC_PATH -->/icon/favicon-16x16.png\"\n    />\n    <link rel=\"icon\" href=\"<!-- STATIC_PATH -->/icon/favicon.ico\" sizes=\"any\" />\n    <!-- highlight.js CSS -->\n    <link\n      rel=\"stylesheet\"\n      href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\"\n    />\n  </head>\n  <style>\n    :root {\n      --primary-color: <!-- THEME_COLOR -->;\n    }\n    html,\n    body {\n      height: 100%;\n      margin: 0;\n    }\n    body {\n      display: flex;\n      flex-direction: column;\n    }\n    #graph {\n      flex: 1 1 auto;\n      overflow: auto;\n    }\n    .inherit-flow {\n      stroke-dasharray: 8 6;\n      stroke-linecap: round;\n      animation: dash 2s linear infinite;\n      animation-direction: reverse;\n    }\n    @keyframes dash {\n      to {\n        stroke-dashoffset: -14;\n      }\n    }\n    .adjust-fit {\n      height: calc(100vh - 54px);\n    }\n    .github-corner:hover .octo-arm {\n      animation: octocat-wave 560ms ease-in-out;\n    }\n    @keyframes octocat-wave {\n      0%,\n      100% {\n        transform: rotate(0);\n      }\n      20%,\n      60% {\n        transform: rotate(-25deg);\n      }\n      40%,\n      80% {\n        transform: rotate(10deg);\n      }\n    }\n    @media (max-width: 500px) {\n      .github-corner:hover .octo-arm {\n        animation: none;\n      }\n      .github-corner .octo-arm {\n        animation: octocat-wave 560ms ease-in-out;\n      }\n    }\n    .tag-navigator-collapse-btn-right {\n      position: absolute;\n      bottom: 8px;\n      left: 8px;\n      width: 32px;\n      height: 32px;\n      border-radius: 50%;\n      background: var(--primary-color);\n      color: white;\n      cursor: pointer;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      z-index: 100;\n      transition: all 0.2s ease;\n    }\n    .tag-navigator-collapse-btn-right:hover {\n      filter: brightness(0.9);\n      box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);\n    }\n    #app-loading-overlay {\n      position: fixed;\n      inset: 0;\n      display: none;\n      align-items: center;\n      justify-content: center;\n      gap: 12px;\n      background: #ffffff;\n      z-index: 9999;\n      font-family:\n        -apple-system,\n        BlinkMacSystemFont,\n        Segoe UI,\n        Roboto,\n        Helvetica,\n        Arial,\n        sans-serif;\n      color: var(--primary-color);\n    }\n    .loading-text {\n      font-size: 14px;\n    }\n    .spinner {\n      width: 20px;\n      height: 20px;\n      border: 2px solid rgba(0, 148, 133, 0.2);\n      border-top-color: var(--primary-color);\n      border-radius: 50%;\n      animation: frv-spin 0.8s linear infinite;\n    }\n    @keyframes frv-spin {\n      to {\n        transform: rotate(360deg);\n      }\n    }\n    body.app-loading #app {\n      visibility: hidden;\n    }\n    body.app-loading #app-loading-overlay {\n      display: flex;\n    }\n  </style>\n  <body class=\"app-loading\">\n    <script>\n      window.FRAMEWORK_THEME_COLOR = \"<!-- THEME_COLOR -->\"\n    </script>\n    <div id=\"app-loading-overlay\" aria-busy=\"true\" aria-live=\"polite\">\n      <div class=\"spinner\" aria-hidden=\"true\"></div>\n      <div class=\"loading-text\">Loading…</div>\n    </div>\n    <div id=\"app\"></div>\n\n    <!-- CDN libraries (not bundled) -->\n    <script\n      src=\"https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js\"\n      integrity=\"sha512-egJ/Y+22P9NQ9aIyVCh0VCOsfydyn8eNmqBy+y2CnJG+fpRIxXMS6jbWP8tVKp0jp+NO5n8WtMUAnNnGoJKi4w==\"\n      crossorigin=\"anonymous\"\n      referrerpolicy=\"no-referrer\"\n    ></script>\n    <script\n      src=\"https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js\"\n      integrity=\"sha512-vc58qvvBdrDR4etbxMdlTt4GBQk1qjvyORR2nrsPsFPyrs+/u5c3+1Ct6upOgdZoIl7eq6k3a1UPDSNAQi/32A==\"\n      crossorigin=\"anonymous\"\n      referrerpolicy=\"no-referrer\"\n    ></script>\n    <script src=\"https://unpkg.com/@hpcc-js/wasm@2.20.0/dist/graphviz.umd.js\"></script>\n    <script\n      src=\"https://cdnjs.cloudflare.com/ajax/libs/d3-graphviz/5.6.0/d3-graphviz.min.js\"\n      integrity=\"sha512-Le8HpIpS2Tc7SDHLM6AOgAKq6ZR4uDwLhjPSR20DtXE5dFb9xECHRwgpc1nxxnU0Dv+j6FNMoSddky5gyvI3lQ==\"\n      crossorigin=\"anonymous\"\n      referrerpolicy=\"no-referrer\"\n    ></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/jquery-mousewheel/3.1.13/jquery.mousewheel.min.js\"></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/jquery-color/2.1.2/jquery.color.min.js\"></script>\n\n    <!-- highlight.js (async module) -->\n    <script type=\"module\">\n      window.addEventListener(\"DOMContentLoaded\", async () => {\n        if (!window.hljs) {\n          try {\n            const { default: hljs } =\n              await import(\"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/es/highlight.min.js\")\n            const { default: python } =\n              await import(\"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/es/languages/python.min.js\")\n            hljs.registerLanguage(\"python\", python)\n            window.hljs = hljs\n          } catch (e) {\n            console.warn(\"Failed to preload highlight.js\", e)\n          }\n        }\n      })\n    </script>\n\n    <!-- Graphviz SVG renderer (local) -->\n    <script src=\"<!-- STATIC_PATH -->/graphviz.svg.js<!-- VERSION_PLACEHOLDER -->\"></script>\n\n    <!-- Vite entry -->\n    <script type=\"module\" src=\"/src/main.js\"></script>\n\n    <!-- GA_SNIPPET -->\n  </body>\n</html>\n"
  },
  {
    "path": "src/fastapi_voyager/web/magnifying-glass.js",
    "content": "/**\n * Magnifying Glass for SVG Graph Visualization\n *\n * Provides a circular magnifying glass effect that follows the mouse cursor.\n * Activated by pressing the Space key.\n *\n * Usage:\n *   const magnifier = new MagnifyingGlass(svgElement, {\n *     magnification: 2.0\n *   })\n *\n * The lens radius is automatically calculated based on viewBox width.\n */\n\nexport class MagnifyingGlass {\n  // Class constants\n  static DEFAULT_MAGNIFICATION = 2.0\n  static RADIUS_PERCENTAGE = 0.2 // Percentage of viewBox width\n  static LENS_OFFSET = 10 // 放大镜相对于鼠标的偏移量\n  static BORDER_WIDTH = 2 // 边框宽度\n  static UPDATE_THROTTLE_MS = 16 // 更新节流（约60fps）\n\n  /**\n   * Extract viewBox dimensions from SVG element (called dynamically each time)\n   * @private\n   */\n  _getViewBoxDimensions() {\n    const viewBoxAttr = this.svg.getAttribute(\"viewBox\")\n    if (viewBoxAttr) {\n      const parts = viewBoxAttr.trim().split(/\\s+/)\n      if (parts.length === 4) {\n        const [, , width, height] = parts.map(parseFloat)\n        if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {\n          return { width, height }\n        }\n      }\n    }\n    // Fallback to getBoundingClientRect if no viewBox\n    const rect = this.svg.getBoundingClientRect()\n    return { width: rect.width || 1000, height: rect.height || 1000 }\n  }\n\n  /**\n   * Get current radius (dynamically calculated based on current viewBox width)\n   * @returns {number} Radius in SVG units\n   */\n  get radius() {\n    const { width } = this._getViewBoxDimensions()\n    return Math.round(width * MagnifyingGlass.RADIUS_PERCENTAGE)\n  }\n\n  /**\n   * @param {SVGElement} svgElement - The SVG element to magnify\n   * @param {Object} options - Configuration options\n   * @param {number} options.magnification - Zoom level (default: 2.0)\n   * @param {boolean} options.debug - Enable debug logging (default: false)\n   */\n  constructor(svgElement, options = {}) {\n    // Validate SVG element\n    if (!svgElement || !(svgElement instanceof SVGElement)) {\n      throw new Error(\"[MagnifyingGlass] Invalid SVG element provided\")\n    }\n\n    this.svg = svgElement\n\n    // Calculate magnification\n    this._magnification = this._validateNumber(\n      options.magnification,\n      MagnifyingGlass.DEFAULT_MAGNIFICATION,\n      0.1,\n      10\n    )\n\n    this.debug = options.debug || false\n    this.active = false\n\n    // Throttle updates for performance\n    this._pendingUpdate = false\n    this._lastPosition = null\n\n    // Content caching for performance\n    this._cachedContent = null\n    this._contentDirty = true\n\n    this._initLens()\n    this._bindEvents()\n  }\n\n  /**\n   * Get current magnification\n   */\n  get magnification() {\n    return this._magnification\n  }\n\n  /**\n   * Set magnification and update lens if active\n   * @param {number} value - New magnification value\n   */\n  set magnification(value) {\n    const validated = this._validateNumber(value, MagnifyingGlass.DEFAULT_MAGNIFICATION, 0.1, 10)\n    if (validated !== this._magnification) {\n      this._magnification = validated\n      this._log(\"Magnification updated to:\", validated)\n\n      // 如果放大镜当前激活，立即更新显示\n      if (this.active && this._lastPosition) {\n        this._updateTransform(this._lastPosition.x, this._lastPosition.y)\n      }\n    }\n  }\n\n  /**\n   * Validate and sanitize number input\n   * @param {*} value - Value to validate\n   * @param {number} defaultValue - Default value if invalid\n   * @param {number} min - Minimum allowed value\n   * @param {number} max - Maximum allowed value\n   * @returns {number} Validated number\n   * @private\n   */\n  _validateNumber(value, defaultValue, min, max) {\n    if (typeof value !== \"number\" || isNaN(value)) {\n      return defaultValue\n    }\n    return Math.max(min, Math.min(max, value))\n  }\n\n  /**\n   * Internal logging method\n   * @private\n   */\n  _log(...args) {\n    if (this.debug) {\n      console.log(\"[MagnifyingGlass]\", ...args)\n    }\n  }\n\n  /**\n   * Initialize the lens SVG elements\n   * @private\n   */\n  _initLens() {\n    this._log(\"Initializing lens...\")\n    // 1. Create defs and clipPath\n    const defs = d3.select(this.svg).append(\"defs\")\n    this.clipPathId = `lens-clip-${Math.random().toString(36).substr(2, 9)}`\n    this._log(\"clipPathId:\", this.clipPathId)\n\n    defs\n      .append(\"clipPath\")\n      .attr(\"id\", this.clipPathId)\n      .append(\"circle\")\n      .attr(\"r\", this.radius)\n      .attr(\"cx\", 0)\n      .attr(\"cy\", 0)\n\n    // 2. Create lens group (initially hidden)\n    this.lensGroup = d3\n      .select(this.svg)\n      .append(\"g\")\n      .attr(\"class\", \"magnifying-lens\")\n      .style(\"display\", \"none\")\n\n    // 3. Create lens border circle\n    this.lensGroup\n      .append(\"circle\")\n      .attr(\"class\", \"lens-border\")\n      .attr(\"r\", this.radius + MagnifyingGlass.BORDER_WIDTH)\n      .attr(\"fill\", \"rgba(255,255,255,0.95)\")\n      .attr(\"stroke\", \"#999\")\n      .attr(\"stroke-width\", MagnifyingGlass.BORDER_WIDTH)\n      .attr(\"cx\", 0) // Initialize at origin, will be updated on mouse move\n      .attr(\"cy\", 0)\n\n    // 4. Create clipped content group\n    this.lensContent = this.lensGroup\n      .append(\"g\")\n      .attr(\"clip-path\", `url(#${this.clipPathId})`)\n      .append(\"g\")\n      .attr(\"class\", \"lens-content\")\n\n    this._log(\"Lens initialized successfully\")\n  }\n\n  /**\n   * Bind keyboard and mouse events\n   * @private\n   */\n  _bindEvents() {\n    // Space key to toggle\n    this._handleKeyDown = (e) => {\n      if (e.code === \"Space\" && !e.repeat) {\n        e.preventDefault()\n        this._log(\"Space pressed, activating...\")\n        this.toggle()\n      }\n    }\n\n    this._handleKeyUp = (e) => {\n      if (e.code === \"Space\") {\n        this._log(\"Space released, deactivating...\")\n        this.deactivate()\n      }\n    }\n\n    this._handleMouseMove = (e) => {\n      // 记录最后鼠标位置，用于第一次激活时的位置计算\n      const rect = this.svg.getBoundingClientRect()\n      this._lastMousePos = {\n        x: e.clientX - rect.left,\n        y: e.clientY - rect.top,\n      }\n\n      if (this.active) {\n        this._updatePosition(e)\n      }\n    }\n\n    this._handleClick = (e) => {\n      if (this.active) {\n        this._log(\"Clicked, deactivating...\")\n        this.deactivate()\n      }\n    }\n\n    document.addEventListener(\"keydown\", this._handleKeyDown)\n    document.addEventListener(\"keyup\", this._handleKeyUp)\n    this.svg.addEventListener(\"mousemove\", this._handleMouseMove)\n    this.svg.addEventListener(\"click\", this._handleClick)\n\n    this._log(\"Events bound successfully\")\n  }\n\n  /**\n   * Update lens position and content based on mouse position\n   * @private\n   */\n  _updatePosition(event) {\n    // Use requestAnimationFrame for smooth performance\n    if (this._pendingUpdate) return\n\n    this._pendingUpdate = true\n    requestAnimationFrame(() => {\n      this._performUpdate(event)\n      this._pendingUpdate = false\n    })\n  }\n\n  /**\n   * Perform the actual position update\n   * @private\n   */\n  _performUpdate(event) {\n    try {\n      // 使用 SVG 标准的坐标转换方法，代替 getBoundingClientRect()\n      const pt = this.svg.createSVGPoint()\n      pt.x = event.clientX\n      pt.y = event.clientY\n\n      let svgP\n      try {\n        // 转换为 SVG 坐标（考虑 SVG 内部所有变换）\n        const ctm = this.svg.getScreenCTM()\n        if (!ctm || !ctm.inverse) {\n          // 如果 getScreenCTM() 失败，退回到简单方法\n          const rect = this.svg.getBoundingClientRect()\n          svgP = {\n            x: event.clientX - rect.left,\n            y: event.clientY - rect.top,\n          }\n        } else {\n          svgP = pt.matrixTransform(ctm.inverse())\n        }\n      } catch (e) {\n        // 容错处理\n        const rect = this.svg.getBoundingClientRect()\n        svgP = {\n          x: event.clientX - rect.left,\n          y: event.clientY - rect.top,\n        }\n      }\n\n      // Get current radius (dynamically calculated from viewBox)\n      const currentRadius = this.radius\n\n      // 调整放大镜位置，使其在鼠标上方，靠近下方边缘外侧\n      // 偏移量：向下一点，让鼠标位于放大镜底部边缘外侧\n      const offsetX = 0\n      const offsetY = currentRadius + MagnifyingGlass.LENS_OFFSET // 放大镜半径 + 偏移量\n\n      const lensX = svgP.x + offsetX\n      const lensY = svgP.y - offsetY // 向上偏移\n\n      // Move lens group to adjusted position\n      this.lensGroup.attr(\"transform\", `translate(${lensX}, ${lensY})`)\n\n      // 计算相对于 lensGroup 的坐标\n      const relativeCX = svgP.x - lensX\n      const relativeCY = svgP.y - lensY\n\n      // Update clipPath circle radius and position (radius is dynamic now)\n      d3.select(`#${this.clipPathId} circle`)\n        .attr(\"r\", currentRadius)\n        .attr(\"cx\", relativeCX)\n        .attr(\"cy\", relativeCY)\n\n      // Update lens border circle radius and position\n      this.lensGroup\n        .select(\".lens-border\")\n        .attr(\"r\", currentRadius + MagnifyingGlass.BORDER_WIDTH)\n        .attr(\"cx\", relativeCX)\n        .attr(\"cy\", relativeCY)\n\n      // Update magnified content with absolute coordinates\n      this._updateContent(svgP.x, svgP.y)\n    } catch (error) {\n      this._log(\"Error in _performUpdate:\", error)\n      // 发生错误时停用放大镜，避免持续出错\n      this.deactivate()\n    }\n  }\n\n  /**\n   * Update the magnified content\n   * @param {number} absoluteX - Absolute X coordinate in SVG space\n   * @param {number} absoluteY - Absolute Y coordinate in SVG space\n   * @private\n   */\n  _updateContent(absoluteX, absoluteY) {\n    // Use D3 selection (don't convert to DOM node)\n    const mainGroup = d3.select(this.svg).select(\"g\")\n    if (mainGroup.empty()) return\n\n    // 只在首次或内容变化时克隆\n    if (!this._cachedContent || this._contentDirty) {\n      this.lensContent.html(\"\")\n      const clonedContent = mainGroup.clone(true).node()\n      this.lensContent.node().appendChild(clonedContent)\n      this._cachedContent = clonedContent\n      this._contentDirty = false\n      this._log(\"Content cloned and cached\")\n    }\n\n    // 只更新 transform\n    this._updateTransform(absoluteX, absoluteY)\n  }\n\n  /**\n   * Update the transform of lens content\n   * @param {number} absoluteX - Absolute X coordinate in SVG space\n   * @param {number} absoluteY - Absolute Y coordinate in SVG space\n   * @private\n   */\n  _updateTransform(absoluteX, absoluteY) {\n    const scale = this.magnification\n    const currentRadius = this.radius\n    const offsetY = currentRadius + MagnifyingGlass.LENS_OFFSET\n\n    // 正确的公式:\n    // tx = -scale * absoluteX (让 absoluteX 变换后对应 x=0)\n    // ty = offsetY - scale * absoluteY (让 absoluteY 变换后对应 y=offsetY，即 clipPath 圆心)\n    const transform = `translate(${-scale * absoluteX}, ${offsetY - scale * absoluteY}) scale(${scale})`\n    this.lensContent.attr(\"transform\", transform)\n\n    this._lastPosition = { x: absoluteX, y: absoluteY }\n  }\n\n  /**\n   * Activate the magnifying glass\n   */\n  activate() {\n    this._log(\"Activating magnifier...\")\n    this.active = true\n    this._contentDirty = true // 标记内容为脏，激活时会重新克隆\n    this.lensGroup.style(\"display\", null)\n    d3.select(this.svg).classed(\"magnifier-active\", true)\n\n    // 解决第一次激活时的位置问题\n    // 获取当前鼠标位置并立即更新内容\n    this._updateContentFromCurrentMouse()\n  }\n\n  // 获取当前鼠标位置（跨浏览器兼容）\n  _getCurrentMousePosition() {\n    if (typeof this._lastMousePos !== \"undefined\") {\n      return this._lastMousePos\n    }\n\n    // 作为备用方案，如果没有记录位置，返回 SVG 中心\n    const rect = this.svg.getBoundingClientRect()\n    return { x: rect.width / 2, y: rect.height / 2 }\n  }\n\n  // 使用当前鼠标位置更新内容\n  _updateContentFromCurrentMouse() {\n    const currentMousePos = this._getCurrentMousePosition()\n    if (currentMousePos) {\n      // 模拟事件对象\n      this._performUpdate({\n        clientX: currentMousePos.x + this.svg.getBoundingClientRect().left,\n        clientY: currentMousePos.y + this.svg.getBoundingClientRect().top,\n      })\n    }\n  }\n\n  /**\n   * Deactivate the magnifying glass\n   */\n  deactivate() {\n    this._log(\"Deactivating magnifier...\")\n    this.active = false\n    this.lensGroup.style(\"display\", \"none\")\n    d3.select(this.svg).classed(\"magnifier-active\", false)\n    this._lastPosition = null\n  }\n\n  /**\n   * Toggle magnifying glass on/off\n   */\n  toggle() {\n    this.active ? this.deactivate() : this.activate()\n  }\n\n  /**\n   * Clean up and remove lens elements\n   */\n  destroy() {\n    this._log(\"Destroying...\")\n    // Remove event listeners\n    document.removeEventListener(\"keydown\", this._handleKeyDown)\n    document.removeEventListener(\"keyup\", this._handleKeyUp)\n    this.svg.removeEventListener(\"mousemove\", this._handleMouseMove)\n    this.svg.removeEventListener(\"click\", this._handleClick)\n\n    // Clean up DOM elements\n    if (this.lensGroup) this.lensGroup.remove()\n    const defs = d3.select(this.svg).select(\"defs\")\n    if (defs) defs.select(`#${this.clipPathId}`).remove()\n\n    // Clean up references\n    this._cachedContent = null\n    this.lensGroup = null\n    this.lensContent = null\n    this.svg = null\n  }\n}\n"
  },
  {
    "path": "src/fastapi_voyager/web/package.json",
    "content": "{\n  \"name\": \"fastapi-voyager-web\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\"\n  },\n  \"dependencies\": {\n    \"@vicons/ionicons5\": \"^0.13.0\",\n    \"naive-ui\": \"^2.40\",\n    \"vue\": \"^3.5\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-vue\": \"^5\",\n    \"vite\": \"^6\"\n  }\n}\n"
  },
  {
    "path": "src/fastapi_voyager/web/src/App.vue",
    "content": "<template>\n  <n-config-provider :theme-overrides=\"themeOverrides\">\n    <n-notification-provider>\n      <div style=\"display: flex; flex-direction: column; height: 100vh\">\n        <!-- Header / Toolbar -->\n        <header\n          style=\"\n            border-bottom: 2px solid var(--primary-color);\n            background: #fff;\n            color: #424242;\n            flex-shrink: 0;\n          \"\n        >\n          <div\n            style=\"display: flex; align-items: center; height: 52px; padding: 0 8px; width: 100%\"\n          >\n            <div\n              style=\"\n                font-size: 18px;\n                font-weight: bold;\n                display: flex;\n                align-items: baseline;\n                color: var(--primary-color);\n                flex-shrink: 0;\n              \"\n            >\n              <n-icon size=\"20\" style=\"margin-right: 8px\"><RocketOutline /></n-icon>\n              <span>{{ store.state.framework_name }} Voyager</span>\n              <span\n                v-if=\"store.state.version\"\n                style=\"font-size: 12px; margin-left: 8px; font-weight: normal\"\n                >{{ store.state.version }}</span\n              >\n            </div>\n            <div style=\"flex-shrink: 0\">\n              <n-button\n                size=\"small\"\n                quaternary\n                @click=\"onReset\"\n                title=\"clear tag, route selection\"\n                style=\"margin-left: 80px\"\n              >\n                <template #icon\n                  ><n-icon><ExpandOutline /></n-icon\n                ></template>\n              </n-button>\n            </div>\n            <div style=\"font-size: 16px; flex-shrink: 0\">\n              <n-radio-group\n                v-model:value=\"store.state.filter.showFields\"\n                @update:value=\"(val) => toggleShowField(val)\"\n                size=\"small\"\n              >\n                <n-radio-button\n                  v-for=\"opt in store.state.fieldOptions\"\n                  :key=\"opt.value\"\n                  :value=\"opt.value\"\n                  :label=\"opt.label\"\n                />\n              </n-radio-group>\n            </div>\n            <div style=\"flex: 1\"></div>\n            <div style=\"display: flex; align-items: center; gap: 8px; flex-shrink: 0\">\n              <n-select\n                v-show=\"!store.state.search.invisible\"\n                v-model:value=\"store.state.search.schemaName\"\n                :options=\"store.state.search.schemaOptions\"\n                filterable\n                clearable\n                placeholder=\"Select schema\"\n                style=\"min-width: 320px\"\n                size=\"small\"\n                @update:value=\"onSearchSchemaChange\"\n                @clear=\"resetSearch\"\n              />\n              <n-select\n                v-show=\"!store.state.search.invisible\"\n                v-model:value=\"store.state.search.fieldName\"\n                :options=\"store.state.search.fieldOptions\"\n                :disabled=\"\n                  !store.state.search.schemaName || store.state.search.fieldOptions.length === 0\n                \"\n                clearable\n                placeholder=\"Select field (optional)\"\n                style=\"min-width: 180px\"\n                size=\"small\"\n                @update:value=\"onSearch\"\n              />\n            </div>\n            <div v-if=\"store.state.config.has_er_diagram\" style=\"flex-shrink: 0; margin-left: 16px\">\n              <n-button-group size=\"small\">\n                <n-button\n                  :type=\"store.state.mode === 'voyager' ? 'primary' : 'default'\"\n                  @click=\"store.state.mode = 'voyager'\"\n                  >Voyager</n-button\n                >\n                <n-button\n                  :type=\"store.state.mode === 'er-diagram' ? 'primary' : 'default'\"\n                  @click=\"store.state.mode = 'er-diagram'\"\n                  >ER diagram</n-button\n                >\n              </n-button-group>\n            </div>\n            <div style=\"flex-shrink: 0\">\n              <n-tooltip trigger=\"hover\">\n                <template #trigger>\n                  <n-button\n                    size=\"small\"\n                    quaternary\n                    circle\n                    style=\"margin-right: 50px; margin-left: 20px\"\n                  >\n                    <template #icon\n                      ><n-icon><HelpCircleOutline /></n-icon\n                    ></template>\n                  </n-button>\n                </template>\n                <div style=\"text-align: left; line-height: 1.4; font-size: 14px\">\n                  <ul style=\"margin: 0; padding-left: 20px\">\n                    <li>scroll to zoom in/out</li>\n                    <li>double click node to view details.</li>\n                    <li>shift + click to search the schema and highlight related nodes.</li>\n                    <li>hold <strong>Space</strong> to activate magnifying glass.</li>\n                  </ul>\n                </div>\n              </n-tooltip>\n              <a\n                href=\"https://github.com/allmonday/fastapi-voyager\"\n                target=\"_blank\"\n                class=\"github-corner\"\n                aria-label=\"View source on GitHub\"\n              >\n                <svg\n                  width=\"52\"\n                  height=\"52\"\n                  viewBox=\"0 0 250 250\"\n                  :style=\"`fill:${themeColor}; color:#fff; position:absolute; top:0; border:0; right:0;`\"\n                  aria-hidden=\"true\"\n                >\n                  <path d=\"M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z\" />\n                  <path\n                    d=\"M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2\"\n                    fill=\"currentColor\"\n                    style=\"transform-origin: 130px 106px\"\n                    class=\"octo-arm\"\n                  />\n                  <path\n                    d=\"M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z\"\n                    fill=\"currentColor\"\n                    class=\"octo-body\"\n                  />\n                </svg>\n              </a>\n            </div>\n          </div>\n        </header>\n\n        <!-- Main content area -->\n        <div style=\"flex: 1; display: flex; overflow: hidden; position: relative\">\n          <!-- Left panel: Tag Navigator -->\n          <div\n            id=\"tag-navigator\"\n            v-show=\"store.state.mode === 'voyager' && store.state.leftPanel.width > 0\"\n            :style=\"{\n              width: store.state.leftPanel.width + 'px',\n              borderRight: '1px solid #e0e0e0',\n              backgroundColor: '#fff',\n              minHeight: 0,\n              height: '100%',\n              flexShrink: 0,\n              overflow: 'hidden',\n            }\"\n          >\n            <n-scrollbar style=\"height: 100%\">\n              <n-collapse\n                v-model:expanded-names=\"expandedTagNames\"\n                accordion\n                style=\"padding-top: 16px\"\n              >\n                <n-collapse-item\n                  v-for=\"tag in store.state.leftPanel.tags\"\n                  :key=\"tag.name\"\n                  :name=\"tag.name\"\n                >\n                  <template #header>\n                    <div style=\"white-space: nowrap; width: 100%\">\n                      <n-icon size=\"20\" style=\"vertical-align: top; margin-right: 8px\"\n                        ><component\n                          :is=\"\n                            store.state.leftPanel.tag === tag.name\n                              ? FolderOutline\n                              : FolderOpenOutline\n                          \"\n                      /></n-icon>\n                      <span\n                        >{{ tag.name }}\n                        <n-tag\n                          size=\"small\"\n                          round\n                          style=\"position: relative; top: -1px; margin-left: 8px\"\n                          >{{ tag.routes.length }}</n-tag\n                        >\n                      </span>\n                      <a\n                        v-if=\"store.state.leftPanel._tag === tag.name && store.state.swagger.url\"\n                        target=\"_blank\"\n                        style=\"margin-left: 8px\"\n                        :href=\"store.state.swagger.url + '#/' + tag.name\"\n                      >\n                        <n-icon\n                          size=\"18\"\n                          style=\"color: var(--primary-color)\"\n                          title=\"open in swagger\"\n                          ><LinkOutline\n                        /></n-icon>\n                      </a>\n                    </div>\n                  </template>\n                  <div style=\"overflow: auto; max-height: 60vh\">\n                    <div\n                      v-for=\"route in store.state.filter.hidePrimitiveRoute\n                        ? tag.routes.filter((r) => !r.is_primitive)\n                        : tag.routes || []\"\n                      :key=\"route.id\"\n                      style=\"\n                        display: flex;\n                        align-items: center;\n                        padding: 4px 8px 4px 24px;\n                        cursor: pointer;\n                        white-space: nowrap;\n                      \"\n                      :style=\"{\n                        background:\n                          store.state.leftPanel.routeId === route.id\n                            ? 'rgba(0,148,133,0.08)'\n                            : 'transparent',\n                        color:\n                          store.state.leftPanel.routeId === route.id\n                            ? 'var(--primary-color)'\n                            : 'inherit',\n                        fontWeight: store.state.leftPanel.routeId === route.id ? 'bold' : 'normal',\n                      }\"\n                      @click=\"selectRoute(route.id)\"\n                    >\n                      <n-icon size=\"18\" style=\"margin-right: 8px\"><CodeWorkingOutline /></n-icon>\n                      <span style=\"flex: 1\">{{ route.name }}</span>\n                      <a\n                        v-if=\"store.state.leftPanel.routeId === route.id && store.state.swagger.url\"\n                        target=\"_blank\"\n                        style=\"margin-left: 8px; display: flex; align-items: center\"\n                        :href=\"store.state.swagger.url + '#/' + tag.name + '/' + route.unique_id\"\n                        @click.stop\n                      >\n                        <n-icon\n                          size=\"18\"\n                          style=\"color: var(--primary-color)\"\n                          title=\"open in swagger\"\n                          ><LinkOutline\n                        /></n-icon>\n                      </a>\n                    </div>\n                    <div\n                      v-if=\"!tag.routes || tag.routes.length === 0\"\n                      style=\"padding: 4px 8px 4px 24px; color: #757575\"\n                    >\n                      No routes\n                    </div>\n                  </div>\n                </n-collapse-item>\n              </n-collapse>\n            </n-scrollbar>\n          </div>\n\n          <!-- Left panel resize handle -->\n          <div\n            v-show=\"store.state.mode === 'voyager'\"\n            @mousedown=\"startDragLeftPanel\"\n            style=\"\n              width: 6px;\n              cursor: col-resize;\n              background: transparent;\n              flex-shrink: 0;\n              position: relative;\n              z-index: 5;\n            \"\n            title=\"drag to resize\"\n          ></div>\n\n          <!-- Center: Graph area -->\n          <div style=\"flex: 1; position: relative; overflow: hidden\">\n            <div id=\"graph\" class=\"adjust-fit\"></div>\n\n            <!-- Floating controls -->\n            <div\n              style=\"\n                position: absolute;\n                left: 8px;\n                top: 8px;\n                z-index: 10;\n                background: rgba(255, 255, 255, 0.85);\n                border-radius: 4px;\n                padding: 2px 8px;\n                font-size: 12px;\n                color: #666;\n              \"\n            >\n              <div\n                style=\"display: flex; align-items: center; gap: 6px; margin-top: 6px\"\n                v-if=\"\n                  store.state.modeControl.briefModeEnabled &&\n                  store.state.search.mode === false &&\n                  store.state.mode === 'voyager'\n                \"\n              >\n                <n-switch\n                  v-model:value=\"store.state.filter.brief\"\n                  size=\"small\"\n                  @update:value=\"(val) => toggleBrief(val)\"\n                />\n                <span>Brief Mode</span>\n              </div>\n              <div\n                style=\"display: flex; align-items: center; gap: 6px; margin-top: 6px\"\n                v-if=\"store.state.search.mode === false && store.state.mode === 'voyager'\"\n              >\n                <n-switch\n                  v-model:value=\"store.state.filter.hidePrimitiveRoute\"\n                  size=\"small\"\n                  @update:value=\"(val) => toggleHidePrimitiveRoute(val)\"\n                />\n                <span>Hide Primitive</span>\n              </div>\n              <div style=\"display: flex; align-items: center; gap: 6px; margin-top: 6px\">\n                <n-switch\n                  v-model:value=\"store.state.filter.showModule\"\n                  size=\"small\"\n                  @update:value=\"(val) => toggleShowModule(val)\"\n                />\n                <span>Show Module Cluster</span>\n              </div>\n              <div\n                style=\"display: flex; align-items: center; gap: 6px; margin-top: 6px\"\n                v-if=\"store.state.mode === 'er-diagram'\"\n              >\n                <n-switch\n                  v-model:value=\"store.state.filter.showMethods\"\n                  size=\"small\"\n                  @update:value=\"(val) => toggleShowMethods(val)\"\n                />\n                <span>Show Methods</span>\n              </div>\n              <div\n                style=\"display: flex; align-items: center; gap: 6px; margin-top: 6px\"\n                v-if=\"\n                  store.state.mode === 'voyager' && store.state.config.enable_pydantic_resolve_meta\n                \"\n              >\n                <n-switch\n                  v-model:value=\"store.state.modeControl.pydanticResolveMetaEnabled\"\n                  size=\"small\"\n                  @update:value=\"(val) => togglePydanticResolveMeta(val)\"\n                />\n                <span>Pydantic Resolve Meta</span>\n              </div>\n              <div style=\"margin-top: 8px; margin-left: 8px\" v-if=\"store.state.mode === 'voyager'\">\n                <div style=\"font-size: 12px; color: #666; margin-bottom: 4px\">\n                  Magnification: {{ store.state.filter.magnification.toFixed(1) }}x\n                </div>\n                <n-slider\n                  v-model:value=\"store.state.filter.magnification\"\n                  :min=\"2\"\n                  :max=\"5\"\n                  :step=\"0.5\"\n                  :marks=\"{ 2: '2x', 3: '3x', 4: '4x', 5: '5x' }\"\n                  @update:value=\"(val) => updateMagnification(val)\"\n                  style=\"max-width: 200px\"\n                />\n              </div>\n              <div style=\"margin-top: 8px\" v-if=\"store.state.mode === 'er-diagram'\">\n                <div style=\"font-size: 12px; color: #666; margin-bottom: 4px\">Edge Length</div>\n                <n-button-group size=\"small\">\n                  <n-button\n                    :type=\"store.state.filter.edgeMinlen === 3 ? 'primary' : 'default'\"\n                    @click=\"updateEdgeMinlen(3)\"\n                    >Small</n-button\n                  >\n                  <n-button\n                    :type=\"store.state.filter.edgeMinlen === 7 ? 'primary' : 'default'\"\n                    @click=\"updateEdgeMinlen(7)\"\n                    >Middle</n-button\n                  >\n                  <n-button\n                    :type=\"store.state.filter.edgeMinlen === 10 ? 'primary' : 'default'\"\n                    @click=\"updateEdgeMinlen(10)\"\n                    >Large</n-button\n                  >\n                </n-button-group>\n              </div>\n            </div>\n\n            <!-- Collapse toggle for tag navigator -->\n            <div\n              v-show=\"store.state.mode === 'voyager'\"\n              class=\"tag-navigator-collapse-btn-right\"\n              @click=\"toggleTagNavigatorCollapse\"\n              :title=\"\n                store.state.leftPanel.collapsed ? 'Expand tag navigator' : 'Collapse tag navigator'\n              \"\n            >\n              <n-icon size=\"18\"\n                ><component\n                  :is=\"\n                    store.state.leftPanel.collapsed ? ChevronForwardOutline : ChevronBackOutline\n                  \"\n              /></n-icon>\n            </div>\n          </div>\n        </div>\n\n        <!-- Right drawer -->\n        <n-drawer\n          v-model:show=\"store.state.rightDrawer.drawer\"\n          :width=\"store.state.rightDrawer.width\"\n          placement=\"right\"\n          :mask-closable=\"true\"\n        >\n          <n-drawer-content :native-scrollbar=\"false\" style=\"padding: 0\" :closable=\"true\">\n            <div\n              @mousedown=\"startDragDrawer\"\n              style=\"\n                position: absolute;\n                left: -3px;\n                top: 0;\n                width: 6px;\n                height: 100%;\n                cursor: col-resize;\n                background: transparent;\n                z-index: 10;\n              \"\n              title=\"drag to resize\"\n            ></div>\n            <SchemaCodeDisplay\n              v-if=\"store.state.schemaDetail.schemaCodeName\"\n              :schema-name=\"store.state.schemaDetail.schemaCodeName\"\n              :schemas=\"\n                store.state.mode === 'er-diagram'\n                  ? store.state.erDiagramSchemas\n                  : store.state.graph.schemaMap\n              \"\n            />\n            <LoaderCodeDisplay\n              v-else-if=\"store.state.edgeDetail.loaderFullname\"\n              :loader-fullname=\"store.state.edgeDetail.loaderFullname\"\n              :source-entity=\"store.state.edgeDetail.sourceEntity\"\n              :target-entity=\"store.state.edgeDetail.targetEntity\"\n              :label=\"store.state.edgeDetail.label\"\n            />\n          </n-drawer-content>\n        </n-drawer>\n\n        <!-- Route detail modal (bottom) -->\n        <n-modal\n          v-model:show=\"store.state.routeDetail.show\"\n          :bordered=\"true\"\n          style=\"width: 1100px; max-width: 1100px; max-height: 40vh; position: fixed; bottom: 0\"\n        >\n          <n-card style=\"max-height: 40vh\" :bordered=\"true\">\n            <RouteCodeDisplay\n              :route-id=\"store.state.routeDetail.routeCodeId\"\n              @close=\"store.state.routeDetail.show = false\"\n            />\n          </n-card>\n        </n-modal>\n      </div>\n    </n-notification-provider>\n  </n-config-provider>\n</template>\n\n<script setup>\nimport { ref, computed, watch, onMounted, onUnmounted } from \"vue\"\nimport {\n  NConfigProvider,\n  NNotificationProvider,\n  NDrawer,\n  NDrawerContent,\n  NButton,\n  NButtonGroup,\n  NRadioGroup,\n  NRadioButton,\n  NSelect,\n  NSwitch,\n  NSlider,\n  NTooltip,\n  NTag,\n  NScrollbar,\n  NCollapse,\n  NCollapseItem,\n  NModal,\n  NCard,\n  NDivider,\n  NIcon,\n} from \"naive-ui\"\nimport {\n  RocketOutline,\n  ExpandOutline,\n  HelpCircleOutline,\n  FolderOutline,\n  FolderOpenOutline,\n  LinkOutline,\n  CodeWorkingOutline,\n  ChevronForwardOutline,\n  ChevronBackOutline,\n} from \"@vicons/ionicons5\"\nimport { GraphUI } from \"./graph-ui.js\"\nimport { store } from \"./store.js\"\nimport SchemaCodeDisplay from \"./component/SchemaCodeDisplay.vue\"\nimport RouteCodeDisplay from \"./component/RouteCodeDisplay.vue\"\nimport LoaderCodeDisplay from \"./component/LoaderCodeDisplay.vue\"\n\nconst themeColor = window.FRAMEWORK_THEME_COLOR || \"#009485\"\n\nconst themeOverrides = {\n  common: {\n    primaryColor: themeColor,\n    primaryColorHover: themeColor + \"cc\",\n    primaryColorPressed: themeColor + \"aa\",\n  },\n}\n\nlet graphUI = null\nconst erDiagramLoading = ref(false)\nconst erDiagramCache = ref(\"\")\n\n// Initialize toggle states from localStorage\nfunction loadToggleState(key, defaultValue = false) {\n  if (typeof window === \"undefined\") return defaultValue\n  try {\n    const saved = localStorage.getItem(key)\n    return saved !== null ? JSON.parse(saved) : defaultValue\n  } catch (e) {\n    console.warn(`Failed to load ${key} from localStorage`, e)\n    return defaultValue\n  }\n}\n\nstore.state.modeControl.pydanticResolveMetaEnabled = loadToggleState(\"pydantic_resolve_meta\", false)\nstore.state.filter.hidePrimitiveRoute = loadToggleState(\"hide_primitive\", false)\nstore.state.filter.brief = loadToggleState(\"brief_mode\", false)\nstore.state.filter.showModule = loadToggleState(\"show_module_cluster\", false)\nstore.state.filter.magnification = loadToggleState(\"magnification\", 3.0)\nstore.state.filter.edgeMinlen = loadToggleState(\"edge_minlen\", 3)\nstore.state.filter.showMethods = loadToggleState(\"show_methods\", true)\n\n// Expanded tag names for NCollapse\nconst expandedTagNames = computed({\n  get() {\n    if (store.state.search.mode) {\n      return store.state.leftPanel.tags?.map((t) => t.name) || []\n    }\n    return store.state.leftPanel._tag ? [store.state.leftPanel._tag] : []\n  },\n  set(names) {\n    if (store.state.search.mode) return\n    if (names.length === 0) {\n      // Collapsed\n      store.state.leftPanel._tag = null\n      store.state.rightDrawer.drawer = false\n      store.state.routeDetail.show = false\n      store.actions.syncSelectionToUrl()\n    } else {\n      // Expanded a tag (accordion: only one at a time)\n      const newTag = names[names.length - 1]\n      store.state.leftPanel._tag = newTag\n      store.state.leftPanel.tag = newTag\n      store.state.leftPanel.routeId = \"\"\n      store.state.schemaDetail.schemaCodeName = \"\"\n      store.state.rightDrawer.drawer = false\n      store.state.routeDetail.show = false\n      store.actions.syncSelectionToUrl()\n      onGenerate()\n    }\n  },\n})\n\nfunction initGraphUI() {\n  if (graphUI) return\n  graphUI = new GraphUI(\"#graph\", {\n    onSchemaShiftClick: (id) => {\n      if (store.state.graph.schemaKeys.has(id)) {\n        store.state.search.mode = true\n        store.state.search.schemaName = id\n        onSearch()\n      }\n    },\n    onSchemaClick: (id) => {\n      store.actions.resetDetailPanels()\n      if (store.state.mode === \"er-diagram\" || store.state.graph.schemaKeys.has(id)) {\n        store.state.schemaDetail.schemaCodeName = id\n        store.state.rightDrawer.drawer = true\n      }\n      if (id in store.state.graph.routeItems) {\n        store.state.routeDetail.routeCodeId = id\n        store.state.routeDetail.show = true\n      }\n    },\n    onEdgeClick: (edgeName, edgeLabel) => {\n      const [sourceRaw, targetRaw] = edgeName.split(\"->\")\n      const source = sourceRaw.split(\":\")[0]\n      const target = targetRaw.split(\":\")[0]\n      const link = store.state.erDiagramLinks.find(\n        (l) => l.source_origin === source && l.target_origin === target\n      )\n      if (link && link.loader_fullname) {\n        store.actions.resetDetailPanels()\n        store.state.edgeDetail.loaderFullname = link.loader_fullname\n        store.state.edgeDetail.sourceEntity = link.source_origin\n        store.state.edgeDetail.targetEntity = link.target_origin\n        store.state.edgeDetail.label = link.label\n        store.state.rightDrawer.drawer = true\n      }\n    },\n    resetCb: () => {\n      store.actions.resetDetailPanels()\n    },\n    magnifyingGlassMagnification: store.state.filter.magnification,\n  })\n}\n\nasync function resetSearch() {\n  const hadPreviousValue = store.actions.resetSearchState()\n  if (hadPreviousValue) {\n    onGenerate()\n  } else {\n    store.actions.renderBasedOnInitialPolicy(onGenerate)\n  }\n}\n\nasync function onSearch() {\n  if (!store.state.previousTagRoute.hasValue) {\n    store.state.previousTagRoute.tag = store.state.leftPanel.tag\n    store.state.previousTagRoute.routeId = store.state.leftPanel.routeId\n    store.state.previousTagRoute.hasValue = true\n  }\n  store.state.search.mode = true\n  store.state.leftPanel.tag = null\n  store.state.leftPanel._tag = null\n  store.state.leftPanel.routeId = null\n  store.actions.syncSelectionToUrl()\n  await store.actions.loadSearchedTags()\n  await onGenerate()\n}\n\nasync function loadInitial() {\n  await store.actions.loadInitial(onGenerate, (cb) => store.actions.renderBasedOnInitialPolicy(cb))\n}\n\nasync function onGenerate(resetZoom = true) {\n  switch (store.state.mode) {\n    case \"voyager\":\n      await renderVoyager(resetZoom)\n      break\n    case \"er-diagram\":\n      await renderErDiagram(resetZoom)\n      break\n  }\n}\n\nasync function renderVoyager(resetZoom = true) {\n  store.state.generating = true\n  try {\n    const payload = store.actions.buildVoyagerPayload()\n    initGraphUI()\n    graphUI.setHighlightMode(\"deep\")\n    const res = await fetch(\"dot\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload),\n    })\n    const dotText = await res.text()\n    await graphUI.render(dotText, resetZoom)\n  } catch (e) {\n    console.error(\"Generate failed\", e)\n  } finally {\n    store.state.generating = false\n  }\n}\n\nasync function renderErDiagram(resetZoom = true) {\n  initGraphUI()\n  graphUI.setHighlightMode(\"shallow\")\n  erDiagramLoading.value = true\n  const payload = store.actions.buildErDiagramPayload()\n  try {\n    const res = await fetch(\"er-diagram\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload),\n    })\n    if (!res.ok) throw new Error(`failed with status ${res.status}`)\n    const data = await res.json()\n    erDiagramCache.value = data.dot\n    store.state.erDiagramLinks = data.links || []\n    const schemasArr = Array.isArray(data.schemas) ? data.schemas : []\n    store.state.erDiagramSchemas = Object.fromEntries(schemasArr.map((s) => [s.id, s]))\n    await graphUI.render(data.dot, resetZoom)\n  } catch (err) {\n    console.error(err)\n  } finally {\n    erDiagramLoading.value = false\n  }\n}\n\nasync function onModeChange(val) {\n  if (val === \"er-diagram\") {\n    store.state.search.schemaName = null\n    store.state.search.fieldName = null\n    store.state.search.invisible = true\n    if (store.state.leftPanel.width > 0) {\n      store.state.leftPanel.previousWidth = store.state.leftPanel.width\n    }\n    store.state.leftPanel.width = 0\n    store.actions.syncSelectionToUrl()\n    await renderErDiagram()\n  } else {\n    store.state.search.invisible = false\n    const fallbackWidth = store.state.leftPanel.previousWidth || 300\n    store.state.leftPanel.width = fallbackWidth\n    store.actions.syncSelectionToUrl()\n    await onGenerate()\n  }\n}\n\nfunction toggleTagNavigatorCollapse() {\n  if (store.state.leftPanel.collapsed) {\n    const fallbackWidth = store.state.leftPanel.previousWidth || 300\n    store.state.leftPanel.width = fallbackWidth\n    store.state.leftPanel.collapsed = false\n  } else {\n    if (store.state.leftPanel.width > 0) {\n      store.state.leftPanel.previousWidth = store.state.leftPanel.width\n    }\n    store.state.leftPanel.width = 0\n    store.state.leftPanel.collapsed = true\n  }\n}\n\nfunction selectRoute(routeId) {\n  const belongingTag = store.getters.findTagByRoute(routeId)\n  if (belongingTag) {\n    store.state.leftPanel.tag = belongingTag\n    store.state.leftPanel._tag = belongingTag\n  }\n  if (store.state.leftPanel.routeId === routeId) {\n    store.state.leftPanel.routeId = \"\"\n  } else {\n    store.state.leftPanel.routeId = routeId\n  }\n  store.state.rightDrawer.drawer = false\n  store.state.routeDetail.show = false\n  store.state.schemaDetail.schemaCodeName = \"\"\n  store.actions.syncSelectionToUrl()\n  onGenerate()\n}\n\nfunction startDragDrawer(e) {\n  const startX = e.clientX\n  const startWidth = store.state.rightDrawer.width\n  function onMouseMove(moveEvent) {\n    const deltaX = startX - moveEvent.clientX\n    const newWidth = Math.max(300, Math.min(800, startWidth + deltaX))\n    store.state.rightDrawer.width = newWidth\n  }\n  function onMouseUp() {\n    document.removeEventListener(\"mousemove\", onMouseMove)\n    document.removeEventListener(\"mouseup\", onMouseUp)\n    document.body.style.cursor = \"\"\n    document.body.style.userSelect = \"\"\n  }\n  document.addEventListener(\"mousemove\", onMouseMove)\n  document.addEventListener(\"mouseup\", onMouseUp)\n  document.body.style.cursor = \"col-resize\"\n  document.body.style.userSelect = \"none\"\n  e.preventDefault()\n}\n\nfunction startDragLeftPanel(e) {\n  const startX = e.clientX\n  const startWidth = store.state.leftPanel.width\n  function onMouseMove(moveEvent) {\n    const deltaX = moveEvent.clientX - startX\n    const newWidth = Math.max(0, Math.min(800, startWidth + deltaX))\n    store.state.leftPanel.width = newWidth\n  }\n  function onMouseUp() {\n    document.removeEventListener(\"mousemove\", onMouseMove)\n    document.removeEventListener(\"mouseup\", onMouseUp)\n    document.body.style.cursor = \"\"\n    document.body.style.userSelect = \"\"\n  }\n  document.addEventListener(\"mousemove\", onMouseMove)\n  document.addEventListener(\"mouseup\", onMouseUp)\n  document.body.style.cursor = \"col-resize\"\n  document.body.style.userSelect = \"none\"\n  e.preventDefault()\n}\n\nfunction onSearchSchemaChange(val) {\n  store.actions.onSearchSchemaChange(val, onSearch)\n}\n\nfunction toggleBrief(val) {\n  store.actions.toggleBrief(val, onGenerate)\n}\nfunction toggleHidePrimitiveRoute(val) {\n  store.actions.toggleHidePrimitiveRoute(val, onGenerate)\n}\nfunction toggleShowModule(val) {\n  store.actions.toggleShowModule(val, onGenerate)\n}\nfunction togglePydanticResolveMeta(val) {\n  store.actions.togglePydanticResolveMeta(val, onGenerate)\n}\nfunction toggleShowField(field) {\n  store.actions.toggleShowField(field, onGenerate)\n}\nfunction toggleShowMethods(val) {\n  store.actions.toggleShowMethods(val, onGenerate)\n}\nfunction updateMagnification(val) {\n  store.actions.updateMagnification(val)\n  if (graphUI && graphUI.magnifyingGlass) {\n    graphUI.magnifyingGlass.magnification = val\n  }\n}\nfunction updateEdgeMinlen(val) {\n  store.actions.updateEdgeMinlen(val, onGenerate)\n}\n\n// Watchers\nwatch(\n  () => store.state.graph.schemaMap,\n  () => store.actions.rebuildSchemaOptions(),\n  { deep: false }\n)\nwatch(\n  () => store.state.leftPanel.width,\n  (val) => {\n    if (store.state.mode === \"voyager\" && typeof val === \"number\" && val > 0) {\n      store.state.leftPanel.previousWidth = val\n    }\n  }\n)\nwatch(\n  () => store.state.mode,\n  (mode) => onModeChange(mode)\n)\nwatch(\n  () => store.state.search.schemaName,\n  (schemaId) => {\n    store.state.search.schemaOptions = store.state.allSchemaOptions.slice()\n    store.actions.populateFieldOptions(schemaId)\n    if (!schemaId) store.state.search.mode = false\n  }\n)\n\nonMounted(async () => {\n  document.body.classList.remove(\"app-loading\")\n  await loadInitial()\n  if (store.state.framework_name) {\n    document.title = `${store.state.framework_name} Voyager`\n  }\n  const handleKeyDown = (event) => {\n    if (event.key === \"Escape\" && store.state.search.mode) {\n      resetSearch()\n    }\n  }\n  document.addEventListener(\"keydown\", handleKeyDown)\n  onUnmounted(() => document.removeEventListener(\"keydown\", handleKeyDown))\n})\n</script>\n"
  },
  {
    "path": "src/fastapi_voyager/web/src/component/LoaderCodeDisplay.vue",
    "content": "<template>\n  <div\n    class=\"frv-loader-display\"\n    style=\"\n      border: 1px solid #ccc;\n      border-left: none;\n      position: relative;\n      height: 100%;\n      background: #fff;\n    \"\n  >\n    <div v-show=\"loading\" style=\"position: absolute; top: 0; left: 0; right: 0; z-index: 10\">\n      <n-progress processing :height=\"2\" color=\"var(--primary-color)\" />\n    </div>\n    <div style=\"margin-left: 24px; margin-top: 12px\">\n      <p style=\"font-size: 14px; font-weight: bold\">\n        {{ shortName(sourceEntity) }} → {{ shortName(targetEntity) }}\n      </p>\n      <p v-if=\"label\" style=\"font-size: 12px; color: #666; margin-top: 2px; white-space: pre-line\">\n        {{ label }}\n      </p>\n      <p style=\"font-size: 12px; color: #888; margin-top: 4px\">\n        {{ loaderFullname }}\n      </p>\n      <a\n        v-if=\"link\"\n        :href=\"link\"\n        target=\"_blank\"\n        rel=\"noopener\"\n        style=\"font-size: 12px; color: #3b82f6\"\n      >\n        Open in VSCode\n      </a>\n    </div>\n    <n-divider style=\"margin: 8px 0 0 0\" />\n    <div style=\"padding: 8px 16px 16px 16px; box-sizing: border-box; overflow: auto\">\n      <div v-if=\"error\" style=\"color: #c10015; font-family: Menlo, monospace; font-size: 12px\">\n        {{ error }}\n      </div>\n      <div v-else>\n        <pre style=\"margin: 0\"><code class=\"language-python\">{{ code }}</code></pre>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, watch, onMounted } from \"vue\"\nimport { NProgress, NDivider } from \"naive-ui\"\n\nconst props = defineProps({\n  loaderFullname: { type: String, default: null },\n  sourceEntity: { type: String, default: null },\n  targetEntity: { type: String, default: null },\n  label: { type: String, default: null },\n})\n\nconst code = ref(\"\")\nconst link = ref(\"\")\nconst error = ref(\"\")\nconst loading = ref(false)\n\nfunction highlightLater() {\n  requestAnimationFrame(() => {\n    try {\n      if (window.hljs) {\n        const block = document.querySelector(\".frv-loader-display pre code.language-python\")\n        if (block) {\n          if (block.dataset && block.dataset.highlighted) {\n            block.removeAttribute(\"data-highlighted\")\n          }\n          window.hljs.highlightElement(block)\n        }\n      }\n    } catch (e) {\n      console.warn(\"highlight failed\", e)\n    }\n  })\n}\n\nfunction resetState() {\n  code.value = \"\"\n  link.value = \"\"\n  error.value = null\n  loading.value = true\n}\n\nasync function loadSource() {\n  if (!props.loaderFullname) return\n  resetState()\n\n  const payload = { schema_name: props.loaderFullname }\n  try {\n    const resp = await fetch(`source`, {\n      method: \"POST\",\n      headers: { Accept: \"application/json\", \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload),\n    })\n    const data = await resp.json().catch(() => ({}))\n    if (resp.ok) {\n      code.value = data.source_code || \"# no source code available\"\n    } else {\n      error.value = (data && data.error) || \"Failed to load source\"\n    }\n\n    const resp2 = await fetch(`vscode-link`, {\n      method: \"POST\",\n      headers: { Accept: \"application/json\", \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload),\n    })\n    const data2 = await resp2.json().catch(() => ({}))\n    if (resp2.ok) {\n      link.value = data2.link || \"\"\n    }\n  } catch (e) {\n    error.value = \"Failed to load source\"\n  } finally {\n    loading.value = false\n    highlightLater()\n  }\n}\n\nfunction shortName(fullname) {\n  if (!fullname) return \"\"\n  const parts = fullname.split(\".\")\n  return parts[parts.length - 1]\n}\n\nwatch(\n  () => props.loaderFullname,\n  () => {\n    if (props.loaderFullname) loadSource()\n  }\n)\nonMounted(() => {\n  if (props.loaderFullname) loadSource()\n})\n</script>\n"
  },
  {
    "path": "src/fastapi_voyager/web/src/component/RenderGraph.vue",
    "content": "<template>\n  <div style=\"height: 100%; position: relative; background: #fff\">\n    <n-button\n      size=\"small\"\n      quaternary\n      circle\n      aria-label=\"Close\"\n      @click=\"close\"\n      style=\"\n        position: absolute;\n        top: 6px;\n        right: 6px;\n        z-index: 11;\n        background: rgba(255, 255, 255, 0.85);\n      \"\n    >\n      <template #icon\n        ><n-icon size=\"18\"><CloseOutline /></n-icon\n      ></template>\n    </n-button>\n    <n-button\n      size=\"small\"\n      quaternary\n      circle\n      aria-label=\"Reload\"\n      :loading=\"loading\"\n      @click=\"reload\"\n      style=\"\n        position: absolute;\n        top: 6px;\n        right: 46px;\n        z-index: 11;\n        background: rgba(255, 255, 255, 0.85);\n      \"\n    >\n      <template #icon\n        ><n-icon size=\"18\"><RefreshOutline /></n-icon\n      ></template>\n    </n-button>\n    <div\n      :id=\"containerId\"\n      style=\"width: 100%; height: 100%; overflow: auto; background: #fafafa\"\n    ></div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, nextTick } from \"vue\"\nimport { NButton, NIcon, createDiscreteApi } from \"naive-ui\"\nimport { CloseOutline, RefreshOutline } from \"@vicons/ionicons5\"\nimport { GraphUI } from \"../graph-ui.js\"\n\nconst { notification } = createDiscreteApi([\"notification\"])\n\nconst props = defineProps({\n  coreData: { type: [Object, Array], required: false, default: null },\n})\nconst emit = defineEmits([\"close\"])\n\nconst containerId = `graph-render-${Math.random().toString(36).slice(2, 9)}`\nconst hasRendered = ref(false)\nconst loading = ref(false)\nlet graphInstance = null\n\nasync function ensureGraph() {\n  await nextTick()\n  if (!graphInstance) {\n    graphInstance = new GraphUI(`#${containerId}`)\n  }\n}\n\nasync function renderFromDot(dotText) {\n  if (!dotText) return\n  await ensureGraph()\n  await graphInstance.render(dotText)\n  hasRendered.value = true\n}\n\nasync function renderFromCoreData() {\n  if (!props.coreData) return\n  loading.value = true\n  try {\n    const res = await fetch(\"dot-render-core-data\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(props.coreData),\n    })\n    const dotText = await res.text()\n    await renderFromDot(dotText)\n    notification.success({ content: \"Rendered\" })\n  } catch (e) {\n    console.error(\"Render from core data failed\", e)\n    notification.error({ content: \"Render failed\" })\n  } finally {\n    loading.value = false\n  }\n}\n\nasync function reload() {\n  await renderFromCoreData()\n}\n\nfunction close() {\n  emit(\"close\")\n}\n\nonMounted(reload)\n</script>\n"
  },
  {
    "path": "src/fastapi_voyager/web/src/component/RouteCodeDisplay.vue",
    "content": "<template>\n  <div\n    class=\"frv-route-code-display\"\n    style=\"border: 1px solid #ccc; position: relative; background: #fff\"\n  >\n    <n-button\n      size=\"small\"\n      quaternary\n      circle\n      style=\"\n        position: absolute;\n        top: 6px;\n        right: 6px;\n        z-index: 10;\n        background: rgba(255, 255, 255, 0.85);\n      \"\n      @click=\"close\"\n      aria-label=\"Close\"\n    >\n      <template #icon\n        ><n-icon size=\"18\"><CloseOutline /></n-icon\n      ></template>\n    </n-button>\n    <div v-if=\"link\" style=\"margin-left: 16px; margin-top: 12px; padding-top: 4px\">\n      <a :href=\"link\" target=\"_blank\" rel=\"noopener\" style=\"font-size: 12px; color: #3b82f6\"\n        >Open in VSCode</a\n      >\n    </div>\n    <div style=\"padding: 40px 16px 16px 16px; box-sizing: border-box; overflow: auto\">\n      <div v-if=\"loading\" style=\"font-family: Menlo, monospace; font-size: 12px\">\n        Loading source...\n      </div>\n      <div v-else-if=\"error\" style=\"color: #c10015; font-family: Menlo, monospace; font-size: 12px\">\n        {{ error }}\n      </div>\n      <pre v-else style=\"margin: 0\"><code class=\"language-python\">{{ code }}</code></pre>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, watch, onMounted } from \"vue\"\nimport { NButton, NIcon } from \"naive-ui\"\nimport { CloseOutline } from \"@vicons/ionicons5\"\n\nconst props = defineProps({\n  routeId: { type: String, required: true },\n})\nconst emit = defineEmits([\"close\"])\n\nconst loading = ref(false)\nconst code = ref(\"\")\nconst error = ref(\"\")\nconst link = ref(\"\")\n\nfunction close() {\n  emit(\"close\")\n}\n\nfunction highlightLater() {\n  requestAnimationFrame(() => {\n    try {\n      if (window.hljs) {\n        const block = document.querySelector(\".frv-route-code-display pre code.language-python\")\n        if (block) {\n          window.hljs.highlightElement(block)\n        }\n      }\n    } catch (e) {\n      console.warn(\"highlight failed\", e)\n    }\n  })\n}\n\nasync function load() {\n  if (!props.routeId) {\n    code.value = \"\"\n    return\n  }\n\n  loading.value = true\n  error.value = null\n  code.value = \"\"\n  link.value = \"\"\n\n  const payload = { schema_name: props.routeId }\n  try {\n    const resp = await fetch(`source`, {\n      method: \"POST\",\n      headers: { Accept: \"application/json\", \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload),\n    })\n    const data = await resp.json().catch(() => ({}))\n    if (resp.ok) {\n      code.value = data.source_code || \"// no source code available\"\n    } else {\n      error.value = (data && data.error) || \"Failed to load source\"\n    }\n  } catch (e) {\n    error.value = e && e.message ? e.message : \"Failed to load source\"\n  } finally {\n    loading.value = false\n  }\n\n  try {\n    const resp = await fetch(`vscode-link`, {\n      method: \"POST\",\n      headers: { Accept: \"application/json\", \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload),\n    })\n    const data = await resp.json().catch(() => ({}))\n    if (resp.ok) {\n      link.value = data.link || \"\"\n    } else {\n      error.value += (data && data.error) || \"Failed to load vscode link\"\n    }\n  } catch (e) {\n  } finally {\n    loading.value = false\n  }\n\n  if (!error.value) {\n    highlightLater()\n  }\n}\n\nwatch(() => props.routeId, load)\nonMounted(load)\n</script>\n"
  },
  {
    "path": "src/fastapi_voyager/web/src/component/SchemaCodeDisplay.vue",
    "content": "<template>\n  <div class=\"frv-code-display\" style=\"position: relative; height: 100%; background: #fff\">\n    <div v-show=\"loading\" style=\"position: absolute; top: 0; left: 0; right: 0; z-index: 10\">\n      <n-progress processing :height=\"2\" color=\"var(--primary-color)\" />\n    </div>\n    <div style=\"margin-left: 24px; margin-top: 12px\">\n      <p style=\"font-size: 16px\">{{ schemaName }}</p>\n      <a :href=\"link\" target=\"_blank\" rel=\"noopener\" style=\"font-size: 12px; color: #3b82f6\">\n        Open in VSCode\n      </a>\n    </div>\n\n    <div style=\"padding: 8px 12px 0 12px; box-sizing: border-box\">\n      <n-tabs v-model:value=\"tab\" type=\"line\" size=\"small\" animated>\n        <n-tab-pane name=\"fields\" tab=\"Fields\">\n          <table\n            style=\"\n              border-collapse: collapse;\n              width: 100%;\n              min-width: 500px;\n              font-size: 12px;\n              font-family: Menlo, monospace;\n            \"\n          >\n            <thead>\n              <tr>\n                <th style=\"text-align: left; border-bottom: 1px solid #ddd; padding: 4px 6px\">\n                  Field\n                </th>\n                <th style=\"text-align: left; border-bottom: 1px solid #ddd; padding: 4px 6px\">\n                  Type\n                </th>\n                <th style=\"text-align: left; border-bottom: 1px solid #ddd; padding: 4px 6px\">\n                  Description\n                </th>\n                <th style=\"text-align: left; border-bottom: 1px solid #ddd; padding: 4px 6px\">\n                  Inherited\n                </th>\n              </tr>\n            </thead>\n            <tbody>\n              <tr v-for=\"f in fields\" :key=\"f.name\">\n                <td style=\"padding: 4px 6px; border-bottom: 1px solid #f0f0f0\">{{ f.name }}</td>\n                <td style=\"padding: 4px 6px; border-bottom: 1px solid #f0f0f0; white-space: nowrap\">\n                  {{ f.type_name }}\n                </td>\n                <td style=\"padding: 4px 6px; border-bottom: 1px solid #f0f0f0; max-width: 200px\">\n                  {{ f.desc }}\n                </td>\n                <td style=\"padding: 4px 6px; border-bottom: 1px solid #f0f0f0; text-align: left\">\n                  {{ f.from_base ? \"✔︎\" : \"\" }}\n                </td>\n              </tr>\n              <tr v-if=\"!fields.length\">\n                <td colspan=\"3\" style=\"padding: 8px 6px; color: #666; font-style: italic\">\n                  No fields\n                </td>\n              </tr>\n            </tbody>\n          </table>\n        </n-tab-pane>\n        <n-tab-pane name=\"source\" tab=\"Source Code\">\n          <pre style=\"margin: 0\"><code class=\"language-python\">{{ code }}</code></pre>\n        </n-tab-pane>\n      </n-tabs>\n    </div>\n    <div\n      v-if=\"error\"\n      style=\"color: #c10015; font-family: Menlo, monospace; font-size: 12px; padding: 8px 16px\"\n    >\n      {{ error }}\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, watch, onMounted } from \"vue\"\nimport { NProgress, NTabs, NTabPane } from \"naive-ui\"\n\nconst props = defineProps({\n  schemaName: { type: String, required: true },\n  schemas: { type: Object, default: () => ({}) },\n  modelValue: { type: Boolean, default: true },\n})\n\nconst code = ref(\"\")\nconst link = ref(\"\")\nconst error = ref(\"\")\nconst fields = ref([])\nconst tab = ref(\"fields\")\nconst loading = ref(false)\n\nfunction highlightLater() {\n  requestAnimationFrame(() => {\n    try {\n      if (window.hljs) {\n        const block = document.querySelector(\".frv-code-display pre code.language-python\")\n        if (block) {\n          if (block.dataset && block.dataset.highlighted) {\n            block.removeAttribute(\"data-highlighted\")\n          }\n          window.hljs.highlightElement(block)\n        }\n      }\n    } catch (e) {\n      console.warn(\"highlight failed\", e)\n    }\n  })\n}\n\nfunction resetState() {\n  code.value = \"\"\n  link.value = \"\"\n  error.value = null\n  fields.value = []\n  loading.value = true\n}\n\nasync function loadSource() {\n  if (!props.schemaName) return\n\n  error.value = null\n  code.value = \"\"\n  link.value = \"\"\n  loading.value = true\n\n  const payload = { schema_name: props.schemaName }\n  try {\n    const resp = await fetch(`source`, {\n      method: \"POST\",\n      headers: { Accept: \"application/json\", \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload),\n    })\n    const data = await resp.json().catch(() => ({}))\n    if (resp.ok) {\n      code.value = data.source_code || \"// no source code available\"\n    } else {\n      error.value = (data && data.error) || \"Failed to load source\"\n    }\n\n    const resp2 = await fetch(`vscode-link`, {\n      method: \"POST\",\n      headers: { Accept: \"application/json\", \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload),\n    })\n    const data2 = await resp2.json().catch(() => ({}))\n    if (resp2.ok) {\n      link.value = data2.link || \"\"\n    } else {\n      error.value = (error.value || \"\") + ((data2 && data2.error) || \"Failed to load source\")\n    }\n  } catch (e) {\n    error.value = \"Failed to load source\"\n  } finally {\n    loading.value = false\n  }\n\n  const schema = props.schemas && props.schemas[props.schemaName]\n  fields.value = Array.isArray(schema?.fields) ? schema.fields : []\n\n  if (tab.value === \"source\") {\n    highlightLater()\n  }\n}\n\nwatch(\n  () => tab.value,\n  (val) => {\n    if (val === \"source\") highlightLater()\n  }\n)\nwatch(\n  () => props.schemaName,\n  () => {\n    resetState()\n    loadSource()\n  }\n)\nwatch(\n  () => props.modelValue,\n  (val) => {\n    if (val) {\n      resetState()\n      loadSource()\n    }\n  }\n)\nonMounted(() => {\n  if (props.modelValue) {\n    resetState()\n    loadSource()\n  }\n})\n</script>\n"
  },
  {
    "path": "src/fastapi_voyager/web/src/graph-ui.js",
    "content": "export class GraphUI {\n  // ====================\n  // Constants\n  // ====================\n\n  static HIGHLIGHT_COLOR = \"#FF8C00\"\n  static HIGHLIGHT_STROKE_WIDTH = \"3.0\"\n\n  // ====================\n  // Constructor\n  // ====================\n\n  constructor(selector = \"#graph\", options = {}) {\n    this.selector = selector\n    this.options = options // e.g. { onSchemaClick: (name) => {} }\n    this.graphviz = d3.select(this.selector).graphviz().zoom(false)\n\n    this.gv = null\n    this.currentSelection = []\n    this.magnifyingGlass = null\n    this.highlightMode = options.highlightMode || \"deep\"\n\n    // Magnifying glass magnification setting (radius is percentage of viewBox width)\n    this._magnification = options.magnifyingGlassMagnification || 3.0\n\n    // Highlight state snapshot for restoring after re-render\n    this._lastHighlight = null // { type: 'node', name } or { type: 'edge', source, target }\n\n    this._init()\n  }\n\n  // ====================\n  // Highlight Methods\n  // ====================\n\n  _highlight(mode = \"bidirectional\") {\n    let highlightedNodes = $()\n    for (const selection of this.currentSelection) {\n      const nodes = this._getAffectedNodes(selection.set, mode)\n      highlightedNodes = highlightedNodes.add(nodes)\n    }\n    if (this.gv) {\n      this.gv.highlight(highlightedNodes)\n      this.gv.bringToFront(highlightedNodes)\n    }\n  }\n\n  _highlightEdgeNodes() {\n    let highlightedNodes = $()\n    const [up, down, edge] = this.currentSelection\n    highlightedNodes = highlightedNodes.add(this._getAffectedNodes(up.set, up.direction))\n    highlightedNodes = highlightedNodes.add(this._getAffectedNodes(down.set, down.direction))\n    highlightedNodes = highlightedNodes.add(edge.set)\n    if (this.gv) {\n      this.gv.highlight(highlightedNodes)\n      this.gv.bringToFront(highlightedNodes)\n    }\n  }\n\n  _highlightEdgeOnly(edgeEl, sourceNodeName, targetNodeName) {\n    const nodes = this.gv.nodesByName()\n    let $set = $()\n    $set = $set.add(edgeEl)\n    if (nodes[sourceNodeName]) {\n      $set = $set.add(nodes[sourceNodeName])\n    }\n    if (nodes[targetNodeName]) {\n      $set = $set.add(nodes[targetNodeName])\n    }\n    if (this.gv) {\n      this.gv.highlight($set)\n      this.gv.bringToFront($set)\n    }\n    // Highlight node banners\n    if (nodes[sourceNodeName]) {\n      this.highlightSchemaBanner(nodes[sourceNodeName])\n    }\n    if (nodes[targetNodeName]) {\n      this.highlightSchemaBanner(nodes[targetNodeName])\n    }\n  }\n\n  _getAffectedNodes($set, mode = \"bidirectional\") {\n    let $result = $().add($set)\n    if (mode === \"bidirectional\" || mode === \"downstream\") {\n      $set.each((i, el) => {\n        if (el.className.baseVal === \"edge\") {\n          const edge = $(el).data(\"name\")\n          const nodes = this.gv.nodesByName()\n          const downStreamNode = edge.split(\"->\")[1]\n          if (downStreamNode) {\n            $result.push(nodes[downStreamNode])\n            $result = $result.add(this.gv.linkedFrom(nodes[downStreamNode], true))\n          }\n        } else {\n          $result = $result.add(this.gv.linkedFrom(el, true))\n        }\n      })\n    }\n    if (mode === \"bidirectional\" || mode === \"upstream\") {\n      $set.each((i, el) => {\n        if (el.className.baseVal === \"edge\") {\n          const edge = $(el).data(\"name\")\n          const nodes = this.gv.nodesByName()\n          const upStreamNode = edge.split(\"->\")[0]\n          if (upStreamNode) {\n            $result.push(nodes[upStreamNode])\n            $result = $result.add(this.gv.linkedTo(nodes[upStreamNode], true))\n          }\n        } else {\n          $result = $result.add(this.gv.linkedTo(el, true))\n        }\n      })\n    }\n    return $result\n  }\n\n  // ====================\n  // Schema Banner Methods\n  // ====================\n\n  highlightSchemaBanner(node) {\n    const polygons = node.querySelectorAll(\"polygon\")\n    const outerFrame = polygons[0]\n    const titleBg = polygons[1]\n\n    if (outerFrame) {\n      this._saveOriginalAttributes(outerFrame)\n      outerFrame.setAttribute(\"stroke\", GraphUI.HIGHLIGHT_COLOR)\n      outerFrame.setAttribute(\"stroke-width\", GraphUI.HIGHLIGHT_STROKE_WIDTH)\n    }\n\n    if (titleBg) {\n      this._saveOriginalAttributes(titleBg)\n      titleBg.setAttribute(\"fill\", GraphUI.HIGHLIGHT_COLOR)\n      titleBg.setAttribute(\"stroke\", GraphUI.HIGHLIGHT_COLOR)\n    }\n  }\n\n  clearSchemaBanners() {\n    if (this.gv) {\n      this.gv.highlight()\n    }\n    this._lastHighlight = null\n\n    const allPolygons = document.querySelectorAll(\"polygon[data-original-stroke]\")\n    allPolygons.forEach((polygon) => {\n      polygon.removeAttribute(\"data-original-stroke\")\n      polygon.removeAttribute(\"data-original-stroke-width\")\n      polygon.removeAttribute(\"data-original-fill\")\n    })\n  }\n\n  _saveOriginalAttributes(element) {\n    if (!element.hasAttribute(\"data-original-stroke\")) {\n      element.setAttribute(\"data-original-stroke\", element.getAttribute(\"stroke\") || \"\")\n      element.setAttribute(\n        \"data-original-stroke-width\",\n        element.getAttribute(\"stroke-width\") || \"1\"\n      )\n      element.setAttribute(\"data-original-fill\", element.getAttribute(\"fill\") || \"\")\n    }\n  }\n\n  _highlightNodeShallow(node) {\n    const nodeName = $(node).attr(\"data-name\")\n    const nodesByName = this.gv.nodesByName()\n    let $set = $().add(node)\n\n    // Find directly connected edges and their neighbor nodes (no recursion)\n    for (const edgeName in this.gv._edgesByName) {\n      const parts = edgeName.split(\"->\")\n      const srcNode = parts[0].split(\":\")[0]\n      const tgtNode = parts[1] ? parts[1].split(\":\")[0] : null\n\n      if (srcNode === nodeName || tgtNode === nodeName) {\n        this.gv._edgesByName[edgeName].forEach((edge) => {\n          $set = $set.add(edge)\n        })\n        if (srcNode === nodeName && tgtNode && nodesByName[tgtNode]) {\n          $set = $set.add(nodesByName[tgtNode])\n        }\n        if (tgtNode === nodeName && nodesByName[srcNode]) {\n          $set = $set.add(nodesByName[srcNode])\n        }\n      }\n    }\n\n    this.gv.highlight($set)\n    this.gv.bringToFront($set)\n    this.highlightSchemaBanner(node)\n    this._lastHighlight = { type: \"node\", name: nodeName }\n  }\n\n  _applyNodeHighlight(node) {\n    const set = $()\n    set.push(node)\n    const obj = { set, direction: \"bidirectional\" }\n\n    this.clearSchemaBanners()\n    this.currentSelection = [obj]\n    this._highlight()\n\n    this._lastHighlight = { type: \"node\", name: $(node).attr(\"data-name\") }\n\n    return obj\n  }\n\n  setHighlightMode(mode) {\n    this.highlightMode = mode\n  }\n\n  _restoreHighlight() {\n    if (!this._lastHighlight || !this.gv) return\n\n    if (this._lastHighlight.type === \"node\") {\n      const nodes = this.gv.nodesByName()\n      const node = nodes[this._lastHighlight.name]\n      if (node) {\n        if (this.highlightMode === \"shallow\") {\n          this._highlightNodeShallow(node)\n        } else {\n          this._applyNodeHighlight(node)\n          try {\n            this.highlightSchemaBanner(node)\n          } catch (e) {\n            console.warn(\"[restore-highlight] banner error:\", e)\n          }\n        }\n      }\n    } else if (this._lastHighlight.type === \"edge\") {\n      const { source, target } = this._lastHighlight\n      const edgeName = Object.keys(this.gv._edgesByName).find((name) => {\n        const [s, t] = name.split(\"->\")\n        return s.split(\":\")[0] === source && t.split(\":\")[0] === target\n      })\n      if (edgeName && this.gv._edgesByName[edgeName]?.[0]) {\n        if (this.highlightMode === \"shallow\") {\n          this._highlightEdgeOnly(this.gv._edgesByName[edgeName][0], source, target)\n        } else {\n          const nodes = this.gv.nodesByName()\n          const up = $()\n          const down = $()\n          const edge = $()\n          if (nodes[source]) up.push(nodes[source])\n          if (nodes[target]) down.push(nodes[target])\n          edge.push(this.gv._edgesByName[edgeName][0])\n          this.currentSelection = [\n            { set: up, direction: \"upstream\" },\n            { set: down, direction: \"downstream\" },\n            { set: edge, direction: \"single\" },\n          ]\n          this._highlightEdgeNodes()\n        }\n      }\n    }\n  }\n\n  _triggerCallback(callbackName, ...args) {\n    const callback = this.options[callbackName]\n    if (callback) {\n      try {\n        callback(...args)\n      } catch (e) {\n        console.warn(`${callbackName} callback failed`, e)\n      }\n    }\n  }\n\n  // ====================\n  // Magnifying Glass Methods\n  // ====================\n\n  _initMagnifyingGlass() {\n    // Destroy existing magnifier if any\n    if (this.magnifyingGlass) {\n      this.magnifyingGlass.destroy()\n      this.magnifyingGlass = null\n    }\n\n    // Only initialize if enabled in options (default: true)\n    if (this.options.enableMagnifyingGlass !== false) {\n      const svgElement = document.querySelector(`${this.selector} svg`)\n      if (svgElement) {\n        import(\"./magnifying-glass.js\")\n          .then((module) => {\n            const { MagnifyingGlass } = module\n            this.magnifyingGlass = new MagnifyingGlass(svgElement, {\n              magnification: this._magnification,\n            })\n          })\n          .catch((err) => {\n            console.warn(\"Failed to load magnifying glass module:\", err)\n          })\n      }\n    }\n  }\n\n  // ====================\n  // Initialization & Events\n  // ====================\n\n  _init() {\n    const self = this\n    $(this.selector).graphviz({\n      shrink: null,\n      zoom: false,\n      ready: function () {\n        self.gv = this\n\n        const nodes = self.gv.nodes()\n        const edges = self.gv.edges()\n\n        nodes.off(\".graphui\")\n        edges.off(\".graphui\")\n\n        nodes.on(\"dblclick.graphui\", function (event) {\n          event.stopPropagation()\n\n          if (self.highlightMode === \"shallow\") {\n            self.clearSchemaBanners()\n            self._highlightNodeShallow(this)\n          } else {\n            self._applyNodeHighlight(this)\n            try {\n              self.highlightSchemaBanner(this)\n            } catch (e) {\n              console.log(e)\n            }\n          }\n\n          self._triggerCallback(\"onSchemaClick\", event.currentTarget.dataset.name)\n        })\n\n        edges.on(\"click.graphui\", function (event) {\n          event.stopPropagation()\n          const [upStreamNodeRaw, downStreamNodeRaw] = event.currentTarget.dataset.name.split(\"->\")\n          // Strip port info (e.g. \"ClassA:f.owner_id\" -> \"ClassA\")\n          const upStreamNode = upStreamNodeRaw.split(\":\")[0]\n          const downStreamNode = downStreamNodeRaw.split(\":\")[0]\n\n          if (self.highlightMode === \"shallow\") {\n            self.clearSchemaBanners()\n            try {\n              self._highlightEdgeOnly(this, upStreamNode, downStreamNode)\n            } catch (e) {\n              console.warn(\"[edge-click] highlight error:\", e)\n            }\n            self._lastHighlight = { type: \"edge\", source: upStreamNode, target: downStreamNode }\n          } else {\n            const nodes = self.gv.nodesByName()\n            const up = $()\n            const down = $()\n            const edge = $()\n            if (nodes[upStreamNode]) up.push(nodes[upStreamNode])\n            if (nodes[downStreamNode]) down.push(nodes[downStreamNode])\n            edge.push(this)\n            self.currentSelection = [\n              { set: up, direction: \"upstream\" },\n              { set: down, direction: \"downstream\" },\n              { set: edge, direction: \"single\" },\n            ]\n            try {\n              self._highlightEdgeNodes()\n            } catch (e) {\n              console.warn(\"[edge-click] highlight error:\", e)\n            }\n            self._lastHighlight = { type: \"edge\", source: upStreamNode, target: downStreamNode }\n          }\n        })\n\n        edges.on(\"dblclick.graphui\", function (event) {\n          event.stopPropagation()\n          self._triggerCallback(\"onEdgeClick\", event.currentTarget.dataset.name)\n        })\n\n        nodes.on(\"click.graphui\", function (event) {\n          if (event.shiftKey) {\n            self._triggerCallback(\"onSchemaShiftClick\", event.currentTarget.dataset.name)\n          } else if (self.highlightMode === \"shallow\") {\n            self.clearSchemaBanners()\n            self._highlightNodeShallow(this)\n          } else {\n            self._applyNodeHighlight(this)\n          }\n        })\n\n        $(document)\n          .off(\"click.graphui\")\n          .on(\"click.graphui\", function (evt) {\n            const graphContainer = $(self.selector)[0]\n            if (!graphContainer || !evt.target || !graphContainer.contains(evt.target)) {\n              return\n            }\n\n            const $everything = self.gv.$nodes.add(self.gv.$edges).add(self.gv.$clusters)\n            // Walk up from click target to find if it's inside a node/edge/cluster\n            let el = evt.target\n            let isNode = false\n            while (el && el !== graphContainer) {\n              if ($everything.is(el)) {\n                isNode = true\n                break\n              }\n              el = el.parentNode\n            }\n\n            if (!isNode && self.gv) {\n              self.clearSchemaBanners()\n\n              if (self.options.resetCb) {\n                self.options.resetCb()\n              }\n            }\n          })\n      },\n    })\n  }\n\n  // ====================\n  // Render Method\n  // ====================\n\n  async render(dotSrc, resetZoom = true) {\n    const height = this.options.height || \"100%\"\n    // Save current zoom transform before re-render\n    let savedTransform = null\n    if (!resetZoom) {\n      const svgEl = document.querySelector(`${this.selector} svg`)\n      if (svgEl) {\n        savedTransform = d3.zoomTransform(svgEl)\n      }\n    }\n    return new Promise((resolve, reject) => {\n      try {\n        this.graphviz\n          .engine(\"dot\")\n          .tweenPaths(false)\n          .tweenShapes(false)\n          .zoomScaleExtent([0, Infinity])\n          .zoom(true)\n          .width(\"100%\")\n          .height(height)\n          .fit(true)\n          .renderDot(dotSrc)\n          .on(\"end\", () => {\n            $(this.selector).data(\"graphviz.svg\").setup()\n            this._restoreHighlight()\n            if (resetZoom) {\n              this.graphviz.resetZoom()\n            } else if (savedTransform) {\n              this.graphviz\n                .zoomSelection()\n                .call(this.graphviz.zoomBehavior().transform, savedTransform)\n            }\n\n            // Initialize magnifying glass after render\n            this._initMagnifyingGlass()\n\n            resolve()\n          })\n      } catch (err) {\n        reject(err)\n      }\n    })\n  }\n}\n"
  },
  {
    "path": "src/fastapi_voyager/web/src/magnifying-glass.js",
    "content": "/**\n * Magnifying Glass for SVG Graph Visualization\n *\n * Provides a circular magnifying glass effect that follows the mouse cursor.\n * Activated by pressing the Space key.\n *\n * Usage:\n *   const magnifier = new MagnifyingGlass(svgElement, {\n *     magnification: 2.0\n *   })\n *\n * The lens radius is automatically calculated based on viewBox width.\n */\n\nexport class MagnifyingGlass {\n  // Class constants\n  static DEFAULT_MAGNIFICATION = 2.0\n  static RADIUS_PERCENTAGE = 0.2 // Percentage of viewBox width\n  static LENS_OFFSET = 10 // 放大镜相对于鼠标的偏移量\n  static BORDER_WIDTH = 2 // 边框宽度\n  static UPDATE_THROTTLE_MS = 16 // 更新节流（约60fps）\n\n  /**\n   * Extract viewBox dimensions from SVG element (called dynamically each time)\n   * @private\n   */\n  _getViewBoxDimensions() {\n    const viewBoxAttr = this.svg.getAttribute(\"viewBox\")\n    if (viewBoxAttr) {\n      const parts = viewBoxAttr.trim().split(/\\s+/)\n      if (parts.length === 4) {\n        const [, , width, height] = parts.map(parseFloat)\n        if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {\n          return { width, height }\n        }\n      }\n    }\n    // Fallback to getBoundingClientRect if no viewBox\n    const rect = this.svg.getBoundingClientRect()\n    return { width: rect.width || 1000, height: rect.height || 1000 }\n  }\n\n  /**\n   * Get current radius (dynamically calculated based on current viewBox width)\n   * @returns {number} Radius in SVG units\n   */\n  get radius() {\n    const { width } = this._getViewBoxDimensions()\n    return Math.round(width * MagnifyingGlass.RADIUS_PERCENTAGE)\n  }\n\n  /**\n   * @param {SVGElement} svgElement - The SVG element to magnify\n   * @param {Object} options - Configuration options\n   * @param {number} options.magnification - Zoom level (default: 2.0)\n   * @param {boolean} options.debug - Enable debug logging (default: false)\n   */\n  constructor(svgElement, options = {}) {\n    // Validate SVG element\n    if (!svgElement || !(svgElement instanceof SVGElement)) {\n      throw new Error(\"[MagnifyingGlass] Invalid SVG element provided\")\n    }\n\n    this.svg = svgElement\n\n    // Calculate magnification\n    this._magnification = this._validateNumber(\n      options.magnification,\n      MagnifyingGlass.DEFAULT_MAGNIFICATION,\n      0.1,\n      10\n    )\n\n    this.debug = options.debug || false\n    this.active = false\n\n    // Throttle updates for performance\n    this._pendingUpdate = false\n    this._lastPosition = null\n\n    // Content caching for performance\n    this._cachedContent = null\n    this._contentDirty = true\n\n    this._initLens()\n    this._bindEvents()\n  }\n\n  /**\n   * Get current magnification\n   */\n  get magnification() {\n    return this._magnification\n  }\n\n  /**\n   * Set magnification and update lens if active\n   * @param {number} value - New magnification value\n   */\n  set magnification(value) {\n    const validated = this._validateNumber(value, MagnifyingGlass.DEFAULT_MAGNIFICATION, 0.1, 10)\n    if (validated !== this._magnification) {\n      this._magnification = validated\n      this._log(\"Magnification updated to:\", validated)\n\n      // 如果放大镜当前激活，立即更新显示\n      if (this.active && this._lastPosition) {\n        this._updateTransform(this._lastPosition.x, this._lastPosition.y)\n      }\n    }\n  }\n\n  /**\n   * Validate and sanitize number input\n   * @param {*} value - Value to validate\n   * @param {number} defaultValue - Default value if invalid\n   * @param {number} min - Minimum allowed value\n   * @param {number} max - Maximum allowed value\n   * @returns {number} Validated number\n   * @private\n   */\n  _validateNumber(value, defaultValue, min, max) {\n    if (typeof value !== \"number\" || isNaN(value)) {\n      return defaultValue\n    }\n    return Math.max(min, Math.min(max, value))\n  }\n\n  /**\n   * Internal logging method\n   * @private\n   */\n  _log(...args) {\n    if (this.debug) {\n      console.log(\"[MagnifyingGlass]\", ...args)\n    }\n  }\n\n  /**\n   * Initialize the lens SVG elements\n   * @private\n   */\n  _initLens() {\n    this._log(\"Initializing lens...\")\n    // 1. Create defs and clipPath\n    const defs = d3.select(this.svg).append(\"defs\")\n    this.clipPathId = `lens-clip-${Math.random().toString(36).substr(2, 9)}`\n    this._log(\"clipPathId:\", this.clipPathId)\n\n    defs\n      .append(\"clipPath\")\n      .attr(\"id\", this.clipPathId)\n      .append(\"circle\")\n      .attr(\"r\", this.radius)\n      .attr(\"cx\", 0)\n      .attr(\"cy\", 0)\n\n    // 2. Create lens group (initially hidden)\n    this.lensGroup = d3\n      .select(this.svg)\n      .append(\"g\")\n      .attr(\"class\", \"magnifying-lens\")\n      .style(\"display\", \"none\")\n\n    // 3. Create lens border circle\n    this.lensGroup\n      .append(\"circle\")\n      .attr(\"class\", \"lens-border\")\n      .attr(\"r\", this.radius + MagnifyingGlass.BORDER_WIDTH)\n      .attr(\"fill\", \"rgba(255,255,255,0.95)\")\n      .attr(\"stroke\", \"#999\")\n      .attr(\"stroke-width\", MagnifyingGlass.BORDER_WIDTH)\n      .attr(\"cx\", 0) // Initialize at origin, will be updated on mouse move\n      .attr(\"cy\", 0)\n\n    // 4. Create clipped content group\n    this.lensContent = this.lensGroup\n      .append(\"g\")\n      .attr(\"clip-path\", `url(#${this.clipPathId})`)\n      .append(\"g\")\n      .attr(\"class\", \"lens-content\")\n\n    this._log(\"Lens initialized successfully\")\n  }\n\n  /**\n   * Bind keyboard and mouse events\n   * @private\n   */\n  _bindEvents() {\n    // Space key to toggle\n    this._handleKeyDown = (e) => {\n      if (e.code === \"Space\" && !e.repeat) {\n        e.preventDefault()\n        this._log(\"Space pressed, activating...\")\n        this.toggle()\n      }\n    }\n\n    this._handleKeyUp = (e) => {\n      if (e.code === \"Space\") {\n        this._log(\"Space released, deactivating...\")\n        this.deactivate()\n      }\n    }\n\n    this._handleMouseMove = (e) => {\n      // 记录最后鼠标位置，用于第一次激活时的位置计算\n      const rect = this.svg.getBoundingClientRect()\n      this._lastMousePos = {\n        x: e.clientX - rect.left,\n        y: e.clientY - rect.top,\n      }\n\n      if (this.active) {\n        this._updatePosition(e)\n      }\n    }\n\n    this._handleClick = (e) => {\n      if (this.active) {\n        this._log(\"Clicked, deactivating...\")\n        this.deactivate()\n      }\n    }\n\n    document.addEventListener(\"keydown\", this._handleKeyDown)\n    document.addEventListener(\"keyup\", this._handleKeyUp)\n    this.svg.addEventListener(\"mousemove\", this._handleMouseMove)\n    this.svg.addEventListener(\"click\", this._handleClick)\n\n    this._log(\"Events bound successfully\")\n  }\n\n  /**\n   * Update lens position and content based on mouse position\n   * @private\n   */\n  _updatePosition(event) {\n    // Use requestAnimationFrame for smooth performance\n    if (this._pendingUpdate) return\n\n    this._pendingUpdate = true\n    requestAnimationFrame(() => {\n      this._performUpdate(event)\n      this._pendingUpdate = false\n    })\n  }\n\n  /**\n   * Perform the actual position update\n   * @private\n   */\n  _performUpdate(event) {\n    try {\n      // 使用 SVG 标准的坐标转换方法，代替 getBoundingClientRect()\n      const pt = this.svg.createSVGPoint()\n      pt.x = event.clientX\n      pt.y = event.clientY\n\n      let svgP\n      try {\n        // 转换为 SVG 坐标（考虑 SVG 内部所有变换）\n        const ctm = this.svg.getScreenCTM()\n        if (!ctm || !ctm.inverse) {\n          // 如果 getScreenCTM() 失败，退回到简单方法\n          const rect = this.svg.getBoundingClientRect()\n          svgP = {\n            x: event.clientX - rect.left,\n            y: event.clientY - rect.top,\n          }\n        } else {\n          svgP = pt.matrixTransform(ctm.inverse())\n        }\n      } catch (e) {\n        // 容错处理\n        const rect = this.svg.getBoundingClientRect()\n        svgP = {\n          x: event.clientX - rect.left,\n          y: event.clientY - rect.top,\n        }\n      }\n\n      // Get current radius (dynamically calculated from viewBox)\n      const currentRadius = this.radius\n\n      // 调整放大镜位置，使其在鼠标上方，靠近下方边缘外侧\n      // 偏移量：向下一点，让鼠标位于放大镜底部边缘外侧\n      const offsetX = 0\n      const offsetY = currentRadius + MagnifyingGlass.LENS_OFFSET // 放大镜半径 + 偏移量\n\n      const lensX = svgP.x + offsetX\n      const lensY = svgP.y - offsetY // 向上偏移\n\n      // Move lens group to adjusted position\n      this.lensGroup.attr(\"transform\", `translate(${lensX}, ${lensY})`)\n\n      // 计算相对于 lensGroup 的坐标\n      const relativeCX = svgP.x - lensX\n      const relativeCY = svgP.y - lensY\n\n      // Update clipPath circle radius and position (radius is dynamic now)\n      d3.select(`#${this.clipPathId} circle`)\n        .attr(\"r\", currentRadius)\n        .attr(\"cx\", relativeCX)\n        .attr(\"cy\", relativeCY)\n\n      // Update lens border circle radius and position\n      this.lensGroup\n        .select(\".lens-border\")\n        .attr(\"r\", currentRadius + MagnifyingGlass.BORDER_WIDTH)\n        .attr(\"cx\", relativeCX)\n        .attr(\"cy\", relativeCY)\n\n      // Update magnified content with absolute coordinates\n      this._updateContent(svgP.x, svgP.y)\n    } catch (error) {\n      this._log(\"Error in _performUpdate:\", error)\n      // 发生错误时停用放大镜，避免持续出错\n      this.deactivate()\n    }\n  }\n\n  /**\n   * Update the magnified content\n   * @param {number} absoluteX - Absolute X coordinate in SVG space\n   * @param {number} absoluteY - Absolute Y coordinate in SVG space\n   * @private\n   */\n  _updateContent(absoluteX, absoluteY) {\n    // Use D3 selection (don't convert to DOM node)\n    const mainGroup = d3.select(this.svg).select(\"g\")\n    if (mainGroup.empty()) return\n\n    // 只在首次或内容变化时克隆\n    if (!this._cachedContent || this._contentDirty) {\n      this.lensContent.html(\"\")\n      const clonedContent = mainGroup.clone(true).node()\n      this.lensContent.node().appendChild(clonedContent)\n      this._cachedContent = clonedContent\n      this._contentDirty = false\n      this._log(\"Content cloned and cached\")\n    }\n\n    // 只更新 transform\n    this._updateTransform(absoluteX, absoluteY)\n  }\n\n  /**\n   * Update the transform of lens content\n   * @param {number} absoluteX - Absolute X coordinate in SVG space\n   * @param {number} absoluteY - Absolute Y coordinate in SVG space\n   * @private\n   */\n  _updateTransform(absoluteX, absoluteY) {\n    const scale = this.magnification\n    const currentRadius = this.radius\n    const offsetY = currentRadius + MagnifyingGlass.LENS_OFFSET\n\n    // 正确的公式:\n    // tx = -scale * absoluteX (让 absoluteX 变换后对应 x=0)\n    // ty = offsetY - scale * absoluteY (让 absoluteY 变换后对应 y=offsetY，即 clipPath 圆心)\n    const transform = `translate(${-scale * absoluteX}, ${offsetY - scale * absoluteY}) scale(${scale})`\n    this.lensContent.attr(\"transform\", transform)\n\n    this._lastPosition = { x: absoluteX, y: absoluteY }\n  }\n\n  /**\n   * Activate the magnifying glass\n   */\n  activate() {\n    this._log(\"Activating magnifier...\")\n    this.active = true\n    this._contentDirty = true // 标记内容为脏，激活时会重新克隆\n    this.lensGroup.style(\"display\", null)\n    d3.select(this.svg).classed(\"magnifier-active\", true)\n\n    // 解决第一次激活时的位置问题\n    // 获取当前鼠标位置并立即更新内容\n    this._updateContentFromCurrentMouse()\n  }\n\n  // 获取当前鼠标位置（跨浏览器兼容）\n  _getCurrentMousePosition() {\n    if (typeof this._lastMousePos !== \"undefined\") {\n      return this._lastMousePos\n    }\n\n    // 作为备用方案，如果没有记录位置，返回 SVG 中心\n    const rect = this.svg.getBoundingClientRect()\n    return { x: rect.width / 2, y: rect.height / 2 }\n  }\n\n  // 使用当前鼠标位置更新内容\n  _updateContentFromCurrentMouse() {\n    const currentMousePos = this._getCurrentMousePosition()\n    if (currentMousePos) {\n      // 模拟事件对象\n      this._performUpdate({\n        clientX: currentMousePos.x + this.svg.getBoundingClientRect().left,\n        clientY: currentMousePos.y + this.svg.getBoundingClientRect().top,\n      })\n    }\n  }\n\n  /**\n   * Deactivate the magnifying glass\n   */\n  deactivate() {\n    this._log(\"Deactivating magnifier...\")\n    this.active = false\n    this.lensGroup.style(\"display\", \"none\")\n    d3.select(this.svg).classed(\"magnifier-active\", false)\n    this._lastPosition = null\n  }\n\n  /**\n   * Toggle magnifying glass on/off\n   */\n  toggle() {\n    this.active ? this.deactivate() : this.activate()\n  }\n\n  /**\n   * Clean up and remove lens elements\n   */\n  destroy() {\n    this._log(\"Destroying...\")\n    // Remove event listeners\n    document.removeEventListener(\"keydown\", this._handleKeyDown)\n    document.removeEventListener(\"keyup\", this._handleKeyUp)\n    this.svg.removeEventListener(\"mousemove\", this._handleMouseMove)\n    this.svg.removeEventListener(\"click\", this._handleClick)\n\n    // Clean up DOM elements\n    if (this.lensGroup) this.lensGroup.remove()\n    const defs = d3.select(this.svg).select(\"defs\")\n    if (defs) defs.select(`#${this.clipPathId}`).remove()\n\n    // Clean up references\n    this._cachedContent = null\n    this.lensGroup = null\n    this.lensContent = null\n    this.svg = null\n  }\n}\n"
  },
  {
    "path": "src/fastapi_voyager/web/src/main.js",
    "content": "import { createApp } from \"vue\"\nimport App from \"./App.vue\"\n\nconst app = createApp(App)\napp.mount(\"#app\")\n"
  },
  {
    "path": "src/fastapi_voyager/web/src/store.js",
    "content": "import { reactive } from \"vue\"\n\nconst state = reactive({\n  version: \"\",\n  framework_name: \"\",\n  config: {\n    initial_page_policy: \"first\",\n    has_er_diagram: false,\n    enable_pydantic_resolve_meta: false,\n  },\n\n  mode: \"voyager\", // voyager / er-diagram\n\n  previousTagRoute: {\n    hasValue: false,\n    tag: null,\n    routeId: null,\n  },\n\n  swagger: {\n    url: \"\",\n  },\n\n  rightDrawer: {\n    drawer: false,\n    width: 300,\n  },\n\n  fieldOptions: [\n    { label: \"No field\", value: \"single\" },\n    { label: \"Object fields\", value: \"object\" },\n    { label: \"All fields\", value: \"all\" },\n  ],\n\n  leftPanel: {\n    width: 300,\n    previousWidth: 300,\n    tags: null,\n    fullTagsCache: null,\n    tag: null,\n    _tag: null,\n    routeId: null,\n    collapsed: false,\n  },\n\n  graph: {\n    schemaId: null,\n    schemaKeys: new Set(),\n    schemaMap: {},\n    routeItems: [],\n  },\n\n  erDiagramLinks: [],\n  erDiagramSchemas: {},\n\n  edgeDetail: {\n    loaderFullname: null,\n    sourceEntity: null,\n    targetEntity: null,\n    label: null,\n  },\n\n  search: {\n    mode: false,\n    invisible: false,\n    schemaName: null,\n    fieldName: null,\n    schemaOptions: [],\n    fieldOptions: [],\n  },\n\n  allSchemaOptions: [],\n\n  routeDetail: {\n    show: false,\n    routeCodeId: \"\",\n  },\n\n  schemaDetail: {\n    show: false,\n    schemaCodeName: \"\",\n  },\n\n  searchDialog: {\n    show: false,\n    schema: null,\n  },\n\n  status: {\n    generating: false,\n    loading: false,\n    initializing: true,\n  },\n\n  modeControl: {\n    focus: false,\n    briefModeEnabled: false,\n    pydanticResolveMetaEnabled: false,\n  },\n\n  filter: {\n    hidePrimitiveRoute: false,\n    showFields: \"object\",\n    brief: false,\n    showModule: false,\n    magnification: 3.0,\n    edgeMinlen: 3,\n    showMethods: true,\n  },\n})\n\nconst getters = {\n  findTagByRoute(routeId) {\n    return (\n      state.leftPanel.tags.find((tag) => (tag.routes || []).some((route) => route.id === routeId))\n        ?.name || null\n    )\n  },\n}\n\nconst actions = {\n  readQuerySelection() {\n    if (typeof window === \"undefined\") {\n      return { tag: null, route: null, mode: null }\n    }\n    const params = new URLSearchParams(window.location.search)\n    return {\n      tag: params.get(\"tag\") || null,\n      route: params.get(\"route\") || null,\n      mode: params.get(\"mode\") || null,\n    }\n  },\n\n  syncSelectionToUrl() {\n    if (typeof window === \"undefined\") {\n      return\n    }\n    const params = new URLSearchParams(window.location.search)\n    if (state.leftPanel.tag) {\n      params.set(\"tag\", state.leftPanel.tag)\n    } else {\n      params.delete(\"tag\")\n    }\n    if (state.leftPanel.routeId) {\n      params.set(\"route\", state.leftPanel.routeId)\n    } else {\n      params.delete(\"route\")\n    }\n    if (state.mode) {\n      params.set(\"mode\", state.mode)\n    } else {\n      params.delete(\"mode\")\n    }\n    const hash = window.location.hash || \"\"\n    const search = params.toString()\n    const base = window.location.pathname\n    const newUrl = search ? `${base}?${search}${hash}` : `${base}${hash}`\n    window.history.replaceState({}, \"\", newUrl)\n  },\n\n  applySelectionFromQuery(selection) {\n    let applied = false\n    if (selection.tag && state.leftPanel.tags.some((tag) => tag.name === selection.tag)) {\n      state.leftPanel.tag = selection.tag\n      state.leftPanel._tag = selection.tag\n      applied = true\n    }\n    if (selection.route && state.graph.routeItems?.[selection.route]) {\n      state.leftPanel.routeId = selection.route\n      applied = true\n      const inferredTag = getters.findTagByRoute(selection.route)\n      if (inferredTag) {\n        state.leftPanel.tag = inferredTag\n        state.leftPanel._tag = inferredTag\n      }\n    }\n    if (selection.mode === \"voyager\" || selection.mode === \"er-diagram\") {\n      state.mode = selection.mode\n      applied = true\n    }\n    return applied\n  },\n\n  loadFullTags() {\n    state.leftPanel.tags = state.leftPanel.fullTagsCache\n  },\n\n  populateFieldOptions(schemaId) {\n    if (!schemaId) {\n      state.search.fieldOptions = []\n      state.search.fieldName = null\n      return\n    }\n    const schema = state.graph.schemaMap?.[schemaId]\n    if (!schema) {\n      state.search.fieldOptions = []\n      state.search.fieldName = null\n      return\n    }\n    const fieldNames = Array.isArray(schema.fields) ? schema.fields.map((f) => f.name) : []\n    state.search.fieldOptions = fieldNames.map((f) => ({ label: f, value: f }))\n    if (!fieldNames.includes(state.search.fieldName)) {\n      state.search.fieldName = null\n    }\n  },\n\n  rebuildSchemaOptions() {\n    const dict = state.graph.schemaMap || {}\n    const opts = Object.values(dict).map((s) => ({\n      label: `${s.name} (${s.id})`,\n      value: s.id,\n    }))\n    state.allSchemaOptions = opts\n    state.search.schemaOptions = opts.slice()\n    this.populateFieldOptions(state.search.schemaName)\n  },\n\n  async loadSearchedTags() {\n    try {\n      const payload = {\n        schema_name: state.search.schemaName,\n        schema_field: state.search.fieldName || null,\n        show_fields: state.filter.showFields,\n        brief: state.filter.brief,\n        hide_primitive_route: state.filter.hidePrimitiveRoute,\n        show_module: state.filter.showModule,\n      }\n      const res = await fetch(\"dot-search\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify(payload),\n      })\n      if (res.ok) {\n        const data = await res.json()\n        const tags = Array.isArray(data.tags) ? data.tags : []\n        state.leftPanel.tags = tags\n      }\n    } catch (err) {\n      console.error(\"dot-search failed\", err)\n    }\n  },\n\n  async loadInitial(onGenerate, renderBasedOnInitialPolicy) {\n    state.initializing = true\n    try {\n      const res = await fetch(\"dot\")\n      const data = await res.json()\n      const tags = Array.isArray(data.tags) ? data.tags : []\n      state.leftPanel.tags = tags\n      state.leftPanel.fullTagsCache = tags\n\n      const schemasArr = Array.isArray(data.schemas) ? data.schemas : []\n      const schemaMap = Object.fromEntries(schemasArr.map((s) => [s.id, s]))\n      state.graph.schemaMap = schemaMap\n      state.graph.schemaKeys = new Set(Object.keys(schemaMap))\n      state.graph.routeItems = data.tags\n        .map((t) => t.routes)\n        .flat()\n        .reduce((acc, r) => {\n          acc[r.id] = r\n          return acc\n        }, {})\n      state.modeControl.briefModeEnabled = data.enable_brief_mode || false\n      state.version = data.version || \"\"\n      state.swagger.url = data.swagger_url || null\n      state.config.has_er_diagram = data.has_er_diagram || false\n      state.config.enable_pydantic_resolve_meta = data.enable_pydantic_resolve_meta || false\n      state.framework_name = data.framework_name || \"API\"\n\n      this.rebuildSchemaOptions()\n\n      const querySelection = this.readQuerySelection()\n      const restoredFromQuery = this.applySelectionFromQuery(querySelection)\n      if (restoredFromQuery) {\n        this.syncSelectionToUrl()\n        onGenerate()\n        return\n      } else {\n        state.config.initial_page_policy = data.initial_page_policy\n        if (\n          querySelection.mode &&\n          (querySelection.mode === \"voyager\" || querySelection.mode === \"er-diagram\")\n        ) {\n          this.syncSelectionToUrl()\n          onGenerate()\n          return\n        }\n        renderBasedOnInitialPolicy(onGenerate)\n      }\n    } catch (e) {\n      console.error(\"Initial load failed\", e)\n    } finally {\n      state.initializing = false\n    }\n  },\n\n  onSearchSchemaChange(val, onSearch) {\n    state.search.schemaName = val\n    state.search.mode = false\n    if (!val) {\n      return\n    }\n    onSearch()\n  },\n\n  resetDetailPanels() {\n    state.rightDrawer.drawer = false\n    state.routeDetail.show = false\n    state.schemaDetail.schemaCodeName = \"\"\n    state.edgeDetail.loaderFullname = null\n    state.edgeDetail.sourceEntity = null\n    state.edgeDetail.targetEntity = null\n    state.edgeDetail.label = null\n  },\n\n  onReset(onGenerate) {\n    state.leftPanel.tag = null\n    state.leftPanel._tag = null\n    state.leftPanel.routeId = null\n    this.syncSelectionToUrl()\n    onGenerate()\n  },\n\n  togglePydanticResolveMeta(val, onGenerate) {\n    state.modeControl.pydanticResolveMetaEnabled = val\n    try {\n      localStorage.setItem(\"pydantic_resolve_meta\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save pydantic_resolve_meta to localStorage\", e)\n    }\n    onGenerate()\n  },\n\n  toggleShowModule(val, onGenerate) {\n    state.filter.showModule = val\n    try {\n      localStorage.setItem(\"show_module_cluster\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save show_module_cluster to localStorage\", e)\n    }\n    onGenerate()\n  },\n\n  toggleShowField(field, onGenerate) {\n    state.filter.showFields = field\n    onGenerate(false)\n  },\n\n  toggleBrief(val, onGenerate) {\n    state.filter.brief = val\n    try {\n      localStorage.setItem(\"brief_mode\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save brief_mode to localStorage\", e)\n    }\n    onGenerate()\n  },\n\n  toggleHidePrimitiveRoute(val, onGenerate) {\n    state.filter.hidePrimitiveRoute = val\n    try {\n      localStorage.setItem(\"hide_primitive\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save hide_primitive to localStorage\", e)\n    }\n    onGenerate(false)\n  },\n\n  updateMagnification(val) {\n    const validatedValue = Math.max(2, Math.min(5, val))\n    state.filter.magnification = validatedValue\n    try {\n      localStorage.setItem(\"magnification\", JSON.stringify(validatedValue))\n    } catch (e) {\n      console.warn(\"Failed to save magnification to localStorage\", e)\n    }\n  },\n\n  updateEdgeMinlen(val, onGenerate) {\n    const validatedValue = Math.max(3, Math.min(10, val))\n    state.filter.edgeMinlen = validatedValue\n    try {\n      localStorage.setItem(\"edge_minlen\", JSON.stringify(validatedValue))\n    } catch (e) {\n      console.warn(\"Failed to save edge_minlen to localStorage\", e)\n    }\n    onGenerate(true)\n  },\n\n  toggleShowMethods(val, onGenerate) {\n    state.filter.showMethods = val\n    try {\n      localStorage.setItem(\"show_methods\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save show_methods to localStorage\", e)\n    }\n    onGenerate(false)\n  },\n\n  renderBasedOnInitialPolicy(onGenerate) {\n    switch (state.config.initial_page_policy) {\n      case \"full\":\n        onGenerate()\n        return\n      case \"empty\":\n        return\n      case \"first\":\n        state.leftPanel.tag = state.leftPanel.tags.length > 0 ? state.leftPanel.tags[0].name : null\n        state.leftPanel._tag = state.leftPanel.tag\n        this.syncSelectionToUrl()\n        onGenerate()\n        return\n    }\n  },\n\n  buildVoyagerPayload() {\n    const activeSchema = state.search.mode ? state.search.schemaName : null\n    const activeField = state.search.mode ? state.search.fieldName : null\n    return {\n      tags: state.leftPanel.tag ? [state.leftPanel.tag] : null,\n      schema_name: activeSchema || null,\n      schema_field: activeField || null,\n      route_name: state.leftPanel.routeId || null,\n      show_fields: state.filter.showFields,\n      brief: state.filter.brief,\n      hide_primitive_route: state.filter.hidePrimitiveRoute,\n      show_module: state.filter.showModule,\n      show_pydantic_resolve_meta: state.modeControl.pydanticResolveMetaEnabled,\n    }\n  },\n\n  buildErDiagramPayload() {\n    return {\n      show_fields: state.filter.showFields,\n      show_module: state.filter.showModule,\n      edge_minlen: state.filter.edgeMinlen,\n      show_methods: state.filter.showMethods,\n    }\n  },\n\n  resetSearchState() {\n    state.search.mode = false\n    state.search.schemaName = null\n    state.search.fieldName = null\n    state.search.fieldOptions = []\n\n    const hadPreviousValue = state.previousTagRoute.hasValue\n\n    if (hadPreviousValue) {\n      state.leftPanel.tag = state.previousTagRoute.tag\n      state.leftPanel._tag = state.previousTagRoute.tag\n      state.leftPanel.routeId = state.previousTagRoute.routeId\n      state.previousTagRoute.hasValue = false\n    } else {\n      state.leftPanel.tag = null\n      state.leftPanel._tag = null\n      state.leftPanel.routeId = null\n    }\n\n    this.syncSelectionToUrl()\n    this.loadFullTags()\n\n    return hadPreviousValue\n  },\n}\n\nexport const store = {\n  state,\n  getters,\n  actions,\n}\n"
  },
  {
    "path": "src/fastapi_voyager/web/store.js",
    "content": "const { reactive } = window.Vue\n\nconst state = reactive({\n  version: \"\",\n  framework_name: \"\",\n  config: {\n    initial_page_policy: \"first\",\n    has_er_diagram: false,\n    enable_pydantic_resolve_meta: false,\n  },\n\n  mode: \"voyager\", // voyager / er-diagram\n\n  previousTagRoute: {\n    // Store the last non-search tag/route selection for restoration when clearing search\n    // Used by resetSearch to return to the state before entering search mode\n    hasValue: false,\n    tag: null,\n    routeId: null,\n  },\n\n  swagger: {\n    url: \"\",\n  },\n\n  rightDrawer: {\n    drawer: false,\n    width: 300,\n  },\n\n  fieldOptions: [\n    { label: \"No field\", value: \"single\" },\n    { label: \"Object fields\", value: \"object\" },\n    { label: \"All fields\", value: \"all\" },\n  ],\n\n  // tags and routes\n  leftPanel: {\n    width: 300,\n    previousWidth: 300,\n    tags: null,\n    fullTagsCache: null, // Cache for full tags (before search)\n    tag: null,\n    _tag: null,\n    routeId: null,\n    collapsed: false,\n  },\n\n  graph: {\n    schemaId: null,\n    schemaKeys: new Set(),\n    schemaMap: {},\n    routeItems: [],\n  },\n\n  // ER diagram edge metadata\n  erDiagramLinks: [],\n  // ER diagram schema metadata (id -> {id, name, module, fields})\n  erDiagramSchemas: {},\n\n  // edge detail sidebar state\n  edgeDetail: {\n    loaderFullname: null,\n    sourceEntity: null,\n    targetEntity: null,\n    label: null,\n  },\n\n  // schema options, schema, fields\n  search: {\n    mode: false,\n    invisible: false,\n    schemaName: null,\n    fieldName: null,\n    schemaOptions: [],\n    fieldOptions: [],\n  },\n\n  // cache all schema options for filtering\n  allSchemaOptions: [],\n\n  // route information\n  routeDetail: {\n    show: false,\n    routeCodeId: \"\",\n  },\n\n  // schema information\n  schemaDetail: {\n    show: false,\n    schemaCodeName: \"\",\n  },\n\n  searchDialog: {\n    show: false,\n    schema: null,\n  },\n\n  // global status\n  status: {\n    generating: false,\n    loading: false,\n    initializing: true,\n  },\n\n  // brief, hide primitive ...\n  modeControl: {\n    focus: false, // control the schema param\n    briefModeEnabled: false, // show brief mode toggle\n    pydanticResolveMetaEnabled: false, // show pydantic resolve meta toggle\n  },\n\n  // api filters\n  filter: {\n    hidePrimitiveRoute: false,\n    showFields: \"object\",\n    brief: false,\n    showModule: false,\n    magnification: 3.0, // Magnifying glass zoom level (2-5)\n    edgeMinlen: 3, // ER diagram edge minimum length (3-10)\n    showMethods: true, // ER diagram show query/mutation methods\n  },\n})\n\nconst getters = {\n  /**\n   * Find tag name by route ID\n   * Used to determine which tag a route belongs to\n   */\n  findTagByRoute(routeId) {\n    return (\n      state.leftPanel.tags.find((tag) => (tag.routes || []).some((route) => route.id === routeId))\n        ?.name || null\n    )\n  },\n}\n\nconst actions = {\n  /**\n   * Read tag, route and mode from URL query parameters\n   * @returns {{ tag: string|null, route: string|null, mode: string|null }}\n   */\n  readQuerySelection() {\n    if (typeof window === \"undefined\") {\n      return { tag: null, route: null, mode: null }\n    }\n    const params = new URLSearchParams(window.location.search)\n    return {\n      tag: params.get(\"tag\") || null,\n      route: params.get(\"route\") || null,\n      mode: params.get(\"mode\") || null,\n    }\n  },\n\n  /**\n   * Sync current tag, route and mode selection to URL\n   * Updates browser URL without reloading the page\n   */\n  syncSelectionToUrl() {\n    if (typeof window === \"undefined\") {\n      return\n    }\n    const params = new URLSearchParams(window.location.search)\n    if (state.leftPanel.tag) {\n      params.set(\"tag\", state.leftPanel.tag)\n    } else {\n      params.delete(\"tag\")\n    }\n    if (state.leftPanel.routeId) {\n      params.set(\"route\", state.leftPanel.routeId)\n    } else {\n      params.delete(\"route\")\n    }\n    // Always sync mode to URL for consistency\n    if (state.mode) {\n      params.set(\"mode\", state.mode)\n    } else {\n      params.delete(\"mode\")\n    }\n    const hash = window.location.hash || \"\"\n    const search = params.toString()\n    const base = window.location.pathname\n    const newUrl = search ? `${base}?${search}${hash}` : `${base}${hash}`\n    window.history.replaceState({}, \"\", newUrl)\n  },\n\n  /**\n   * Apply selection from URL query parameters to state\n   * @param {{ tag: string|null, route: string|null, mode: string|null }} selection\n   * @returns {boolean} - true if any selection was applied\n   */\n  applySelectionFromQuery(selection) {\n    let applied = false\n    if (selection.tag && state.leftPanel.tags.some((tag) => tag.name === selection.tag)) {\n      state.leftPanel.tag = selection.tag\n      state.leftPanel._tag = selection.tag\n      applied = true\n    }\n    if (selection.route && state.graph.routeItems?.[selection.route]) {\n      state.leftPanel.routeId = selection.route\n      applied = true\n      const inferredTag = getters.findTagByRoute(selection.route)\n      if (inferredTag) {\n        state.leftPanel.tag = inferredTag\n        state.leftPanel._tag = inferredTag\n      }\n    }\n    // Apply mode from URL if it's valid\n    if (selection.mode === \"voyager\" || selection.mode === \"er-diagram\") {\n      state.mode = selection.mode\n      applied = true\n    }\n    return applied\n  },\n\n  /**\n   * Restore full tags from cache\n   * Used when resetting search mode\n   */\n  loadFullTags() {\n    state.leftPanel.tags = state.leftPanel.fullTagsCache\n  },\n\n  /**\n   * Populate field options based on selected schema\n   * @param {string} schemaId - Schema ID\n   */\n  populateFieldOptions(schemaId) {\n    if (!schemaId) {\n      state.search.fieldOptions = []\n      state.search.fieldName = null\n      return\n    }\n    const schema = state.graph.schemaMap?.[schemaId]\n    if (!schema) {\n      state.search.fieldOptions = []\n      state.search.fieldName = null\n      return\n    }\n    const fields = Array.isArray(schema.fields) ? schema.fields.map((f) => f.name) : []\n    state.search.fieldOptions = fields\n    if (!fields.includes(state.search.fieldName)) {\n      state.search.fieldName = null\n    }\n  },\n\n  /**\n   * Rebuild schema options from schema map\n   * Should be called when schema map changes\n   */\n  rebuildSchemaOptions() {\n    const dict = state.graph.schemaMap || {}\n    const opts = Object.values(dict).map((s) => ({\n      label: s.name,\n      desc: s.id,\n      value: s.id,\n    }))\n    state.allSchemaOptions = opts\n    state.search.schemaOptions = opts.slice()\n    this.populateFieldOptions(state.search.schemaName)\n  },\n\n  /**\n   * Load tags based on search criteria\n   * @returns {Promise<void>}\n   */\n  async loadSearchedTags() {\n    try {\n      const payload = {\n        schema_name: state.search.schemaName,\n        schema_field: state.search.fieldName || null,\n        show_fields: state.filter.showFields,\n        brief: state.filter.brief,\n        hide_primitive_route: state.filter.hidePrimitiveRoute,\n        show_module: state.filter.showModule,\n      }\n      const res = await fetch(\"dot-search\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify(payload),\n      })\n      if (res.ok) {\n        const data = await res.json()\n        const tags = Array.isArray(data.tags) ? data.tags : []\n        state.leftPanel.tags = tags\n      }\n    } catch (err) {\n      console.error(\"dot-search failed\", err)\n    }\n  },\n\n  /**\n   * Load initial data from API\n   * @param {Function} onGenerate - Callback to generate graph after load\n   * @param {Function} renderBasedOnInitialPolicy - Callback to render based on policy\n   * @returns {Promise<void>}\n   */\n  async loadInitial(onGenerate, renderBasedOnInitialPolicy) {\n    state.initializing = true\n    try {\n      const res = await fetch(\"dot\")\n      const data = await res.json()\n      const tags = Array.isArray(data.tags) ? data.tags : []\n      state.leftPanel.tags = tags\n      // Cache the full tags for later use (e.g., resetSearch)\n      state.leftPanel.fullTagsCache = tags\n\n      const schemasArr = Array.isArray(data.schemas) ? data.schemas : []\n      // Build dict keyed by id for faster lookups and simpler prop passing\n      const schemaMap = Object.fromEntries(schemasArr.map((s) => [s.id, s]))\n      state.graph.schemaMap = schemaMap\n      state.graph.schemaKeys = new Set(Object.keys(schemaMap))\n      state.graph.routeItems = data.tags\n        .map((t) => t.routes)\n        .flat()\n        .reduce((acc, r) => {\n          acc[r.id] = r\n          return acc\n        }, {})\n      state.modeControl.briefModeEnabled = data.enable_brief_mode || false\n      state.version = data.version || \"\"\n      state.swagger.url = data.swagger_url || null\n      state.config.has_er_diagram = data.has_er_diagram || false\n      state.config.enable_pydantic_resolve_meta = data.enable_pydantic_resolve_meta || false\n      state.framework_name = data.framework_name || \"API\"\n\n      this.rebuildSchemaOptions()\n\n      const querySelection = this.readQuerySelection()\n      const restoredFromQuery = this.applySelectionFromQuery(querySelection)\n      if (restoredFromQuery) {\n        this.syncSelectionToUrl()\n        onGenerate()\n        return\n      } else {\n        state.config.initial_page_policy = data.initial_page_policy\n        // Check if mode was applied from URL even if tag/route wasn't\n        if (\n          querySelection.mode &&\n          (querySelection.mode === \"voyager\" || querySelection.mode === \"er-diagram\")\n        ) {\n          this.syncSelectionToUrl()\n          onGenerate()\n          return\n        }\n        renderBasedOnInitialPolicy(onGenerate)\n      }\n\n      // default route options placeholder\n    } catch (e) {\n      console.error(\"Initial load failed\", e)\n    } finally {\n      state.initializing = false\n    }\n  },\n\n  /**\n   * Filter schema options based on search text\n   * Used by Quasar select component's filter function\n   * @param {string} val - Search text\n   * @param {Function} update - Quasar update callback\n   */\n  filterSearchSchemas(val, update) {\n    const needle = (val || \"\").toLowerCase()\n    update(() => {\n      if (!needle) {\n        state.search.schemaOptions = state.allSchemaOptions.slice()\n        return\n      }\n      state.search.schemaOptions = state.allSchemaOptions.filter((option) =>\n        option.label.toLowerCase().includes(needle)\n      )\n    })\n  },\n\n  /**\n   * Handle schema selection change\n   * Updates state and triggers search if a schema is selected\n   * @param {string} val - Selected schema ID\n   * @param {Function} onSearch - Callback to trigger search\n   */\n  onSearchSchemaChange(val, onSearch) {\n    state.search.schemaName = val\n    state.search.mode = false\n    if (!val) {\n      // Clearing the select should only run resetSearch via @clear\n      return\n    }\n    onSearch()\n  },\n\n  /**\n   * Reset detail panels (right drawer and route detail)\n   */\n  resetDetailPanels() {\n    state.rightDrawer.drawer = false\n    state.routeDetail.show = false\n    state.schemaDetail.schemaCodeName = \"\"\n    state.edgeDetail.loaderFullname = null\n    state.edgeDetail.sourceEntity = null\n    state.edgeDetail.targetEntity = null\n    state.edgeDetail.label = null\n  },\n\n  /**\n   * Reset left panel selection and regenerate\n   * @param {Function} onGenerate - Callback to regenerate graph\n   */\n  onReset(onGenerate) {\n    state.leftPanel.tag = null\n    state.leftPanel._tag = null\n    state.leftPanel.routeId = null\n    this.syncSelectionToUrl()\n    onGenerate()\n  },\n\n  /**\n   * Toggle pydantic resolve meta visibility\n   * @param {boolean} val - New value\n   * @param {Function} onGenerate - Callback to regenerate graph\n   */\n  togglePydanticResolveMeta(val, onGenerate) {\n    state.modeControl.pydanticResolveMetaEnabled = val\n    try {\n      localStorage.setItem(\"pydantic_resolve_meta\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save pydantic_resolve_meta to localStorage\", e)\n    }\n    onGenerate()\n  },\n\n  /**\n   * Toggle show module clustering\n   * @param {boolean} val - New value\n   * @param {Function} onGenerate - Callback to regenerate graph\n   */\n  toggleShowModule(val, onGenerate) {\n    state.filter.showModule = val\n    try {\n      localStorage.setItem(\"show_module_cluster\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save show_module_cluster to localStorage\", e)\n    }\n    onGenerate()\n  },\n\n  /**\n   * Toggle show fields option\n   * @param {string} field - Field display option (\"single\", \"object\", \"all\")\n   * @param {Function} onGenerate - Callback to regenerate graph\n   */\n  toggleShowField(field, onGenerate) {\n    state.filter.showFields = field\n    onGenerate(false)\n  },\n\n  /**\n   * Toggle brief mode\n   * @param {boolean} val - New value\n   * @param {Function} onGenerate - Callback to regenerate graph\n   */\n  toggleBrief(val, onGenerate) {\n    state.filter.brief = val\n    try {\n      localStorage.setItem(\"brief_mode\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save brief_mode to localStorage\", e)\n    }\n    onGenerate()\n  },\n\n  /**\n   * Toggle hide primitive route\n   * @param {boolean} val - New value\n   * @param {Function} onGenerate - Callback to regenerate graph\n   */\n  toggleHidePrimitiveRoute(val, onGenerate) {\n    state.filter.hidePrimitiveRoute = val\n    try {\n      localStorage.setItem(\"hide_primitive\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save hide_primitive to localStorage\", e)\n    }\n    onGenerate(false)\n  },\n\n  /**\n   * Update magnifying glass magnification\n   * @param {number} val - New magnification value (2-5)\n   */\n  updateMagnification(val) {\n    const validatedValue = Math.max(2, Math.min(5, val))\n    state.filter.magnification = validatedValue\n    try {\n      localStorage.setItem(\"magnification\", JSON.stringify(validatedValue))\n    } catch (e) {\n      console.warn(\"Failed to save magnification to localStorage\", e)\n    }\n  },\n\n  /**\n   * Update ER diagram edge minimum length\n   * @param {number} val - New edge length value (3-8)\n   * @param {Function} onGenerate - Callback to regenerate graph\n   */\n  updateEdgeMinlen(val, onGenerate) {\n    const validatedValue = Math.max(3, Math.min(10, val))\n    state.filter.edgeMinlen = validatedValue\n    try {\n      localStorage.setItem(\"edge_minlen\", JSON.stringify(validatedValue))\n    } catch (e) {\n      console.warn(\"Failed to save edge_minlen to localStorage\", e)\n    }\n    onGenerate(true)\n  },\n\n  /**\n   * Toggle show query/mutation methods in ER diagram\n   * @param {boolean} val - New value\n   * @param {Function} onGenerate - Callback to regenerate graph\n   */\n  toggleShowMethods(val, onGenerate) {\n    state.filter.showMethods = val\n    try {\n      localStorage.setItem(\"show_methods\", JSON.stringify(val))\n    } catch (e) {\n      console.warn(\"Failed to save show_methods to localStorage\", e)\n    }\n    onGenerate(false)\n  },\n\n  /**\n   * Render based on initial page policy\n   * @param {Function} onGenerate - Callback to regenerate graph\n   */\n  renderBasedOnInitialPolicy(onGenerate) {\n    switch (state.config.initial_page_policy) {\n      case \"full\":\n        onGenerate()\n        return\n      case \"empty\":\n        return\n      case \"first\":\n        state.leftPanel.tag = state.leftPanel.tags.length > 0 ? state.leftPanel.tags[0].name : null\n        state.leftPanel._tag = state.leftPanel.tag\n        this.syncSelectionToUrl()\n        onGenerate()\n        return\n    }\n  },\n\n  /**\n   * Build payload for Voyager rendering\n   * @returns {Object} Payload for dot API\n   */\n  buildVoyagerPayload() {\n    const activeSchema = state.search.mode ? state.search.schemaName : null\n    const activeField = state.search.mode ? state.search.fieldName : null\n    return {\n      tags: state.leftPanel.tag ? [state.leftPanel.tag] : null,\n      schema_name: activeSchema || null,\n      schema_field: activeField || null,\n      route_name: state.leftPanel.routeId || null,\n      show_fields: state.filter.showFields,\n      brief: state.filter.brief,\n      hide_primitive_route: state.filter.hidePrimitiveRoute,\n      show_module: state.filter.showModule,\n      show_pydantic_resolve_meta: state.modeControl.pydanticResolveMetaEnabled,\n    }\n  },\n\n  /**\n   * Build payload for ER Diagram rendering\n   * @returns {Object} Payload for er-diagram API\n   */\n  buildErDiagramPayload() {\n    return {\n      show_fields: state.filter.showFields,\n      show_module: state.filter.showModule,\n      edge_minlen: state.filter.edgeMinlen,\n      show_methods: state.filter.showMethods,\n    }\n  },\n\n  /**\n   * Restore search state and return whether to regenerate\n   * @returns {boolean} - true if should regenerate with previous selection\n   */\n  resetSearchState() {\n    state.search.mode = false\n    // Clear search schema and field selection\n    state.search.schemaName = null\n    state.search.fieldName = null\n    state.search.fieldOptions = []\n\n    const hadPreviousValue = state.previousTagRoute.hasValue\n\n    if (hadPreviousValue) {\n      state.leftPanel.tag = state.previousTagRoute.tag\n      state.leftPanel._tag = state.previousTagRoute.tag\n      state.leftPanel.routeId = state.previousTagRoute.routeId\n      // Clear the saved state\n      state.previousTagRoute.hasValue = false\n    } else {\n      state.leftPanel.tag = null\n      state.leftPanel._tag = null\n      state.leftPanel.routeId = null\n    }\n\n    this.syncSelectionToUrl()\n    this.loadFullTags()\n\n    return hadPreviousValue\n  },\n}\n\nconst mutations = {}\n\nexport const store = {\n  state,\n  getters,\n  actions,\n  mutations,\n}\n"
  },
  {
    "path": "src/fastapi_voyager/web/sw.js",
    "content": "/**\n * Service Worker for fastapi-voyager\n *\n * Provides caching for CDN and local static resources.\n * Uses version-based cache management - old caches are cleaned on version update.\n */\n\nconst CACHE_PREFIX = \"fastapi-voyager-v\"\nconst VERSION = \"<!-- VERSION_PLACEHOLDER -->\"\nconst CACHE_NAME = CACHE_PREFIX + VERSION\nconst STATIC_PATH = \"<!-- STATIC_PATH -->\"\n\n// CDN resources to cache (cache-first strategy)\nconst CDN_ASSETS = [\n  \"https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js\",\n  \"https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js\",\n  \"https://unpkg.com/@hpcc-js/wasm@2.20.0/dist/graphviz.umd.js\",\n  \"https://cdnjs.cloudflare.com/ajax/libs/d3-graphviz/5.6.0/d3-graphviz.min.js\",\n  \"https://cdnjs.cloudflare.com/ajax/libs/jquery-mousewheel/3.1.13/jquery.mousewheel.min.js\",\n  \"https://cdnjs.cloudflare.com/ajax/libs/jquery-color/2.1.2/jquery.color.min.js\",\n  \"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\",\n]\n\n// CDN domains for dynamic matching (catches all resources from these domains)\nconst CDN_DOMAINS = [\n  \"unpkg.com\",\n  \"cdnjs.cloudflare.com\",\n  \"cdn.jsdelivr.net\",\n  \"fonts.googleapis.com\",\n  \"fonts.gstatic.com\",\n]\n\n/**\n * Install event - pre-cache CDN resources\n * Uses Promise.allSettled to handle individual CDN failures gracefully\n */\nself.addEventListener(\"install\", (event) => {\n  event.waitUntil(\n    caches.open(CACHE_NAME).then((cache) => {\n      return Promise.allSettled(\n        CDN_ASSETS.map((url) =>\n          cache.add(new Request(url, { mode: \"cors\" })).catch(() => {\n            // Silently fail for individual CDN resources\n            console.log(\"[Voyager SW] Failed to cache:\", url)\n          })\n        )\n      )\n    })\n  )\n  // Activate immediately without waiting for existing clients to close\n  self.skipWaiting()\n})\n\n/**\n * Activate event - clean up old version caches\n */\nself.addEventListener(\"activate\", (event) => {\n  event.waitUntil(\n    caches.keys().then((cacheNames) => {\n      return Promise.all(\n        cacheNames\n          .filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)\n          .map((name) => {\n            console.log(\"[Voyager SW] Deleting old cache:\", name)\n            return caches.delete(name)\n          })\n      )\n    })\n  )\n  // Take control of all clients immediately\n  self.clients.claim()\n})\n\n/**\n * Fetch event - implement caching strategies\n */\nself.addEventListener(\"fetch\", (event) => {\n  const url = new URL(event.request.url)\n\n  // Local static resources: stale-while-revalidate\n  // Returns cached version immediately, updates cache in background\n  if (url.pathname.includes(\"fastapi-voyager-static\")) {\n    event.respondWith(\n      caches.open(CACHE_NAME).then((cache) => {\n        return cache.match(event.request).then((cachedResponse) => {\n          const fetchPromise = fetch(event.request)\n            .then((networkResponse) => {\n              if (networkResponse.ok) {\n                cache.put(event.request, networkResponse.clone())\n              }\n              return networkResponse\n            })\n            .catch(() => cachedResponse)\n\n          return cachedResponse || fetchPromise\n        })\n      })\n    )\n    return\n  }\n\n  // CDN resources: cache-first strategy\n  // Match by domain to catch all CDN resources (including dynamic imports)\n  const isCdnRequest = CDN_DOMAINS.some((domain) => url.hostname === domain)\n  if (isCdnRequest) {\n    event.respondWith(\n      caches.match(event.request).then((cachedResponse) => {\n        if (cachedResponse) {\n          console.log(\"[Voyager SW] Cache hit:\", event.request.url)\n          return cachedResponse\n        }\n        console.log(\"[Voyager SW] Cache miss, fetching:\", event.request.url)\n        return fetch(event.request).then((networkResponse) => {\n          if (networkResponse.ok) {\n            caches.open(CACHE_NAME).then((cache) => {\n              cache.put(event.request, networkResponse.clone())\n            })\n          }\n          return networkResponse\n        })\n      })\n    )\n    return\n  }\n\n  // API requests: network-only (no caching)\n  // These are dynamic and should always be fresh\n  const apiEndpoints = [\n    \"/dot\",\n    \"/source\",\n    \"/vscode-link\",\n    \"/er-diagram\",\n    \"/dot-search\",\n    \"/dot-core-data\",\n    \"/dot-render-core-data\",\n  ]\n  if (apiEndpoints.some((p) => url.pathname.endsWith(p))) {\n    return // Let the browser handle it normally\n  }\n})\n"
  },
  {
    "path": "src/fastapi_voyager/web/vite.config.js",
    "content": "import { defineConfig } from \"vite\"\nimport vue from \"@vitejs/plugin-vue\"\n\nexport default defineConfig({\n  plugins: [vue()],\n  root: \".\",\n  base: process.env.VITE_BASE_PATH || \"fastapi-voyager-static/dist/\",\n  build: {\n    outDir: \"dist\",\n    emptyOutDir: true,\n    rollupOptions: {\n      output: {\n        manualChunks: {\n          \"vue-vendor\": [\"vue\"],\n          \"naive-vendor\": [\"naive-ui\"],\n        },\n      },\n    },\n  },\n  server: {\n    port: 5173,\n    proxy: {\n      \"/dot\": \"http://localhost:8000\",\n      \"/er-diagram\": \"http://localhost:8000\",\n      \"/schema\": \"http://localhost:8000\",\n      \"/source\": \"http://localhost:8000\",\n      \"/vscode-link\": \"http://localhost:8000\",\n      \"/route\": \"http://localhost:8000\",\n      \"/voyager\": \"http://localhost:8000\",\n    },\n  },\n})\n"
  },
  {
    "path": "tests/README.md",
    "content": "# Tests Directory Structure\n\nThis directory contains all tests for fastapi-voyager.\n\n## Directory Structure\n\n```\ntests/\n├── __init__.py\n├── test_*.py              # Unit tests for individual modules\n├── service/               # Shared test utilities (reused across frameworks)\n│   ├── __init__.py\n│   └── schema/           # Shared schema definitions\n├── fastapi/              # FastAPI-specific test examples\n│   ├── __init__.py\n│   ├── demo.py           # Demo FastAPI application\n│   ├── demo_anno.py     # Demo with annotations\n│   └── embedding.py      # Example of embedding voyager in FastAPI app\n├── django_ninja/          # Django Ninja-specific test examples\n│   ├── __init__.py\n│   ├── demo.py           # Demo Django Ninja application\n│   └── embedding.py      # Example of embedding voyager in Django Ninja\n├── litestar/              # Litestar-specific test examples\n│   ├── __init__.py\n│   ├── demo.py           # Demo Litestar application\n│   └── embedding.py      # Example of embedding voyager in Litestar\n└── README.md\n```\n\n## Test Organization\n\n### Unit Tests (`test_*.py`)\n- `test_analysis.py` - Core voyager analysis functionality\n- `test_filter.py` - Graph filtering logic\n- `test_generic.py` - Generic type handling\n- `test_import.py` - Import validation\n- `test_module.py` - Module tree building\n- `test_resolve_util_impl.py` - Pydantic resolve utilities\n- `test_type_helper.py` - Type extraction and analysis\n\n### Shared Utilities (`service/`)\n- Reusable test utilities and schema definitions\n- Used across different framework tests\n- Contains shared Pydantic models and test data\n- Includes `Member`, `Sprint`, `Story`, `Task` models\n- Includes pydantic-resolve BaseEntity and diagram\n\n### Framework-Specific Tests\n\nEach supported framework has its own directory with similar structure:\n\n#### FastAPI (`fastapi/`)\n- `demo.py` - Example FastAPI application with various route patterns\n- `embedding.py` - Shows how to mount voyager into FastAPI app\n- Demonstrates pydantic-resolve integration\n\n#### Django Ninja (`django_ninja/`)\n- `demo.py` - Django Ninja version of the demo application\n- Uses `NinjaAPI` instead of `FastAPI`\n- Shows similar functionality with framework-specific differences\n\n#### Litestar (`litestar/`)\n- `demo.py` - Litestar version using Controller pattern\n- Uses `@get` decorator and Controller classes\n- Demonstrates framework-specific patterns\n\n## Running Tests\n\nRun all tests:\n```bash\nuv run pytest tests/\n```\n\nRun specific test file:\n```bash\nuv run pytest tests/test_analysis.py\n```\n\nRun framework-specific demos:\n```bash\n# FastAPI\npython tests/fastapi/embedding.py\n\n# Django Ninja (requires Django setup)\n# See tests/django_ninja/embedding.py for integration instructions\n\n# Litestar\npython tests/litestar/embedding.py\n```\n\n## Key Differences Between Frameworks\n\n### FastAPI\n```python\nfrom fastapi import FastAPI\napp = FastAPI()\n\n@app.get(\"/path\", response_model=Model)\ndef route():\n    return Model()\n\napp.mount(\"/voyager\", create_voyager(app))\n```\n\n### Django Ninja\n```python\nfrom ninja import NinjaAPI\napi = NinjaAPI()\n\n@api.get(\"/path\")\ndef route(request) -> Model:\n    return Model()\n\n# Integrated via Django urls.py\n# See embedding.py for details\n```\n\n### Litestar\n```python\nfrom litestar import Litestar, Controller\n\nclass MyController(Controller):\n    @get(\"/path\")\n    def path(self) -> Model:\n        return Model()\n\napp = Litestar(route_handlers=[MyController])\napp.mount(\"/voyager\", voyager_app)\n```\n\n## Adding Tests for New Frameworks\n\nWhen adding support for a new framework:\n\n1. Create directory: `tests/<framework_name>/`\n2. Add `__init__.py`\n3. Create `demo.py` with example routes\n   - Reuse `tests.service.schema` models\n   - Mirror FastAPI demo functionality\n   - Use framework-specific patterns\n4. Create `embedding.py` with voyager integration\n5. Add introspector in `src/fastapi_voyager/introspectors/`\n6. Update `Voyager._get_introspector()` to detect framework\n7. Add framework-specific tests if needed\n\nAll frameworks share the same:\n- Pydantic models from `tests.service.schema`\n- pydantic-resolve BaseEntity diagram\n- Testing patterns\n\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/django_ninja/__init__.py",
    "content": "\"\"\"\nDjango Ninja test examples and utilities.\n\nThis directory contains test applications and utilities specifically for Django Ninja framework testing.\n\"\"\"\n"
  },
  {
    "path": "tests/django_ninja/demo.py",
    "content": "import os\n\nimport django\n\n# Configure Django settings before importing django-ninja\nos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.django_ninja.settings')\ndjango.setup()\n\nfrom dataclasses import dataclass\nfrom typing import Annotated, Generic, Optional, TypeVar\n\nfrom django.http import HttpResponse\nfrom ninja import NinjaAPI\nfrom pydantic import BaseModel, Field\nfrom pydantic_resolve import (\n    Collector,\n    DefineSubset,\n    ExposeAs,\n    GraphQLHandler,\n    Resolver,\n    SchemaBuilder,\n    SendTo,\n    config_global_resolver,\n)\n\nfrom tests.service.schema.extra import A\nfrom tests.service.schema.schema import Brand, Product, ProductVariant, User, diagram, init_db\n\n# 创建 AutoLoad 工厂（v4: 从 diagram 实例创建）\nAutoLoad = diagram.create_auto_load()\n\n# 配置全局 resolver\nconfig_global_resolver(diagram)\n\n# 创建 GraphQL handler 和 schema builder\ngraphql_handler = GraphQLHandler(diagram, enable_from_attribute_in_type_adapter=True)\nschema_builder = SchemaBuilder(diagram)\n\n# Create Django Ninja API instance\napi = NinjaAPI(title=\"Demo API (Django Ninja)\", description=\"A demo Django Ninja application for router visualization\")\n\n\n@api.get(\"/products\", tags=['for-restapi', 'group_a'])\ndef get_products(request) -> list[Product]:\n    return []\n\n\n# =====================================\n# GraphQL Support\n# =====================================\n\nclass GraphQLRequest(BaseModel):\n    query: str\n    operationName: Optional[str] = None\n\n\n# GraphiQL Playground HTML\nGRAPHIQL_HTML = \"\"\"\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <title>GraphiQL - Django Ninja Demo</title>\n  <style>\n    body { margin: 0; }\n    #graphiql { height: 100dvh; }\n    .loading {\n      height: 100%;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      font-size: 2rem;\n    }\n  </style>\n  <link rel=\"stylesheet\" href=\"https://esm.sh/graphiql/dist/style.css\" />\n  <link rel=\"stylesheet\" href=\"https://esm.sh/@graphiql/plugin-explorer/dist/style.css\" />\n  <script type=\"importmap\">\n    {\n      \"imports\": {\n        \"react\": \"https://esm.sh/react@19.1.0\",\n        \"react/jsx-runtime\": \"https://esm.sh/react@19.1.0/jsx-runtime\",\n        \"react-dom\": \"https://esm.sh/react-dom@19.1.0\",\n        \"react-dom/client\": \"https://esm.sh/react-dom@19.1.0/client\",\n        \"@emotion/is-prop-valid\": \"data:text/javascript,\",\n        \"graphiql\": \"https://esm.sh/graphiql?standalone&external=react,react-dom,@graphiql/react,graphql\",\n        \"graphiql/\": \"https://esm.sh/graphiql/\",\n        \"@graphiql/plugin-explorer\": \"https://esm.sh/@graphiql/plugin-explorer?standalone&external=react,@graphiql/react,graphql\",\n        \"@graphiql/react\": \"https://esm.sh/@graphiql/react?standalone&external=react,react-dom,graphql,@emotion/is-prop-valid\",\n        \"@graphiql/toolkit\": \"https://esm.sh/@graphiql/toolkit?standalone&external=graphql\",\n        \"graphql\": \"https://esm.sh/graphql@16.11.0\"\n      }\n    }\n  </script>\n</head>\n<body>\n  <div id=\"graphiql\">\n    <div class=\"loading\">Loading…</div>\n  </div>\n  <script type=\"module\">\n    import React from 'react';\n    import ReactDOM from 'react-dom/client';\n    import { GraphiQL, HISTORY_PLUGIN } from 'graphiql';\n    import { createGraphiQLFetcher } from '@graphiql/toolkit';\n    import { explorerPlugin } from '@graphiql/plugin-explorer';\n\n    const fetcher = createGraphiQLFetcher({ url: '/graphql' });\n    const plugins = [HISTORY_PLUGIN, explorerPlugin()];\n\n    function App() {\n      return React.createElement(GraphiQL, {\n        fetcher: fetcher,\n        plugins: plugins,\n      });\n    }\n\n    const container = document.getElementById('graphiql');\n    const root = ReactDOM.createRoot(container);\n    root.render(React.createElement(App));\n  </script>\n</body>\n</html>\n\"\"\"\n\n\n# =====================================\n# GraphQL 视图函数 (用于 urls.py 直接引用)\n# =====================================\n\ndef graphiql_playground(request) -> HttpResponse:\n    \"\"\"GraphiQL 交互式查询界面 (GET)\"\"\"\n    return HttpResponse(GRAPHIQL_HTML, content_type=\"text/html\")\n\n\ndef graphql_endpoint(request):\n    \"\"\"GraphQL 查询端点 (POST)\"\"\"\n    import json\n    import asyncio\n    body = json.loads(request.body)\n    query = body.get('query', '')\n    result = asyncio.run(graphql_handler.execute(query=query))\n    return HttpResponse(json.dumps(result), content_type=\"application/json\")\n\n\ndef graphql_schema(request) -> HttpResponse:\n    \"\"\"GraphQL Schema 端点 (GET)\"\"\"\n    schema_sdl = schema_builder.build_schema()\n    return HttpResponse(schema_sdl, content_type=\"text/plain; charset=utf-8\")\n\n\n# =====================================\n# Django Ninja API 路由\n# =====================================\n\n@api.get(\"/graphql\", tags=['graphql'])\ndef api_graphiql_playground(request) -> HttpResponse:\n    \"\"\"GraphiQL 交互式查询界面\"\"\"\n    return HttpResponse(GRAPHIQL_HTML, content_type=\"text/html\")\n\n\n@api.post(\"/graphql\", tags=['graphql'])\nasync def api_graphql_endpoint(request, req: GraphQLRequest):\n    \"\"\"GraphQL 查询端点\"\"\"\n    result = await graphql_handler.execute(query=req.query)\n    return result\n\n\n@api.get(\"/graphql/schema\", tags=['graphql'])\ndef api_graphql_schema(request) -> HttpResponse:\n    \"\"\"GraphQL Schema 端点（返回 SDL 格式）\"\"\"\n    schema_sdl = schema_builder.build_schema()\n    return HttpResponse(schema_sdl, content_type=\"text/plain; charset=utf-8\")\n\n\n# =====================================\n# Page Models\n# =====================================\n\nclass PageUser(User):\n    display_name: str = ''\n\n    def post_display_name(self):\n        return self.username + ' (' + self.email + ')'\n\n    sh: 'Something'  # forward reference\n\n\n@dataclass\nclass Something:\n    id: int\n\n\nclass VariantA(ProductVariant):\n    variant_type: str = 'A'\n\n\nclass VariantB(ProductVariant):\n    variant_type: str = 'B'\n\n\ntype VariantUnion = VariantA | VariantB\n\n\nclass PageVariant(ProductVariant):\n    owner: Annotated[PageUser | None, AutoLoad()] = None\n\n\nclass MiddleProduct(DefineSubset):\n    __subset__ = (Product, ('id', 'name', 'price', 'category_id'))\n\n\nclass PageProduct(DefineSubset):\n    __subset__ = (Product, ('id', 'name'))\n\n    price: Annotated[float, ExposeAs('product_price')] = Field(exclude=True)\n\n    def post_price_label(self):\n        return f'¥{self.price}'\n\n    desc: Annotated[str, ExposeAs('product_desc')] = ''\n\n    def resolve_desc(self):\n        return self.desc\n\n    def post_desc(self):\n        return self.desc + ' (processed........................)'\n\n    variants: Annotated[list[PageVariant], AutoLoad(), SendTo(\"SomeCollector\")] = []\n\n    coll: list[str] = []\n\n    def post_coll(self, c=Collector(alias=\"top_collector\")):\n        return c.values()\n\n\nclass PageBrand(Brand):\n    products: list[PageProduct]\n\n\nclass PageOverall(BaseModel):\n    brands: list[PageBrand]\n\n\nclass PageOverallWrap(PageOverall):\n    content: str\n\n    all_variants: list[PageVariant] = []\n\n    def post_all_variants(self, collector=Collector(alias=\"SomeCollector\")):\n        return collector.values()\n\n\n@api.get(\"/page_overall\", tags=['for-ui-page'])\nasync def get_page_info(request) -> PageOverallWrap:\n    page_overall = PageOverallWrap(content=\"Page Overall Content\", brands=[])\n    return await Resolver().resolve(page_overall)\n\n\nclass PageProducts(BaseModel):\n    products: list[PageProduct]\n\n\n@api.get(\"/page_info/\", tags=['for-ui-page'])\ndef get_page_stories(request) -> PageProducts:\n    return {}\n\n\nT = TypeVar('T')\n\n\nclass DataModel(BaseModel, Generic[T]):\n    data: T\n    id: int\n\n\ntype DataModelPageProduct = DataModel[PageProduct]\n\n\n@api.get(\"/page_test_1/\", tags=['for-ui-page'])\ndef get_page_test_1(request) -> DataModelPageProduct:\n    return {}\n\n\n@api.get(\"/page_test_2/\", tags=['for-ui-page'])\ndef get_page_test_2(request) -> A:\n    return {}\n\n\n@api.get(\"/page_test_3/\", tags=['for-ui-page'])\ndef get_page_test_3_long_long_long_name(request) -> bool:\n    return True\n\n\n@api.get(\"/page_test_4/\", tags=['for-ui-page'])\ndef get_page_test_3_no_response_model(request):\n    return True\n\n\n@api.get(\"/page_test_5/\", tags=['long_long_long_tag_name', 'group_b'])\ndef get_page_test_3_no_response_model_long_long_long_name(request):\n    return True\n"
  },
  {
    "path": "tests/django_ninja/embedding.py",
    "content": "\"\"\"\nDjango Ninja embedding example for fastapi-voyager.\n\nThis module demonstrates how to integrate voyager with a Django Ninja application.\n\"\"\"\nimport os\n\nimport django\nfrom django.core.asgi import get_asgi_application\n\n# Configure Django settings before importing django-ninja\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"tests.django_ninja.settings\")\ndjango.setup()\n\nfrom fastapi_voyager import create_voyager\nfrom tests.django_ninja.demo import api, diagram\n\n# Create the voyager ASGI application\n# Note: create_voyager automatically detects Django Ninja and returns an ASGI app\nvoyager_asgi_app = create_voyager(\n    api,\n    er_diagram=diagram,\n    module_color={\"tests.service\": \"purple\"},\n    module_prefix=\"tests.service\",\n    swagger_url=\"/api/docs\",  # Django Ninja's swagger URL\n    initial_page_policy=\"first\",\n    ga_id=\"G-R64S7Q49VL\",\n    online_repo_url=\"https://github.com/allmonday/fastapi-voyager/blob/main\",\n    enable_pydantic_resolve_meta=True,\n)\n\n\nasync def application(scope, receive, send):\n    \"\"\"\n    ASGI application that routes between Django and Voyager.\n\n    This is a simple router that:\n    - Sends /voyager/* requests to the voyager UI\n    - Sends everything else to Django\n\n    For production, you might want to use Django's URL routing instead.\n    \"\"\"\n    # Route /voyager/* to voyager_app\n    if scope[\"type\"] == \"http\" and scope[\"path\"].startswith(\"/voyager\"):\n        return await voyager_asgi_app(scope, receive, send)\n    else:\n        # Pass everything else to Django's ASGI application\n        django_asgi_app = get_asgi_application()\n        return await django_asgi_app(scope, receive, send)\n\n\n# Export app for uvicorn\napp = application\n\n\n# ALTERNATIVE: Integration with Django URLs\n# ==========================================\n# If you prefer to integrate voyager through Django's URL system,\n# you can use the following approach in your Django project's urls.py:\n#\n# from django.urls import path\n# from tests.django_ninja.embedding import voyager_asgi_app\n#\n# def voyager_wrapper(request):\n#     '''Wrap voyager ASGI app for Django'''\n#     async def asgi_wrapper(receive, send):\n#         scope = {\n#             'type': 'http',\n#             'asgi': {'version': '3.0'},\n#             'http_method': request.method,\n#             'path': request.path.replace('/voyager', '') or '/',\n#             'query_string': request.META.get('QUERY_STRING', '').encode(),\n#             'headers': [\n#                 (k.lower().encode(), v.encode())\n#                 for k, v in request.META.items()\n#                 if k.startswith('HTTP_')\n#             ],\n#         }\n#         await voyager_asgi_app(scope, receive, send)\n#\n#     return asgi_wrapper\n#\n# urlpatterns = [\n#     path('voyager/', voyager_wrapper),\n#     # ... other URL patterns\n# ]\n"
  },
  {
    "path": "tests/django_ninja/settings.py",
    "content": "\"\"\"\nMinimal Django settings for django-ninja test app.\n\"\"\"\nfrom pathlib import Path\n\n# Build paths\nBASE_DIR = Path(__file__).resolve().parent.parent\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = 'django-insecure-test-key-for-development-only'\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = True\n\nALLOWED_HOSTS = ['*']\n\n# Application definition\nINSTALLED_APPS = [\n    'django.contrib.contenttypes',\n    'django.contrib.auth',\n    'ninja',  # django-ninja\n]\n\nMIDDLEWARE = []\n\nROOT_URLCONF = 'tests.django_ninja.urls'\n\nTEMPLATES = []\n\n# Database\nDATABASES = {\n    'default': {\n        'ENGINE': 'django.db.backends.sqlite3',\n        'NAME': ':memory:',\n    }\n}\n\n# Internationalization\nLANGUAGE_CODE = 'en-us'\nTIME_ZONE = 'UTC'\nUSE_I18N = True\nUSE_TZ = True\n\n# Static files (CSS, JavaScript, Images)\nSTATIC_URL = 'static/'\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n"
  },
  {
    "path": "tests/django_ninja/urls.py",
    "content": "\"\"\"\nURL configuration for django-ninja test app.\n\"\"\"\nfrom django.urls import path\nfrom django.views.decorators.csrf import csrf_exempt\n\nfrom tests.django_ninja import demo\n\n\ndef graphql_view(request):\n    \"\"\"统一处理 GET 和 POST 请求\"\"\"\n    if request.method == 'GET':\n        return demo.graphiql_playground(request)\n    elif request.method == 'POST':\n        return demo.graphql_endpoint(request)\n    return demo.HttpResponse('Method not allowed', status=405)\n\n\nurlpatterns = [\n    path('api/', demo.api.urls),\n    # GraphQL endpoints at root path\n    path('graphql', csrf_exempt(graphql_view)),\n    path('graphql/', csrf_exempt(graphql_view)),\n    path('graphql/schema', demo.graphql_schema),\n]\n"
  },
  {
    "path": "tests/embedding_test_utils.py",
    "content": "\"\"\"\nShared utilities for testing embedding services across different frameworks.\n\nThis module provides common test functions that can be reused across\nFastAPI, Django Ninja, and Litestar embedding tests.\n\"\"\"\nimport httpx\nimport pytest\n\n\n# Expected routes - same across all frameworks after standardization\nEXPECTED_ROUTES = [\n    \"get_products\",\n    \"get_page_info\",\n    \"get_page_stories\",\n    \"get_page_test_1\",\n    \"get_page_test_2\",\n    \"get_page_test_3_long_long_long_name\",\n    \"get_page_test_3_no_response_model\",\n    \"get_page_test_3_no_response_model_long_long_long_name\",\n]\n\n\nasync def test_dot_endpoint_returns_success(async_client: httpx.AsyncClient):\n    \"\"\"Test that /voyager/dot endpoint returns 200 OK.\"\"\"\n    response = await async_client.get(\"/voyager/dot\")\n    assert response.status_code == 200\n\n\nasync def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient):\n    \"\"\"Test that /voyager/dot endpoint returns tags data.\"\"\"\n    response = await async_client.get(\"/voyager/dot\")\n    assert response.status_code == 200\n    data = response.json()\n\n    # Check that tags key exists and is a list\n    assert \"tags\" in data\n    assert isinstance(data[\"tags\"], list)\n\n    # Should have tags defined in demo.py\n    tags = data[\"tags\"]\n    tag_names = [tag[\"name\"] for tag in tags]\n\n    # Check expected tags from demo.py\n    assert \"for-restapi\" in tag_names\n    assert \"for-ui-page\" in tag_names\n    assert \"long_long_long_tag_name\" in tag_names\n\n    # Note: group_a and group_b tags might not be returned if they're not\n    # properly recognized by the framework introspection\n    # This is acceptable behavior as tag filtering varies by framework\n\n\nasync def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncClient):\n    \"\"\"Test that tags have associated routes.\"\"\"\n    response = await async_client.get(\"/voyager/dot\")\n    assert response.status_code == 200\n    data = response.json()\n\n    tags = data[\"tags\"]\n\n    # Each tag should have routes\n    for tag in tags:\n        assert \"routes\" in tag\n        assert isinstance(tag[\"routes\"], list)\n        # Routes should be sorted by name\n        route_names = [r[\"name\"] for r in tag[\"routes\"]]\n        assert route_names == sorted(route_names)\n\n\nasync def test_dot_endpoint_routes_structure(async_client: httpx.AsyncClient):\n    \"\"\"Test that routes have correct structure.\"\"\"\n    response = await async_client.get(\"/voyager/dot\")\n    assert response.status_code == 200\n    data = response.json()\n\n    tags = data[\"tags\"]\n\n    # Find a tag with routes and check route structure\n    for tag in tags:\n        if tag[\"routes\"]:\n            route = tag[\"routes\"][0]\n            # Check required fields\n            assert \"id\" in route\n            assert \"name\" in route\n            assert \"module\" in route\n            assert \"unique_id\" in route\n\n            # Check types\n            assert isinstance(route[\"id\"], str)\n            assert isinstance(route[\"name\"], str)\n            assert isinstance(route[\"module\"], str)\n            assert isinstance(route[\"unique_id\"], str)\n            break\n    else:\n        pytest.fail(\"No routes found in any tag\")\n\n\nasync def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient, expected_framework_name: str):\n    \"\"\"Test other required fields in /dot response.\"\"\"\n    response = await async_client.get(\"/voyager/dot\")\n    assert response.status_code == 200\n    data = response.json()\n\n    # Check other required fields\n    assert \"schemas\" in data\n    assert isinstance(data[\"schemas\"], list)\n\n    assert \"dot\" in data\n    assert isinstance(data[\"dot\"], str)\n\n    assert \"version\" in data\n    assert isinstance(data[\"version\"], str)\n\n    assert \"initial_page_policy\" in data\n    assert data[\"initial_page_policy\"] in [\"first\", \"full\", \"empty\"]\n\n    assert \"framework_name\" in data\n    assert isinstance(data[\"framework_name\"], str)\n    assert data[\"framework_name\"] == expected_framework_name\n\n    assert \"has_er_diagram\" in data\n    assert isinstance(data[\"has_er_diagram\"], bool)\n\n    assert \"enable_pydantic_resolve_meta\" in data\n    assert isinstance(data[\"enable_pydantic_resolve_meta\"], bool)\n    assert data[\"enable_pydantic_resolve_meta\"] is True\n\n\nasync def test_dot_endpoint_expected_routes(\n    async_client: httpx.AsyncClient,\n    expected_routes: list[str]\n):\n    \"\"\"Test that expected routes from demo.py are present.\"\"\"\n    response = await async_client.get(\"/voyager/dot\")\n    assert response.status_code == 200\n    data = response.json()\n\n    # Collect all route names\n    all_routes = []\n    for tag in data[\"tags\"]:\n        for route in tag[\"routes\"]:\n            all_routes.append(route[\"name\"])\n\n    # Check expected routes\n    for expected_route in expected_routes:\n        assert expected_route in all_routes, f\"Expected route '{expected_route}' not found\"\n"
  },
  {
    "path": "tests/fastapi/__init__.py",
    "content": "\"\"\"\nFastAPI test examples and utilities.\n\nThis directory contains test applications and utilities specifically for FastAPI framework testing.\nThese can be used as examples or for testing the introspector implementation.\n\"\"\"\n"
  },
  {
    "path": "tests/fastapi/demo.py",
    "content": "from contextlib import asynccontextmanager\nfrom dataclasses import dataclass\nfrom typing import Annotated, Generic, Optional, TypeVar\n\nfrom fastapi import FastAPI\nfrom fastapi.responses import HTMLResponse, PlainTextResponse\nfrom pydantic import BaseModel, Field\nfrom pydantic_resolve import (\n    Collector,\n    DefineSubset,\n    ExposeAs,\n    GraphQLHandler,\n    Resolver,\n    SchemaBuilder,\n    SendTo,\n    config_global_resolver,\n)\n\nfrom tests.service.schema.extra import A\nfrom tests.service.schema.schema import (\n    Brand,\n    Order,\n    Product,\n    ProductVariant,\n    User,\n    diagram,\n    init_db,\n)\n\n# 创建 AutoLoad 工厂（v4: 从 diagram 实例创建）\nAutoLoad = diagram.create_auto_load()\n\n# 配置全局 resolver\nconfig_global_resolver(diagram)\n\n# 创建 GraphQL handler 和 schema builder\ngraphql_handler = GraphQLHandler(diagram, enable_from_attribute_in_type_adapter=True)\nschema_builder = SchemaBuilder(diagram)\n\n@asynccontextmanager\nasync def lifespan(app):\n    await init_db()\n    yield\n\n\napp = FastAPI(title=\"Demo API\", description=\"A demo FastAPI application for router visualization\", lifespan=lifespan)\n\n\n@app.get(\"/products\", tags=['for-restapi', 'group_a'], response_model=list[Product])\ndef get_products():\n    return []\n\n\n# =====================================\n# GraphQL Support\n# =====================================\n\nclass GraphQLRequest(BaseModel):\n    query: str\n    operationName: Optional[str] = None\n\n\n# GraphiQL Playground HTML\nGRAPHIQL_HTML = \"\"\"\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <title>GraphiQL - FastAPI Demo</title>\n  <style>\n    body { margin: 0; }\n    #graphiql { height: 100dvh; }\n    .loading {\n      height: 100%;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      font-size: 2rem;\n    }\n  </style>\n  <link rel=\"stylesheet\" href=\"https://esm.sh/graphiql/dist/style.css\" />\n  <link rel=\"stylesheet\" href=\"https://esm.sh/@graphiql/plugin-explorer/dist/style.css\" />\n  <script type=\"importmap\">\n    {\n      \"imports\": {\n        \"react\": \"https://esm.sh/react@19.1.0\",\n        \"react/jsx-runtime\": \"https://esm.sh/react@19.1.0/jsx-runtime\",\n        \"react-dom\": \"https://esm.sh/react-dom@19.1.0\",\n        \"react-dom/client\": \"https://esm.sh/react-dom@19.1.0/client\",\n        \"@emotion/is-prop-valid\": \"data:text/javascript,\",\n        \"graphiql\": \"https://esm.sh/graphiql?standalone&external=react,react-dom,@graphiql/react,graphql\",\n        \"graphiql/\": \"https://esm.sh/graphiql/\",\n        \"@graphiql/plugin-explorer\": \"https://esm.sh/@graphiql/plugin-explorer?standalone&external=react,@graphiql/react,graphql\",\n        \"@graphiql/react\": \"https://esm.sh/@graphiql/react?standalone&external=react,react-dom,graphql,@emotion/is-prop-valid\",\n        \"@graphiql/toolkit\": \"https://esm.sh/@graphiql/toolkit?standalone&external=graphql\",\n        \"graphql\": \"https://esm.sh/graphql@16.11.0\"\n      }\n    }\n  </script>\n</head>\n<body>\n  <div id=\"graphiql\">\n    <div class=\"loading\">Loading…</div>\n  </div>\n  <script type=\"module\">\n    import React from 'react';\n    import ReactDOM from 'react-dom/client';\n    import { GraphiQL, HISTORY_PLUGIN } from 'graphiql';\n    import { createGraphiQLFetcher } from '@graphiql/toolkit';\n    import { explorerPlugin } from '@graphiql/plugin-explorer';\n\n    const fetcher = createGraphiQLFetcher({ url: '/graphql' });\n    const plugins = [HISTORY_PLUGIN, explorerPlugin()];\n\n    function App() {\n      return React.createElement(GraphiQL, {\n        fetcher: fetcher,\n        plugins: plugins,\n      });\n    }\n\n    const container = document.getElementById('graphiql');\n    const root = ReactDOM.createRoot(container);\n    root.render(React.createElement(App));\n  </script>\n</body>\n</html>\n\"\"\"\n\n\n@app.get(\"/graphql\", response_class=HTMLResponse, tags=['graphql'])\nasync def graphiql_playground():\n    \"\"\"GraphiQL 交互式查询界面\"\"\"\n    return GRAPHIQL_HTML\n\n\n@app.post(\"/graphql\", tags=['graphql'])\nasync def graphql_endpoint(req: GraphQLRequest):\n    \"\"\"GraphQL 查询端点\"\"\"\n    result = await graphql_handler.execute(query=req.query)\n    return result\n\n\n@app.get(\"/schema\", response_class=PlainTextResponse, tags=['graphql'])\nasync def graphql_schema():\n    \"\"\"GraphQL Schema 端点（返回 SDL 格式）\"\"\"\n    schema_sdl = schema_builder.build_schema()\n    return PlainTextResponse(\n        content=schema_sdl,\n        media_type=\"text/plain; charset=utf-8\"\n    )\n\n\n# =====================================\n# Page Models\n# =====================================\n\nclass PageUser(User):\n    display_name: str = ''\n    def post_display_name(self):\n        return self.username + ' (' + self.email + ')'\n    sh: 'Something'  # forward reference\n\n\n@dataclass\nclass Something:\n    id: int\n\n\nclass VariantA(ProductVariant):\n    variant_type: str = 'A'\n\n\nclass VariantB(ProductVariant):\n    variant_type: str = 'B'\n\n\ntype VariantUnion = VariantA | VariantB\n\n\nclass PageVariant(ProductVariant):\n    product: Annotated[Product | None, AutoLoad()] = None\n\n\nclass MiddleProduct(DefineSubset):\n    __subset__ = (Product, ('id', 'name', 'price', 'category_id'))\n\n\nclass PageProduct(DefineSubset):\n    __subset__ = (Product, ('id', 'name'))\n\n    price: Annotated[float, ExposeAs('product_price')] = Field(exclude=True)\n    def post_price_label(self):\n        return f'¥{self.price}'\n\n    desc: Annotated[str, ExposeAs('product_desc')] = ''\n    def resolve_desc(self):\n        return self.desc\n\n    def post_desc(self):\n        return self.name + ' (processed........................)'\n\n    variants: Annotated[list[PageVariant], AutoLoad(), SendTo(\"SomeCollector\")] = []\n    owner: PageUser | None = None  # placeholder, not a real relationship\n    union_variants: list[VariantUnion] = []\n\n    coll: list[str] = []\n    def post_coll(self, c=Collector(alias=\"top_collector\")):\n        return c.values()\n\n\nclass PageBrand(Brand):\n    products: list[PageProduct]\n    owner: PageUser | None = None\n\n\nclass PageOverall(BaseModel):\n    brands: list[PageBrand]\n\n\nclass PageOverallWrap(PageOverall):\n    content: str\n\n    all_variants: list[PageVariant] = []\n    def post_all_variants(self, collector=Collector(alias=\"SomeCollector\")):\n        return collector.values()\n\n\n@app.get(\"/page_overall\", tags=['for-ui-page'], response_model=PageOverallWrap)\nasync def get_page_info():\n    page_overall = PageOverallWrap(content=\"Page Overall Content\", brands=[])\n    return await Resolver().resolve(page_overall)\n\n\nclass PageProducts(BaseModel):\n    products: list[PageProduct]\n\n\n@app.get(\"/page_info/\", tags=['for-ui-page'], response_model=PageProducts)\ndef get_page_stories():\n    return {}  # no implementation\n\n\nT = TypeVar('T')\n\n\nclass DataModel(BaseModel, Generic[T]):\n    data: T\n    id: int\n\n\ntype DataModelPageProduct = DataModel[PageProduct]\n\n\n@app.get(\"/page_test_1/\", tags=['for-ui-page'], response_model=DataModelPageProduct)\ndef get_page_test_1():\n    return {}  # no implementation\n\n\n@app.get(\"/page_test_2/\", tags=['for-ui-page'], response_model=A)\ndef get_page_test_2():\n    return {}\n\n\n@app.get(\"/page_test_3/\", tags=['for-ui-page'], response_model=bool)\ndef get_page_test_3_long_long_long_name():\n    return True\n\n\n@app.get(\"/page_test_4/\", tags=['for-ui-page'])\ndef get_page_test_3_no_response_model():\n    return True\n\n\n@app.get(\"/page_test_5/\", tags=['long_long_long_tag_name', 'group_b'])\ndef get_page_test_3_no_response_model_long_long_long_name():\n    return True\n\n\nfor r in app.router.routes:\n    r.operation_id = r.name\n"
  },
  {
    "path": "tests/fastapi/demo_anno.py",
    "content": "from __future__ import annotations\n\nfrom typing import Annotated\n\nfrom fastapi import FastAPI\nfrom pydantic import BaseModel, Field\nfrom pydantic_resolve import Resolver, ensure_subset\n\nfrom tests.service.schema.schema import Product, ProductVariant, User\n\napp = FastAPI(title=\"Demo API\", description=\"A demo FastAPI application for router visualization\")\n\n@app.get(\"/products\", tags=['for-restapi'], response_model=list[Product])\ndef get_product():\n    return []\n\nclass PageUser(User):\n    display_name: str = ''\n    def post_display_name(self):\n        return self.username + ' (' + self.email + ')'\n\nclass VariantA(ProductVariant):\n    variant_type: str = 'A'\n\nclass VariantB(ProductVariant):\n    variant_type: str = 'B'\n\n\ntype VariantUnion = VariantA | VariantB\nclass PageVariant(ProductVariant):\n    product: PageUser | None\n\n\nclass PageOverall(BaseModel):\n    brands: Annotated[list[PageBrand], Field(description=\"List of brands\")]\n\nclass PageBrand(Product):\n    products: Annotated[list[PageProduct], Field(description=\"List of products\")]\n    owner: Annotated[PageUser | None, Field(description=\"Owner of the brand\")] = None\n\n\n@ensure_subset(Product)\nclass PageProduct(BaseModel):\n    id: int\n    name: str\n    price: float = Field(exclude=True)\n\n    desc: str = ''\n    def post_desc(self):\n        return self.name + ' (processed)'\n\n    variants: list[PageVariant] = []\n    owner: PageUser | None = None\n    union_variants: list[VariantUnion] = []\n\n@app.get(\"/page_overall\", tags=['for-page'], response_model=PageOverall)\nasync def get_page_info():\n    page_overall = PageOverall(brands=[]) # focus on schema only\n    return await Resolver().resolve(page_overall)\n"
  },
  {
    "path": "tests/fastapi/embedding.py",
    "content": "from fastapi_voyager import create_voyager\n\n# from tests.fastapi.demo_anno import app\nfrom tests.fastapi.demo import app, diagram\n\napp.mount(\n    '/voyager', \n    create_voyager(\n        app, \n        er_diagram=diagram,\n        module_color={\"tests.service\": \"purple\"}, \n        module_prefix=\"tests.service\", \n        swagger_url=\"/docs\",\n        initial_page_policy='first',\n        ga_id='G-R64S7Q49VL',\n        online_repo_url=\"https://github.com/allmonday/fastapi-voyager/blob/main\", \n        enable_pydantic_resolve_meta=True))\n"
  },
  {
    "path": "tests/litestar/__init__.py",
    "content": "\"\"\"\nLitestar test examples and utilities.\n\nThis directory contains test applications and utilities specifically for Litestar framework testing.\n\"\"\"\n"
  },
  {
    "path": "tests/litestar/demo.py",
    "content": "from dataclasses import dataclass\nfrom typing import Annotated, Generic, Optional, TypeVar\n\nfrom litestar import Controller, Litestar, Request, Response, get, post\nfrom litestar.handlers import HTTPRouteHandler\nfrom pydantic import BaseModel, Field\nfrom pydantic_resolve import (\n    Collector,\n    DefineSubset,\n    ExposeAs,\n    GraphQLHandler,\n    Resolver,\n    SchemaBuilder,\n    SendTo,\n    config_global_resolver,\n)\n\nfrom tests.service.schema.extra import A\nfrom tests.service.schema.schema import Brand, Product, ProductVariant, User, diagram, init_db\n\n# 创建 AutoLoad 工厂（v4: 从 diagram 实例创建）\nAutoLoad = diagram.create_auto_load()\n\n# 配置全局 resolver\nconfig_global_resolver(diagram)\n\n# 创建 GraphQL handler 和 schema builder\ngraphql_handler = GraphQLHandler(diagram, enable_from_attribute_in_type_adapter=True)\nschema_builder = SchemaBuilder(diagram)\n\n\n# =====================================\n# GraphQL Support\n# =====================================\n\nclass GraphQLRequest(BaseModel):\n    query: str\n    operationName: Optional[str] = None\n\n\n# GraphiQL Playground HTML\nGRAPHIQL_HTML = \"\"\"\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <title>GraphiQL - Litestar Demo</title>\n  <style>\n    body { margin: 0; }\n    #graphiql { height: 100dvh; }\n    .loading {\n      height: 100%;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      font-size: 2rem;\n    }\n  </style>\n  <link rel=\"stylesheet\" href=\"https://esm.sh/graphiql/dist/style.css\" />\n  <link rel=\"stylesheet\" href=\"https://esm.sh/@graphiql/plugin-explorer/dist/style.css\" />\n  <script type=\"importmap\">\n    {\n      \"imports\": {\n        \"react\": \"https://esm.sh/react@19.1.0\",\n        \"react/jsx-runtime\": \"https://esm.sh/react@19.1.0/jsx-runtime\",\n        \"react-dom\": \"https://esm.sh/react-dom@19.1.0\",\n        \"react-dom/client\": \"https://esm.sh/react-dom@19.1.0/client\",\n        \"@emotion/is-prop-valid\": \"data:text/javascript,\",\n        \"graphiql\": \"https://esm.sh/graphiql?standalone&external=react,react-dom,@graphiql/react,graphql\",\n        \"graphiql/\": \"https://esm.sh/graphiql/\",\n        \"@graphiql/plugin-explorer\": \"https://esm.sh/@graphiql/plugin-explorer?standalone&external=react,@graphiql/react,graphql\",\n        \"@graphiql/react\": \"https://esm.sh/@graphiql/react?standalone&external=react,react-dom,graphql,@emotion/is-prop-valid\",\n        \"@graphiql/toolkit\": \"https://esm.sh/@graphiql/toolkit?standalone&external=graphql\",\n        \"graphql\": \"https://esm.sh/graphql@16.11.0\"\n      }\n    }\n  </script>\n</head>\n<body>\n  <div id=\"graphiql\">\n    <div class=\"loading\">Loading…</div>\n  </div>\n  <script type=\"module\">\n    import React from 'react';\n    import ReactDOM from 'react-dom/client';\n    import { GraphiQL, HISTORY_PLUGIN } from 'graphiql';\n    import { createGraphiQLFetcher } from '@graphiql/toolkit';\n    import { explorerPlugin } from '@graphiql/plugin-explorer';\n\n    const fetcher = createGraphiQLFetcher({ url: '/graphql' });\n    const plugins = [HISTORY_PLUGIN, explorerPlugin()];\n\n    function App() {\n      return React.createElement(GraphiQL, {\n        fetcher: fetcher,\n        plugins: plugins,\n      });\n    }\n\n    const container = document.getElementById('graphiql');\n    const root = ReactDOM.createRoot(container);\n    root.render(React.createElement(App));\n  </script>\n</body>\n</html>\n\"\"\"\n\n\n# =====================================\n# GraphQL Controllers (root path)\n# =====================================\n\nclass GraphQLController(Controller):\n    \"\"\"GraphQL endpoints at root path\"\"\"\n    path = \"\"\n\n    @get(\"/graphql\", tags=['graphql'])\n    async def graphiql_playground(self) -> Response[str]:\n        \"\"\"GraphiQL 交互式查询界面\"\"\"\n        return Response(content=GRAPHIQL_HTML, media_type=\"text/html\")\n\n    @post(\"/graphql\", tags=['graphql'])\n    async def graphql_endpoint(self, data: GraphQLRequest) -> dict:\n        \"\"\"GraphQL 查询端点\"\"\"\n        result = await graphql_handler.execute(query=data.query)\n        return result\n\n    @get(\"/graphql/schema\", tags=['graphql'])\n    async def graphql_schema(self) -> Response[str]:\n        \"\"\"GraphQL Schema 端点（返回 SDL 格式）\"\"\"\n        schema_sdl = schema_builder.build_schema()\n        return Response(content=schema_sdl, media_type=\"text/plain; charset=utf-8\")\n\n\n# =====================================\n# Page Models\n# =====================================\n\nclass PageUser(User):\n    display_name: str = ''\n\n    def post_display_name(self):\n        return self.username + ' (' + self.email + ')'\n\n    sh: 'Something'  # forward reference\n\n\n@dataclass\nclass Something:\n    id: int\n\n\nclass VariantA(ProductVariant):\n    variant_type: str = 'A'\n\n\nclass VariantB(ProductVariant):\n    variant_type: str = 'B'\n\n\ntype VariantUnion = VariantA | VariantB\n\n\nclass PageVariant(ProductVariant):\n    product: Annotated[Product | None, AutoLoad()] = None\n\n\nclass MiddleProduct(DefineSubset):\n    __subset__ = (Product, ('id', 'name', 'price', 'category_id'))\n\n\nclass PageProduct(DefineSubset):\n    __subset__ = (Product, ('id', 'name'))\n\n    price: Annotated[float, ExposeAs('product_price')] = Field(exclude=True)\n\n    def post_price_label(self):\n        return f'¥{self.price}'\n\n    desc: Annotated[str, ExposeAs('product_desc')] = ''\n\n    def resolve_desc(self):\n        return self.desc\n\n    def post_desc(self):\n        return self.name + ' (processed........................)'\n\n    variants: Annotated[list[PageVariant], AutoLoad(), SendTo(\"SomeCollector\")] = []\n\n    coll: list[str] = []\n\n    def post_coll(self, c=Collector(alias=\"top_collector\")):\n        return c.values()\n\n\nclass PageBrand(Brand):\n    products: list[PageProduct]\n\n\nclass PageOverall(BaseModel):\n    brands: list[PageBrand]\n\n\nclass PageOverallWrap(PageOverall):\n    content: str\n\n    all_variants: list[PageVariant] = []\n\n    def post_all_variants(self, collector=Collector(alias=\"SomeCollector\")):\n        return collector.values()\n\n\nclass PageProducts(BaseModel):\n    products: list[PageProduct]\n\n\nT = TypeVar('T')\n\n\nclass DataModel(BaseModel, Generic[T]):\n    data: T\n    id: int\n\n\ntype DataModelPageProduct = DataModel[PageProduct]\n\n\nclass DemoController(Controller):\n    path = \"/demo\"\n\n    @get(\"/products\", tags=['for-restapi', 'group_a'], sync_to_thread=False)\n    def get_products(self) -> list[Product]:\n        return []\n\n    @get(\"/page_overall\", tags=['for-ui-page'])\n    async def get_page_info(self) -> PageOverallWrap:\n        page_overall = PageOverallWrap(content=\"Page Overall Content\", brands=[])\n        return await Resolver().resolve(page_overall)\n\n    @get(\"/page_info/\", tags=['for-ui-page'], sync_to_thread=False)\n    def get_page_stories(self) -> PageProducts:\n        return {}\n\n    @get(\"/page_test_1/\", tags=['for-ui-page'], sync_to_thread=False)\n    def get_page_test_1(self) -> DataModelPageProduct:\n        return {}\n\n    @get(\"/page_test_2/\", tags=['for-ui-page'], sync_to_thread=False)\n    def get_page_test_2(self) -> A:\n        return {}\n\n    @get(\"/page_test_3/\", tags=['for-ui-page'], sync_to_thread=False)\n    def get_page_test_3_long_long_long_name(self) -> bool:\n        return True\n\n    @get(\"/page_test_4/\", tags=['for-ui-page'], sync_to_thread=False)\n    def get_page_test_3_no_response_model(self) -> bool:\n        return True\n\n    @get(\"/page_test_5/\", tags=['long_long_long_tag_name', 'group_b'], sync_to_thread=False)\n    def get_page_test_3_no_response_model_long_long_long_name(self) -> bool:\n        return True\n\n\n# Export route handlers for extension (e.g., adding voyager)\nROUTE_HANDLERS = [GraphQLController, DemoController]\n\n# Create a Litestar app instance - this is the main app that can be run directly\napp = Litestar(\n    route_handlers=ROUTE_HANDLERS\n)\n"
  },
  {
    "path": "tests/litestar/embedding.py",
    "content": "\"\"\"\nLitestar embedding example for fastapi-voyager.\n\nThis module demonstrates how to integrate voyager with a Litestar application.\n\nUnlike FastAPI, Litestar doesn't support mounting to an existing app after creation.\nThe recommended pattern is to reuse the ROUTE_HANDLERS from demo.py.\n\"\"\"\nfrom typing import Any, Awaitable, Callable\n\nfrom litestar import Litestar, asgi\n\nfrom fastapi_voyager import create_voyager\nfrom tests.litestar.demo import ROUTE_HANDLERS, app as demo_app, diagram\n\n# Create voyager app (returns a Litestar app)\nvoyager_app = create_voyager(\n    demo_app,\n    er_diagram=diagram,\n    module_color={\"tests.service\": \"purple\"},\n    module_prefix=\"tests.service\",\n    swagger_url=\"/schema/swagger\",\n    initial_page_policy='first',\n    ga_id='G-R64S7Q49VL',\n    online_repo_url=\"https://github.com/allmonday/fastapi-voyager/blob/main\",\n    enable_pydantic_resolve_meta=True\n)\n\n# Mount voyager using Litestar's @asgi() decorator\n@asgi(\"/voyager\", is_mount=True, copy_scope=True)\nasync def voyager_mount(\n    scope: dict[str, Any],\n    receive: Callable[[], Awaitable[dict[str, Any]]],\n    send: Callable[[dict[str, Any]], Awaitable[None]]\n) -> None:\n    await voyager_app(scope, receive, send)\n\n# Create combined app by reusing ROUTE_HANDLERS from demo.py\n# This is the recommended pattern for Litestar\napp = Litestar(route_handlers=ROUTE_HANDLERS + [voyager_mount])\n\n# Exports\n# - Use `uvicorn tests.litestar.embedding:app --reload` for combined app\n# - Use `uvicorn tests.litestar.embedding:demo_app --reload` for demo only\n"
  },
  {
    "path": "tests/service/__init__.py",
    "content": ""
  },
  {
    "path": "tests/service/schema/__init__.py",
    "content": "from .schema import (\n    Attribute,\n    AttributeValue,\n    Brand,\n    Category,\n    Coupon,\n    CouponUsage,\n    Inventory,\n    Order,\n    OrderItem,\n    Payment,\n    Product,\n    ProductImage,\n    ProductVariant,\n    Refund,\n    Review,\n    Shipment,\n    ShipmentItem,\n    Store,\n    Tag,\n    User,\n    UserAddress,\n    Warehouse,\n    diagram,\n    init_db,\n)\n\n__all__ = [\n    \"Attribute\",\n    \"AttributeValue\",\n    \"Brand\",\n    \"Category\",\n    \"Coupon\",\n    \"CouponUsage\",\n    \"Inventory\",\n    \"Order\",\n    \"OrderItem\",\n    \"Payment\",\n    \"Product\",\n    \"ProductImage\",\n    \"ProductVariant\",\n    \"Refund\",\n    \"Review\",\n    \"Shipment\",\n    \"ShipmentItem\",\n    \"Store\",\n    \"Tag\",\n    \"User\",\n    \"UserAddress\",\n    \"Warehouse\",\n    \"diagram\",\n    \"init_db\",\n]\n"
  },
  {
    "path": "tests/service/schema/base_entity.py",
    "content": "from pydantic_resolve import base_entity\n\nBaseEntity = base_entity()"
  },
  {
    "path": "tests/service/schema/db.py",
    "content": "\"\"\"\nSQLAlchemy async engine and session factory for test schema.\nUses SQLite in-memory for testing/demo purposes.\n\"\"\"\nfrom sqlalchemy.ext.asyncio import (\n    AsyncSession,\n    async_sessionmaker,\n    create_async_engine,\n)\nfrom sqlalchemy.orm import DeclarativeBase\n\nengine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\nasync_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\n\nclass OrmBase(DeclarativeBase):\n    pass\n\n\nasync def create_tables():\n    async with engine.begin() as conn:\n        await conn.run_sync(OrmBase.metadata.create_all)\n"
  },
  {
    "path": "tests/service/schema/dto/__init__.py",
    "content": "from .attribute import Attribute, AttributeValue\nfrom .inventory import Inventory, Warehouse\nfrom .marketing import Coupon, CouponUsage\nfrom .order import Order, OrderItem, Payment, Refund\nfrom .product import Brand, Category, Product, ProductImage, ProductVariant, Review\nfrom .shipment import Shipment, ShipmentItem\nfrom .store import Store\nfrom .tag import Tag\nfrom .user import User, UserAddress\n\n__all__ = [\n    \"Attribute\",\n    \"AttributeValue\",\n    \"Brand\",\n    \"Category\",\n    \"Coupon\",\n    \"CouponUsage\",\n    \"Inventory\",\n    \"Order\",\n    \"OrderItem\",\n    \"Payment\",\n    \"Product\",\n    \"ProductImage\",\n    \"ProductVariant\",\n    \"Refund\",\n    \"Review\",\n    \"Shipment\",\n    \"ShipmentItem\",\n    \"Store\",\n    \"Tag\",\n    \"User\",\n    \"UserAddress\",\n    \"Warehouse\",\n]\n"
  },
  {
    "path": "tests/service/schema/dto/attribute.py",
    "content": "\"\"\"\nAttribute and AttributeValue DTOs.\n\"\"\"\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Attribute(BaseModel):\n    \"\"\"属性定义\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"属性 ID\")\n    name: str = Field(description=\"属性名称\")\n\n\nclass AttributeValue(BaseModel):\n    \"\"\"属性值\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"属性值 ID\")\n    attribute_id: int = Field(description=\"属性 ID\")\n    value: str = Field(description=\"属性值\")\n"
  },
  {
    "path": "tests/service/schema/dto/inventory.py",
    "content": "\"\"\"\nWarehouse and Inventory DTOs.\n\"\"\"\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Warehouse(BaseModel):\n    \"\"\"仓库\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"仓库 ID\")\n    name: str = Field(description=\"仓库名称\")\n    location: str = Field(description=\"仓库位置\")\n\n\nclass Inventory(BaseModel):\n    \"\"\"库存\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"库存 ID\")\n    warehouse_id: int = Field(description=\"仓库 ID\")\n    variant_id: int = Field(description=\"商品规格 ID\")\n    quantity: int = Field(default=0, description=\"库存数量\")\n"
  },
  {
    "path": "tests/service/schema/dto/marketing.py",
    "content": "\"\"\"\nCoupon and CouponUsage DTOs.\n\"\"\"\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Coupon(BaseModel):\n    \"\"\"优惠券\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"优惠券 ID\")\n    code: str = Field(description=\"优惠券代码\")\n    discount: float = Field(description=\"折扣金额\")\n    min_amount: float = Field(default=0, description=\"最低消费金额\")\n    status: str = Field(default=\"active\", description=\"状态\")\n\n\nclass CouponUsage(BaseModel):\n    \"\"\"优惠券使用记录\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"使用记录 ID\")\n    coupon_id: int = Field(description=\"优惠券 ID\")\n    user_id: int = Field(description=\"用户 ID\")\n    order_id: int = Field(description=\"订单 ID\")\n"
  },
  {
    "path": "tests/service/schema/dto/order.py",
    "content": "\"\"\"\nOrder, OrderItem, Payment, Refund DTOs.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Order(BaseModel):\n    \"\"\"订单\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"订单 ID\")\n    user_id: int = Field(description=\"用户 ID\")\n    status: str = Field(default=\"pending\", description=\"订单状态\")\n    total_amount: float = Field(default=0, description=\"订单总额\")\n\n\nclass OrderItem(BaseModel):\n    \"\"\"订单明细\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"明细 ID\")\n    order_id: int = Field(description=\"订单 ID\")\n    variant_id: int = Field(description=\"商品规格 ID\")\n    quantity: int = Field(description=\"数量\")\n    unit_price: float = Field(description=\"单价\")\n\n\nclass Payment(BaseModel):\n    \"\"\"支付记录\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"支付 ID\")\n    order_id: int = Field(description=\"订单 ID\")\n    method: str = Field(description=\"支付方式\")\n    amount: float = Field(description=\"支付金额\")\n    status: str = Field(default=\"pending\", description=\"支付状态\")\n\n\nclass Refund(BaseModel):\n    \"\"\"退款\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"退款 ID\")\n    order_id: int = Field(description=\"订单 ID\")\n    amount: float = Field(description=\"退款金额\")\n    reason: Optional[str] = Field(default=None, description=\"退款原因\")\n    status: str = Field(default=\"pending\", description=\"退款状态\")\n"
  },
  {
    "path": "tests/service/schema/dto/product.py",
    "content": "\"\"\"\nProduct, ProductVariant, ProductImage, Brand, Category, Review DTOs.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Category(BaseModel):\n    \"\"\"商品分类\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"分类 ID\")\n    name: str = Field(description=\"分类名称\")\n    parent_id: Optional[int] = Field(default=None, description=\"父分类 ID\")\n\n\nclass Brand(BaseModel):\n    \"\"\"品牌\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"品牌 ID\")\n    name: str = Field(description=\"品牌名称\")\n    logo: Optional[str] = Field(default=None, description=\"品牌 Logo URL\")\n\n\nclass Product(BaseModel):\n    \"\"\"商品\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"商品 ID\")\n    name: str = Field(description=\"商品名称\")\n    description: Optional[str] = Field(default=None, description=\"商品描述\")\n    price: float = Field(description=\"价格\")\n    brand_id: Optional[int] = Field(default=None, description=\"品牌 ID\")\n    category_id: Optional[int] = Field(default=None, description=\"分类 ID\")\n    store_id: Optional[int] = Field(default=None, description=\"店铺 ID\")\n\n\nclass ProductVariant(BaseModel):\n    \"\"\"商品规格 (SKU)\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"规格 ID\")\n    product_id: int = Field(description=\"商品 ID\")\n    sku: str = Field(description=\"SKU 编码\")\n    price: float = Field(description=\"规格价格\")\n    stock: int = Field(default=0, description=\"库存数量\")\n\n\nclass ProductImage(BaseModel):\n    \"\"\"商品图片\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"图片 ID\")\n    product_id: int = Field(description=\"商品 ID\")\n    url: str = Field(description=\"图片 URL\")\n    sort_order: int = Field(default=0, description=\"排序\")\n\n\nclass Review(BaseModel):\n    \"\"\"商品评价\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"评价 ID\")\n    product_id: int = Field(description=\"商品 ID\")\n    user_id: int = Field(description=\"用户 ID\")\n    rating: int = Field(description=\"评分 (1-5)\")\n    content: Optional[str] = Field(default=None, description=\"评价内容\")\n"
  },
  {
    "path": "tests/service/schema/dto/shipment.py",
    "content": "\"\"\"\nShipment and ShipmentItem DTOs.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Shipment(BaseModel):\n    \"\"\"发货单\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"发货单 ID\")\n    warehouse_id: int = Field(description=\"仓库 ID\")\n    store_id: Optional[int] = Field(default=None, description=\"店铺 ID\")\n    status: str = Field(default=\"pending\", description=\"发货状态\")\n    tracking_no: Optional[str] = Field(default=None, description=\"物流单号\")\n\n\nclass ShipmentItem(BaseModel):\n    \"\"\"发货明细\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"发货明细 ID\")\n    shipment_id: int = Field(description=\"发货单 ID\")\n    order_item_id: int = Field(description=\"订单明细 ID\")\n    quantity: int = Field(description=\"发货数量\")\n"
  },
  {
    "path": "tests/service/schema/dto/store.py",
    "content": "\"\"\"\nStore DTO.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Store(BaseModel):\n    \"\"\"店铺\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"店铺 ID\")\n    name: str = Field(description=\"店铺名称\")\n    description: Optional[str] = Field(default=None, description=\"店铺描述\")\n"
  },
  {
    "path": "tests/service/schema/dto/tag.py",
    "content": "\"\"\"\nTag DTO.\n\"\"\"\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Tag(BaseModel):\n    \"\"\"标签\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"标签 ID\")\n    name: str = Field(description=\"标签名称\")\n"
  },
  {
    "path": "tests/service/schema/dto/user.py",
    "content": "\"\"\"\nUser and UserAddress DTOs.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass User(BaseModel):\n    \"\"\"用户\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"用户唯一标识 ID\")\n    username: str = Field(description=\"用户名\")\n    email: str = Field(description=\"邮箱\")\n    phone: Optional[str] = Field(default=None, description=\"手机号\")\n\n\nclass UserAddress(BaseModel):\n    \"\"\"用户地址\"\"\"\n    model_config = ConfigDict(from_attributes=True)\n\n    id: int = Field(description=\"地址 ID\")\n    user_id: int = Field(description=\"用户 ID\")\n    province: str = Field(description=\"省份\")\n    city: str = Field(description=\"城市\")\n    district: str = Field(description=\"区县\")\n    detail: str = Field(description=\"详细地址\")\n    is_default: bool = Field(default=False, description=\"是否默认地址\")\n"
  },
  {
    "path": "tests/service/schema/extra.py",
    "content": "from pydantic import BaseModel\n\n\nclass B(BaseModel):\n    id: int\n\nclass A(BaseModel):\n    id: int\n    b: B"
  },
  {
    "path": "tests/service/schema/orm/__init__.py",
    "content": "from .attribute import AttributeOrm, AttributeValueOrm\nfrom .inventory import InventoryOrm, WarehouseOrm\nfrom .marketing import CouponOrm, CouponUsageOrm\nfrom .order import OrderItemOrm, OrderOrm, PaymentOrm, RefundOrm\nfrom .product import (\n    BrandOrm,\n    CategoryOrm,\n    ProductImageOrm,\n    ProductOrm,\n    ProductVariantOrm,\n    ReviewOrm,\n    TagOrm,\n)\nfrom .shipment import ShipmentItemOrm, ShipmentOrm\nfrom .store import StoreOrm\nfrom .tables import product_attribute, product_tag, store_staff\nfrom .user import UserAddressOrm, UserOrm\n\n__all__ = [\n    \"AttributeOrm\",\n    \"AttributeValueOrm\",\n    \"BrandOrm\",\n    \"CategoryOrm\",\n    \"CouponOrm\",\n    \"CouponUsageOrm\",\n    \"InventoryOrm\",\n    \"OrderItemOrm\",\n    \"OrderOrm\",\n    \"PaymentOrm\",\n    \"ProductAttribute\",\n    \"ProductImageOrm\",\n    \"ProductOrm\",\n    \"ProductTag\",\n    \"ProductVariantOrm\",\n    \"RefundOrm\",\n    \"ReviewOrm\",\n    \"ShipmentItemOrm\",\n    \"ShipmentOrm\",\n    \"StoreOrm\",\n    \"TagOrm\",\n    \"UserAddressOrm\",\n    \"UserOrm\",\n    \"WarehouseOrm\",\n    \"product_attribute\",\n    \"product_tag\",\n    \"store_staff\",\n]\n"
  },
  {
    "path": "tests/service/schema/orm/attribute.py",
    "content": "\"\"\"\nAttribute and AttributeValue ORM models.\n\"\"\"\nfrom sqlalchemy import ForeignKey, Integer, String\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom ..db import OrmBase\n\n\nclass AttributeOrm(OrmBase):\n    __tablename__ = \"attributes\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n\n    # Relationships\n    values: Mapped[list[\"AttributeValueOrm\"]] = relationship(back_populates=\"attribute\")\n\n\nclass AttributeValueOrm(OrmBase):\n    __tablename__ = \"attribute_values\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    attribute_id: Mapped[int] = mapped_column(ForeignKey(\"attributes.id\"))\n    value: Mapped[str] = mapped_column(String(100))\n\n    # Relationships\n    attribute: Mapped[\"AttributeOrm\"] = relationship(back_populates=\"values\")\n    variants: Mapped[list[\"ProductVariantOrm\"]] = relationship(\n        secondary=\"product_attribute\",\n        back_populates=\"attribute_values\",\n    )\n"
  },
  {
    "path": "tests/service/schema/orm/inventory.py",
    "content": "\"\"\"\nWarehouse and Inventory ORM models.\n\"\"\"\nfrom sqlalchemy import ForeignKey, Integer, String\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom ..db import OrmBase\n\n\nclass WarehouseOrm(OrmBase):\n    __tablename__ = \"warehouses\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n    location: Mapped[str] = mapped_column(String(200))\n\n    # Relationships\n    inventories: Mapped[list[\"InventoryOrm\"]] = relationship(back_populates=\"warehouse\")\n    shipments: Mapped[list[\"ShipmentOrm\"]] = relationship(back_populates=\"warehouse\")\n\n\nclass InventoryOrm(OrmBase):\n    __tablename__ = \"inventories\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    warehouse_id: Mapped[int] = mapped_column(ForeignKey(\"warehouses.id\"))\n    variant_id: Mapped[int] = mapped_column(ForeignKey(\"product_variants.id\"))\n    quantity: Mapped[int] = mapped_column(Integer, default=0)\n\n    # Relationships\n    warehouse: Mapped[\"WarehouseOrm\"] = relationship(back_populates=\"inventories\")\n    variant: Mapped[\"ProductVariantOrm\"] = relationship(back_populates=\"inventories\")\n"
  },
  {
    "path": "tests/service/schema/orm/marketing.py",
    "content": "\"\"\"\nCoupon and CouponUsage ORM models.\n\"\"\"\nfrom sqlalchemy import Float, ForeignKey, Integer, String\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom ..db import OrmBase\n\n\nclass CouponOrm(OrmBase):\n    __tablename__ = \"coupons\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    code: Mapped[str] = mapped_column(String(50))\n    discount: Mapped[float] = mapped_column(Float)\n    min_amount: Mapped[float] = mapped_column(Float, default=0)\n    status: Mapped[str] = mapped_column(String(20), default=\"active\")\n\n    # Relationships\n    usages: Mapped[list[\"CouponUsageOrm\"]] = relationship(back_populates=\"coupon\")\n\n\nclass CouponUsageOrm(OrmBase):\n    __tablename__ = \"coupon_usages\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    coupon_id: Mapped[int] = mapped_column(ForeignKey(\"coupons.id\"))\n    user_id: Mapped[int] = mapped_column(ForeignKey(\"users.id\"))\n    order_id: Mapped[int] = mapped_column(ForeignKey(\"orders.id\"))\n\n    # Relationships\n    coupon: Mapped[\"CouponOrm\"] = relationship(back_populates=\"usages\")\n    user: Mapped[\"UserOrm\"] = relationship(back_populates=\"coupon_usages\")\n    order: Mapped[\"OrderOrm\"] = relationship(back_populates=\"coupon_usages\")\n"
  },
  {
    "path": "tests/service/schema/orm/order.py",
    "content": "\"\"\"\nOrder, OrderItem, Payment, Refund ORM models.\n\"\"\"\nfrom sqlalchemy import Float, ForeignKey, Integer, String, Text\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom ..db import OrmBase\n\n\nclass OrderOrm(OrmBase):\n    __tablename__ = \"orders\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    user_id: Mapped[int] = mapped_column(ForeignKey(\"users.id\"))\n    status: Mapped[str] = mapped_column(String(20), default=\"pending\")\n    total_amount: Mapped[float] = mapped_column(Float, default=0)\n\n    # Relationships\n    user: Mapped[\"UserOrm\"] = relationship(back_populates=\"orders\")\n    items: Mapped[list[\"OrderItemOrm\"]] = relationship(back_populates=\"order\")\n    payment: Mapped[\"PaymentOrm | None\"] = relationship(\n        back_populates=\"order\",\n        uselist=False,\n    )\n    refunds: Mapped[list[\"RefundOrm\"]] = relationship(back_populates=\"order\")\n    coupon_usages: Mapped[list[\"CouponUsageOrm\"]] = relationship(back_populates=\"order\")\n\n\nclass OrderItemOrm(OrmBase):\n    __tablename__ = \"order_items\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    order_id: Mapped[int] = mapped_column(ForeignKey(\"orders.id\"))\n    variant_id: Mapped[int] = mapped_column(ForeignKey(\"product_variants.id\"))\n    quantity: Mapped[int] = mapped_column(Integer)\n    unit_price: Mapped[float] = mapped_column(Float)\n\n    # Relationships\n    order: Mapped[\"OrderOrm\"] = relationship(back_populates=\"items\")\n    variant: Mapped[\"ProductVariantOrm\"] = relationship(back_populates=\"order_items\")\n\n\nclass PaymentOrm(OrmBase):\n    __tablename__ = \"payments\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    order_id: Mapped[int] = mapped_column(ForeignKey(\"orders.id\"))\n    method: Mapped[str] = mapped_column(String(20))\n    amount: Mapped[float] = mapped_column(Float)\n    status: Mapped[str] = mapped_column(String(20), default=\"pending\")\n\n    # Relationships\n    order: Mapped[\"OrderOrm\"] = relationship(back_populates=\"payment\")\n\n\nclass RefundOrm(OrmBase):\n    __tablename__ = \"refunds\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    order_id: Mapped[int] = mapped_column(ForeignKey(\"orders.id\"))\n    amount: Mapped[float] = mapped_column(Float)\n    reason: Mapped[str | None] = mapped_column(Text)\n    status: Mapped[str] = mapped_column(String(20), default=\"pending\")\n\n    # Relationships\n    order: Mapped[\"OrderOrm\"] = relationship(back_populates=\"refunds\")\n"
  },
  {
    "path": "tests/service/schema/orm/product.py",
    "content": "\"\"\"\nProduct, ProductVariant, ProductImage, Brand, Category ORM models.\n\"\"\"\nfrom sqlalchemy import Float, ForeignKey, Integer, String, Text\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom ..db import OrmBase\n\n\nclass CategoryOrm(OrmBase):\n    __tablename__ = \"categories\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n    parent_id: Mapped[int | None] = mapped_column(ForeignKey(\"categories.id\"))\n\n    # Relationships\n    parent: Mapped[\"CategoryOrm | None\"] = relationship(\n        remote_side=\"CategoryOrm.id\",\n        back_populates=\"children\",\n    )\n    children: Mapped[list[\"CategoryOrm\"]] = relationship(back_populates=\"parent\")\n    products: Mapped[list[\"ProductOrm\"]] = relationship(back_populates=\"category\")\n\n\nclass BrandOrm(OrmBase):\n    __tablename__ = \"brands\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n    logo: Mapped[str | None] = mapped_column(String(500))\n\n    # Relationships\n    products: Mapped[list[\"ProductOrm\"]] = relationship(back_populates=\"brand\")\n\n\nclass ProductOrm(OrmBase):\n    __tablename__ = \"products\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    name: Mapped[str] = mapped_column(String(200))\n    description: Mapped[str | None] = mapped_column(Text)\n    price: Mapped[float] = mapped_column(Float)\n    brand_id: Mapped[int | None] = mapped_column(ForeignKey(\"brands.id\"))\n    category_id: Mapped[int | None] = mapped_column(ForeignKey(\"categories.id\"))\n    store_id: Mapped[int | None] = mapped_column(ForeignKey(\"stores.id\"))\n\n    # Relationships\n    brand: Mapped[\"BrandOrm | None\"] = relationship(back_populates=\"products\")\n    category: Mapped[\"CategoryOrm | None\"] = relationship(back_populates=\"products\")\n    store: Mapped[\"StoreOrm | None\"] = relationship(back_populates=\"products\")\n    variants: Mapped[list[\"ProductVariantOrm\"]] = relationship(back_populates=\"product\")\n    images: Mapped[list[\"ProductImageOrm\"]] = relationship(back_populates=\"product\")\n    reviews: Mapped[list[\"ReviewOrm\"]] = relationship(back_populates=\"product\")\n    tags: Mapped[list[\"TagOrm\"]] = relationship(\n        secondary=\"product_tag\",\n        back_populates=\"products\",\n    )\n\n\nclass ProductVariantOrm(OrmBase):\n    __tablename__ = \"product_variants\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    product_id: Mapped[int] = mapped_column(ForeignKey(\"products.id\"))\n    sku: Mapped[str] = mapped_column(String(100))\n    price: Mapped[float] = mapped_column(Float)\n    stock: Mapped[int] = mapped_column(Integer, default=0)\n\n    # Relationships\n    product: Mapped[\"ProductOrm\"] = relationship(back_populates=\"variants\")\n    order_items: Mapped[list[\"OrderItemOrm\"]] = relationship(back_populates=\"variant\")\n    inventories: Mapped[list[\"InventoryOrm\"]] = relationship(back_populates=\"variant\")\n    attribute_values: Mapped[list[\"AttributeValueOrm\"]] = relationship(\n        secondary=\"product_attribute\",\n        back_populates=\"variants\",\n    )\n\n\nclass ProductImageOrm(OrmBase):\n    __tablename__ = \"product_images\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    product_id: Mapped[int] = mapped_column(ForeignKey(\"products.id\"))\n    url: Mapped[str] = mapped_column(String(500))\n    sort_order: Mapped[int] = mapped_column(Integer, default=0)\n\n    # Relationships\n    product: Mapped[\"ProductOrm\"] = relationship(back_populates=\"images\")\n\n\nclass TagOrm(OrmBase):\n    __tablename__ = \"tags\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n\n    # Relationships\n    products: Mapped[list[\"ProductOrm\"]] = relationship(\n        secondary=\"product_tag\",\n        back_populates=\"tags\",\n    )\n\n\nclass ReviewOrm(OrmBase):\n    __tablename__ = \"reviews\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    product_id: Mapped[int] = mapped_column(ForeignKey(\"products.id\"))\n    user_id: Mapped[int] = mapped_column(ForeignKey(\"users.id\"))\n    rating: Mapped[int] = mapped_column(Integer)\n    content: Mapped[str | None] = mapped_column(Text)\n\n    # Relationships\n    product: Mapped[\"ProductOrm\"] = relationship(back_populates=\"reviews\")\n    user: Mapped[\"UserOrm\"] = relationship(back_populates=\"reviews\")\n"
  },
  {
    "path": "tests/service/schema/orm/shipment.py",
    "content": "\"\"\"\nShipment and ShipmentItem ORM models.\n\"\"\"\nfrom sqlalchemy import ForeignKey, Integer, String\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom ..db import OrmBase\n\n\nclass ShipmentOrm(OrmBase):\n    __tablename__ = \"shipments\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    warehouse_id: Mapped[int] = mapped_column(ForeignKey(\"warehouses.id\"))\n    store_id: Mapped[int | None] = mapped_column(ForeignKey(\"stores.id\"))\n    status: Mapped[str] = mapped_column(String(20), default=\"pending\")\n    tracking_no: Mapped[str | None] = mapped_column(String(100))\n\n    # Relationships\n    warehouse: Mapped[\"WarehouseOrm\"] = relationship(back_populates=\"shipments\")\n    store: Mapped[\"StoreOrm | None\"] = relationship(back_populates=\"shipments\")\n    items: Mapped[list[\"ShipmentItemOrm\"]] = relationship(back_populates=\"shipment\")\n\n\nclass ShipmentItemOrm(OrmBase):\n    __tablename__ = \"shipment_items\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    shipment_id: Mapped[int] = mapped_column(ForeignKey(\"shipments.id\"))\n    order_item_id: Mapped[int] = mapped_column(ForeignKey(\"order_items.id\"))\n    quantity: Mapped[int] = mapped_column(Integer)\n\n    # Relationships\n    shipment: Mapped[\"ShipmentOrm\"] = relationship(back_populates=\"items\")\n    order_item: Mapped[\"OrderItemOrm\"] = relationship()\n"
  },
  {
    "path": "tests/service/schema/orm/store.py",
    "content": "\"\"\"\nStore ORM model.\n\"\"\"\nfrom sqlalchemy import Integer, String\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom ..db import OrmBase\n\n\nclass StoreOrm(OrmBase):\n    __tablename__ = \"stores\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n    description: Mapped[str | None] = mapped_column(String(500))\n\n    # Relationships\n    products: Mapped[list[\"ProductOrm\"]] = relationship(back_populates=\"store\")\n    staff_members: Mapped[list[\"UserOrm\"]] = relationship(\n        secondary=\"store_staff\",\n        back_populates=\"managed_stores\",\n    )\n    shipments: Mapped[list[\"ShipmentOrm\"]] = relationship(back_populates=\"store\")\n"
  },
  {
    "path": "tests/service/schema/orm/tables.py",
    "content": "\"\"\"\nM:N association tables for e-commerce schema.\n\"\"\"\nfrom sqlalchemy import Column, ForeignKey, Integer, Table\n\nfrom ..db import OrmBase\n\n# Product <-> Tag\nproduct_tag = Table(\n    \"product_tag\",\n    OrmBase.metadata,\n    Column(\"id\", Integer, primary_key=True),\n    Column(\"product_id\", Integer, ForeignKey(\"products.id\"), nullable=False),\n    Column(\"tag_id\", Integer, ForeignKey(\"tags.id\"), nullable=False),\n)\n\n# Store <-> User (staff)\nstore_staff = Table(\n    \"store_staff\",\n    OrmBase.metadata,\n    Column(\"id\", Integer, primary_key=True),\n    Column(\"store_id\", Integer, ForeignKey(\"stores.id\"), nullable=False),\n    Column(\"user_id\", Integer, ForeignKey(\"users.id\"), nullable=False),\n)\n\n# ProductVariant <-> AttributeValue\nproduct_attribute = Table(\n    \"product_attribute\",\n    OrmBase.metadata,\n    Column(\"id\", Integer, primary_key=True),\n    Column(\"variant_id\", Integer, ForeignKey(\"product_variants.id\"), nullable=False),\n    Column(\"value_id\", Integer, ForeignKey(\"attribute_values.id\"), nullable=False),\n)\n"
  },
  {
    "path": "tests/service/schema/orm/user.py",
    "content": "\"\"\"\nUser and UserAddress ORM models.\n\"\"\"\nfrom sqlalchemy import Boolean, ForeignKey, Integer, String\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom ..db import OrmBase\n\n\nclass UserOrm(OrmBase):\n    __tablename__ = \"users\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    username: Mapped[str] = mapped_column(String(100))\n    email: Mapped[str] = mapped_column(String(255))\n    phone: Mapped[str | None] = mapped_column(String(20))\n\n    # Relationships\n    addresses: Mapped[list[\"UserAddressOrm\"]] = relationship(back_populates=\"user\")\n    orders: Mapped[list[\"OrderOrm\"]] = relationship(back_populates=\"user\")\n    reviews: Mapped[list[\"ReviewOrm\"]] = relationship(back_populates=\"user\")\n    coupon_usages: Mapped[list[\"CouponUsageOrm\"]] = relationship(back_populates=\"user\")\n    managed_stores: Mapped[list[\"StoreOrm\"]] = relationship(\n        secondary=\"store_staff\",\n        back_populates=\"staff_members\",\n    )\n\n\nclass UserAddressOrm(OrmBase):\n    __tablename__ = \"user_addresses\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    user_id: Mapped[int] = mapped_column(ForeignKey(\"users.id\"))\n    province: Mapped[str] = mapped_column(String(50))\n    city: Mapped[str] = mapped_column(String(50))\n    district: Mapped[str] = mapped_column(String(50))\n    detail: Mapped[str] = mapped_column(String(255))\n    is_default: Mapped[bool] = mapped_column(Boolean, default=False)\n\n    # Relationships\n    user: Mapped[\"UserOrm\"] = relationship(back_populates=\"addresses\")\n"
  },
  {
    "path": "tests/service/schema/schema.py",
    "content": "\"\"\"\n电商系统实体定义 - 用于 GraphQL 和 REST API 演示\n使用 SQLAlchemy ORM + build_relationship 自动构建 relationships 和 loaders\n\"\"\"\n\nfrom typing import List, Optional\n\nfrom pydantic import BaseModel\nfrom pydantic_resolve import ErDiagram, MutationConfig, QueryConfig\nfrom pydantic_resolve.integration.mapping import Mapping\nfrom pydantic_resolve.integration.sqlalchemy import build_relationship\nfrom sqlalchemy import select\n\nfrom .db import async_session, create_tables\nfrom .dto.attribute import Attribute, AttributeValue\nfrom .dto.inventory import Inventory, Warehouse\nfrom .dto.marketing import Coupon, CouponUsage\nfrom .dto.order import Order, OrderItem, Payment, Refund\nfrom .dto.product import Brand, Category, Product, ProductImage, ProductVariant, Review\nfrom .dto.shipment import Shipment, ShipmentItem\nfrom .dto.store import Store\nfrom .dto.tag import Tag\nfrom .dto.user import User, UserAddress\nfrom .orm import (\n    AttributeOrm,\n    AttributeValueOrm,\n    BrandOrm,\n    CategoryOrm,\n    CouponOrm,\n    CouponUsageOrm,\n    InventoryOrm,\n    OrderItemOrm,\n    OrderOrm,\n    PaymentOrm,\n    ProductImageOrm,\n    ProductOrm,\n    ProductVariantOrm,\n    RefundOrm,\n    ReviewOrm,\n    ShipmentItemOrm,\n    ShipmentOrm,\n    StoreOrm,\n    TagOrm,\n    UserAddressOrm,\n    UserOrm,\n    WarehouseOrm,\n)\n\n# =====================================\n# Input Types for Mutations\n# =====================================\n\n\nclass CreateProductInput(BaseModel):\n    \"\"\"创建商品的输入类型\"\"\"\n\n    name: str\n    description: str = \"\"\n    price: float\n    brand_id: Optional[int] = None\n    category_id: Optional[int] = None\n    store_id: Optional[int] = None\n\n\nclass CreateOrderInput(BaseModel):\n    \"\"\"创建订单的输入类型\"\"\"\n\n    user_id: int\n    total_amount: float = 0\n\n\n# =====================================\n# Build Relationships from SQLAlchemy ORM\n# =====================================\n\n_mappings = [\n    Mapping(entity=User, orm=UserOrm),\n    Mapping(entity=UserAddress, orm=UserAddressOrm),\n    Mapping(entity=Category, orm=CategoryOrm),\n    Mapping(entity=Brand, orm=BrandOrm),\n    Mapping(entity=Product, orm=ProductOrm),\n    Mapping(entity=ProductVariant, orm=ProductVariantOrm),\n    Mapping(entity=ProductImage, orm=ProductImageOrm),\n    Mapping(entity=Review, orm=ReviewOrm),\n    Mapping(entity=Tag, orm=TagOrm),\n    Mapping(entity=Order, orm=OrderOrm),\n    Mapping(entity=OrderItem, orm=OrderItemOrm),\n    Mapping(entity=Payment, orm=PaymentOrm),\n    Mapping(entity=Refund, orm=RefundOrm),\n    Mapping(entity=Warehouse, orm=WarehouseOrm),\n    Mapping(entity=Inventory, orm=InventoryOrm),\n    Mapping(entity=Shipment, orm=ShipmentOrm),\n    Mapping(entity=ShipmentItem, orm=ShipmentItemOrm),\n    Mapping(entity=Coupon, orm=CouponOrm),\n    Mapping(entity=CouponUsage, orm=CouponUsageOrm),\n    Mapping(entity=Store, orm=StoreOrm),\n    Mapping(entity=Attribute, orm=AttributeOrm),\n    Mapping(entity=AttributeValue, orm=AttributeValueOrm),\n]\n\n_entities = build_relationship(\n    mappings=_mappings,\n    session_factory=lambda: async_session(),\n)\n\n\n# =====================================\n# Query & Mutation Functions\n# =====================================\n\n\n# --- User ---\nasync def user_get_all(limit: int = 10, offset: int = 0) -> List[User]:\n    \"\"\"获取所有用户（分页）\"\"\"\n    async with async_session() as session:\n        stmt = select(UserOrm).offset(offset).limit(limit)\n        rows = (await session.scalars(stmt)).all()\n        return [User.model_validate(r) for r in rows]\n\n\nasync def user_get_by_id(id: int) -> Optional[User]:\n    \"\"\"根据 ID 获取用户\"\"\"\n    async with async_session() as session:\n        row = await session.get(UserOrm, id)\n        return User.model_validate(row) if row else None\n\n\nasync def user_create(username: str, email: str, phone: str = \"\") -> User:\n    \"\"\"创建新用户\"\"\"\n    async with async_session() as session:\n        async with session.begin():\n            orm = UserOrm(username=username, email=email, phone=phone or None)\n            session.add(orm)\n            await session.flush()\n            return User.model_validate(orm)\n\n\n# --- Product ---\nasync def product_get_all(\n    limit: int = 10, offset: int = 0, category_id: Optional[int] = None\n) -> List[Product]:\n    \"\"\"获取所有商品（分页，可按分类筛选）\"\"\"\n    async with async_session() as session:\n        stmt = select(ProductOrm)\n        if category_id:\n            stmt = stmt.where(ProductOrm.category_id == category_id)\n        stmt = stmt.offset(offset).limit(limit)\n        rows = (await session.scalars(stmt)).all()\n        return [Product.model_validate(r) for r in rows]\n\n\nasync def product_get_by_id(id: int) -> Optional[Product]:\n    \"\"\"根据 ID 获取商品\"\"\"\n    async with async_session() as session:\n        row = await session.get(ProductOrm, id)\n        return Product.model_validate(row) if row else None\n\n\nasync def product_create(\n    name: str,\n    price: float,\n    description: str = \"\",\n    brand_id: Optional[int] = None,\n    category_id: Optional[int] = None,\n    store_id: Optional[int] = None,\n) -> Product:\n    \"\"\"创建新商品\"\"\"\n    async with async_session() as session:\n        async with session.begin():\n            orm = ProductOrm(\n                name=name,\n                price=price,\n                description=description or None,\n                brand_id=brand_id,\n                category_id=category_id,\n                store_id=store_id,\n            )\n            session.add(orm)\n            await session.flush()\n            return Product.model_validate(orm)\n\n\n# --- Order ---\nasync def order_get_all(\n    limit: int = 10, offset: int = 0, user_id: Optional[int] = None\n) -> List[Order]:\n    \"\"\"获取所有订单（分页，可按用户筛选）\"\"\"\n    async with async_session() as session:\n        stmt = select(OrderOrm)\n        if user_id:\n            stmt = stmt.where(OrderOrm.user_id == user_id)\n        stmt = stmt.offset(offset).limit(limit)\n        rows = (await session.scalars(stmt)).all()\n        return [Order.model_validate(r) for r in rows]\n\n\nasync def order_get_by_id(id: int) -> Optional[Order]:\n    \"\"\"根据 ID 获取订单\"\"\"\n    async with async_session() as session:\n        row = await session.get(OrderOrm, id)\n        return Order.model_validate(row) if row else None\n\n\nasync def order_create(user_id: int, total_amount: float = 0) -> Order:\n    \"\"\"创建新订单\"\"\"\n    async with async_session() as session:\n        async with session.begin():\n            orm = OrderOrm(user_id=user_id, total_amount=total_amount)\n            session.add(orm)\n            await session.flush()\n            return Order.model_validate(orm)\n\n\nasync def order_update_status(id: int, status: str) -> Optional[Order]:\n    \"\"\"更新订单状态\"\"\"\n    async with async_session() as session:\n        async with session.begin():\n            row = await session.get(OrderOrm, id)\n            if row:\n                row.status = status\n                await session.flush()\n                return Order.model_validate(row)\n    return None\n\n\n# --- Category ---\nasync def category_get_all() -> List[Category]:\n    \"\"\"获取所有分类\"\"\"\n    async with async_session() as session:\n        stmt = select(CategoryOrm)\n        rows = (await session.scalars(stmt)).all()\n        return [Category.model_validate(r) for r in rows]\n\n\nasync def category_create(name: str, parent_id: Optional[int] = None) -> Category:\n    \"\"\"创建分类\"\"\"\n    async with async_session() as session:\n        async with session.begin():\n            orm = CategoryOrm(name=name, parent_id=parent_id)\n            session.add(orm)\n            await session.flush()\n            return Category.model_validate(orm)\n\n\n# --- Brand ---\nasync def brand_get_all() -> List[Brand]:\n    \"\"\"获取所有品牌\"\"\"\n    async with async_session() as session:\n        stmt = select(BrandOrm)\n        rows = (await session.scalars(stmt)).all()\n        return [Brand.model_validate(r) for r in rows]\n\n\nasync def brand_create(name: str, logo: str = \"\") -> Brand:\n    \"\"\"创建品牌\"\"\"\n    async with async_session() as session:\n        async with session.begin():\n            orm = BrandOrm(name=name, logo=logo or None)\n            session.add(orm)\n            await session.flush()\n            return Brand.model_validate(orm)\n\n\n# --- Tag ---\nasync def tag_get_all() -> List[Tag]:\n    \"\"\"获取所有标签\"\"\"\n    async with async_session() as session:\n        stmt = select(TagOrm)\n        rows = (await session.scalars(stmt)).all()\n        return [Tag.model_validate(r) for r in rows]\n\n\nasync def tag_create(name: str) -> Tag:\n    \"\"\"创建标签\"\"\"\n    async with async_session() as session:\n        async with session.begin():\n            orm = TagOrm(name=name)\n            session.add(orm)\n            await session.flush()\n            return Tag.model_validate(orm)\n\n\n# --- Coupon ---\nasync def coupon_get_all() -> List[Coupon]:\n    \"\"\"获取所有优惠券\"\"\"\n    async with async_session() as session:\n        stmt = select(CouponOrm)\n        rows = (await session.scalars(stmt)).all()\n        return [Coupon.model_validate(r) for r in rows]\n\n\nasync def coupon_create(code: str, discount: float, min_amount: float = 0) -> Coupon:\n    \"\"\"创建优惠券\"\"\"\n    async with async_session() as session:\n        async with session.begin():\n            orm = CouponOrm(code=code, discount=discount, min_amount=min_amount)\n            session.add(orm)\n            await session.flush()\n            return Coupon.model_validate(orm)\n\n\n# --- Store ---\nasync def store_get_all() -> List[Store]:\n    \"\"\"获取所有店铺\"\"\"\n    async with async_session() as session:\n        stmt = select(StoreOrm)\n        rows = (await session.scalars(stmt)).all()\n        return [Store.model_validate(r) for r in rows]\n\n\nasync def store_create(name: str, description: str = \"\") -> Store:\n    \"\"\"创建店铺\"\"\"\n    async with async_session() as session:\n        async with session.begin():\n            orm = StoreOrm(name=name, description=description or None)\n            session.add(orm)\n            await session.flush()\n            return Store.model_validate(orm)\n\n\n# --- Warehouse ---\nasync def warehouse_get_all() -> List[Warehouse]:\n    \"\"\"获取所有仓库\"\"\"\n    async with async_session() as session:\n        stmt = select(WarehouseOrm)\n        rows = (await session.scalars(stmt)).all()\n        return [Warehouse.model_validate(r) for r in rows]\n\n\n# --- ProductVariant ---\nasync def product_variant_get_by_product(product_id: int) -> List[ProductVariant]:\n    \"\"\"根据商品 ID 获取规格列表\"\"\"\n    async with async_session() as session:\n        stmt = select(ProductVariantOrm).where(\n            ProductVariantOrm.product_id == product_id\n        )\n        rows = (await session.scalars(stmt)).all()\n        return [ProductVariant.model_validate(r) for r in rows]\n\n\n# --- OrderItem ---\nasync def order_item_get_by_order(order_id: int) -> List[OrderItem]:\n    \"\"\"根据订单 ID 获取明细\"\"\"\n    async with async_session() as session:\n        stmt = select(OrderItemOrm).where(OrderItemOrm.order_id == order_id)\n        rows = (await session.scalars(stmt)).all()\n        return [OrderItem.model_validate(r) for r in rows]\n\n\n# =====================================\n# Build ErDiagram with QueryConfig\n# =====================================\n\n_entity_queries: dict[type, list] = {\n    User: [\n        QueryConfig(method=user_get_all),\n        QueryConfig(method=user_get_by_id),\n    ],\n    Product: [\n        QueryConfig(method=product_get_all),\n        QueryConfig(method=product_get_by_id),\n    ],\n    Order: [\n        QueryConfig(method=order_get_all),\n        QueryConfig(method=order_get_by_id),\n    ],\n    Category: [QueryConfig(method=category_get_all)],\n    Brand: [QueryConfig(method=brand_get_all)],\n    Tag: [QueryConfig(method=tag_get_all)],\n    Coupon: [QueryConfig(method=coupon_get_all)],\n    Store: [QueryConfig(method=store_get_all)],\n    Warehouse: [QueryConfig(method=warehouse_get_all)],\n    ProductVariant: [QueryConfig(method=product_variant_get_by_product)],\n    OrderItem: [QueryConfig(method=order_item_get_by_order)],\n}\n\n_entity_mutations: dict[type, list] = {\n    User: [MutationConfig(method=user_create)],\n    Product: [MutationConfig(method=product_create)],\n    Order: [MutationConfig(method=order_create), MutationConfig(method=order_update_status)],\n    Category: [MutationConfig(method=category_create)],\n    Brand: [MutationConfig(method=brand_create)],\n    Tag: [MutationConfig(method=tag_create)],\n    Coupon: [MutationConfig(method=coupon_create)],\n    Store: [MutationConfig(method=store_create)],\n}\n\n# Attach QueryConfig/MutationConfig to entities\nfor entity in _entities:\n    kls = entity.kls\n    if kls in _entity_queries:\n        entity.queries = _entity_queries[kls]\n    if kls in _entity_mutations:\n        entity.mutations = _entity_mutations[kls]\n\ndiagram = ErDiagram(entities=[]).add_relationship(_entities)\n\n\n# =====================================\n# 初始化种子数据\n# =====================================\n\n\nasync def init_db():\n    \"\"\"创建表并插入种子数据\"\"\"\n    await create_tables()\n\n    async with async_session() as session:\n        async with session.begin():\n            # Users\n            session.add_all(\n                [\n                    UserOrm(id=1, username=\"zhangsan\", email=\"zhangsan@example.com\"),\n                    UserOrm(id=2, username=\"lisi\", email=\"lisi@example.com\"),\n                    UserOrm(id=3, username=\"wangwu\", email=\"wangwu@example.com\"),\n                ]\n            )\n            # Addresses\n            session.add_all(\n                [\n                    UserAddressOrm(\n                        id=1,\n                        user_id=1,\n                        province=\"广东\",\n                        city=\"深圳\",\n                        district=\"南山区\",\n                        detail=\"科技园路1号\",\n                        is_default=True,\n                    ),\n                    UserAddressOrm(\n                        id=2,\n                        user_id=1,\n                        province=\"广东\",\n                        city=\"深圳\",\n                        district=\"福田区\",\n                        detail=\"华强北路2号\",\n                    ),\n                    UserAddressOrm(\n                        id=3,\n                        user_id=2,\n                        province=\"北京\",\n                        city=\"北京\",\n                        district=\"朝阳区\",\n                        detail=\"望京路3号\",\n                        is_default=True,\n                    ),\n                    UserAddressOrm(\n                        id=4,\n                        user_id=3,\n                        province=\"上海\",\n                        city=\"上海\",\n                        district=\"浦东新区\",\n                        detail=\"张江路4号\",\n                        is_default=True,\n                    ),\n                ]\n            )\n            # Brands\n            session.add_all(\n                [\n                    BrandOrm(id=1, name=\"Apple\"),\n                    BrandOrm(id=2, name=\"Nike\"),\n                ]\n            )\n            # Categories (with nesting)\n            session.add_all(\n                [\n                    CategoryOrm(id=1, name=\"电子产品\"),\n                    CategoryOrm(id=2, name=\"手机\", parent_id=1),\n                    CategoryOrm(id=3, name=\"电脑\", parent_id=1),\n                    CategoryOrm(id=4, name=\"服装\"),\n                    CategoryOrm(id=5, name=\"鞋子\", parent_id=4),\n                ]\n            )\n            # Stores\n            session.add_all(\n                [\n                    StoreOrm(id=1, name=\"Apple 官方旗舰店\", description=\"Apple 官方授权\"),\n                    StoreOrm(id=2, name=\"Nike 运动专营\", description=\"Nike 品牌直营\"),\n                ]\n            )\n            # Tags\n            session.add_all(\n                [\n                    TagOrm(id=1, name=\"新品\"),\n                    TagOrm(id=2, name=\"热卖\"),\n                    TagOrm(id=3, name=\"折扣\"),\n                    TagOrm(id=4, name=\"高端\"),\n                    TagOrm(id=5, name=\"运动\"),\n                ]\n            )\n            # Products\n            session.add_all(\n                [\n                    ProductOrm(\n                        id=1,\n                        name=\"iPhone 15\",\n                        price=5999.0,\n                        brand_id=1,\n                        category_id=2,\n                        store_id=1,\n                    ),\n                    ProductOrm(\n                        id=2,\n                        name=\"MacBook Pro\",\n                        price=12999.0,\n                        brand_id=1,\n                        category_id=3,\n                        store_id=1,\n                    ),\n                    ProductOrm(\n                        id=3,\n                        name=\"Air Jordan 1\",\n                        price=1299.0,\n                        brand_id=2,\n                        category_id=5,\n                        store_id=2,\n                    ),\n                    ProductOrm(\n                        id=4,\n                        name=\"iPad Air\",\n                        price=4799.0,\n                        brand_id=1,\n                        category_id=2,\n                        store_id=1,\n                    ),\n                    ProductOrm(\n                        id=5,\n                        name=\"Nike Air Max\",\n                        price=899.0,\n                        brand_id=2,\n                        category_id=5,\n                        store_id=2,\n                    ),\n                ]\n            )\n            # Product Variants\n            session.add_all(\n                [\n                    ProductVariantOrm(id=1, product_id=1, sku=\"IP15-128-BLK\", price=5999.0, stock=100),\n                    ProductVariantOrm(id=2, product_id=1, sku=\"IP15-256-WHT\", price=6499.0, stock=50),\n                    ProductVariantOrm(id=3, product_id=2, sku=\"MBP14-512\", price=12999.0, stock=30),\n                    ProductVariantOrm(id=4, product_id=2, sku=\"MBP16-1T\", price=18999.0, stock=10),\n                    ProductVariantOrm(id=5, product_id=3, sku=\"AJ1-42-RED\", price=1299.0, stock=80),\n                    ProductVariantOrm(id=6, product_id=3, sku=\"AJ1-43-BLK\", price=1299.0, stock=60),\n                    ProductVariantOrm(id=7, product_id=4, sku=\"IPA-64-BLU\", price=4799.0, stock=40),\n                    ProductVariantOrm(id=8, product_id=5, sku=\"NAM-42-WHT\", price=899.0, stock=120),\n                ]\n            )\n            # Product Images\n            session.add_all(\n                [\n                    ProductImageOrm(id=1, product_id=1, url=\"/img/iphone15-1.jpg\", sort_order=1),\n                    ProductImageOrm(id=2, product_id=1, url=\"/img/iphone15-2.jpg\", sort_order=2),\n                    ProductImageOrm(id=3, product_id=2, url=\"/img/macbook-1.jpg\", sort_order=1),\n                    ProductImageOrm(id=4, product_id=2, url=\"/img/macbook-2.jpg\", sort_order=2),\n                    ProductImageOrm(id=5, product_id=3, url=\"/img/aj1-1.jpg\", sort_order=1),\n                    ProductImageOrm(id=6, product_id=3, url=\"/img/aj1-2.jpg\", sort_order=2),\n                    ProductImageOrm(id=7, product_id=4, url=\"/img/ipad-1.jpg\", sort_order=1),\n                    ProductImageOrm(id=8, product_id=4, url=\"/img/ipad-2.jpg\", sort_order=2),\n                    ProductImageOrm(id=9, product_id=5, url=\"/img/airmax-1.jpg\", sort_order=1),\n                    ProductImageOrm(id=10, product_id=5, url=\"/img/airmax-2.jpg\", sort_order=2),\n                ]\n            )\n            # Product Tags (M:N)\n            from .orm.tables import product_tag\n\n            await session.execute(\n                product_tag.insert(),\n                [\n                    {\"product_id\": 1, \"tag_id\": 1},\n                    {\"product_id\": 1, \"tag_id\": 4},\n                    {\"product_id\": 2, \"tag_id\": 2},\n                    {\"product_id\": 2, \"tag_id\": 4},\n                    {\"product_id\": 3, \"tag_id\": 1},\n                    {\"product_id\": 3, \"tag_id\": 5},\n                    {\"product_id\": 4, \"tag_id\": 2},\n                    {\"product_id\": 5, \"tag_id\": 3},\n                    {\"product_id\": 5, \"tag_id\": 5},\n                ],\n            )\n            # Store Staff (M:N)\n            from .orm.tables import store_staff\n\n            await session.execute(\n                store_staff.insert(),\n                [\n                    {\"store_id\": 1, \"user_id\": 1},\n                    {\"store_id\": 1, \"user_id\": 2},\n                    {\"store_id\": 2, \"user_id\": 2},\n                ],\n            )\n            # Attributes & Values\n            session.add_all(\n                [\n                    AttributeOrm(id=1, name=\"颜色\"),\n                    AttributeOrm(id=2, name=\"尺寸\"),\n                    AttributeOrm(id=3, name=\"材质\"),\n                    AttributeOrm(id=4, name=\"容量\"),\n                    AttributeOrm(id=5, name=\"款式\"),\n                ]\n            )\n            session.add_all(\n                [\n                    AttributeValueOrm(id=1, attribute_id=1, value=\"黑色\"),\n                    AttributeValueOrm(id=2, attribute_id=1, value=\"白色\"),\n                    AttributeValueOrm(id=3, attribute_id=1, value=\"红色\"),\n                    AttributeValueOrm(id=4, attribute_id=2, value=\"S\"),\n                    AttributeValueOrm(id=5, attribute_id=2, value=\"M\"),\n                    AttributeValueOrm(id=6, attribute_id=2, value=\"L\"),\n                    AttributeValueOrm(id=7, attribute_id=3, value=\"皮革\"),\n                    AttributeValueOrm(id=8, attribute_id=3, value=\"网布\"),\n                    AttributeValueOrm(id=9, attribute_id=4, value=\"128GB\"),\n                    AttributeValueOrm(id=10, attribute_id=4, value=\"256GB\"),\n                    AttributeValueOrm(id=11, attribute_id=4, value=\"512GB\"),\n                    AttributeValueOrm(id=12, attribute_id=4, value=\"1TB\"),\n                    AttributeValueOrm(id=13, attribute_id=5, value=\"低帮\"),\n                    AttributeValueOrm(id=14, attribute_id=5, value=\"高帮\"),\n                    AttributeValueOrm(id=15, attribute_id=1, value=\"蓝色\"),\n                ]\n            )\n            # Product Attribute Values (M:N)\n            from .orm.tables import product_attribute\n\n            await session.execute(\n                product_attribute.insert(),\n                [\n                    {\"variant_id\": 1, \"value_id\": 1},  # iPhone 15 Black\n                    {\"variant_id\": 1, \"value_id\": 9},  # 128GB\n                    {\"variant_id\": 2, \"value_id\": 2},  # iPhone 15 White\n                    {\"variant_id\": 2, \"value_id\": 10},  # 256GB\n                    {\"variant_id\": 3, \"value_id\": 11},  # MBP 512GB\n                    {\"variant_id\": 4, \"value_id\": 12},  # MBP 1TB\n                    {\"variant_id\": 5, \"value_id\": 3},  # AJ1 Red\n                    {\"variant_id\": 5, \"value_id\": 14},  # 高帮\n                    {\"variant_id\": 6, \"value_id\": 1},  # AJ1 Black\n                    {\"variant_id\": 6, \"value_id\": 14},  # 高帮\n                ],\n            )\n            # Orders\n            session.add_all(\n                [\n                    OrderOrm(id=1, user_id=1, status=\"completed\", total_amount=7298.0),\n                    OrderOrm(id=2, user_id=1, status=\"shipped\", total_amount=12999.0),\n                    OrderOrm(id=3, user_id=2, status=\"pending\", total_amount=2598.0),\n                    OrderOrm(id=4, user_id=2, status=\"paid\", total_amount=4799.0),\n                    OrderOrm(id=5, user_id=3, status=\"pending\", total_amount=899.0),\n                    OrderOrm(id=6, user_id=3, status=\"refunded\", total_amount=1299.0),\n                ]\n            )\n            # Order Items\n            session.add_all(\n                [\n                    OrderItemOrm(id=1, order_id=1, variant_id=1, quantity=1, unit_price=5999.0),\n                    OrderItemOrm(id=2, order_id=1, variant_id=5, quantity=1, unit_price=1299.0),\n                    OrderItemOrm(id=3, order_id=2, variant_id=3, quantity=1, unit_price=12999.0),\n                    OrderItemOrm(id=4, order_id=3, variant_id=5, quantity=2, unit_price=1299.0),\n                    OrderItemOrm(id=5, order_id=4, variant_id=7, quantity=1, unit_price=4799.0),\n                    OrderItemOrm(id=6, order_id=5, variant_id=8, quantity=1, unit_price=899.0),\n                    OrderItemOrm(id=7, order_id=6, variant_id=6, quantity=1, unit_price=1299.0),\n                    OrderItemOrm(id=8, order_id=1, variant_id=2, quantity=1, unit_price=6499.0),\n                    OrderItemOrm(id=9, order_id=2, variant_id=4, quantity=1, unit_price=18999.0),\n                    OrderItemOrm(id=10, order_id=3, variant_id=6, quantity=1, unit_price=1299.0),\n                    OrderItemOrm(id=11, order_id=4, variant_id=1, quantity=1, unit_price=5999.0),\n                    OrderItemOrm(id=12, order_id=6, variant_id=8, quantity=1, unit_price=899.0),\n                ]\n            )\n            # Payments\n            session.add_all(\n                [\n                    PaymentOrm(id=1, order_id=1, method=\"wechat\", amount=7298.0, status=\"success\"),\n                    PaymentOrm(id=2, order_id=2, method=\"alipay\", amount=12999.0, status=\"success\"),\n                    PaymentOrm(id=3, order_id=4, method=\"wechat\", amount=4799.0, status=\"success\"),\n                    PaymentOrm(id=4, order_id=6, method=\"alipay\", amount=1299.0, status=\"refunded\"),\n                ]\n            )\n            # Refunds\n            session.add_all(\n                [\n                    RefundOrm(id=1, order_id=6, amount=1299.0, reason=\"不想要了\", status=\"approved\"),\n                    RefundOrm(id=2, order_id=2, amount=18999.0, reason=\"质量问题\", status=\"pending\"),\n                ]\n            )\n            # Warehouses\n            session.add_all(\n                [\n                    WarehouseOrm(id=1, name=\"华南仓\", location=\"深圳\"),\n                    WarehouseOrm(id=2, name=\"华东仓\", location=\"上海\"),\n                ]\n            )\n            # Inventory\n            session.add_all(\n                [\n                    InventoryOrm(id=1, warehouse_id=1, variant_id=1, quantity=60),\n                    InventoryOrm(id=2, warehouse_id=2, variant_id=1, quantity=40),\n                    InventoryOrm(id=3, warehouse_id=1, variant_id=2, quantity=30),\n                    InventoryOrm(id=4, warehouse_id=2, variant_id=2, quantity=20),\n                    InventoryOrm(id=5, warehouse_id=1, variant_id=3, quantity=20),\n                    InventoryOrm(id=6, warehouse_id=2, variant_id=3, quantity=10),\n                    InventoryOrm(id=7, warehouse_id=1, variant_id=5, quantity=50),\n                    InventoryOrm(id=8, warehouse_id=2, variant_id=5, quantity=30),\n                ]\n            )\n            # Reviews\n            session.add_all(\n                [\n                    ReviewOrm(id=1, product_id=1, user_id=1, rating=5, content=\"很好用\"),\n                    ReviewOrm(id=2, product_id=1, user_id=2, rating=4, content=\"不错\"),\n                    ReviewOrm(id=3, product_id=2, user_id=1, rating=5, content=\"性能强劲\"),\n                    ReviewOrm(id=4, product_id=3, user_id=2, rating=4, content=\"好看\"),\n                ]\n            )\n            # Coupons\n            session.add_all(\n                [\n                    CouponOrm(id=1, code=\"NEW100\", discount=100.0, min_amount=500.0),\n                    CouponOrm(id=2, code=\"VIP500\", discount=500.0, min_amount=3000.0),\n                    CouponOrm(id=3, code=\"SALE200\", discount=200.0, min_amount=1000.0),\n                ]\n            )\n            # Coupon Usages\n            session.add_all(\n                [\n                    CouponUsageOrm(id=1, coupon_id=1, user_id=1, order_id=1),\n                    CouponUsageOrm(id=2, coupon_id=2, user_id=1, order_id=2),\n                    CouponUsageOrm(id=3, coupon_id=3, user_id=2, order_id=3),\n                    CouponUsageOrm(id=4, coupon_id=1, user_id=3, order_id=5),\n                ]\n            )\n            # Shipments\n            session.add_all(\n                [\n                    ShipmentOrm(\n                        id=1, warehouse_id=1, store_id=1, status=\"delivered\",\n                        tracking_no=\"SF1234567890\",\n                    ),\n                    ShipmentOrm(\n                        id=2, warehouse_id=2, store_id=1, status=\"shipped\",\n                        tracking_no=\"SF0987654321\",\n                    ),\n                    ShipmentOrm(\n                        id=3, warehouse_id=1, store_id=2, status=\"pending\",\n                        tracking_no=None,\n                    ),\n                ]\n            )\n            # Shipment Items\n            session.add_all(\n                [\n                    ShipmentItemOrm(id=1, shipment_id=1, order_item_id=1, quantity=1),\n                    ShipmentItemOrm(id=2, shipment_id=1, order_item_id=2, quantity=1),\n                    ShipmentItemOrm(id=3, shipment_id=2, order_item_id=3, quantity=1),\n                    ShipmentItemOrm(id=4, shipment_id=2, order_item_id=9, quantity=1),\n                    ShipmentItemOrm(id=5, shipment_id=3, order_item_id=4, quantity=1),\n                    ShipmentItemOrm(id=6, shipment_id=3, order_item_id=10, quantity=1),\n                ]\n            )\n"
  },
  {
    "path": "tests/test_adapter_interface.py",
    "content": "\"\"\"\nTest adapter interface design to ensure clean and consistent API.\n\nThis test validates:\n1. Adapters don't have get_mount_path() method (mount path is user's choice)\n2. Adapters have create_app() method\n3. Adapters work correctly with user-defined mount paths\n\"\"\"\nimport os\n\nimport pytest\n\nfrom fastapi_voyager import create_voyager\nfrom fastapi_voyager.adapters.base import VoyagerAdapter\n\n\ndef test_adapter_base_class_does_not_have_get_mount_path():\n    \"\"\"Test that VoyagerAdapter base class does not define get_mount_path method.\"\"\"\n    # The base class should only have create_app as abstract method\n    abstract_methods = VoyagerAdapter.__abstractmethods__\n\n    # Should only have create_app\n    assert \"create_app\" in abstract_methods, \"create_app must be abstract\"\n    assert \"get_mount_path\" not in abstract_methods, \"get_mount_path should not exist in base class\"\n\n    # Verify get_mount_path is not defined anywhere in the class\n    assert not hasattr(VoyagerAdapter, \"get_mount_path\"), \\\n        \"VoyagerAdapter should not have get_mount_path method - mount path is user's choice\"\n\n\n@pytest.mark.parametrize(\"app_factory\", [\n    pytest.param(lambda: __import__(\"fastapi\", fromlist=[\"FastAPI\"]).FastAPI(), id=\"fastapi\"),\n    pytest.param(lambda: __import__(\"litestar\", fromlist=[\"Litestar\"]).Litestar(), id=\"litestar\"),\n])\ndef test_adapter_create_app_exists_and_works(app_factory):\n    \"\"\"Test that adapters have create_app method and it works correctly.\"\"\"\n    # Import and setup\n    app = app_factory()\n    voyager_app = create_voyager(app)\n\n    # Should return a valid app object\n    assert voyager_app is not None, \"create_voyager should return an app\"\n\n    # The returned app should be callable (ASGI interface)\n    assert callable(voyager_app), \"Voyager app must be callable (ASGI interface)\"\n\n\ndef test_django_ninja_adapter_create_app_works():\n    \"\"\"Test that Django Ninja adapter works correctly.\"\"\"\n    import django\n\n    # Setup Django BEFORE importing NinjaAPI\n    os.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"tests.django_ninja.settings\")\n    django.setup()\n\n    from ninja import NinjaAPI\n\n    # Create API and voyager\n    api = NinjaAPI()\n    voyager_app = create_voyager(api)\n\n    # Should return a valid ASGI app\n    assert voyager_app is not None, \"create_voyager should return an app\"\n    assert callable(voyager_app), \"Voyager app must be callable (ASGI interface)\"\n\n\ndef test_adapter_instances_do_not_have_get_mount_path():\n    \"\"\"Test that adapter instances do not have get_mount_path method.\"\"\"\n    from fastapi import FastAPI\n    from litestar import Litestar\n    import django\n\n    # Test FastAPI adapter\n    fastapi_app = FastAPI()\n    voyager_app = create_voyager(fastapi_app)\n    assert not hasattr(voyager_app, \"get_mount_path\"), \\\n        \"FastAPI voyager app should not have get_mount_path method\"\n\n    # Test Litestar adapter\n    litestar_app = Litestar()\n    voyager_app = create_voyager(litestar_app)\n    assert not hasattr(voyager_app, \"get_mount_path\"), \\\n        \"Litestar voyager app should not have get_mount_path method\"\n\n    # Test Django Ninja adapter\n    os.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"tests.django_ninja.settings\")\n    django.setup()\n    from ninja import NinjaAPI\n    ninja_api = NinjaAPI()\n    voyager_app = create_voyager(ninja_api)\n    assert not hasattr(voyager_app, \"get_mount_path\"), \\\n        \"Django Ninja voyager app should not have get_mount_path method\"\n\n\ndef test_mount_path_is_user_responsibility():\n    \"\"\"\n    Test that mount path is completely under user control.\n\n    This test documents and enforces the design principle that\n    mount path should be decided by users in their embedding code,\n    not hardcoded in adapters.\n    \"\"\"\n    from fastapi import FastAPI\n    import httpx\n\n    # Test multiple different mount paths - all should work\n    test_paths = [\n        \"/voyager\",\n        \"/docs\",\n        \"/api/viz\",\n        \"/my-custom-path\",\n    ]\n\n    for path in test_paths:\n        app = FastAPI()\n        voyager_app = create_voyager(app)\n        app.mount(path, voyager_app)\n\n        # Verify the path works using async client\n        transport = httpx.ASGITransport(app=app)\n        # Use loop.run_until_complete for sync context\n        import asyncio\n        async def check_path():\n            async with httpx.AsyncClient(transport=transport, base_url=\"http://test\") as client:\n                response = await client.get(f\"{path}/dot\")\n                assert response.status_code == 200, \\\n                    f\"Mount path {path} should work - proves path is user's choice\"\n\n        asyncio.run(check_path())\n\n\ndef test_adapter_design_principles():\n    \"\"\"\n    Test that adapter design follows correct principles.\n\n    This test documents the design decision that:\n    - Adapters create framework-specific apps\n    - Users decide mount paths in their embedding code\n    - Adapters should NOT hardcode mount paths\n    \"\"\"\n    from fastapi import FastAPI\n    from fastapi_voyager.adapters.base import VoyagerAdapter\n    import inspect\n\n    # Check that VoyagerAdapter only has one abstract method\n    abstract_methods = VoyagerAdapter.__abstractmethods__\n    assert len(abstract_methods) == 1, \"Should only have one abstract method\"\n    assert \"create_app\" in abstract_methods, \"Abstract method should be create_app\"\n\n    # Check that create_app signature is correct\n    sig = inspect.signature(VoyagerAdapter.create_app)\n    assert len(sig.parameters) == 1, \"create_app should only take self parameter\"\n    assert sig.return_annotation != inspect.Signature.empty, \"create_app should have return type annotation\"\n\n    # Verify the principle: voyager app doesn't impose mount path\n    app = FastAPI()\n    voyager_app = create_voyager(app)\n\n    # The voyager app is just a standard ASGI app\n    # It doesn't know or care about where it's mounted\n    assert callable(voyager_app), \"Voyager app must be callable ASGI interface\"\n\n    # User can mount it anywhere they want\n    # This is verified by test_mount_path_is_user_responsibility\n    assert True, \"Design principle validated: mount path is user's responsibility\"\n\n"
  },
  {
    "path": "tests/test_analysis.py",
    "content": "\nfrom fastapi import FastAPI\nfrom pydantic import BaseModel\n\nfrom fastapi_voyager.voyager import Voyager\n\n\ndef test_analysis():\n\n    class B(BaseModel):\n        id: int\n        value: str\n\n    class A(BaseModel):\n        id: int\n        name: str\n        b: B\n    \n    class C(BaseModel):\n        id: int\n        name: str\n        b: B\n\n    app = FastAPI()\n\n    @app.get(\"/test\", response_model=A | None)\n    def home():\n        return None\n\n    @app.get(\"/test2\", response_model=C | None)\n    def home2():\n        return None\n\n    analytics = Voyager()\n    analytics.analysis(app)\n    assert len(analytics.nodes) == 3\n    assert len(analytics.links) == 6\n\n\ndef test_analysis_with_non_class_response_model():\n    \"\"\"Regression test for TypeError: issubclass() arg 1 must be a class.\n\n    Real-world trigger: a route uses a PEP 695 type alias as response_model, e.g.\n        type ResourceActionDict = dict[AccessResourceUnion, set[AccessActionUnion]]\n    FastAPI infers response_model from the return annotation. get_core_types() unwraps\n    the type alias to dict[X, set[Y]] (a types.GenericAlias), which is not a class.\n\n    Python version behavior difference:\n    - Python 3.12: issubclass(dict[X, Y], BaseModel) returns False (no error)\n    - Python 3.13: issubclass(dict[X, Y], BaseModel) raises TypeError\n\n    In Pydantic <= 2.11, ModelMetaclass.__subclasscheck__ (pure-Python) masks this\n    via hasattr short-circuit. In Pydantic >= 2.13 (compiled Rust extension), the\n    guard no longer catches all GenericAlias types, exposing the bug on Python 3.13.\n\n    We patch out __subclasscheck__ to simulate the Python 3.13 + Pydantic 2.13 behavior.\n    \"\"\"\n    from unittest.mock import patch\n    from typing import Callable\n    from pydantic._internal._model_construction import ModelMetaclass\n    from enum import Enum\n\n    class ResourceEnum(str, Enum):\n        FILE = \"file\"\n\n    class ActionEnum(str, Enum):\n        READ = \"read\"\n\n    ResourceActionDict = dict[ResourceEnum, set[ActionEnum]]\n\n    app = FastAPI()\n\n    @app.get(\"/permissions\", response_model=ResourceActionDict)\n    def get_permissions():\n        return {}\n\n    @app.get(\"/callback\", response_model=Callable[[int], str])\n    def callback_endpoint():\n        pass\n\n    with patch.object(ModelMetaclass, '__subclasscheck__', type.__subclasscheck__):\n        voyager = Voyager()\n        voyager.analysis(app)\n        assert len(voyager.nodes) == 0"
  },
  {
    "path": "tests/test_embedding_django_ninja.py",
    "content": "\"\"\"\nTest Django Ninja embedding service with /dot endpoint.\n\nThis test starts the Django Ninja embedding service and validates the /dot endpoint.\n\"\"\"\nimport asyncio\nfrom typing import AsyncGenerator, Generator\n\nimport httpx\nimport pytest\nimport pytest_asyncio\n\nfrom tests import embedding_test_utils\n\n\n@pytest.fixture(scope=\"session\")\ndef event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:\n    \"\"\"Create an instance of the default event loop for the test session.\"\"\"\n    loop = asyncio.get_event_loop_policy().new_event_loop()\n    yield loop\n    loop.close()\n\n\n@pytest_asyncio.fixture\nasync def async_client() -> AsyncGenerator[httpx.AsyncClient, None]:\n    \"\"\"Create an async HTTP client for testing Django Ninja embedding.\"\"\"\n    # Import the ASGI application from tests.django_ninja.embedding\n    from tests.django_ninja.embedding import application\n\n    # Use ASGITransport for testing ASGI apps with httpx\n    transport = httpx.ASGITransport(app=application)\n    async with httpx.AsyncClient(transport=transport, base_url=\"http://test\") as client:\n        yield client\n\n\n@pytest.fixture\ndef expected_framework_name() -> str:\n    \"\"\"Return the expected framework name for Django Ninja.\"\"\"\n    return \"Django Ninja\"\n\n\n@pytest.fixture\ndef expected_routes() -> list[str]:\n    \"\"\"Return expected route names for Django Ninja.\"\"\"\n    return embedding_test_utils.EXPECTED_ROUTES\n\n\n# Reuse shared test functions\n@pytest.mark.asyncio\nasync def test_dot_endpoint_returns_success(async_client: httpx.AsyncClient):\n    \"\"\"Test that /voyager/dot endpoint returns 200 OK.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_returns_success(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient):\n    \"\"\"Test that /voyager/dot endpoint returns tags data.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_has_tags(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncClient):\n    \"\"\"Test that tags have associated routes.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_tags_have_routes(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_routes_structure(async_client: httpx.AsyncClient):\n    \"\"\"Test that routes have correct structure.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_routes_structure(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_expected_routes(async_client: httpx.AsyncClient, expected_routes: list[str]):\n    \"\"\"Test that expected routes from demo.py are present.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_expected_routes(async_client, expected_routes)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient, expected_framework_name: str):\n    \"\"\"Test other required fields in /dot response.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_other_fields(async_client, expected_framework_name)\n"
  },
  {
    "path": "tests/test_embedding_fastapi.py",
    "content": "\"\"\"\nTest FastAPI embedding service with /dot endpoint.\n\nThis test starts the FastAPI embedding service and validates the /dot endpoint.\n\"\"\"\nimport asyncio\nfrom typing import AsyncGenerator, Generator\n\nimport httpx\nimport pytest\nimport pytest_asyncio\n\nfrom tests.fastapi.embedding import app\nfrom tests import embedding_test_utils\n\n\n@pytest.fixture(scope=\"session\")\ndef event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:\n    \"\"\"Create an instance of the default event loop for the test session.\"\"\"\n    loop = asyncio.get_event_loop_policy().new_event_loop()\n    yield loop\n    loop.close()\n\n\n@pytest_asyncio.fixture\nasync def async_client() -> AsyncGenerator[httpx.AsyncClient, None]:\n    \"\"\"Create an async HTTP client for testing.\"\"\"\n    # Use ASGITransport for testing ASGI apps with httpx\n    transport = httpx.ASGITransport(app=app)\n    async with httpx.AsyncClient(transport=transport, base_url=\"http://test\") as client:\n        yield client\n\n\n@pytest.fixture\ndef expected_framework_name() -> str:\n    \"\"\"Return the expected framework name for FastAPI.\"\"\"\n    return \"FastAPI\"\n\n\n@pytest.fixture\ndef expected_routes() -> list[str]:\n    \"\"\"Return expected route names for FastAPI.\"\"\"\n    return embedding_test_utils.EXPECTED_ROUTES\n\n\n# Reuse shared test functions\n@pytest.mark.asyncio\nasync def test_dot_endpoint_returns_success(async_client: httpx.AsyncClient):\n    \"\"\"Test that /voyager/dot endpoint returns 200 OK.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_returns_success(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient):\n    \"\"\"Test that /voyager/dot endpoint returns tags data.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_has_tags(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncClient):\n    \"\"\"Test that tags have associated routes.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_tags_have_routes(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_routes_structure(async_client: httpx.AsyncClient):\n    \"\"\"Test that routes have correct structure.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_routes_structure(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_expected_routes(async_client: httpx.AsyncClient, expected_routes: list[str]):\n    \"\"\"Test that expected routes from demo.py are present.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_expected_routes(async_client, expected_routes)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient, expected_framework_name: str):\n    \"\"\"Test other required fields in /dot response.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_other_fields(async_client, expected_framework_name)\n"
  },
  {
    "path": "tests/test_embedding_litestar.py",
    "content": "\"\"\"\nTest Litestar embedding service with /dot endpoint.\n\nThis test starts the Litestar embedding service and validates the /dot endpoint.\n\"\"\"\nimport asyncio\nfrom typing import AsyncGenerator, Generator\n\nimport httpx\nimport pytest\nimport pytest_asyncio\n\nfrom tests import embedding_test_utils\n\n\n@pytest.fixture(scope=\"session\")\ndef event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:\n    \"\"\"Create an instance of the default event loop for the test session.\"\"\"\n    loop = asyncio.get_event_loop_policy().new_event_loop()\n    yield loop\n    loop.close()\n\n\n@pytest_asyncio.fixture\nasync def async_client() -> AsyncGenerator[httpx.AsyncClient, None]:\n    \"\"\"Create an async HTTP client for testing Litestar embedding.\"\"\"\n    # Import the combined app from tests.litestar.embedding\n    from tests.litestar.embedding import app\n\n    # Use ASGITransport for testing ASGI apps with httpx\n    transport = httpx.ASGITransport(app=app)\n    async with httpx.AsyncClient(transport=transport, base_url=\"http://test\") as client:\n        yield client\n\n\n@pytest.fixture\ndef expected_framework_name() -> str:\n    \"\"\"Return the expected framework name for Litestar.\"\"\"\n    return \"Litestar\"\n\n\n@pytest.fixture\ndef expected_routes() -> list[str]:\n    \"\"\"Return expected route names for Litestar.\"\"\"\n    return embedding_test_utils.EXPECTED_ROUTES\n\n\n# Reuse shared test functions\n@pytest.mark.asyncio\nasync def test_dot_endpoint_returns_success(async_client: httpx.AsyncClient):\n    \"\"\"Test that /voyager/dot endpoint returns 200 OK.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_returns_success(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient):\n    \"\"\"Test that /voyager/dot endpoint returns tags data.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_has_tags(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncClient):\n    \"\"\"Test that tags have associated routes.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_tags_have_routes(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_routes_structure(async_client: httpx.AsyncClient):\n    \"\"\"Test that routes have correct structure.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_routes_structure(async_client)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_expected_routes(async_client: httpx.AsyncClient, expected_routes: list[str]):\n    \"\"\"Test that expected routes from demo.py are present.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_expected_routes(async_client, expected_routes)\n\n\n@pytest.mark.asyncio\nasync def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient, expected_framework_name: str):\n    \"\"\"Test other required fields in /dot response.\"\"\"\n    await embedding_test_utils.test_dot_endpoint_other_fields(async_client, expected_framework_name)\n"
  },
  {
    "path": "tests/test_filter.py",
    "content": "from fastapi_voyager.filter import filter_subgraph_by_module_prefix\nfrom fastapi_voyager.type import PK, Link, Route, SchemaNode, Tag\n\n\ndef _make_tag_route_link(tag: Tag, route: Route) -> Link:\n    return Link(\n        source=tag.id,\n        source_origin=tag.id,\n        target=route.id,\n        target_origin=route.id,\n        type=\"tag_route\",\n    )\n\n\ndef test_filter_subgraph_filters_nodes_and_links():\n    tag = Tag(id=\"tag1\", name=\"Tag 1\", routes=[])\n    route = Route(id=\"route1\", name=\"route1\", module=\"api.routes\")\n    tag.routes.append(route)\n\n    node_a = SchemaNode(id=\"pkg.ModelA\", name=\"ModelA\", module=\"pkg.moduleA\")\n    node_b = SchemaNode(id=\"pkg.ModelB\", name=\"ModelB\", module=\"target.moduleB\")\n\n    links = [\n        _make_tag_route_link(tag, route),\n        Link(\n            source=route.id,\n            source_origin=route.id,\n            target=f\"{node_a.id}::{PK}\",\n            target_origin=node_a.id,\n            type=\"route_to_schema\",\n        ),\n        Link(\n            source=f\"{node_a.id}::ffield\",\n            source_origin=node_a.id,\n            target=f\"{node_b.id}::{PK}\",\n            target_origin=node_b.id,\n            type=\"schema\",\n        ),\n    ]\n\n    tags = [tag]\n    routes = [route]\n    nodes = [node_a, node_b]\n\n    _, _, filtered_nodes, filtered_links = filter_subgraph_by_module_prefix(\n        tags=tags,\n        routes=routes,\n        links=links,\n        nodes=nodes,\n        module_prefix=\"target\",\n    )\n\n    assert filtered_nodes == [node_b]\n    assert any(\n        lk.type == \"route_to_schema\" and \\\n        lk.source_origin == route.id and \\\n        lk.target_origin == node_b.id\n        for lk in filtered_links\n    )\n    assert len(filtered_links) == 2  # tag -> route and merged route -> filtered node\n\n\n\ndef test_filter_subgraph_handles_cycles_and_multiple_matches():\n    tag = Tag(id=\"tag-main\", name=\"Tag\", routes=[])\n    route = Route(id=\"route-main\", name=\"route\", module=\"api.routes\")\n    tag.routes.append(route)\n\n    node_root = SchemaNode(id=\"pkg.Root\", name=\"Root\", module=\"pkg.root\")\n    node_mid = SchemaNode(id=\"pkg.Mid\", name=\"Mid\", module=\"pkg.mid\")\n    node_target1 = SchemaNode(id=\"pkg.Target1\", name=\"Target1\", module=\"target.mod.alpha\")\n    node_target2 = SchemaNode(id=\"pkg.Target2\", name=\"Target2\", module=\"target.mod.beta\")\n\n    links = [\n        _make_tag_route_link(tag, route),\n        Link(\n            source=route.id,\n            source_origin=route.id,\n            target=f\"{node_root.id}::{PK}\",\n            target_origin=node_root.id,\n            type=\"route_to_schema\",\n        ),\n        Link(\n            source=f\"{node_root.id}::ffield\",\n            source_origin=node_root.id,\n            target=f\"{node_mid.id}::{PK}\",\n            target_origin=node_mid.id,\n            type=\"schema\",\n        ),\n        Link(\n            source=f\"{node_mid.id}::{PK}\",\n            source_origin=node_mid.id,\n            target=f\"{node_target1.id}::{PK}\",\n            target_origin=node_target1.id,\n            type=\"parent\",\n        ),\n        Link(\n            source=f\"{node_mid.id}::ffield\",\n            source_origin=node_mid.id,\n            target=f\"{node_target2.id}::{PK}\",\n            target_origin=node_target2.id,\n            type=\"subset\",\n        ),\n        Link(\n            source=f\"{node_target1.id}::ffield\",\n            source_origin=node_target1.id,\n            target=f\"{node_root.id}::{PK}\",\n            target_origin=node_root.id,\n            type=\"schema\",\n        ),\n    ]\n\n    nodes = [node_root, node_mid, node_target1, node_target2]\n\n    _, _, filtered_nodes, filtered_links = filter_subgraph_by_module_prefix(\n        tags=[tag],\n        routes=[route],\n        links=links,\n        nodes=nodes,\n        module_prefix=\"target.mod\",\n    )\n\n    assert filtered_nodes == [node_target1, node_target2]\n\n    route_to_schema_targets = {\n        (lk.source_origin, lk.target_origin)\n        for lk in filtered_links\n        if lk.type == \"route_to_schema\"\n    }\n    assert route_to_schema_targets == {\n        (route.id, node_target1.id),\n        (route.id, node_target2.id),\n    }\n\n    assert all(lk.type in {\"tag_route\", \"route_to_schema\"} for lk in filtered_links)\n    assert len(filtered_links) == 3  # 1 tag_route + 2 merged links\n"
  },
  {
    "path": "tests/test_generic.py",
    "content": "import sys\nfrom typing import Generic, TypeVar\n\nfrom pydantic import BaseModel\n\nfrom fastapi_voyager.type_helper import is_generic_container\n\n\nclass PageStory(BaseModel):\n    id: int\n    title: str\n\nT = TypeVar('T')\nclass DataModel(BaseModel, Generic[T]):\n    data: T\n    id: int\n\nDataModelPageStory: object  # Stub declaration for static analysis\nif sys.version_info >= (3, 12):\n    exec(\"type DataModelPageStory = DataModel[PageStory]\")\nelse:\n    DataModelPageStory = DataModel[PageStory]\n\ndef test_is_generic_container():\n    print(DataModelPageStory.__value__.__bases__)\n    print(DataModelPageStory.__value__.model_fields.items())\n    assert is_generic_container(DataModel) is True\n    assert is_generic_container(DataModelPageStory) is False"
  },
  {
    "path": "tests/test_import.py",
    "content": "def test_import():\n    import fastapi_voyager as pkg\n    assert hasattr(pkg, \"__version__\")\n"
  },
  {
    "path": "tests/test_module.py",
    "content": "from fastapi_voyager.module import build_module_schema_tree\nfrom fastapi_voyager.type import SchemaNode\n\n\ndef _sn(id: str, module: str, name: str) -> SchemaNode:\n    return SchemaNode(id=id, module=module, name=name, fields=[])\n\n\ndef _find_child(module_node, name: str):\n    return next((m for m in module_node.modules if m.name == name), None)\n\ndef _find_top(top_modules, name: str):\n    return next((m for m in top_modules if m.name == name), None)\n\n\ndef test_build_module_tree_basic():\n    # Arrange: schema nodes in various module depths\n    schema_nodes = [\n        _sn(\"A\", \"pkg\", \"A\"),\n        _sn(\"B\", \"pkg.sub\", \"B\"),\n        _sn(\"B2\", \"pkg.sub\", \"B2\"),\n        _sn(\"C\", \"pkg.other\", \"C\"),\n        _sn(\"D\", \"x.y.z\", \"D\"),\n    ]\n\n    # Act\n    top_modules = build_module_schema_tree(schema_nodes)\n    from pprint import pprint\n    pprint(top_modules)\n\n    # Assert: top-level modules\n    names = sorted(m.name for m in top_modules)\n    assert names == [\"pkg\", \"x.y.z\"]\n\n    # pkg level\n    pkg = _find_top(top_modules, \"pkg\")\n    assert pkg is not None\n    assert [sn.name for sn in pkg.schema_nodes] == [\"A\"]\n    assert sorted(m.name for m in pkg.modules) == [\"other\", \"sub\"]\n\n    # pkg.sub level\n    sub = _find_child(pkg, \"sub\")\n    assert sub is not None\n    assert sorted(sn.name for sn in sub.schema_nodes) == [\"B\", \"B2\"]\n    assert sub.modules == []\n\n    # pkg.other level\n    other = _find_child(pkg, \"other\")\n    assert other is not None\n    assert [sn.name for sn in other.schema_nodes] == [\"C\"]\n    assert other.modules == []\n\n    # x.y.z chain should collapse to a single module named \"x.y.z\"\n    x = _find_top(top_modules, \"x.y.z\")\n    assert x is not None\n    assert [sn.name for sn in x.schema_nodes] == [\"D\"]\n    assert x.modules == []\n\n\ndef test_build_module_tree_empty_input():\n    top_modules = build_module_schema_tree([])\n    assert top_modules == []\n\n\ndef test_build_module_tree_root_level_nodes():\n    # Nodes without module path should be attached to __root__\n    schema_nodes = [\n        _sn(\"Root1\", \"\", \"Root1\"),\n        _sn(\"Root2\", \"\", \"Root2\"),\n        _sn(\"PkgA\", \"pkg\", \"PkgA\"),\n    ]\n\n    top_modules = build_module_schema_tree(schema_nodes)\n    names = sorted(m.name for m in top_modules)\n    assert names == [\"__root__\", \"pkg\"]\n    root = _find_top(top_modules, \"__root__\")\n    assert root is not None\n    assert sorted(sn.name for sn in root.schema_nodes) == [\"Root1\", \"Root2\"]\n    pkg = _find_top(top_modules, \"pkg\")\n    assert pkg is not None and [sn.name for sn in pkg.schema_nodes] == [\"PkgA\"]\n\n\ndef test_collapse_single_child_empty_modules():\n    # Construct a deeper chain with empty intermediate modules that should collapse\n    schema_nodes = [\n        _sn(\"Deep\", \"a.b.c.d\", \"Deep\"),\n        _sn(\"Peer\", \"a.b.x\", \"Peer\"),\n    ]\n    top_modules = build_module_schema_tree(schema_nodes)\n    print(top_modules)\n    # 'a' should have one child path 'b', but due to branching at x, only a.b collapses into a.b\n    # and below it, 'c.d' should collapse to 'c.d'. Final structure:\n    # a\n    #  └── b\n    #       ├── c.d (holds Deep)\n    #       └── x (holds Peer)\n    a = _find_top(top_modules, \"a.b\")\n    assert a is not None\n    assert a.schema_nodes == []\n    # b remains as child of a\n    b = _find_child(a, \"c.d\")\n    assert b is not None\n    assert [sn.name for sn in b.schema_nodes] == ['Deep']\n    # collapsed node under b is named \"c.d\"\n    x = _find_child(a, \"x\")\n    assert x is not None\n    assert [sn.name for sn in x.schema_nodes] == [\"Peer\"]"
  },
  {
    "path": "tests/test_resolve_util_impl.py",
    "content": "from typing import Annotated\n\nfrom pydantic import BaseModel\nfrom pydantic_resolve import Collector\nfrom pydantic_resolve.utils.er_diagram import LoaderInfo\n\nfrom fastapi_voyager.pydantic_resolve_util import analysis_pydantic_resolve_fields\n\n\nclass SchemaA(BaseModel):\n    __pydantic_resolve_expose__ = {\"exposed_field\": \"alias_name\"}\n    __pydantic_resolve_collect__ = {\n        \"collected_field\": \"collector_name\",\n        (\"collected_field_a\", \"collected_field_b\"): \"collector_x\",\n        (\"collected_field_c\", \"collected_field_d\"): (\"collector_y\", \"collector_z\"),\n\n        (\"collected_field\", \"collected_field_c\"): (\"collector_u\", \"collector_v\"),\n    }\n\n    id: int\n    resolved_field: Annotated[str, LoaderInfo(origin=\"id\")] = \"\"\n    exposed_field: str = \"\"\n    collected_field: str = \"\"\n\n    collected_field_a: str = \"\"\n    collected_field_b: str = \"\"\n\n    collected_field_c: str = \"\"\n    collected_field_d: str = \"\"\n\n    post_field: str = \"\"\n\n    def resolve_resolved_field(self):\n        return \"resolved\"\n\n    def post_post_field(self):\n        return \"posted\"\n\n    collector: list[str] = []\n    def post_collector(self, collector=Collector(alias=\"top_collector\")):    \n        return collector.values()\n\ndef test_resolve_util():\n    # Test resolved field\n    res = analysis_pydantic_resolve_fields(SchemaA, \"resolved_field\")\n    assert res[\"is_resolve\"] is True\n\n    # Test exposed field\n    res = analysis_pydantic_resolve_fields(SchemaA, \"exposed_field\")\n    assert res[\"expose_as_info\"] == \"alias_name\"\n\n    # Test collected field\n    res = analysis_pydantic_resolve_fields(SchemaA, \"collected_field\")\n    assert set(res[\"send_to_info\"]) == {\"collector_name\", \"collector_u\", \"collector_v\"}\n\n    # Test collected field a (tuple key)\n    res = analysis_pydantic_resolve_fields(SchemaA, \"collected_field_a\")\n    assert set(res[\"send_to_info\"]) == {\"collector_x\"}\n\n    # Test collected field c (tuple key and tuple value)\n    res = analysis_pydantic_resolve_fields(SchemaA, \"collected_field_c\")\n    assert set(res[\"send_to_info\"]) == {\"collector_y\", \"collector_z\", \"collector_u\", \"collector_v\"}\n\n    # Test post field\n    res = analysis_pydantic_resolve_fields(SchemaA, \"post_field\")\n    assert res[\"is_post\"] is True\n\n\n    res = analysis_pydantic_resolve_fields(SchemaA, \"collector\")\n    assert set(res[\"collect_info\"]) == {\"top_collector\"}"
  },
  {
    "path": "tests/test_type_helper.py",
    "content": "import sys\nfrom typing import Annotated\n\nimport pytest\n\nfrom fastapi_voyager.type_helper import get_core_types\n\n\ndef test_optional_and_list_core_types():\n    class T: ...\n\n    # Optional[T] -> (T,)\n    opt = T | None\n    core = get_core_types(opt)\n    assert core == (T,)\n\n    # list[T] -> (T,)\n    lst = list[T]\n    core2 = get_core_types(lst)\n    assert core2 == (T,)\n\n\ndef test_typing_union_core_types():\n    class A: ...\n    class B: ...\n\n    u = A | B\n    core = get_core_types(u)\n    # order preserved\n    assert core == (A, B)\n\n\n@pytest.mark.skipif(sys.version_info < (3, 10), reason=\"PEP 604 union (|) requires Python 3.10+\")\ndef test_uniontype_pep604_core_types():\n    class A: ...\n    class B: ...\n\n    u = A | B\n    core = get_core_types(u)\n    assert core == (A, B)\n\n\ndef test_mixed_optional_list():\n    class T: ...\n\n    # Optional[list[T]] -> (T,) (list unwrapped after removing None)\n    anno = list[T] | None\n    core = get_core_types(anno)\n    assert core == (T,)\n\n\ndef test_nested_union_flattening():\n    class A: ...\n    class B: ...\n    class C: ...\n\n    anno = A | (B | C)\n    core = get_core_types(anno)\n    # typing normalizes nested unions -> (A, B, C)\n    assert core == (A, B, C)\n\n\n@pytest.mark.skipif(sys.version_info < (3, 10), reason=\"PEP 604 union (|) requires Python 3.10+\")\ndef test_uniontype_with_list_member():\n    class A: ...\n    class B: ...\n\n    anno = A | list[B]\n    anno2 = A | list[list[B]]\n    core = get_core_types(anno)\n    core2 = get_core_types(anno2)\n    assert core == (A, B)\n    assert core2 == (A, B)\n\n\n# Only Python 3.12+ supports the PEP 695 `type` statement producing TypeAliasType\n@pytest.mark.skipif(sys.version_info < (3, 12), reason=\"PEP 695 type aliases require Python 3.12+\")\ndef test_union_type_alias_and_list():\n    # Dynamically exec a type alias using the new syntax \n    # so test file stays valid on <3.12 (even though skipped)\n    ns: dict = {}\n    code = \"\"\"\nclass A: ...\nclass B: ...\n\ntype MyAlias = A | B\n\"\"\"\n    exec(code, ns, ns)\n    MyAlias = ns['MyAlias']\n    A = ns['A']\n    B = ns['B']\n\n    # list[MyAlias] should yield (A, B)\n    core = get_core_types(list[MyAlias])\n    assert set(core) == {A, B}\n\n    # Direct alias should also work\n    core2 = get_core_types(MyAlias)\n    assert set(core2) == {A, B}\n\n\ndef test_annotated():\n    class A: ...\n\n    core = get_core_types(Annotated[A, 'hello'])\n    assert set(core) == {A}\n\n"
  }
]