create-caspian-app 1.3.21 ā 1.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/settings/_markup_equivalence.py +591 -45
- package/dist/settings/format.py +16 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{execSync,spawnSync}from"child_process";import fs from"fs";import{fileURLToPath}from"url";import path from"path";import chalk from"chalk";import prompts from"prompts";import https from"https";import{randomBytes}from"crypto";const __filename=fileURLToPath(import.meta.url),__dirname=path.dirname(__filename),PACKAGE_ROOT=path.resolve(__dirname,".."),OPTIONAL_TEMPLATE_FILES=new Set([".python-version",".prettierrc"]),OPTIONAL_TEMPLATE_DIRECTORIES=new Set([".github",".vscode"]),CASPIAN_SECTION_START="\x3c!-- caspian:start --\x3e",CASPIAN_SECTION_END="\x3c!-- caspian:end --\x3e";let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.py","not-found.py","error.py"],STARTER_KITS={basic:{id:"basic",name:"Basic PHP Application",description:"Simple PHP backend with minimal dependencies",features:{backendOnly:!0,tailwindcss:!1,prisma:!1,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","src/app/layout.py","src/app/index.py"]},fullstack:{id:"fullstack",name:"Full-Stack Application",description:"Complete web application with frontend and backend",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/app/layout.py","src/app/index.py","public/js/main.js","src/app/globals.css"]},api:{id:"api",name:"REST API",description:"Backend API with database and documentation",features:{backendOnly:!0,tailwindcss:!1,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py","pyproject.toml"]},realtime:{id:"realtime",name:"Real-time Application",description:"Application with WebSocket support and MCP",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!0,websocket:!0},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/lib/mcp"]}};function bsConfigUrls(e){const n=e.indexOf("\\htdocs\\");if(-1===n)return console.error("Invalid PROJECT_ROOT_PATH. The path does not contain \\htdocs\\"),{bsTarget:"",bsPathRewrite:{}};const t=e.substring(0,n+8).replace(/\\/g,"\\\\"),s=e.replace(new RegExp(`^${t}`),"").replace(/\\/g,"/");let i=`http://localhost/${s}`;i=i.endsWith("/")?i.slice(0,-1):i;const c=i.replace(/(?<!:)(\/\/+)/g,"/"),a=s.replace(/\/\/+/g,"/");return{bsTarget:`${c}/`,bsPathRewrite:{"^/":`/${a.startsWith("/")?a.substring(1):a}/`}}}async function updatePackageJson(e,n){const t=path.join(e,"package.json");if(checkExcludeFiles(t))return;const s=JSON.parse(fs.readFileSync(t,"utf8"));s.scripts={...s.scripts,projectName:"tsx settings/project-name.ts",format:"uv run python settings/format.py","format:check":"uv run python settings/format.py --check",check:"uv run python settings/check.py","check:fix":"uv run python settings/fix.py",logs:"uv run python settings/browser_log.py",static:"npm run build && uv run python settings/build-static.py","static:serve":"uv run python settings/serve-static.py"};let i=[];n.tailwindcss&&(s.scripts={...s.scripts,tailwind:"tsx settings/run-postcss.ts watch","tailwind:build":"tsx settings/run-postcss.ts build"},i.push("tailwind")),n.typescript&&!n.backendOnly&&(s.scripts={...s.scripts,"ts:watch":"vite build --watch","ts:watch:dev":"tsx settings/run-vite-watch.ts","ts:build":"vite build"},i.push("ts:watch:dev")),n.mcp&&(s.scripts={...s.scripts,mcp:"tsx settings/restart-mcp.ts"},i.push("mcp"));let c={...s.scripts};c.browserSync="tsx settings/bs-config.ts",c.dev=`npm-run-all projectName -l -p browserSync ${i.join(" ")}`;let a=["projectName"];n.tailwindcss&&a.unshift("tailwind:build"),n.typescript&&!n.backendOnly&&a.unshift("ts:build"),c.build=`npm-run-all ${a.join(" ")}`,s.scripts=c,s.type="module",fs.writeFileSync(t,JSON.stringify(s,null,2))}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateHexEncodedKey(e=16){return randomBytes(e).toString("hex")}function buildEnvSection(e,n){return`# =============================================================================\n${e.split("\n").map(e=>e.startsWith("#")?e:`# ${e}`).join("\n")}\n# =============================================================================\n\n${n.trimEnd()}`}function buildCaspianEnvContent(e){const n=generateAuthSecret(),t=generateHexEncodedKey(8),s=generateHexEncodedKey(32),i=[];return e.prisma&&i.push(buildEnvSection("1. DATABASE\n# Enforced by: prisma/schema.prisma, src/lib/prisma/db.py",'# Connection string. Prisma reads this directly from .env.\n# Format reference: https://pris.ly/d/connection-strings\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"\n\n# Connection-pool limit. Defaults: SQLite 5; MySQL and PostgreSQL 20.\n# Use 5 for local development; production does not need this unless you want to\n# limit the pool.\nDB_POOL_SIZE=5\n\n# Seconds idle before the client re-probes its connection. Default 30.\nPRISMA_CONN_PROBE_IDLE_SECONDS=30\n\n# Warn on queries that cause a full table scan. 0/false silences it. Default 1.\nPRISMA_WARN_FULL_SCAN=1')),i.push(buildEnvSection("2. APPLICATION RUNTIME\n# Enforced by: casp/runtime_security.py is_production_environment()",'# Environment selector, resolved FAIL-CLOSED: only an explicit development\n# value (dev, development, local, staging, test, testing) enables the\n# development relaxations. Unset or misspelled counts as production.\n#\n# Production turns on: HTTPS-only session cookie, Secure CSRF cookie, HSTS,\n# generic error messages, mandatory AUTH_SECRET, mandatory MCP_AUTH_TOKEN, and\n# it removes the localhost origin bypass and the WebSocket same-origin fallback.\n#\n# This single value gates most of the security posture. Set it deliberately.\nAPP_ENV="development"\n\n# Calendar timezone for the application, as an IANA name (e.g. "UTC",\n# "America/New_York", "America/Santo_Domingo"). Read by casp/app_time.py and\n# resolved once at boot in main.py.\n#\n# This sets which wall-clock DAY an instant belongs to: what casp.app_time.now()\n# and today() answer, how a stored timestamp reads back to a user, and the\n# boundaries a "today\'s totals" query uses. Timestamps are still STORED in UTC.\n#\n# It deliberately does NOT affect absolute time -- session expiry (casp/auth.py)\n# and cache TTLs (casp/cache_handler.py) stay on UTC, so changing this can never\n# extend a session or a cache entry.\n#\n# An unrecognized name raises InvalidAppTimezoneError at startup rather than\n# silently falling back to UTC. Empty or unset means UTC.\nAPP_TIMEZONE="UTC"'),buildEnvSection("3. PUBLIC URL, CORS, AND ORIGIN VALIDATION\n# Enforced by: casp/rpc.py origin checks, main.py CORS layer",'# Canonical public origin. Leave empty when the browser URL and the app runtime\n# URL match. Set it when they differ, i.e. behind an ingress, reverse proxy,\n# load balancer, gateway, edge network, or TLS terminator.\nAPP_BASE_URL=""\n\n# Extra browser origins allowed to call protected endpoints such as RPC. Use\n# when one deployment is reachable from more than one public origin.\n# Comma-separated, no spaces.\nCORS_ALLOWED_ORIGINS=""\n\n# Trust Forwarded/X-Forwarded-* headers. Enable ONLY when every request passes\n# through infrastructure that strips client-supplied forwarded headers before\n# setting its own, because a direct client can otherwise forge them.\n#\n# Affects two things: which origin RPC accepts, and which address the rate\n# limiter buckets on. Left false, both use the direct request instead.\nTRUST_FORWARDED_HEADERS="false"\n\n# Allow cookies/Authorization on cross-origin requests. Keep true only when\n# credentialed cross-origin requests are actually required.\nCORS_ALLOW_CREDENTIALS="true"\n\n# CORS preflight response fields.\nCORS_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"\nCORS_ALLOWED_HEADERS="Content-Type,Authorization,X-Requested-With"\nCORS_EXPOSE_HEADERS=""\n\n# Preflight cache duration in seconds.\nCORS_MAX_AGE="86400"'),buildEnvSection("4. AUTHENTICATION AND SESSIONS\n# Enforced by: casp/auth.py, main.py SessionMiddleware\n# Route privacy and RBAC live in src/lib/auth/auth_config.py, not here.",`# Session signing secret. Unique and strong per app and per environment.\n# In production the app refuses to start when this is missing or left on a\n# placeholder ("change-me"/"changeme"); in development it falls back.\nAUTH_SECRET="${n}"\n\n# Session cookie name. Use a unique value when several apps share a parent\n# domain, or their sessions overwrite each other.\nAUTH_COOKIE_NAME="${t}"\n\n# Session lifetime in hours (SessionMiddleware max_age).\nSESSION_LIFETIME_HOURS="7"`),buildEnvSection("5. OAUTH PROVIDERS\n# Enforced by: casp/auth.py; routes served by main.py AuthMiddleware","# Google and GitHub sign-in are already wired: AuthMiddleware serves\n# /api/auth/signin/{google,github} and /api/auth/callback/{google,github}.\n# Link a button at those paths, do not hand-roll OAuth.\n#\n# A provider with no client id is skipped SILENTLY: the redirect returns None\n# and the button appears dead, with no error and no log. Empty means disabled.\n\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\n\n# Must match the redirect URI registered in Google Cloud Console exactly.\n# Google is skipped unless BOTH the client id and this value are set.\nGOOGLE_REDIRECT_URI=\n\nGITHUB_CLIENT_ID=\nGITHUB_CLIENT_SECRET="),buildEnvSection("6. REQUEST SECURITY\n# Enforced by: main.py BodySizeLimitMiddleware, RequestDiagnosticsMiddleware",'# Max size of the whole HTTP request body in MB. Caps the entire body (file +\n# form fields + encoding overhead), so usable file size is a bit below this.\n# Middleware rejects oversized requests before the route runs. Raise if valid\n# uploads are blocked. Default 16.\nMAX_CONTENT_LENGTH_MB="16"\n\n# Seconds before a stalled route returns 504. Streaming paths (/mcp) are exempt\n# so long-lived transports are not cut mid-response. Default 20.\nCASPIAN_REQUEST_TIMEOUT_SECONDS=20'),buildEnvSection("7. SECURITY HEADERS\n# Enforced by: casp/runtime_security.py, main.py SecurityHeadersMiddleware","# Replaces the built-in Content-Security-Policy wholesale. Empty keeps the\n# default, which already permits the app's own assets.\n#\n# Any replacement MUST keep 'unsafe-eval' and 'unsafe-inline' in script-src:\n# the PulsePoint runtime compiles component templates with new Function(), so\n# removing them stops every page from rendering. Set this only to widen the\n# policy, e.g. for a CDN, analytics host, or an external frame embedder.\n#\n# Outside production, connect-src also allows http(s)/ws on localhost and\n# 127.0.0.1 on any port, because BrowserSync serves the proxied page on one port\n# while its injected live-reload client polls the BrowserSync server on another.\n# That is a separate origin, so 'self' does not cover it. Those entries are\n# omitted from a production policy. Setting an override here replaces BOTH, so\n# an override used in development must include the loopback sources itself or\n# live reload stops working.\n#\n# img-src and media-src admit remote content by scheme, so posters, avatars, CDN\n# thumbnails, and video load without per-project configuration. Plain http: is\n# development-only. Set an override here to pin them to named origins instead.\n#\n# Default: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';\n# style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;\n# media-src 'self' data: blob: https:; font-src 'self' data:;\n# connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';\n# form-action 'self'; frame-ancestors 'self'\nCONTENT_SECURITY_POLICY="),buildEnvSection("8. RATE LIMITING\n# Enforced by: main.py RateLimitMiddleware, casp/rpc.py RPCRateLimiter\n# Buckets are per client address; see TRUST_FORWARDED_HEADERS for which one.",'# Per-IP cap on page requests, applied before session decryption and rendering.\n# Static assets (/css, /js, /assets, /favicon.ico) and /health are exempt, so a\n# page load does not spend its own budget on its assets. Empty disables it.\n# Default 200/minute.\nRATE_LIMIT_PAGES=200/minute\n\n# Fallback limit for @rpc() actions that declare no limits= of their own.\nRATE_LIMIT_RPC="60 per minute"\n\n# Limit applied to @rpc(require_auth=True) actions that declare no limits=.\n# Tighten this and the per-action limits on sign-in and other credential paths.\nRATE_LIMIT_AUTH="60 per minute"\n\n# Configured for slowapi\'s Limiter. Note that slowapi\'s middleware is not in\n# the stack, so this value is inert today; page limiting is RATE_LIMIT_PAGES.\nRATE_LIMIT_DEFAULT="200 per minute"\n\n# In-memory bucket ceiling and sweep interval for the limiter store.\n# Defaults 10000 buckets, swept every 60 seconds.\nRATE_LIMIT_MAX_BUCKETS=10000\nRATE_LIMIT_CLEANUP_INTERVAL=60')),e.websocket&&i.push(buildEnvSection("9. WEBSOCKETS\n# Enforced by: src/lib/websocket/websocket_security.py, main.py channel loop\n# Only active when caspian.config.json has websocket: true.","# Browser origins allowed to open a socket (anti-CSWSH). Falls back to\n# CORS_ALLOWED_ORIGINS then APP_BASE_URL when empty.\n#\n# REQUIRED IN PRODUCTION. The convenience same-origin fallback is derived from\n# the client-supplied Host header, so it is development-only: without an\n# explicit list a spoofed Host plus matching Origin would validate itself.\n#\n# The HTTP middleware stack skips websocket scopes, so this and the socket\n# guard are the only checks a handshake passes. Comma-separated, no spaces.\nWEBSOCKET_ALLOWED_ORIGINS=\n\n# Seconds a socket may stay silent before the server closes it. Default 120.\nWEBSOCKET_IDLE_TIMEOUT_SECONDS=120\n\n# Max size of one inbound socket message in bytes. Oversized closes with 1009.\n# Default 4096.\nMAX_WEBSOCKET_MESSAGE_BYTES=4096\n\n# Simultaneous connections per pool; authenticated and guest pools are counted\n# separately. Refused connections close with 1013 during the handshake. Every\n# open socket is a live task and a broadcast target. Default 200.\nMAX_WEBSOCKET_CONNECTIONS=200\n\n# Per-connection send budget: messages allowed per rolling window. Each\n# accepted message fans out to the whole pool, so this bounds how much\n# broadcast one connection can generate. Defaults 20 per 10 seconds.\nMAX_WEBSOCKET_MESSAGES_PER_WINDOW=20\nWEBSOCKET_RATE_WINDOW_SECONDS=10")),e.mcp&&i.push(buildEnvSection("10. MCP ENDPOINT\n# Enforced by: main.py MCPAuthMiddleware; tools in src/lib/mcp/mcp_server.py\n# Only active when caspian.config.json has mcp: true.",`# Bearer token required to call /mcp. The MCP app is mounted outside the page\n# routing tree, so AuthMiddleware does NOT protect it, and its tools enumerate\n# the workspace file inventory and component map.\n#\n# generated -> every request needs "Authorization: Bearer <token>"\n#\n# REQUIRED IN PRODUCTION for the endpoint to work at all.\nMCP_AUTH_TOKEN="${s}"`)),i.push(buildEnvSection("11. CACHE\n# Enforced by: casp/cache_handler.py, main.py is_request_cacheable()",'# Master switch for serving pages from the disk cache.\n#\n# Entries are keyed on the URI alone, with no session component, so an\n# authenticated render is never cached: is_request_cacheable() gates both the\n# read and the write, and a route\'s Cache(...) cannot override it.\nCACHE_ENABLED="false"\n\n# Default cache lifetime in seconds, used when a route sets no ttl.\nCACHE_TTL="600"'),buildEnvSection("12. SERVER PROCESS\n# Enforced by: main.py __main__, settings/serve-static.py",'# Uvicorn workers are separate OS processes for the same FastAPI app. More\n# workers can increase throughput under concurrent load, but they do not make a\n# single request faster and they duplicate memory, connection pools, and any\n# in-process state. Keep at 1 unless the app is designed for multi-process\n# coordination and testing shows a real concurrency bottleneck.\nUVICORN_WORKERS="1"')),i.join("\n\n")}function copyRecursiveSync(e,n,t){const s=fs.existsSync(e),i=s&&fs.statSync(e);if(s&&i&&i.isDirectory()){const s=n.toLowerCase();if(!t.mcp&&s.includes("src\\lib\\mcp"))return;if(!t.websocket&&s.includes("src\\lib\\websocket"))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\ts")||s.includes("\\ts\\")))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\vite-plugins")||s.includes("\\vite-plugins\\")||s.includes("\\vite-plugins")))return;if(t.backendOnly&&s.includes("public\\js")||t.backendOnly&&s.includes("public\\css")||t.backendOnly&&s.includes("public\\assets"))return;const i=n.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(i))return;fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),fs.readdirSync(e).forEach(s=>{copyRecursiveSync(path.join(e,s),path.join(n,s),t)})}else{if(checkExcludeFiles(n))return;const s=n.replace(/\\/g,"/").toLowerCase();if(s.endsWith("/settings/run-vite-watch.ts")&&(!t.typescript||t.backendOnly))return;if(s.endsWith("/ts/tailwind-merge.ts")&&(!t.typescript||t.backendOnly||!t.tailwindcss))return;if(!t.tailwindcss&&(n.includes("globals.css")||n.includes("styles.css")))return;if(!t.mcp&&n.includes("restart-mcp.ts"))return;if(!t.websocket&&n.includes("src\\lib\\websocket"))return;if(t.backendOnly&&nonBackendFiles.some(e=>n.includes(e)))return;if(t.backendOnly&&n.includes("layout.py"))return;if(t.tailwindcss&&n.includes("index.css"))return;if(!t.prisma&&n.includes("prisma-schema-config.json"))return;fs.copyFileSync(e,n,0)}}async function executeCopy(e,n,t){n.forEach(({src:n,dest:s})=>{const i=normalizeTemplatePath(n),c=resolveTemplateSourcePath(n,"directory"),a=path.join(e,s);if(!c){if(OPTIONAL_TEMPLATE_DIRECTORIES.has(i))return void console.log(chalk.gray(`Optional template directory not found, skipping: ${i}`));throw new Error(`Template directory not found: ${i}. The package may be incomplete.`)}copyRecursiveSync(c,a,t)})}function modifyLayoutPHP(e,n){const t=path.join(e,"src","app","layout.py");if(!checkExcludeFiles(t))try{let e=fs.readFileSync(t,"utf8"),s="";n.backendOnly||(n.tailwindcss||(s='\n <link href="/css/index.css" rel="stylesheet" />'),s+='\n <script type="module" src="/js/main.js"><\/script>');let i="";n.backendOnly||(i=n.tailwindcss?` <link href="/css/styles.css" rel="stylesheet" />${s}`:s),e=e.replace("</head>",`${i}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.py:"),e)}}async function createOrUpdateEnvFile(e,n){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,n,{flag:"w"})}function ensureClaudeMd(e){const n=path.join(e,"CLAUDE.md");if(checkExcludeFiles(n))return;const t="@AGENTS.md";if(!fs.existsSync(n))return void fs.writeFileSync(n,`${t}\n`,{flag:"w"});const s=fs.readFileSync(n,"utf8").replace(/^\uFEFF/,"");if(s.trimStart().startsWith(t))return;const i=`${t}\n\n${s.trimStart()}`;fs.writeFileSync(n,i,{flag:"w"})}function writeTailwindMainJs(e){const n=path.join(e,"public","js","main.js");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.min.js";\nimport { twMerge } from "/js/tailwind-merge.mjs";\n\nconst pp = (globalThis).pp;\n\nglobalThis.twMerge = twMerge;\n\nif (document.readyState !== "loading") {\n pp?.mount?.();\n} else {\n document.addEventListener(\n "DOMContentLoaded",\n () => pp?.mount?.(),\n { once: true },\n );\n}\n',{flag:"w"}))}function copyTailwindMergeBundle(e){const n=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs"),t=path.join(e,"public","js","tailwind-merge.mjs"),s=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs.map"),i=path.join(e,"public","js","bundle-mjs.mjs.map");if(!checkExcludeFiles(t)){if(!fs.existsSync(n))throw new Error(`tailwind-merge bundle not found at ${n}`);fs.mkdirSync(path.dirname(t),{recursive:!0}),fs.copyFileSync(n,t),!checkExcludeFiles(i)&&fs.existsSync(s)&&fs.copyFileSync(s,i)}}function writeTailwindTypeScriptMain(e){const n=path.join(e,"ts","main.ts");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.min.js";\n\n// The following global names have already been declared elsewhere in the project:\n// - pp: Used for the Reactive Core functionality.\n\n// Imports goes here --Start\nimport { createGlobalSingleton } from "./global-functions.js";\nimport { mergeTailwindClasses } from "./tailwind-merge.js";\n\ncreateGlobalSingleton("twMerge", mergeTailwindClasses);\n\n\n// Imports goes here --End\n\nconst pp = (globalThis as any).pp;\n\nif (document.readyState !== "loading") {\n\tpp?.mount?.();\n} else {\n\tdocument.addEventListener(\n\t\t"DOMContentLoaded",\n\t\t() => pp?.mount?.(),\n\t\t{ once: true },\n\t);\n}\n',{flag:"w"}))}function checkExcludeFiles(e){if(!updateAnswer?.isUpdate)return!1;const n=e.replace(/\\/g,"/");return!!updateAnswer?.excludeFilePath?.includes(n)||!!updateAnswer?.excludeFiles&&updateAnswer.excludeFiles.some(e=>{const t=e.replace(/\\/g,"/");return n.endsWith("/"+t)||n===t})}function normalizeTemplatePath(e){return e.replace(/^[\\/]+/,"")}function resolveTemplateSourcePath(e,n){const t=normalizeTemplatePath(e),s=[path.join(__dirname,t),path.join(PACKAGE_ROOT,t)];for(const e of s){if(!fs.existsSync(e))continue;const t=fs.statSync(e);if("file"===n&&t.isFile())return e;if("directory"===n&&t.isDirectory())return e}return null}function extractCaspianSection(e){const n=e.indexOf(CASPIAN_SECTION_START);if(-1===n)return null;const t=e.indexOf(CASPIAN_SECTION_END,n);return-1===t?null:e.slice(t>n?n:0,t+20)}function mergeAgentsCaspianSection(e,n){const t=extractCaspianSection(n);if(!t)return e;const s=e.indexOf(CASPIAN_SECTION_START),i=e.indexOf(CASPIAN_SECTION_END,s);if(-1!==s&&-1!==i){return`${e.slice(0,s)}${t}${e.slice(i+20)}`}const c=e.endsWith("\n");return`${e}${c?"\n":"\n\n"}${t}\n`}async function createDirectoryStructure(e,n){const t=[{src:"/main.py",dest:"/main.py"},{src:"/.prettierrc",dest:"/.prettierrc"},{src:"/pyproject.toml",dest:"/pyproject.toml"},{src:"/tsconfig.json",dest:"/tsconfig.json"},{src:"/app-gitignore",dest:"/.gitignore"},{src:"/AGENTS.md",dest:"/AGENTS.md"},{src:"/.python-version",dest:"/.python-version"}];n.tailwindcss&&t.push({src:"/postcss.config.js",dest:"/postcss.config.js"}),n.typescript&&!n.backendOnly&&t.push({src:"/vite.config.ts",dest:"/vite.config.ts"});const s=[{src:"/settings",dest:"/settings"},{src:"/tests",dest:"/tests"},{src:"/src",dest:"/src"},{src:"/public",dest:"/public"},{src:"/.github",dest:"/.github"},{src:"/.vscode",dest:"/.vscode"}];n.typescript&&!n.backendOnly&&s.push({src:"/ts",dest:"/ts"}),t.forEach(({src:n,dest:t})=>{const s=normalizeTemplatePath(n),i=resolveTemplateSourcePath(n,"file"),c=path.join(e,t);if(checkExcludeFiles(c))return;if(!i){if(OPTIONAL_TEMPLATE_FILES.has(s))return void console.log(chalk.gray(`Optional template file not found, skipping: ${s}`));throw new Error(`Template file not found: ${s}. The package may be incomplete.`)}if("/pyproject.toml"===n&&updateAnswer?.isUpdate&&fs.existsSync(c))return void console.log(chalk.gray("Preserving existing pyproject.toml during update."));const a=fs.readFileSync(i,"utf8");if("/AGENTS.md"===n&&updateAnswer?.isUpdate&&fs.existsSync(c)){const e=mergeAgentsCaspianSection(fs.readFileSync(c,"utf8"),a);return void fs.writeFileSync(c,e,{flag:"w"})}fs.writeFileSync(c,a,{flag:"w"})}),await executeCopy(e,s,n),ensureClaudeMd(e),n.tailwindcss&&!n.backendOnly&&(n.typescript?writeTailwindTypeScriptMain(e):(copyTailwindMergeBundle(e),writeTailwindMainJs(e))),await updatePackageJson(e,n),!n.tailwindcss&&n.backendOnly||modifyLayoutPHP(e,n),await createOrUpdateEnvFile(e,buildCaspianEnvContent(n))}async function getAnswer(e={},n=!1){if(n)return{projectName:e.projectName??"my-app",backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,websocket:e.websocket??!1,prisma:e.prisma??!1};if(e.starterKit){const n=e.starterKit;let t=null;if(STARTER_KITS[n]&&(t=STARTER_KITS[n]),t){const s={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:t.features.backendOnly??!1,tailwindcss:t.features.tailwindcss??!1,prisma:t.features.prisma??!1,mcp:t.features.mcp??!1,websocket:t.features.websocket??!1,typescript:t.features.typescript??!1},i=process.argv.slice(2);return i.includes("--backend-only")&&(s.backendOnly=!0),i.includes("--tailwindcss")&&(s.tailwindcss=!0),i.includes("--mcp")&&(s.mcp=!0),i.includes("--websocket")&&(s.websocket=!0),i.includes("--prisma")&&(s.prisma=!0),i.includes("--typescript")&&(s.typescript=!0),s}if(e.starterKitSource){const t={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1,typescript:!1},s=process.argv.slice(2);return s.includes("--backend-only")&&(t.backendOnly=!0),s.includes("--tailwindcss")&&(t.tailwindcss=!0),s.includes("--mcp")&&(t.mcp=!0),s.includes("--websocket")&&(t.websocket=!0),s.includes("--prisma")&&(t.prisma=!0),s.includes("--typescript")&&(t.typescript=!0),t}}const t=[];e.projectName||t.push({type:"text",name:"projectName",message:"What is your project named?",initial:"my-app"}),e.backendOnly||updateAnswer?.isUpdate||t.push({type:"toggle",name:"backendOnly",message:`Would you like to create a ${chalk.blue("backend-only project")}?`,initial:!1,active:"Yes",inactive:"No"});const s=()=>{console.warn(chalk.red("Operation cancelled by the user.")),process.exit(0)},i=await prompts(t,{onCancel:s}),c=[];i.backendOnly??e.backendOnly??!1?(e.mcp||c.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.tailwindcss||c.push({type:"toggle",name:"tailwindcss",message:`Would you like to use ${chalk.blue("Tailwind CSS")}?`,initial:!1,active:"Yes",inactive:"No"}),e.typescript||c.push({type:"toggle",name:"typescript",message:`Would you like to use ${chalk.blue("TypeScript")}?`,initial:!1,active:"Yes",inactive:"No"}),e.mcp||c.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"}));const a=await prompts(c,{onCancel:s});return{projectName:i.projectName?String(i.projectName).trim().replace(/ /g,"-"):e.projectName??"my-app",backendOnly:i.backendOnly??e.backendOnly??!1,tailwindcss:a.tailwindcss??e.tailwindcss??!1,typescript:a.typescript??e.typescript??!1,mcp:a.mcp??e.mcp??!1,websocket:a.websocket??e.websocket??!1,prisma:a.prisma??e.prisma??!1}}async function uninstallNpmDependencies(e,n,t=!1){console.log("Uninstalling Node dependencies:"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["uninstall",t?"--save-dev":"--save",...n]);execSync(s,{stdio:"inherit",cwd:e})}function buildManagedNpmCommand(e){return`npm ${e.join(" ")} --ignore-scripts=false --min-release-age=0 --audit=false`}function fetchPackageVersion(e){return new Promise((n,t)=>{https.get(`https://registry.npmjs.org/${e}`,e=>{let s="";e.on("data",e=>s+=e),e.on("end",()=>{try{const e=JSON.parse(s);n(e["dist-tags"].latest)}catch(e){t(new Error("Failed to parse JSON response"))}})}).on("error",e=>t(e))})}const readJsonFile=e=>{const n=fs.readFileSync(e,"utf8");return JSON.parse(n)};function compareVersions(e,n){const t=e.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/),s=n.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);if(!t||!s)return e.localeCompare(n);const i=t.slice(1,4).map(Number),c=s.slice(1,4).map(Number);for(let e=0;e<i.length;e++){if(i[e]>c[e])return 1;if(i[e]<c[e])return-1}const a=t[4]??null,o=s[4]??null;return a&&!o?-1:!a&&o?1:a&&o?a.localeCompare(o):0}function getInstalledPackageInfo(e){try{const n=execSync(buildManagedNpmCommand(["list","-g",e,"--depth=0"])).toString(),t=n.match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)`));return t?{version:t[1],isLinked:n.includes(`${e}@`)&&n.includes("->")}:(console.error(`Package ${e} is not installed`),{version:null,isLinked:!1})}catch(e){return console.error(e instanceof Error?e.message:String(e)),{version:null,isLinked:!1}}}function isRunningFromNpxCache(e){const n=path.resolve(e).toLowerCase(),t=`${path.sep}_npx${path.sep}`.toLowerCase();return n.includes(t)}async function installNpmDependencies(e,n,t=!1){fs.existsSync(path.join(e,"package.json"))?console.log("Updating existing Node.js project..."):console.log("Initializing new Node.js project..."),fs.existsSync(path.join(e,"package.json"))||execSync(buildManagedNpmCommand(["init","-y"]),{stdio:"inherit",cwd:e}),console.log((t?"Installing development dependencies":"Installing dependencies")+":"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["install",...t?["--save-dev"]:[],...n]);execSync(s,{stdio:"inherit",cwd:e})}const npmPinnedVersions={"@tailwindcss/postcss":"4.3.3","@types/browser-sync":"2.29.1","@types/node":"26.2.0","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"6.0.0","chokidar-cli":"3.0.0",cssnano:"8.0.5","npm-run-all":"4.1.5",postcss:"8.5.26","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.12",typescript:"7.0.2",vite:"8.2.0",vitest:"4.1.10","fast-glob":"3.3.3","@lezer/common":"1.5.2","@lezer/python":"1.1.19","caspian-utils":"0.2.x","tailwind-merge":"3.6.0"};function npmPkg(e){return npmPinnedVersions[e]?`${e}@${npmPinnedVersions[e]}`:e}function removeDirectorySafe(e){if(fs.existsSync(e))try{return void fs.rmSync(e,{recursive:!0,force:!0,maxRetries:5,retryDelay:250})}catch(n){const t=n;if("win32"===globalThis.process?.platform&&("EPERM"===t.code||"EACCES"===t.code)){try{spawnSync("cmd",["/c","attrib","-R","-H","-S","/S","/D",`${e}\\*`],{stdio:"ignore"})}catch{}return void spawnSync("cmd",["/c","rd","/s","/q",e],{stdio:"ignore"})}throw n}}async function setupStarterKit(e,n){if(!n.starterKit)return;let t=null;if(STARTER_KITS[n.starterKit]?t=STARTER_KITS[n.starterKit]:n.starterKitSource&&(t={id:n.starterKit,name:`Custom Starter Kit (${n.starterKit})`,description:"Custom starter kit from external source",features:{},requiredFiles:[],source:{type:"git",url:n.starterKitSource}}),t){if(console.log(chalk.green(`Setting up ${t.name}...`)),t.source)try{const s=t.source.branch?`git clone -b ${t.source.branch} --depth 1 ${t.source.url} "${e}"`:`git clone --depth 1 ${t.source.url} "${e}"`;execSync(s,{stdio:"inherit"});removeDirectorySafe(path.join(e,".git")),console.log(chalk.blue("Starter kit cloned successfully!"));const i=path.join(e,"caspian.config.json");if(fs.existsSync(i))try{const t=JSON.parse(fs.readFileSync(i,"utf8")),s=e,c=bsConfigUrls(s);t.projectName=n.projectName,t.projectRootPath=s,t.bsTarget=c.bsTarget,t.bsPathRewrite=c.bsPathRewrite;const a=await fetchPackageVersion("create-caspian-app");t.version=t.version||a,fs.writeFileSync(i,JSON.stringify(t,null,2)),console.log(chalk.green("Updated caspian.config.json with new project details"))}catch(e){console.warn(chalk.yellow("Failed to update caspian.config.json, will create new one"))}}catch(e){throw console.error(chalk.red(`Failed to setup starter kit: ${e}`)),e}t.customSetup&&await t.customSetup(e,n),console.log(chalk.green(`ā ${t.name} setup complete!`))}else console.warn(chalk.yellow(`Starter kit '${n.starterKit}' not found. Skipping...`))}function showStarterKits(){console.log(chalk.blue("\nš Available Starter Kits:\n")),Object.values(STARTER_KITS).forEach(e=>{const n=e.source?" (Custom)":" (Built-in)";console.log(chalk.green(` ${e.id}${chalk.gray(n)}`)),console.log(` ${e.name}`),console.log(chalk.gray(` ${e.description}`)),e.source&&console.log(chalk.cyan(` Source: ${e.source.url}`));const t=Object.entries(e.features).filter(([,e])=>!0===e).map(([e])=>e).join(", ");t&&console.log(chalk.magenta(` Features: ${t}`)),console.log()}),console.log(chalk.yellow("Usage:")),console.log(" npx create-caspian-app my-project --starter-kit=basic"),console.log(" npx create-caspian-app my-project --starter-kit=custom --starter-kit-source=https://github.com/user/repo"),console.log()}function runCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"inherit",shell:!1,encoding:"utf8"});if(s.error)throw s.error;if(0!==s.status)throw new Error(`Command failed (${e} ${n.join(" ")}), exit=${s.status}`)}function tryRunCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"ignore",shell:!1,encoding:"utf8"});return!s.error&&0===s.status}function tryInstallUv(e){console.log(chalk.blue("uv not found. Attempting to install uv..."));const n=[{cmd:"py",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python3",args:["-m","pip","install","--upgrade","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,t.args,e))return!0;return!1}function resolveUvCommand(e){const n=[{cmd:"uv",argsPrefix:[]},{cmd:"py",argsPrefix:["-m","uv"]},{cmd:"python",argsPrefix:["-m","uv"]},{cmd:"python3",argsPrefix:["-m","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;if(tryInstallUv(e))for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;throw new Error("Could not find or install uv. Install uv and ensure `uv`, `py`, or `python` is available in PATH.")}function buildPythonDependencies(e){const n=["fastapi==0.141.1","uvicorn==0.52.1","python-dotenv==1.2.2","tzdata==2026.3","jinja2==3.1.6","beautifulsoup4==4.15.0","slowapi==0.1.10","python-multipart==0.0.32","starsessions==2.2.1","httpx2==2.10.0","werkzeug==3.1.8","cuid2==2.0.1","nanoid==2.0.0","python-ulid==4.0.1","cuid==0.4","caspian-utils~=0.4"];return e.mcp&&n.push("fastmcp==3.4.7"),e.websocket&&n.push("websockets==17.0.1"),e.prisma&&(n.push("psycopg2-binary==2.9.12"),n.push("asyncpg==0.31.0"),n.push("aiosqlite==0.22.1"),n.push("aiomysql==0.3.2")),n}function buildPythonDevDependencies(){return["pyright==1.1.411","ruff==0.16.2","pytest==9.1.1","djlint==1.44.2"]}function getPythonRequirementName(e){const n=e.trim().match(/^([A-Za-z0-9._-]+)/);return n?.[1]??null}function getPyProjectDependencyNames(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))return new Set;const t=fs.readFileSync(n,"utf8").replace(/\r\n/g,"\n").match(/^[ \t]*dependencies[ \t]*=[ \t]*\[([\s\S]*?)\]/m);if(!t)return new Set;const s=new Set,i=/"([^"]+)"/g;let c;for(;null!==(c=i.exec(t[1]));){const e=c[1].trim().match(/^([A-Za-z0-9._-]+)/)?.[1];e&&s.add(e.toLowerCase())}return s}function ensurePyProjectExists(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))throw new Error(`pyproject.toml not found at: ${n}`);let t=fs.readFileSync(n,"utf8");t=t.replace(/\r\n/g,"\n"),t.includes("package = false")||(t=t.includes("[tool.uv]")?t.replace("[tool.uv]","[tool.uv]\npackage = false"):`${t.trimEnd()}\n\n[tool.uv]\npackage = false\n`),fs.writeFileSync(n,t,"utf8")}async function ensurePythonVenvAndDeps(e,n,t=[]){console.log(chalk.green("\n=========================")),console.log(chalk.green("Python setup: syncing dependencies with uv")),console.log(chalk.green("=========================\n")),console.log(chalk.blue("Preparing pyproject.toml...")),ensurePyProjectExists(e);const s=path.join(e,"requirements.txt");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(chalk.gray("Removed legacy requirements.txt")));const i=resolveUvCommand(e),c=path.join(e,".venv");fs.existsSync(c)?console.log(chalk.blue("Existing .venv detected. Reusing it so uv sync can update dependencies without replacing the environment.")):(console.log(chalk.blue("Creating the virtual environment with uv...")),runCmd(i.cmd,[...i.argsPrefix,"venv",".venv"],e));const a=buildPythonDependencies(n),o=buildPythonDevDependencies(),r=a.map(e=>getPythonRequirementName(e)).filter(e=>null!==e),l=o.map(e=>getPythonRequirementName(e)).filter(e=>null!==e);t.length>0&&(console.log(chalk.blue("Removing obsolete Python dependencies via uv remove...")),runCmd(i.cmd,[...i.argsPrefix,"remove",...t],e));const p=r.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dependencies via uv add...")),runCmd(i.cmd,[...i.argsPrefix,"add",...p,...a],e);const d=l.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dev dependencies via uv add --dev...")),runCmd(i.cmd,[...i.argsPrefix,"add","--dev",...d,...o],e),console.log(chalk.blue("Syncing dependencies...")),runCmd(i.cmd,[...i.argsPrefix,"sync"],e),console.log(chalk.green("\nā uv environment ready and dependencies installed.\n"))}async function main(){try{const e=process.argv.slice(2),n=e.includes("-y");let t=e[0];const s=e.find(e=>e.startsWith("--starter-kit=")),i=s?.split("=")[1],c=e.find(e=>e.startsWith("--starter-kit-source=")),a=c?.split("=")[1];if(e.includes("--list-starter-kits"))return void showStarterKits();let o=null,r=!1;if(t){const s=process.cwd(),c=path.join(s,"caspian.config.json");if(i&&a){r=!0;const s={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};o=await getAnswer(s,n)}else if(fs.existsSync(c)){const i=readJsonFile(c);let a=[];i.excludeFiles?.map(e=>{const n=path.join(s,e);fs.existsSync(n)&&a.push(n.replace(/\\/g,"/"))}),updateAnswer={projectName:t,backendOnly:i.backendOnly,tailwindcss:i.tailwindcss,mcp:i.mcp,websocket:i.websocket??!1,prisma:i.prisma,typescript:i.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:s};const r={projectName:t,backendOnly:e.includes("--backend-only")||i.backendOnly,tailwindcss:e.includes("--tailwindcss")||i.tailwindcss,typescript:e.includes("--typescript")||i.typescript,prisma:e.includes("--prisma")||i.prisma,mcp:e.includes("--mcp")||i.mcp,websocket:e.includes("--websocket")||(i.websocket??!1)};o=await getAnswer(r,n),null!==o&&(updateAnswer={projectName:t,backendOnly:o.backendOnly,tailwindcss:o.tailwindcss,mcp:o.mcp,websocket:o.websocket,prisma:o.prisma,typescript:o.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:s})}else{const s={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};o=await getAnswer(s,n)}if(null===o)return void console.log(chalk.red("Installation cancelled."))}else o=await getAnswer({},n);if(null===o)return void console.warn(chalk.red("Installation cancelled."));const l=await fetchPackageVersion("create-caspian-app"),p=getInstalledPackageInfo("create-caspian-app");isRunningFromNpxCache(__dirname)?console.log(chalk.gray("Skipping global create-caspian-app update because this command is running from an npx cache package.")):p.isLinked?console.log(chalk.gray("Skipping global create-caspian-app update because the global install is linked.")):p.version?-1===compareVersions(p.version,l)&&(execSync(buildManagedNpmCommand(["uninstall","-g","create-caspian-app"]),{stdio:"inherit"}),execSync(buildManagedNpmCommand(["install","-g","create-caspian-app"]),{stdio:"inherit"})):execSync(buildManagedNpmCommand(["install","-g","create-caspian-app"]),{stdio:"inherit"});const d=process.cwd();let u;if(t)if(r){const n=path.join(d,t);fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),u=n,await setupStarterKit(u,o),process.chdir(u);const s=path.join(u,"caspian.config.json");if(fs.existsSync(s)){const n=JSON.parse(fs.readFileSync(s,"utf8"));e.includes("--backend-only")&&(n.backendOnly=!0),e.includes("--tailwindcss")&&(n.tailwindcss=!0),e.includes("--typescript")&&(n.typescript=!0),e.includes("--mcp")&&(n.mcp=!0),e.includes("--websocket")&&(n.websocket=!0),e.includes("--prisma")&&(n.prisma=!0),o={...o,backendOnly:n.backendOnly,tailwindcss:n.tailwindcss,typescript:n.typescript,mcp:n.mcp,websocket:n.websocket??!1,prisma:n.prisma};let t=[];n.excludeFiles?.map(e=>{const n=path.join(u,e);fs.existsSync(n)&&t.push(n.replace(/\\/g,"/"))}),updateAnswer={...o,isUpdate:!0,componentScanDirs:n.componentScanDirs??[],excludeFiles:n.excludeFiles??[],excludeFilePath:t??[],filePath:u}}}else{const e=path.join(d,"caspian.config.json"),n=path.join(d,t),s=path.join(n,"caspian.config.json");fs.existsSync(e)?u=d:fs.existsSync(n)&&fs.existsSync(s)?(u=n,process.chdir(n)):(fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),u=n,process.chdir(n))}else fs.mkdirSync(o.projectName,{recursive:!0}),u=path.join(d,o.projectName),process.chdir(o.projectName);let m=[npmPkg("typescript"),npmPkg("@types/node"),npmPkg("tsx"),npmPkg("chalk"),npmPkg("npm-run-all"),npmPkg("browser-sync"),npmPkg("@types/browser-sync"),npmPkg("@lezer/common"),npmPkg("@lezer/python"),npmPkg("caspian-utils")];o.prisma&&m.push(npmPkg("prompts"),npmPkg("@types/prompts")),o.tailwindcss&&m.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano"),npmPkg("tailwind-merge")),o.prisma&&execSync(buildManagedNpmCommand(["install","-g","prisma-client-python@latest"]),{stdio:"inherit"}),o.typescript&&!o.backendOnly&&m.push(npmPkg("vite"),npmPkg("fast-glob")),o.typescript&&m.push(npmPkg("vitest")),o.starterKit&&!r&&await setupStarterKit(u,o),await installNpmDependencies(u,m,!0);let h=[];if(t||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(u,o),o.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],n=[],t=e=>{try{const n=path.join(u,"package.json");if(fs.existsSync(n)){const t=JSON.parse(fs.readFileSync(n,"utf8"));return!!(t.dependencies&&t.dependencies[e]||t.devDependencies&&t.devDependencies[e])}return!1}catch{return!1}};if(updateAnswer.backendOnly){nonBackendFiles.forEach(e=>{const n=path.join(u,"src","app",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const n=path.join(u,"src","app",e);fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log(`${e} was deleted successfully.`))})}if(!updateAnswer.tailwindcss){["postcss.config.js"].forEach(e=>{const n=path.join(u,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const s=path.join(u,"public","js","tailwind-merge.mjs");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${s} was deleted successfully.`));const i=path.join(u,"public","js","bundle-mjs.mjs.map");fs.existsSync(i)&&(fs.unlinkSync(i),console.log(`${i} was deleted successfully.`));const c=path.join(u,"ts","tailwind-merge.ts");fs.existsSync(c)&&(fs.unlinkSync(c),console.log(`${c} was deleted successfully.`));["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano","tailwind-merge"].forEach(n=>{t(n)&&e.push(n)}),n.push("tailwind-merge")}if(o.tailwindcss){const e=path.join(u,"public","css","index.css");if(fs.existsSync(e))try{fs.unlinkSync(e),console.log(`${e} was deleted successfully.`)}catch(n){console.warn(chalk.yellow(`Failed to delete ${e}: ${n}`))}}if(!updateAnswer.mcp){["restart-mcp.ts"].forEach(e=>{const n=path.join(u,"settings",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const e=path.join(u,"src","lib","mcp");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("MCP folder was deleted successfully.")),n.push("fastmcp")}if(!updateAnswer.websocket){const e=path.join(u,"src","lib","websocket");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("WebSocket folder was deleted successfully.")),n.push("websockets")}if(!updateAnswer.prisma){["prisma","@prisma/client","@prisma/internals","better-sqlite3","@prisma/adapter-better-sqlite3","mariadb","@prisma/adapter-mariadb","pg","@prisma/adapter-pg","@types/pg"].forEach(n=>{t(n)&&e.push(n)}),n.push("psycopg2-binary","asyncpg","aiosqlite","aiomysql")}if(!updateAnswer.typescript||updateAnswer.backendOnly){["vite.config.ts",path.join("settings","run-vite-watch.ts")].forEach(e=>{const n=path.join(u,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const n=path.join(u,"ts");fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const s=path.join(u,"settings","vite-plugins");fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log("settings/vite-plugins folder was deleted successfully."));["vite","fast-glob"].forEach(n=>{t(n)&&e.push(n)})}const s=e=>Array.from(new Set(e)),i=s(e);i.length>0&&(console.log(`Uninstalling npm packages: ${i.join(", ")}`),await uninstallNpmDependencies(u,i,!0));const c=s(n),a=getPyProjectDependencyNames(u);h=c.filter(e=>a.has(e.toLowerCase())),h.length>0&&console.log(chalk.gray(`Python dependencies will be removed via uv remove: ${h.join(", ")}`))}if(!r||!fs.existsSync(path.join(u,"caspian.config.json"))){const e=u.replace(/\\/g,"\\"),n=bsConfigUrls(e),t={projectName:o.projectName,projectRootPath:e,bsTarget:n.bsTarget,bsPathRewrite:n.bsPathRewrite,backendOnly:o.backendOnly,tailwindcss:o.tailwindcss,mcp:o.mcp,websocket:o.websocket,prisma:o.prisma,typescript:o.typescript,version:l,componentScanDirs:updateAnswer?.componentScanDirs??["src"],excludeFiles:updateAnswer?.excludeFiles??[]};fs.writeFileSync(path.join(u,"caspian.config.json"),JSON.stringify(t,null,2),{flag:"w"})}await ensurePythonVenvAndDeps(u,o,h),console.log("\n=========================\n"),console.log(`${chalk.green("Success!")} Caspian project successfully created in ${chalk.green(u.replace(/\\/g,"/"))}!`),console.log("\n=========================")}catch(e){console.error("Error while creating the project:",e),process.exit(1)}}main();
|
|
2
|
+
import{execSync,spawnSync}from"child_process";import fs from"fs";import{fileURLToPath}from"url";import path from"path";import chalk from"chalk";import prompts from"prompts";import https from"https";import{randomBytes}from"crypto";const __filename=fileURLToPath(import.meta.url),__dirname=path.dirname(__filename),PACKAGE_ROOT=path.resolve(__dirname,".."),OPTIONAL_TEMPLATE_FILES=new Set([".python-version",".prettierrc"]),OPTIONAL_TEMPLATE_DIRECTORIES=new Set([".github",".vscode"]),CASPIAN_SECTION_START="\x3c!-- caspian:start --\x3e",CASPIAN_SECTION_END="\x3c!-- caspian:end --\x3e",SELF_UPDATE_GUARD_ENV="CREATE_CASPIAN_APP_SELF_UPDATED";let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.py","not-found.py","error.py"],STARTER_KITS={basic:{id:"basic",name:"Basic PHP Application",description:"Simple PHP backend with minimal dependencies",features:{backendOnly:!0,tailwindcss:!1,prisma:!1,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","src/app/layout.py","src/app/index.py"]},fullstack:{id:"fullstack",name:"Full-Stack Application",description:"Complete web application with frontend and backend",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/app/layout.py","src/app/index.py","public/js/main.js","src/app/globals.css"]},api:{id:"api",name:"REST API",description:"Backend API with database and documentation",features:{backendOnly:!0,tailwindcss:!1,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py","pyproject.toml"]},realtime:{id:"realtime",name:"Real-time Application",description:"Application with WebSocket support and MCP",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!0,websocket:!0},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/lib/mcp"]}};function bsConfigUrls(e){const n=e.indexOf("\\htdocs\\");if(-1===n)return console.error("Invalid PROJECT_ROOT_PATH. The path does not contain \\htdocs\\"),{bsTarget:"",bsPathRewrite:{}};const t=e.substring(0,n+8).replace(/\\/g,"\\\\"),s=e.replace(new RegExp(`^${t}`),"").replace(/\\/g,"/");let i=`http://localhost/${s}`;i=i.endsWith("/")?i.slice(0,-1):i;const c=i.replace(/(?<!:)(\/\/+)/g,"/"),a=s.replace(/\/\/+/g,"/");return{bsTarget:`${c}/`,bsPathRewrite:{"^/":`/${a.startsWith("/")?a.substring(1):a}/`}}}async function updatePackageJson(e,n){const t=path.join(e,"package.json");if(checkExcludeFiles(t))return;const s=JSON.parse(fs.readFileSync(t,"utf8"));s.scripts={...s.scripts,projectName:"tsx settings/project-name.ts",format:"uv run python settings/format.py","format:check":"uv run python settings/format.py --check",check:"uv run python settings/check.py","check:fix":"uv run python settings/fix.py",logs:"uv run python settings/browser_log.py",static:"npm run build && uv run python settings/build-static.py","static:serve":"uv run python settings/serve-static.py"};let i=[];n.tailwindcss&&(s.scripts={...s.scripts,tailwind:"tsx settings/run-postcss.ts watch","tailwind:build":"tsx settings/run-postcss.ts build"},i.push("tailwind")),n.typescript&&!n.backendOnly&&(s.scripts={...s.scripts,"ts:watch":"vite build --watch","ts:watch:dev":"tsx settings/run-vite-watch.ts","ts:build":"vite build"},i.push("ts:watch:dev")),n.mcp&&(s.scripts={...s.scripts,mcp:"tsx settings/restart-mcp.ts"},i.push("mcp"));let c={...s.scripts};c.browserSync="tsx settings/bs-config.ts",c.dev=`npm-run-all projectName -l -p browserSync ${i.join(" ")}`;let a=["projectName"];n.tailwindcss&&a.unshift("tailwind:build"),n.typescript&&!n.backendOnly&&a.unshift("ts:build"),c.build=`npm-run-all ${a.join(" ")}`,s.scripts=c,s.type="module",fs.writeFileSync(t,JSON.stringify(s,null,2))}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateHexEncodedKey(e=16){return randomBytes(e).toString("hex")}function buildEnvSection(e,n){return`# =============================================================================\n${e.split("\n").map(e=>e.startsWith("#")?e:`# ${e}`).join("\n")}\n# =============================================================================\n\n${n.trimEnd()}`}function buildCaspianEnvContent(e){const n=generateAuthSecret(),t=generateHexEncodedKey(8),s=generateHexEncodedKey(32),i=[];return e.prisma&&i.push(buildEnvSection("1. DATABASE\n# Enforced by: prisma/schema.prisma, src/lib/prisma/db.py",'# Connection string. Prisma reads this directly from .env.\n# Format reference: https://pris.ly/d/connection-strings\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"\n\n# Connection-pool limit. Defaults: SQLite 5; MySQL and PostgreSQL 20.\n# Use 5 for local development; production does not need this unless you want to\n# limit the pool.\nDB_POOL_SIZE=5\n\n# Seconds idle before the client re-probes its connection. Default 30.\nPRISMA_CONN_PROBE_IDLE_SECONDS=30\n\n# Warn on queries that cause a full table scan. 0/false silences it. Default 1.\nPRISMA_WARN_FULL_SCAN=1')),i.push(buildEnvSection("2. APPLICATION RUNTIME\n# Enforced by: casp/runtime_security.py is_production_environment()",'# Environment selector, resolved FAIL-CLOSED: only an explicit development\n# value (dev, development, local, staging, test, testing) enables the\n# development relaxations. Unset or misspelled counts as production.\n#\n# Production turns on: HTTPS-only session cookie, Secure CSRF cookie, HSTS,\n# generic error messages, mandatory AUTH_SECRET, mandatory MCP_AUTH_TOKEN, and\n# it removes the localhost origin bypass and the WebSocket same-origin fallback.\n#\n# This single value gates most of the security posture. Set it deliberately.\nAPP_ENV="development"\n\n# Calendar timezone for the application, as an IANA name (e.g. "UTC",\n# "America/New_York", "America/Santo_Domingo"). Read by casp/app_time.py and\n# resolved once at boot in main.py.\n#\n# This sets which wall-clock DAY an instant belongs to: what casp.app_time.now()\n# and today() answer, how a stored timestamp reads back to a user, and the\n# boundaries a "today\'s totals" query uses. Timestamps are still STORED in UTC.\n#\n# It deliberately does NOT affect absolute time -- session expiry (casp/auth.py)\n# and cache TTLs (casp/cache_handler.py) stay on UTC, so changing this can never\n# extend a session or a cache entry.\n#\n# An unrecognized name raises InvalidAppTimezoneError at startup rather than\n# silently falling back to UTC. Empty or unset means UTC.\nAPP_TIMEZONE="UTC"'),buildEnvSection("3. PUBLIC URL, CORS, AND ORIGIN VALIDATION\n# Enforced by: casp/rpc.py origin checks, main.py CORS layer",'# Canonical public origin. Leave empty when the browser URL and the app runtime\n# URL match. Set it when they differ, i.e. behind an ingress, reverse proxy,\n# load balancer, gateway, edge network, or TLS terminator.\nAPP_BASE_URL=""\n\n# Extra browser origins allowed to call protected endpoints such as RPC. Use\n# when one deployment is reachable from more than one public origin.\n# Comma-separated, no spaces.\nCORS_ALLOWED_ORIGINS=""\n\n# Trust Forwarded/X-Forwarded-* headers. Enable ONLY when every request passes\n# through infrastructure that strips client-supplied forwarded headers before\n# setting its own, because a direct client can otherwise forge them.\n#\n# Affects two things: which origin RPC accepts, and which address the rate\n# limiter buckets on. Left false, both use the direct request instead.\nTRUST_FORWARDED_HEADERS="false"\n\n# Allow cookies/Authorization on cross-origin requests. Keep true only when\n# credentialed cross-origin requests are actually required.\nCORS_ALLOW_CREDENTIALS="true"\n\n# CORS preflight response fields.\nCORS_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"\nCORS_ALLOWED_HEADERS="Content-Type,Authorization,X-Requested-With"\nCORS_EXPOSE_HEADERS=""\n\n# Preflight cache duration in seconds.\nCORS_MAX_AGE="86400"'),buildEnvSection("4. AUTHENTICATION AND SESSIONS\n# Enforced by: casp/auth.py, main.py SessionMiddleware\n# Route privacy and RBAC live in src/lib/auth/auth_config.py, not here.",`# Session signing secret. Unique and strong per app and per environment.\n# In production the app refuses to start when this is missing or left on a\n# placeholder ("change-me"/"changeme"); in development it falls back.\nAUTH_SECRET="${n}"\n\n# Session cookie name. Use a unique value when several apps share a parent\n# domain, or their sessions overwrite each other.\nAUTH_COOKIE_NAME="${t}"\n\n# Session lifetime in hours (SessionMiddleware max_age).\nSESSION_LIFETIME_HOURS="7"`),buildEnvSection("5. OAUTH PROVIDERS\n# Enforced by: casp/auth.py; routes served by main.py AuthMiddleware","# Google and GitHub sign-in are already wired: AuthMiddleware serves\n# /api/auth/signin/{google,github} and /api/auth/callback/{google,github}.\n# Link a button at those paths, do not hand-roll OAuth.\n#\n# A provider with no client id is skipped SILENTLY: the redirect returns None\n# and the button appears dead, with no error and no log. Empty means disabled.\n\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\n\n# Must match the redirect URI registered in Google Cloud Console exactly.\n# Google is skipped unless BOTH the client id and this value are set.\nGOOGLE_REDIRECT_URI=\n\nGITHUB_CLIENT_ID=\nGITHUB_CLIENT_SECRET="),buildEnvSection("6. REQUEST SECURITY\n# Enforced by: main.py BodySizeLimitMiddleware, RequestDiagnosticsMiddleware",'# Max size of the whole HTTP request body in MB. Caps the entire body (file +\n# form fields + encoding overhead), so usable file size is a bit below this.\n# Middleware rejects oversized requests before the route runs. Raise if valid\n# uploads are blocked. Default 16.\nMAX_CONTENT_LENGTH_MB="16"\n\n# Seconds before a stalled route returns 504. Streaming paths (/mcp) are exempt\n# so long-lived transports are not cut mid-response. Default 20.\nCASPIAN_REQUEST_TIMEOUT_SECONDS=20'),buildEnvSection("7. SECURITY HEADERS\n# Enforced by: casp/runtime_security.py, main.py SecurityHeadersMiddleware","# Replaces the built-in Content-Security-Policy wholesale. Empty keeps the\n# default, which already permits the app's own assets.\n#\n# Any replacement MUST keep 'unsafe-eval' and 'unsafe-inline' in script-src:\n# the PulsePoint runtime compiles component templates with new Function(), so\n# removing them stops every page from rendering. Set this only to widen the\n# policy, e.g. for a CDN, analytics host, or an external frame embedder.\n#\n# Outside production, connect-src also allows http(s)/ws on localhost and\n# 127.0.0.1 on any port, because BrowserSync serves the proxied page on one port\n# while its injected live-reload client polls the BrowserSync server on another.\n# That is a separate origin, so 'self' does not cover it. Those entries are\n# omitted from a production policy. Setting an override here replaces BOTH, so\n# an override used in development must include the loopback sources itself or\n# live reload stops working.\n#\n# img-src and media-src admit remote content by scheme, so posters, avatars, CDN\n# thumbnails, and video load without per-project configuration. Plain http: is\n# development-only. Set an override here to pin them to named origins instead.\n#\n# Default: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';\n# style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;\n# media-src 'self' data: blob: https:; font-src 'self' data:;\n# connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';\n# form-action 'self'; frame-ancestors 'self'\nCONTENT_SECURITY_POLICY="),buildEnvSection("8. RATE LIMITING\n# Enforced by: main.py RateLimitMiddleware, casp/rpc.py RPCRateLimiter\n# Buckets are per client address; see TRUST_FORWARDED_HEADERS for which one.",'# Per-IP cap on page requests, applied before session decryption and rendering.\n# Static assets (/css, /js, /assets, /favicon.ico) and /health are exempt, so a\n# page load does not spend its own budget on its assets. Empty disables it.\n# Default 200/minute.\nRATE_LIMIT_PAGES=200/minute\n\n# Fallback limit for @rpc() actions that declare no limits= of their own.\nRATE_LIMIT_RPC="60 per minute"\n\n# Limit applied to @rpc(require_auth=True) actions that declare no limits=.\n# Tighten this and the per-action limits on sign-in and other credential paths.\nRATE_LIMIT_AUTH="60 per minute"\n\n# Configured for slowapi\'s Limiter. Note that slowapi\'s middleware is not in\n# the stack, so this value is inert today; page limiting is RATE_LIMIT_PAGES.\nRATE_LIMIT_DEFAULT="200 per minute"\n\n# In-memory bucket ceiling and sweep interval for the limiter store.\n# Defaults 10000 buckets, swept every 60 seconds.\nRATE_LIMIT_MAX_BUCKETS=10000\nRATE_LIMIT_CLEANUP_INTERVAL=60')),e.websocket&&i.push(buildEnvSection("9. WEBSOCKETS\n# Enforced by: src/lib/websocket/websocket_security.py, main.py channel loop\n# Only active when caspian.config.json has websocket: true.","# Browser origins allowed to open a socket (anti-CSWSH). Falls back to\n# CORS_ALLOWED_ORIGINS then APP_BASE_URL when empty.\n#\n# REQUIRED IN PRODUCTION. The convenience same-origin fallback is derived from\n# the client-supplied Host header, so it is development-only: without an\n# explicit list a spoofed Host plus matching Origin would validate itself.\n#\n# The HTTP middleware stack skips websocket scopes, so this and the socket\n# guard are the only checks a handshake passes. Comma-separated, no spaces.\nWEBSOCKET_ALLOWED_ORIGINS=\n\n# Seconds a socket may stay silent before the server closes it. Default 120.\nWEBSOCKET_IDLE_TIMEOUT_SECONDS=120\n\n# Max size of one inbound socket message in bytes. Oversized closes with 1009.\n# Default 4096.\nMAX_WEBSOCKET_MESSAGE_BYTES=4096\n\n# Simultaneous connections per pool; authenticated and guest pools are counted\n# separately. Refused connections close with 1013 during the handshake. Every\n# open socket is a live task and a broadcast target. Default 200.\nMAX_WEBSOCKET_CONNECTIONS=200\n\n# Per-connection send budget: messages allowed per rolling window. Each\n# accepted message fans out to the whole pool, so this bounds how much\n# broadcast one connection can generate. Defaults 20 per 10 seconds.\nMAX_WEBSOCKET_MESSAGES_PER_WINDOW=20\nWEBSOCKET_RATE_WINDOW_SECONDS=10")),e.mcp&&i.push(buildEnvSection("10. MCP ENDPOINT\n# Enforced by: main.py MCPAuthMiddleware; tools in src/lib/mcp/mcp_server.py\n# Only active when caspian.config.json has mcp: true.",`# Bearer token required to call /mcp. The MCP app is mounted outside the page\n# routing tree, so AuthMiddleware does NOT protect it, and its tools enumerate\n# the workspace file inventory and component map.\n#\n# generated -> every request needs "Authorization: Bearer <token>"\n#\n# REQUIRED IN PRODUCTION for the endpoint to work at all.\nMCP_AUTH_TOKEN="${s}"`)),i.push(buildEnvSection("11. CACHE\n# Enforced by: casp/cache_handler.py, main.py is_request_cacheable()",'# Master switch for serving pages from the disk cache.\n#\n# Entries are keyed on the URI alone, with no session component, so an\n# authenticated render is never cached: is_request_cacheable() gates both the\n# read and the write, and a route\'s Cache(...) cannot override it.\nCACHE_ENABLED="false"\n\n# Default cache lifetime in seconds, used when a route sets no ttl.\nCACHE_TTL="600"'),buildEnvSection("12. SERVER PROCESS\n# Enforced by: main.py __main__, settings/serve-static.py",'# Uvicorn workers are separate OS processes for the same FastAPI app. More\n# workers can increase throughput under concurrent load, but they do not make a\n# single request faster and they duplicate memory, connection pools, and any\n# in-process state. Keep at 1 unless the app is designed for multi-process\n# coordination and testing shows a real concurrency bottleneck.\nUVICORN_WORKERS="1"')),i.join("\n\n")}function copyRecursiveSync(e,n,t){const s=fs.existsSync(e),i=s&&fs.statSync(e);if(s&&i&&i.isDirectory()){const s=n.toLowerCase();if(!t.mcp&&s.includes("src\\lib\\mcp"))return;if(!t.websocket&&s.includes("src\\lib\\websocket"))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\ts")||s.includes("\\ts\\")))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\vite-plugins")||s.includes("\\vite-plugins\\")||s.includes("\\vite-plugins")))return;if(t.backendOnly&&s.includes("public\\js")||t.backendOnly&&s.includes("public\\css")||t.backendOnly&&s.includes("public\\assets"))return;const i=n.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(i))return;fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),fs.readdirSync(e).forEach(s=>{copyRecursiveSync(path.join(e,s),path.join(n,s),t)})}else{if(checkExcludeFiles(n))return;const s=n.replace(/\\/g,"/").toLowerCase();if(s.endsWith("/settings/run-vite-watch.ts")&&(!t.typescript||t.backendOnly))return;if(s.endsWith("/ts/tailwind-merge.ts")&&(!t.typescript||t.backendOnly||!t.tailwindcss))return;if(!t.tailwindcss&&(n.includes("globals.css")||n.includes("styles.css")))return;if(!t.mcp&&n.includes("restart-mcp.ts"))return;if(!t.websocket&&n.includes("src\\lib\\websocket"))return;if(t.backendOnly&&nonBackendFiles.some(e=>n.includes(e)))return;if(t.backendOnly&&n.includes("layout.py"))return;if(t.tailwindcss&&n.includes("index.css"))return;if(!t.prisma&&n.includes("prisma-schema-config.json"))return;fs.copyFileSync(e,n,0)}}async function executeCopy(e,n,t){n.forEach(({src:n,dest:s})=>{const i=normalizeTemplatePath(n),c=resolveTemplateSourcePath(n,"directory"),a=path.join(e,s);if(!c){if(OPTIONAL_TEMPLATE_DIRECTORIES.has(i))return void console.log(chalk.gray(`Optional template directory not found, skipping: ${i}`));throw new Error(`Template directory not found: ${i}. The package may be incomplete.`)}copyRecursiveSync(c,a,t)})}function modifyLayoutPHP(e,n){const t=path.join(e,"src","app","layout.py");if(!checkExcludeFiles(t))try{let e=fs.readFileSync(t,"utf8"),s="";n.backendOnly||(n.tailwindcss||(s='\n <link href="/css/index.css" rel="stylesheet" />'),s+='\n <script type="module" src="/js/main.js"><\/script>');let i="";n.backendOnly||(i=n.tailwindcss?` <link href="/css/styles.css" rel="stylesheet" />${s}`:s),e=e.replace("</head>",`${i}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.py:"),e)}}async function createOrUpdateEnvFile(e,n){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,n,{flag:"w"})}function ensureClaudeMd(e){const n=path.join(e,"CLAUDE.md");if(checkExcludeFiles(n))return;const t="@AGENTS.md";if(!fs.existsSync(n))return void fs.writeFileSync(n,`${t}\n`,{flag:"w"});const s=fs.readFileSync(n,"utf8").replace(/^\uFEFF/,"");if(s.trimStart().startsWith(t))return;const i=`${t}\n\n${s.trimStart()}`;fs.writeFileSync(n,i,{flag:"w"})}function writeTailwindMainJs(e){const n=path.join(e,"public","js","main.js");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.min.js";\nimport { twMerge } from "/js/tailwind-merge.mjs";\n\nconst pp = (globalThis).pp;\n\nglobalThis.twMerge = twMerge;\n\nif (document.readyState !== "loading") {\n pp?.mount?.();\n} else {\n document.addEventListener(\n "DOMContentLoaded",\n () => pp?.mount?.(),\n { once: true },\n );\n}\n',{flag:"w"}))}function copyTailwindMergeBundle(e){const n=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs"),t=path.join(e,"public","js","tailwind-merge.mjs"),s=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs.map"),i=path.join(e,"public","js","bundle-mjs.mjs.map");if(!checkExcludeFiles(t)){if(!fs.existsSync(n))throw new Error(`tailwind-merge bundle not found at ${n}`);fs.mkdirSync(path.dirname(t),{recursive:!0}),fs.copyFileSync(n,t),!checkExcludeFiles(i)&&fs.existsSync(s)&&fs.copyFileSync(s,i)}}function writeTailwindTypeScriptMain(e){const n=path.join(e,"ts","main.ts");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.min.js";\n\n// The following global names have already been declared elsewhere in the project:\n// - pp: Used for the Reactive Core functionality.\n\n// Imports goes here --Start\nimport { createGlobalSingleton } from "./global-functions.js";\nimport { mergeTailwindClasses } from "./tailwind-merge.js";\n\ncreateGlobalSingleton("twMerge", mergeTailwindClasses);\n\n\n// Imports goes here --End\n\nconst pp = (globalThis as any).pp;\n\nif (document.readyState !== "loading") {\n\tpp?.mount?.();\n} else {\n\tdocument.addEventListener(\n\t\t"DOMContentLoaded",\n\t\t() => pp?.mount?.(),\n\t\t{ once: true },\n\t);\n}\n',{flag:"w"}))}function checkExcludeFiles(e){if(!updateAnswer?.isUpdate)return!1;const n=e.replace(/\\/g,"/");return!!updateAnswer?.excludeFilePath?.includes(n)||!!updateAnswer?.excludeFiles&&updateAnswer.excludeFiles.some(e=>{const t=e.replace(/\\/g,"/");return n.endsWith("/"+t)||n===t})}function normalizeTemplatePath(e){return e.replace(/^[\\/]+/,"")}function resolveTemplateSourcePath(e,n){const t=normalizeTemplatePath(e),s=[path.join(__dirname,t),path.join(PACKAGE_ROOT,t)];for(const e of s){if(!fs.existsSync(e))continue;const t=fs.statSync(e);if("file"===n&&t.isFile())return e;if("directory"===n&&t.isDirectory())return e}return null}function extractCaspianSection(e){const n=e.indexOf(CASPIAN_SECTION_START);if(-1===n)return null;const t=e.indexOf(CASPIAN_SECTION_END,n);return-1===t?null:e.slice(t>n?n:0,t+20)}function mergeAgentsCaspianSection(e,n){const t=extractCaspianSection(n);if(!t)return e;const s=e.indexOf(CASPIAN_SECTION_START),i=e.indexOf(CASPIAN_SECTION_END,s);if(-1!==s&&-1!==i){return`${e.slice(0,s)}${t}${e.slice(i+20)}`}const c=e.endsWith("\n");return`${e}${c?"\n":"\n\n"}${t}\n`}async function createDirectoryStructure(e,n){const t=[{src:"/main.py",dest:"/main.py"},{src:"/.prettierrc",dest:"/.prettierrc"},{src:"/pyproject.toml",dest:"/pyproject.toml"},{src:"/tsconfig.json",dest:"/tsconfig.json"},{src:"/app-gitignore",dest:"/.gitignore"},{src:"/AGENTS.md",dest:"/AGENTS.md"},{src:"/.python-version",dest:"/.python-version"}];n.tailwindcss&&t.push({src:"/postcss.config.js",dest:"/postcss.config.js"}),n.typescript&&!n.backendOnly&&t.push({src:"/vite.config.ts",dest:"/vite.config.ts"});const s=[{src:"/settings",dest:"/settings"},{src:"/tests",dest:"/tests"},{src:"/src",dest:"/src"},{src:"/public",dest:"/public"},{src:"/.github",dest:"/.github"},{src:"/.vscode",dest:"/.vscode"}];n.typescript&&!n.backendOnly&&s.push({src:"/ts",dest:"/ts"}),t.forEach(({src:n,dest:t})=>{const s=normalizeTemplatePath(n),i=resolveTemplateSourcePath(n,"file"),c=path.join(e,t);if(checkExcludeFiles(c))return;if(!i){if(OPTIONAL_TEMPLATE_FILES.has(s))return void console.log(chalk.gray(`Optional template file not found, skipping: ${s}`));throw new Error(`Template file not found: ${s}. The package may be incomplete.`)}if("/pyproject.toml"===n&&updateAnswer?.isUpdate&&fs.existsSync(c))return void console.log(chalk.gray("Preserving existing pyproject.toml during update."));const a=fs.readFileSync(i,"utf8");if("/AGENTS.md"===n&&updateAnswer?.isUpdate&&fs.existsSync(c)){const e=mergeAgentsCaspianSection(fs.readFileSync(c,"utf8"),a);return void fs.writeFileSync(c,e,{flag:"w"})}fs.writeFileSync(c,a,{flag:"w"})}),await executeCopy(e,s,n),ensureClaudeMd(e),n.tailwindcss&&!n.backendOnly&&(n.typescript?writeTailwindTypeScriptMain(e):(copyTailwindMergeBundle(e),writeTailwindMainJs(e))),await updatePackageJson(e,n),!n.tailwindcss&&n.backendOnly||modifyLayoutPHP(e,n),await createOrUpdateEnvFile(e,buildCaspianEnvContent(n))}async function getAnswer(e={},n=!1){if(e.starterKit){const n=e.starterKit;let t=null;if(STARTER_KITS[n]&&(t=STARTER_KITS[n]),t){const s={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:t.features.backendOnly??!1,tailwindcss:t.features.tailwindcss??!1,prisma:t.features.prisma??!1,mcp:t.features.mcp??!1,websocket:t.features.websocket??!1,typescript:t.features.typescript??!1},i=process.argv.slice(2);return i.includes("--backend-only")&&(s.backendOnly=!0),i.includes("--tailwindcss")&&(s.tailwindcss=!0),i.includes("--mcp")&&(s.mcp=!0),i.includes("--websocket")&&(s.websocket=!0),i.includes("--prisma")&&(s.prisma=!0),i.includes("--typescript")&&(s.typescript=!0),s}if(e.starterKitSource){const t={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1,typescript:!1},s=process.argv.slice(2);return s.includes("--backend-only")&&(t.backendOnly=!0),s.includes("--tailwindcss")&&(t.tailwindcss=!0),s.includes("--mcp")&&(t.mcp=!0),s.includes("--websocket")&&(t.websocket=!0),s.includes("--prisma")&&(t.prisma=!0),s.includes("--typescript")&&(t.typescript=!0),t}}if(n)return{projectName:e.projectName??"my-app",starterKit:e.starterKit,starterKitSource:e.starterKitSource,backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,websocket:e.websocket??!1,prisma:e.prisma??!1};const t=[];e.projectName||t.push({type:"text",name:"projectName",message:"What is your project named?",initial:"my-app"}),e.backendOnly||updateAnswer?.isUpdate||t.push({type:"toggle",name:"backendOnly",message:`Would you like to create a ${chalk.blue("backend-only project")}?`,initial:!1,active:"Yes",inactive:"No"});const s=()=>{console.warn(chalk.red("Operation cancelled by the user.")),process.exit(0)},i=await prompts(t,{onCancel:s}),c=[];i.backendOnly??e.backendOnly??!1?(e.mcp||c.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.tailwindcss||c.push({type:"toggle",name:"tailwindcss",message:`Would you like to use ${chalk.blue("Tailwind CSS")}?`,initial:!1,active:"Yes",inactive:"No"}),e.typescript||c.push({type:"toggle",name:"typescript",message:`Would you like to use ${chalk.blue("TypeScript")}?`,initial:!1,active:"Yes",inactive:"No"}),e.mcp||c.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"}));const a=await prompts(c,{onCancel:s});return{projectName:i.projectName?String(i.projectName).trim().replace(/ /g,"-"):e.projectName??"my-app",backendOnly:i.backendOnly??e.backendOnly??!1,tailwindcss:a.tailwindcss??e.tailwindcss??!1,typescript:a.typescript??e.typescript??!1,mcp:a.mcp??e.mcp??!1,websocket:a.websocket??e.websocket??!1,prisma:a.prisma??e.prisma??!1}}async function uninstallNpmDependencies(e,n,t=!1){console.log("Uninstalling Node dependencies:"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["uninstall",t?"--save-dev":"--save",...n]);execSync(s,{stdio:"inherit",cwd:e})}function buildManagedNpmCommand(e){return`npm ${e.join(" ")} --ignore-scripts=false --min-release-age=0 --audit=false`}function fetchPackageVersion(e){return new Promise((n,t)=>{https.get(`https://registry.npmjs.org/${e}`,e=>{let s="";e.on("data",e=>s+=e),e.on("end",()=>{try{const e=JSON.parse(s);n(e["dist-tags"].latest)}catch(e){t(new Error("Failed to parse JSON response"))}})}).on("error",e=>t(e))})}const readJsonFile=e=>{const n=fs.readFileSync(e,"utf8");return JSON.parse(n)};function compareVersions(e,n){const t=e.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/),s=n.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);if(!t||!s)return e.localeCompare(n);const i=t.slice(1,4).map(Number),c=s.slice(1,4).map(Number);for(let e=0;e<i.length;e++){if(i[e]>c[e])return 1;if(i[e]<c[e])return-1}const a=t[4]??null,o=s[4]??null;return a&&!o?-1:!a&&o?1:a&&o?a.localeCompare(o):0}function getInstalledPackageInfo(e){try{const n=execSync(buildManagedNpmCommand(["list","-g",e,"--depth=0"])).toString(),t=n.match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)`));return t?{version:t[1],isLinked:n.includes(`${e}@`)&&n.includes("->")}:(console.error(`Package ${e} is not installed`),{version:null,isLinked:!1})}catch(e){return console.error(e instanceof Error?e.message:String(e)),{version:null,isLinked:!1}}}function isRunningFromNpxCache(e){const n=path.resolve(e).toLowerCase(),t=`${path.sep}_npx${path.sep}`.toLowerCase();return n.includes(t)}function resolveGlobalCliEntryPoint(){const e=[__filename];try{const n=execSync("npm root -g").toString().trim();n&&e.push(path.join(n,"create-caspian-app","dist","index.js"))}catch{}for(const n of e)if(n&&fs.existsSync(n))return n;return null}async function ensureLatestCliVersion(e){const n=await fetchPackageVersion("create-caspian-app");if("1"===process.env[SELF_UPDATE_GUARD_ENV])return n;if(isRunningFromNpxCache(__dirname))return console.log(chalk.gray("Skipping global create-caspian-app update because this command is running from an npx cache package.")),n;const t=getInstalledPackageInfo("create-caspian-app");if(t.isLinked)return console.log(chalk.gray("Skipping global create-caspian-app update because the global install is linked.")),n;if(!(!t.version||-1===compareVersions(t.version,n)))return n;execSync(buildManagedNpmCommand(["install","-g",`create-caspian-app@${n}`]),{stdio:"inherit"});const s=resolveGlobalCliEntryPoint();if(!s)throw new Error(`create-caspian-app was updated to ${n}, but the updated CLI could not be located. Please run the command again.`);console.log(chalk.gray(`Restarting with create-caspian-app@${n}...`));const i=spawnSync(process.execPath,[s,...e],{stdio:"inherit",env:{...process.env,[SELF_UPDATE_GUARD_ENV]:"1"}});if(i.error)throw i.error;process.exit(i.status??1)}async function installNpmDependencies(e,n,t=!1){fs.existsSync(path.join(e,"package.json"))?console.log("Updating existing Node.js project..."):console.log("Initializing new Node.js project..."),fs.existsSync(path.join(e,"package.json"))||execSync(buildManagedNpmCommand(["init","-y"]),{stdio:"inherit",cwd:e}),console.log((t?"Installing development dependencies":"Installing dependencies")+":"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["install",...t?["--save-dev"]:[],...n]);execSync(s,{stdio:"inherit",cwd:e})}const npmPinnedVersions={"@tailwindcss/postcss":"4.3.3","@types/browser-sync":"2.29.1","@types/node":"26.2.0","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"6.0.0","chokidar-cli":"3.0.0",cssnano:"8.0.5","npm-run-all":"4.1.5",postcss:"8.5.26","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.12",typescript:"7.0.2",vite:"8.2.0",vitest:"4.1.10","fast-glob":"3.3.3","@lezer/common":"1.5.2","@lezer/python":"1.1.19","caspian-utils":"0.2.x","tailwind-merge":"3.6.0"};function npmPkg(e){return npmPinnedVersions[e]?`${e}@${npmPinnedVersions[e]}`:e}function removeDirectorySafe(e){if(fs.existsSync(e))try{return void fs.rmSync(e,{recursive:!0,force:!0,maxRetries:5,retryDelay:250})}catch(n){const t=n;if("win32"===globalThis.process?.platform&&("EPERM"===t.code||"EACCES"===t.code)){try{spawnSync("cmd",["/c","attrib","-R","-H","-S","/S","/D",`${e}\\*`],{stdio:"ignore"})}catch{}return void spawnSync("cmd",["/c","rd","/s","/q",e],{stdio:"ignore"})}throw n}}async function setupStarterKit(e,n){if(!n.starterKit)return;let t=null;if(STARTER_KITS[n.starterKit]?t=STARTER_KITS[n.starterKit]:n.starterKitSource&&(t={id:n.starterKit,name:`Custom Starter Kit (${n.starterKit})`,description:"Custom starter kit from external source",features:{},requiredFiles:[],source:{type:"git",url:n.starterKitSource}}),t){if(console.log(chalk.green(`Setting up ${t.name}...`)),t.source)try{const s=t.source.branch?`git clone -b ${t.source.branch} --depth 1 ${t.source.url} "${e}"`:`git clone --depth 1 ${t.source.url} "${e}"`;execSync(s,{stdio:"inherit"});removeDirectorySafe(path.join(e,".git")),console.log(chalk.blue("Starter kit cloned successfully!"));const i=path.join(e,"caspian.config.json");if(fs.existsSync(i))try{const t=JSON.parse(fs.readFileSync(i,"utf8")),s=e,c=bsConfigUrls(s);t.projectName=n.projectName,t.projectRootPath=s,t.bsTarget=c.bsTarget,t.bsPathRewrite=c.bsPathRewrite;const a=await fetchPackageVersion("create-caspian-app");t.version=t.version||a,fs.writeFileSync(i,JSON.stringify(t,null,2)),console.log(chalk.green("Updated caspian.config.json with new project details"))}catch(e){console.warn(chalk.yellow("Failed to update caspian.config.json, will create new one"))}}catch(e){throw console.error(chalk.red(`Failed to setup starter kit: ${e}`)),e}t.customSetup&&await t.customSetup(e,n),console.log(chalk.green(`ā ${t.name} setup complete!`))}else console.warn(chalk.yellow(`Starter kit '${n.starterKit}' not found. Skipping...`))}function showStarterKits(){console.log(chalk.blue("\nš Available Starter Kits:\n")),Object.values(STARTER_KITS).forEach(e=>{const n=e.source?" (Custom)":" (Built-in)";console.log(chalk.green(` ${e.id}${chalk.gray(n)}`)),console.log(` ${e.name}`),console.log(chalk.gray(` ${e.description}`)),e.source&&console.log(chalk.cyan(` Source: ${e.source.url}`));const t=Object.entries(e.features).filter(([,e])=>!0===e).map(([e])=>e).join(", ");t&&console.log(chalk.magenta(` Features: ${t}`)),console.log()}),console.log(chalk.yellow("Usage:")),console.log(" npx create-caspian-app my-project --starter-kit=basic"),console.log(" npx create-caspian-app my-project --starter-kit=custom --starter-kit-source=https://github.com/user/repo"),console.log()}function runCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"inherit",shell:!1,encoding:"utf8"});if(s.error)throw s.error;if(0!==s.status)throw new Error(`Command failed (${e} ${n.join(" ")}), exit=${s.status}`)}function tryRunCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"ignore",shell:!1,encoding:"utf8"});return!s.error&&0===s.status}function tryInstallUv(e){console.log(chalk.blue("uv not found. Attempting to install uv..."));const n=[{cmd:"py",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python3",args:["-m","pip","install","--upgrade","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,t.args,e))return!0;return!1}function resolveUvCommand(e){const n=[{cmd:"uv",argsPrefix:[]},{cmd:"py",argsPrefix:["-m","uv"]},{cmd:"python",argsPrefix:["-m","uv"]},{cmd:"python3",argsPrefix:["-m","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;if(tryInstallUv(e))for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;throw new Error("Could not find or install uv. Install uv and ensure `uv`, `py`, or `python` is available in PATH.")}function buildPythonDependencies(e){const n=["fastapi==0.141.1","uvicorn==0.52.1","python-dotenv==1.2.2","tzdata==2026.3","jinja2==3.1.6","beautifulsoup4==4.15.0","slowapi==0.1.10","python-multipart==0.0.32","starsessions==2.2.1","httpx2==2.10.0","werkzeug==3.1.8","cuid2==2.0.1","nanoid==2.0.0","python-ulid==4.0.1","cuid==0.4","caspian-utils~=0.4"];return e.mcp&&n.push("fastmcp==3.4.7"),e.websocket&&n.push("websockets==17.0.1"),e.prisma&&(n.push("psycopg2-binary==2.9.12"),n.push("asyncpg==0.31.0"),n.push("aiosqlite==0.22.1"),n.push("aiomysql==0.3.2")),n}function buildPythonDevDependencies(){return["pyright==1.1.411","ruff==0.16.2","pytest==9.1.1","djlint==1.44.2"]}function getPythonRequirementName(e){const n=e.trim().match(/^([A-Za-z0-9._-]+)/);return n?.[1]??null}function getPyProjectDependencyNames(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))return new Set;const t=fs.readFileSync(n,"utf8").replace(/\r\n/g,"\n").match(/^[ \t]*dependencies[ \t]*=[ \t]*\[([\s\S]*?)\]/m);if(!t)return new Set;const s=new Set,i=/"([^"]+)"/g;let c;for(;null!==(c=i.exec(t[1]));){const e=c[1].trim().match(/^([A-Za-z0-9._-]+)/)?.[1];e&&s.add(e.toLowerCase())}return s}function ensurePyProjectExists(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))throw new Error(`pyproject.toml not found at: ${n}`);let t=fs.readFileSync(n,"utf8");t=t.replace(/\r\n/g,"\n"),t.includes("package = false")||(t=t.includes("[tool.uv]")?t.replace("[tool.uv]","[tool.uv]\npackage = false"):`${t.trimEnd()}\n\n[tool.uv]\npackage = false\n`),fs.writeFileSync(n,t,"utf8")}async function ensurePythonVenvAndDeps(e,n,t=[]){console.log(chalk.green("\n=========================")),console.log(chalk.green("Python setup: syncing dependencies with uv")),console.log(chalk.green("=========================\n")),console.log(chalk.blue("Preparing pyproject.toml...")),ensurePyProjectExists(e);const s=path.join(e,"requirements.txt");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(chalk.gray("Removed legacy requirements.txt")));const i=resolveUvCommand(e),c=path.join(e,".venv");fs.existsSync(c)?console.log(chalk.blue("Existing .venv detected. Reusing it so uv sync can update dependencies without replacing the environment.")):(console.log(chalk.blue("Creating the virtual environment with uv...")),runCmd(i.cmd,[...i.argsPrefix,"venv",".venv"],e));const a=buildPythonDependencies(n),o=buildPythonDevDependencies(),r=a.map(e=>getPythonRequirementName(e)).filter(e=>null!==e),l=o.map(e=>getPythonRequirementName(e)).filter(e=>null!==e);t.length>0&&(console.log(chalk.blue("Removing obsolete Python dependencies via uv remove...")),runCmd(i.cmd,[...i.argsPrefix,"remove",...t],e));const p=r.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dependencies via uv add...")),runCmd(i.cmd,[...i.argsPrefix,"add",...p,...a],e);const d=l.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dev dependencies via uv add --dev...")),runCmd(i.cmd,[...i.argsPrefix,"add","--dev",...d,...o],e),console.log(chalk.blue("Syncing dependencies...")),runCmd(i.cmd,[...i.argsPrefix,"sync"],e),console.log(chalk.green("\nā uv environment ready and dependencies installed.\n"))}async function main(){try{const e=process.argv.slice(2),n=e.includes("-y");let t=e[0];const s=e.find(e=>e.startsWith("--starter-kit=")),i=s?.split("=")[1],c=e.find(e=>e.startsWith("--starter-kit-source=")),a=c?.split("=")[1];if(e.includes("--list-starter-kits"))return void showStarterKits();const o=await ensureLatestCliVersion(e);let r=null,l=!1;if(t){const s=process.cwd(),c=path.join(s,"caspian.config.json");if(i&&a){l=!0;const s={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};r=await getAnswer(s,n)}else if(fs.existsSync(c)){const i=readJsonFile(c);let a=[];i.excludeFiles?.map(e=>{const n=path.join(s,e);fs.existsSync(n)&&a.push(n.replace(/\\/g,"/"))}),updateAnswer={projectName:t,backendOnly:i.backendOnly,tailwindcss:i.tailwindcss,mcp:i.mcp,websocket:i.websocket??!1,prisma:i.prisma,typescript:i.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:s};const o={projectName:t,backendOnly:e.includes("--backend-only")||i.backendOnly,tailwindcss:e.includes("--tailwindcss")||i.tailwindcss,typescript:e.includes("--typescript")||i.typescript,prisma:e.includes("--prisma")||i.prisma,mcp:e.includes("--mcp")||i.mcp,websocket:e.includes("--websocket")||(i.websocket??!1)};r=await getAnswer(o,n),null!==r&&(updateAnswer={projectName:t,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,websocket:r.websocket,prisma:r.prisma,typescript:r.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:s})}else{const s={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};r=await getAnswer(s,n)}if(null===r)return void console.log(chalk.red("Installation cancelled."))}else r=await getAnswer({},n);if(null===r)return void console.warn(chalk.red("Installation cancelled."));const p=process.cwd();let d;if(t)if(l){const n=path.join(p,t);fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),d=n,await setupStarterKit(d,r),process.chdir(d);const s=path.join(d,"caspian.config.json");if(fs.existsSync(s)){const n=JSON.parse(fs.readFileSync(s,"utf8"));e.includes("--backend-only")&&(n.backendOnly=!0),e.includes("--tailwindcss")&&(n.tailwindcss=!0),e.includes("--typescript")&&(n.typescript=!0),e.includes("--mcp")&&(n.mcp=!0),e.includes("--websocket")&&(n.websocket=!0),e.includes("--prisma")&&(n.prisma=!0),r={...r,backendOnly:n.backendOnly,tailwindcss:n.tailwindcss,typescript:n.typescript,mcp:n.mcp,websocket:n.websocket??!1,prisma:n.prisma};let t=[];n.excludeFiles?.map(e=>{const n=path.join(d,e);fs.existsSync(n)&&t.push(n.replace(/\\/g,"/"))}),updateAnswer={...r,isUpdate:!0,componentScanDirs:n.componentScanDirs??[],excludeFiles:n.excludeFiles??[],excludeFilePath:t??[],filePath:d}}}else{const e=path.join(p,"caspian.config.json"),n=path.join(p,t),s=path.join(n,"caspian.config.json");fs.existsSync(e)?d=p:fs.existsSync(n)&&fs.existsSync(s)?(d=n,process.chdir(n)):(fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),d=n,process.chdir(n))}else fs.mkdirSync(r.projectName,{recursive:!0}),d=path.join(p,r.projectName),process.chdir(r.projectName);let u=[npmPkg("typescript"),npmPkg("@types/node"),npmPkg("tsx"),npmPkg("chalk"),npmPkg("npm-run-all"),npmPkg("browser-sync"),npmPkg("@types/browser-sync"),npmPkg("@lezer/common"),npmPkg("@lezer/python"),npmPkg("caspian-utils")];r.prisma&&u.push(npmPkg("prompts"),npmPkg("@types/prompts")),r.tailwindcss&&u.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano"),npmPkg("tailwind-merge")),r.prisma&&execSync(buildManagedNpmCommand(["install","-g","prisma-client-python@latest"]),{stdio:"inherit"}),r.typescript&&!r.backendOnly&&u.push(npmPkg("vite"),npmPkg("fast-glob")),r.typescript&&u.push(npmPkg("vitest")),r.starterKit&&!l&&await setupStarterKit(d,r),await installNpmDependencies(d,u,!0);let m=[];if(t||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(d,r),r.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],n=[],t=e=>{try{const n=path.join(d,"package.json");if(fs.existsSync(n)){const t=JSON.parse(fs.readFileSync(n,"utf8"));return!!(t.dependencies&&t.dependencies[e]||t.devDependencies&&t.devDependencies[e])}return!1}catch{return!1}};if(updateAnswer.backendOnly){nonBackendFiles.forEach(e=>{const n=path.join(d,"src","app",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const n=path.join(d,"src","app",e);fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log(`${e} was deleted successfully.`))})}if(!updateAnswer.tailwindcss){["postcss.config.js"].forEach(e=>{const n=path.join(d,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const s=path.join(d,"public","js","tailwind-merge.mjs");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${s} was deleted successfully.`));const i=path.join(d,"public","js","bundle-mjs.mjs.map");fs.existsSync(i)&&(fs.unlinkSync(i),console.log(`${i} was deleted successfully.`));const c=path.join(d,"ts","tailwind-merge.ts");fs.existsSync(c)&&(fs.unlinkSync(c),console.log(`${c} was deleted successfully.`));["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano","tailwind-merge"].forEach(n=>{t(n)&&e.push(n)}),n.push("tailwind-merge")}if(r.tailwindcss){const e=path.join(d,"public","css","index.css");if(fs.existsSync(e))try{fs.unlinkSync(e),console.log(`${e} was deleted successfully.`)}catch(n){console.warn(chalk.yellow(`Failed to delete ${e}: ${n}`))}}if(!updateAnswer.mcp){["restart-mcp.ts"].forEach(e=>{const n=path.join(d,"settings",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const e=path.join(d,"src","lib","mcp");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("MCP folder was deleted successfully.")),n.push("fastmcp")}if(!updateAnswer.websocket){const e=path.join(d,"src","lib","websocket");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("WebSocket folder was deleted successfully.")),n.push("websockets")}if(!updateAnswer.prisma){["prisma","@prisma/client","@prisma/internals","better-sqlite3","@prisma/adapter-better-sqlite3","mariadb","@prisma/adapter-mariadb","pg","@prisma/adapter-pg","@types/pg"].forEach(n=>{t(n)&&e.push(n)}),n.push("psycopg2-binary","asyncpg","aiosqlite","aiomysql")}if(!updateAnswer.typescript||updateAnswer.backendOnly){["vite.config.ts",path.join("settings","run-vite-watch.ts")].forEach(e=>{const n=path.join(d,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const n=path.join(d,"ts");fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const s=path.join(d,"settings","vite-plugins");fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log("settings/vite-plugins folder was deleted successfully."));["vite","fast-glob"].forEach(n=>{t(n)&&e.push(n)})}const s=e=>Array.from(new Set(e)),i=s(e);i.length>0&&(console.log(`Uninstalling npm packages: ${i.join(", ")}`),await uninstallNpmDependencies(d,i,!0));const c=s(n),a=getPyProjectDependencyNames(d);m=c.filter(e=>a.has(e.toLowerCase())),m.length>0&&console.log(chalk.gray(`Python dependencies will be removed via uv remove: ${m.join(", ")}`))}if(!l||!fs.existsSync(path.join(d,"caspian.config.json"))){const e=d.replace(/\\/g,"\\"),n=bsConfigUrls(e),t={projectName:r.projectName,projectRootPath:e,bsTarget:n.bsTarget,bsPathRewrite:n.bsPathRewrite,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,websocket:r.websocket,prisma:r.prisma,typescript:r.typescript,version:o,componentScanDirs:updateAnswer?.componentScanDirs??["src"],excludeFiles:updateAnswer?.excludeFiles??[]};fs.writeFileSync(path.join(d,"caspian.config.json"),JSON.stringify(t,null,2),{flag:"w"})}await ensurePythonVenvAndDeps(d,r,m),console.log("\n=========================\n"),console.log(`${chalk.green("Success!")} Caspian project successfully created in ${chalk.green(d.replace(/\\/g,"/"))}!`),console.log("\n=========================")}catch(e){console.error("Error while creating the project:",e),process.exit(1)}}main();
|
|
@@ -24,6 +24,10 @@ Each rule below is a real rendering rule, not a heuristic:
|
|
|
24
24
|
* whitespace touching a block-level boundary collapses and never renders
|
|
25
25
|
* a text node's leading/trailing whitespace collapses when its parent is
|
|
26
26
|
block-level, but not when the parent is inline or an unknown `<x-*>` tag
|
|
27
|
+
* in a flex or grid container, an anonymous item holding only white space is
|
|
28
|
+
not rendered at all (CSS Flexbox 4, CSS Grid 6), so whitespace between that
|
|
29
|
+
container's children collapses -- including when the container is an `<x-*>`
|
|
30
|
+
tag whose rendered root is known (see `component_display.py`)
|
|
27
31
|
* `<pre>` / `<textarea>` text renders verbatim
|
|
28
32
|
* `<script>` / `<style>` bodies are code: indentation is irrelevant
|
|
29
33
|
* attribute ORDER never affects rendering; attribute VALUES always do
|
|
@@ -39,35 +43,119 @@ from __future__ import annotations
|
|
|
39
43
|
import re
|
|
40
44
|
from html.parser import HTMLParser
|
|
41
45
|
|
|
46
|
+
|
|
42
47
|
LITERAL_TAGS = {"pre", "textarea"}
|
|
43
48
|
CODE_TAGS = {"script", "style"}
|
|
44
49
|
|
|
45
|
-
# Whitespace touching one of these collapses away. A custom `<x-*>` tag is
|
|
46
|
-
# deliberately absent: its display is set by CSS the formatter cannot see, so it
|
|
47
|
-
# is treated as inline and its surrounding whitespace is significant.
|
|
48
50
|
# fmt: off
|
|
51
|
+
# Block-level boxes. Whitespace touching one of these collapses: it sits either
|
|
52
|
+
# at the edge of the box's own content or on a line that the block already
|
|
53
|
+
# broke, and in both places it is removed.
|
|
49
54
|
BLOCK_TAGS = {
|
|
50
55
|
"html", "head", "body", "div", "p", "section", "article", "header",
|
|
51
56
|
"footer", "nav", "aside", "main", "form", "fieldset", "legend", "figure",
|
|
52
57
|
"figcaption", "blockquote", "hr", "ul", "ol", "li", "dl", "dt", "dd",
|
|
53
58
|
"table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "colgroup",
|
|
54
59
|
"col", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "details", "summary",
|
|
55
|
-
"dialog", "script", "style", "template", "
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
"dialog", "script", "style", "template", "address", "hgroup", "menu",
|
|
61
|
+
"search", "noscript", "br", "meta", "link", "title",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Inline-level elements that still lay their *content* out as a block. A
|
|
65
|
+
# `<button>` is `inline-block`: the whitespace at the edges of its content
|
|
66
|
+
# collapses exactly as it would in a div, even though whitespace next to the
|
|
67
|
+
# button itself is significant. Keeping these out of BLOCK_TAGS is the point --
|
|
68
|
+
# two adjacent inline-blocks separated by a newline really do render a space.
|
|
69
|
+
CONTENT_BOX_TAGS = {
|
|
70
|
+
"button", "select", "textarea", "option", "optgroup",
|
|
71
|
+
"video", "audio", "canvas", "iframe", "object", "progress", "meter",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Containers where a child made only of whitespace cannot render at all.
|
|
75
|
+
# * SVG lays out no text, so indenting an svg fragment cannot change what is
|
|
76
|
+
# drawn. `<text>`, `<tspan>`, `<textPath>` and `<foreignObject>` are excluded
|
|
77
|
+
# because they do render their content.
|
|
78
|
+
# * A table or select drops stray text between its structural children.
|
|
79
|
+
# Flex and grid containers join this set at runtime, from their class list.
|
|
80
|
+
WS_DROPPING_TAGS = {
|
|
64
81
|
"svg", "g", "defs", "symbol", "use", "path", "circle", "ellipse", "line",
|
|
65
82
|
"polyline", "polygon", "rect", "clippath", "lineargradient",
|
|
66
83
|
"radialgradient", "stop", "mask", "pattern", "filter", "marker", "desc",
|
|
67
84
|
"animate", "animatetransform", "animatemotion", "switch", "image",
|
|
85
|
+
"table", "thead", "tbody", "tfoot", "tr", "colgroup", "select", "optgroup",
|
|
86
|
+
"html", "head",
|
|
68
87
|
}
|
|
69
88
|
# fmt: on
|
|
70
89
|
|
|
90
|
+
# Void elements cannot have children at all. A component written as
|
|
91
|
+
# `<x-input class="..."></x-input>` renders an `<input>`, so anything the author
|
|
92
|
+
# puts between those tags -- including a newline djLint adds -- is discarded.
|
|
93
|
+
VOID_TAGS = {
|
|
94
|
+
"area",
|
|
95
|
+
"base",
|
|
96
|
+
"br",
|
|
97
|
+
"col",
|
|
98
|
+
"embed",
|
|
99
|
+
"hr",
|
|
100
|
+
"img",
|
|
101
|
+
"input",
|
|
102
|
+
"link",
|
|
103
|
+
"meta",
|
|
104
|
+
"param",
|
|
105
|
+
"source",
|
|
106
|
+
"track",
|
|
107
|
+
"wbr",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Tailwind's display utilities that create a flex or grid formatting context.
|
|
111
|
+
# Membership is tested against whitespace-split class tokens, so only the
|
|
112
|
+
# unprefixed form counts: `sm:flex` means the element is *not* flex at every
|
|
113
|
+
# breakpoint, so its whitespace can still render on a narrow screen.
|
|
114
|
+
FLEX_DISPLAY_CLASSES = {"flex", "inline-flex", "grid", "inline-grid"}
|
|
115
|
+
# Display utilities that make an element a block-level box, so whitespace next
|
|
116
|
+
# to it collapses. The `inline-*` forms are deliberately absent: two adjacent
|
|
117
|
+
# inline-blocks separated by a newline really do render a space.
|
|
118
|
+
BLOCK_LEVEL_CLASSES = {"block", "flow-root", "list-item", "table", "flex", "grid"}
|
|
119
|
+
# `display: none` removes the box entirely, so it is not a block-level box that
|
|
120
|
+
# whitespace beside it can collapse against. It says nothing about how its own
|
|
121
|
+
# content is laid out when some state does display it, so it only suppresses
|
|
122
|
+
# `block_level`.
|
|
123
|
+
HIDDEN_CLASS = "hidden"
|
|
124
|
+
# `display: inline` makes an element lay its content out inline, so edge
|
|
125
|
+
# whitespace inside it becomes significant. `display: contents` removes the box
|
|
126
|
+
# and hoists the children into the parent, exactly like `as-child`.
|
|
127
|
+
INLINE_CLASS = "inline"
|
|
128
|
+
CONTENTS_CLASS = "contents"
|
|
129
|
+
|
|
130
|
+
# Display utilities that make an element lay its content out as a block, so the
|
|
131
|
+
# whitespace at the edges of that content collapses. `inline-block` counts: only
|
|
132
|
+
# whitespace *next to* it stays significant, not whitespace inside it.
|
|
133
|
+
BLOCK_CONTAINER_CLASSES = {
|
|
134
|
+
"block",
|
|
135
|
+
"inline-block",
|
|
136
|
+
"flow-root",
|
|
137
|
+
"list-item",
|
|
138
|
+
"table",
|
|
139
|
+
"inline-table",
|
|
140
|
+
"table-cell",
|
|
141
|
+
"table-row",
|
|
142
|
+
"table-caption",
|
|
143
|
+
}
|
|
144
|
+
# Utilities that switch whitespace collapsing off. Every rule here assumes
|
|
145
|
+
# collapsible text, so an element carrying one of these knows nothing.
|
|
146
|
+
PRE_WHITESPACE_CLASSES = {
|
|
147
|
+
"whitespace-pre",
|
|
148
|
+
"whitespace-pre-wrap",
|
|
149
|
+
"whitespace-pre-line",
|
|
150
|
+
"whitespace-break-spaces",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Attributes that make a component transparent: `as-child` renders the child in
|
|
154
|
+
# the component's place, so the child's whitespace lands in the *parent's*
|
|
155
|
+
# formatting context, not in any box the component owns.
|
|
156
|
+
AS_CHILD_ATTRS = ("as-child", "aschild", "as_child")
|
|
157
|
+
_TRUTHY_AS_CHILD = {"", "true", "1", "yes"}
|
|
158
|
+
|
|
71
159
|
JINJA = re.compile(r"\{\{\s*(.*?)\s*\}\}|\{%\s*(.*?)\s*%\}", re.S)
|
|
72
160
|
|
|
73
161
|
|
|
@@ -88,19 +176,436 @@ def _canon_attr_value(value: str | None) -> str | None:
|
|
|
88
176
|
return re.sub(r"\s+", " ", _canon_jinja(value)).strip()
|
|
89
177
|
|
|
90
178
|
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# What an `<x-*>` tag really is
|
|
181
|
+
#
|
|
182
|
+
# A component tag is an ordinary HTML tag with an `x-` prefix: `<x-search />`
|
|
183
|
+
# *is* an `<svg>`, `<x-dialog-close>` *is* a `<button>`. Nothing above needs a
|
|
184
|
+
# custom rule for them -- they only need resolving to the element they render,
|
|
185
|
+
# and then every rule already written for HTML applies unchanged.
|
|
186
|
+
#
|
|
187
|
+
# The mapping is not declared anywhere because it does not have to be: a
|
|
188
|
+
# component is a function returning markup, so rendering it with no props shows
|
|
189
|
+
# its root. That is done once, lazily, the first time a custom tag is seen.
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
_ROOT_TAG_RE = re.compile(r"\s*<([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>")
|
|
193
|
+
_CLASS_ATTR_RE = re.compile(r'(?:^|\s)class="([^"]*)"')
|
|
194
|
+
# `merge_classes` renders a live `{twMerge("base classes", ...)}` expression, so
|
|
195
|
+
# a component's own classes are the first quoted literal inside it.
|
|
196
|
+
_TWMERGE_LITERAL_RE = re.compile(r'twMerge\(\s*(?:"|")(.*?)(?:"|")')
|
|
197
|
+
|
|
198
|
+
_component_roots: dict[str, dict] | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _describe_component(component) -> dict | None:
|
|
202
|
+
"""Render one component with no props and name its root element."""
|
|
203
|
+
import asyncio
|
|
204
|
+
import html as html_module
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
markup = str(asyncio.run(component.acall()))
|
|
208
|
+
except Exception:
|
|
209
|
+
# Needs props, or does not render standalone. Unknown is the safe answer.
|
|
210
|
+
return None
|
|
211
|
+
match = _ROOT_TAG_RE.match(markup)
|
|
212
|
+
if match is None:
|
|
213
|
+
return None
|
|
214
|
+
root, attrs_text = match.group(1), match.group(2)
|
|
215
|
+
if root.startswith("x-"):
|
|
216
|
+
# A composition component whose root is another component: the compiler
|
|
217
|
+
# inserts a `display: contents` host, so resolving the chain would mean
|
|
218
|
+
# reasoning about a box that lays out as if it were not there.
|
|
219
|
+
return None
|
|
220
|
+
classes = ""
|
|
221
|
+
class_match = _CLASS_ATTR_RE.search(attrs_text)
|
|
222
|
+
if class_match is not None:
|
|
223
|
+
literal = _TWMERGE_LITERAL_RE.search(class_match.group(1))
|
|
224
|
+
raw = html_module.unescape(class_match.group(1))
|
|
225
|
+
classes = html_module.unescape(literal.group(1)) if literal else ("" if "{" in raw else raw)
|
|
226
|
+
return {"root": root, "classes": classes}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _load_component_roots() -> dict[str, dict]:
|
|
230
|
+
"""Map every `x-*` tag to the element it renders. `{}` if unavailable."""
|
|
231
|
+
import importlib
|
|
232
|
+
import json
|
|
233
|
+
import sys
|
|
234
|
+
from pathlib import Path
|
|
235
|
+
|
|
236
|
+
settings_dir = Path(__file__).resolve().parent
|
|
237
|
+
project_root = settings_dir.parent
|
|
238
|
+
if str(project_root) not in sys.path:
|
|
239
|
+
sys.path.insert(0, str(project_root))
|
|
240
|
+
try:
|
|
241
|
+
entries = json.loads((settings_dir / "component-map.json").read_text(encoding="utf-8"))
|
|
242
|
+
except OSError, ValueError:
|
|
243
|
+
return {}
|
|
244
|
+
|
|
245
|
+
roots: dict[str, dict] = {}
|
|
246
|
+
for entry in entries:
|
|
247
|
+
name = str(entry.get("componentName") or "")
|
|
248
|
+
route = str(entry.get("importRoute") or "")
|
|
249
|
+
if not name or not route:
|
|
250
|
+
continue
|
|
251
|
+
try:
|
|
252
|
+
component = getattr(importlib.import_module(route), name, None)
|
|
253
|
+
except Exception:
|
|
254
|
+
continue
|
|
255
|
+
if component is None or not hasattr(component, "acall"):
|
|
256
|
+
continue
|
|
257
|
+
info = _describe_component(component)
|
|
258
|
+
if info is None:
|
|
259
|
+
continue
|
|
260
|
+
tag = "x-" + re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
|
|
261
|
+
# Two components claiming one tag resolve by import order; unknown is safer.
|
|
262
|
+
if tag in roots and roots[tag] != info:
|
|
263
|
+
roots[tag] = {}
|
|
264
|
+
continue
|
|
265
|
+
roots[tag] = info
|
|
266
|
+
return roots
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def component_root(tag: str) -> dict:
|
|
270
|
+
"""The rendered root of an `x-*` tag: `{"root": ..., "classes": ...}`."""
|
|
271
|
+
global _component_roots
|
|
272
|
+
if _component_roots is None:
|
|
273
|
+
try:
|
|
274
|
+
_component_roots = _load_component_roots()
|
|
275
|
+
except Exception:
|
|
276
|
+
_component_roots = {}
|
|
277
|
+
return _component_roots.get(tag) or {}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def resolve_tag(tag: str) -> str:
|
|
281
|
+
"""The HTML tag an element behaves as. Non-components are themselves."""
|
|
282
|
+
if not tag.startswith("x-"):
|
|
283
|
+
return tag
|
|
284
|
+
return str(component_root(tag).get("root") or "")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Seeing through `{{ attributes }}`
|
|
289
|
+
#
|
|
290
|
+
# Caspian's props contract forwards a root's attributes as one Jinja value:
|
|
291
|
+
# `<div {{ attributes }}>`. An HTML parser sees an attribute named
|
|
292
|
+
# `{{attributes}}` and no class at all, so the element's display is invisible --
|
|
293
|
+
# which is what froze the last few blocks.
|
|
294
|
+
#
|
|
295
|
+
# The value is built in the same Python file, by the two documented helpers, so
|
|
296
|
+
# an AST walk can recover it. Only a *fully literal* class is accepted:
|
|
297
|
+
# `merge_classes("a b c")`. The moment a props-derived value is merged in
|
|
298
|
+
# (`merge_classes(base, incoming_class)`) the answer is refused, because the
|
|
299
|
+
# call site's class wins at runtime through `twMerge` and the call site is in
|
|
300
|
+
# another file.
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
_MAX_RESOLVE_DEPTH = 6
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _call_name(node) -> str:
|
|
307
|
+
func = node.func
|
|
308
|
+
return getattr(func, "id", None) or getattr(func, "attr", None) or ""
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _literal_classes(node, assigned: dict, depth: int = 0) -> str | None:
|
|
312
|
+
"""The class list a node evaluates to, or None when it is not fully literal."""
|
|
313
|
+
import ast
|
|
314
|
+
|
|
315
|
+
if depth > _MAX_RESOLVE_DEPTH:
|
|
316
|
+
return None
|
|
317
|
+
if isinstance(node, ast.Constant):
|
|
318
|
+
return node.value if isinstance(node.value, str) else None
|
|
319
|
+
if isinstance(node, ast.Name):
|
|
320
|
+
values = assigned.get(node.id)
|
|
321
|
+
# Reassigned under a branch: which value reaches the template is a
|
|
322
|
+
# runtime decision.
|
|
323
|
+
if not values or len(values) != 1:
|
|
324
|
+
return None
|
|
325
|
+
return _literal_classes(values[0], assigned, depth + 1)
|
|
326
|
+
if isinstance(node, ast.Call):
|
|
327
|
+
name = _call_name(node)
|
|
328
|
+
if name == "merge_classes":
|
|
329
|
+
if node.keywords:
|
|
330
|
+
return None
|
|
331
|
+
parts = []
|
|
332
|
+
for arg in node.args:
|
|
333
|
+
value = _literal_classes(arg, assigned, depth + 1)
|
|
334
|
+
if value is None:
|
|
335
|
+
return None
|
|
336
|
+
parts.append(value)
|
|
337
|
+
return " ".join(parts)
|
|
338
|
+
if name == "get_attributes":
|
|
339
|
+
if not node.args or not isinstance(node.args[0], ast.Dict):
|
|
340
|
+
return None
|
|
341
|
+
for key, value in zip(node.args[0].keys, node.args[0].values):
|
|
342
|
+
if isinstance(key, ast.Constant) and key.value == "class":
|
|
343
|
+
return _literal_classes(value, assigned, depth + 1)
|
|
344
|
+
return "" # a forwarded attribute set with no class of its own
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# A component script may also hold a class list behind a helper:
|
|
349
|
+
# `class="{getIndicatorIconClass()}"`. Only the simplest possible shape is
|
|
350
|
+
# accepted -- `const name = () => "literal";` -- because it takes no arguments,
|
|
351
|
+
# closes over nothing and has a single string body, so the attribute's value is
|
|
352
|
+
# that literal on every render. A helper with parameters or a block body is a
|
|
353
|
+
# runtime decision and stays unknown.
|
|
354
|
+
_SCRIPT_CLASS_FN = re.compile(
|
|
355
|
+
r"""(?<![\w$])const\s+([A-Za-z_$][\w$]*)\s*=\s*\(\s*\)\s*=>\s*(['"])(.*?)\2""",
|
|
356
|
+
re.S,
|
|
357
|
+
)
|
|
358
|
+
# Any other binding of that name means the arrow is not the whole story.
|
|
359
|
+
_SCRIPT_ANY_BINDING = r"(?<![\w$])(?:const|let|var|function)\s+{name}(?![\w$])"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def script_class_literals(source: str) -> dict[str, str]:
|
|
363
|
+
"""Map `{name()}` class expressions to the literal they always return."""
|
|
364
|
+
found: dict[str, str] = {}
|
|
365
|
+
for name, _, literal in _SCRIPT_CLASS_FN.findall(source):
|
|
366
|
+
bindings = re.findall(_SCRIPT_ANY_BINDING.format(name=re.escape(name)), source)
|
|
367
|
+
if len(bindings) != 1:
|
|
368
|
+
found.pop(name, None)
|
|
369
|
+
continue
|
|
370
|
+
found[name] = literal
|
|
371
|
+
return {"{" + name + "()}": literal for name, literal in found.items()}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def class_hints(python_source: str) -> dict[str, str]:
|
|
375
|
+
"""Every class list this file hides behind an expression.
|
|
376
|
+
|
|
377
|
+
Keyed by the attribute text the markup actually carries: `{{attributes}}`
|
|
378
|
+
for a forwarded Jinja value, `{helper()}` for a script helper.
|
|
379
|
+
"""
|
|
380
|
+
hints = jinja_attr_classes(python_source)
|
|
381
|
+
hints.update(script_class_literals(python_source))
|
|
382
|
+
return hints
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def jinja_attr_classes(python_source: str) -> dict[str, str]:
|
|
386
|
+
"""Map each `{{ name }}` attribute variable to the class list it carries."""
|
|
387
|
+
import ast
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
tree = ast.parse(python_source)
|
|
391
|
+
except SyntaxError:
|
|
392
|
+
return {}
|
|
393
|
+
|
|
394
|
+
assigned: dict[str, list] = {}
|
|
395
|
+
for node in ast.walk(tree):
|
|
396
|
+
if isinstance(node, ast.Assign):
|
|
397
|
+
for target in node.targets:
|
|
398
|
+
if isinstance(target, ast.Name):
|
|
399
|
+
assigned.setdefault(target.id, []).append(node.value)
|
|
400
|
+
|
|
401
|
+
resolved: dict[str, str] = {}
|
|
402
|
+
for node in ast.walk(tree):
|
|
403
|
+
if not isinstance(node, ast.Call) or _call_name(node) != "html":
|
|
404
|
+
continue
|
|
405
|
+
for keyword in node.keywords:
|
|
406
|
+
if not keyword.arg:
|
|
407
|
+
continue
|
|
408
|
+
classes = _literal_classes(keyword.value, assigned)
|
|
409
|
+
if classes is not None:
|
|
410
|
+
resolved["{{" + keyword.arg.lower() + "}}"] = classes
|
|
411
|
+
return resolved
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# Every token that can set an element's display. A component's base classes are
|
|
415
|
+
# merged with whatever the call site passes, and `twMerge` lets the call site
|
|
416
|
+
# win -- so `<x-button class="inline">` really is inline despite the component's
|
|
417
|
+
# `inline-flex` base.
|
|
418
|
+
_DISPLAY_TOKENS = (
|
|
419
|
+
FLEX_DISPLAY_CLASSES
|
|
420
|
+
| BLOCK_LEVEL_CLASSES
|
|
421
|
+
| BLOCK_CONTAINER_CLASSES
|
|
422
|
+
| {HIDDEN_CLASS, INLINE_CLASS, CONTENTS_CLASS}
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# Class-like words in an attribute value, including the ones inside a
|
|
427
|
+
# PulsePoint expression such as `{compact ? 'flex' : 'block'}`. Quotes and
|
|
428
|
+
# punctuation are not part of a word, so `'flex'` yields `flex` while the
|
|
429
|
+
# flex-grow utility `flex-1` stays `flex-1`.
|
|
430
|
+
_CLASS_WORD = re.compile(r"[A-Za-z][A-Za-z0-9_/-]*(?::[A-Za-z0-9_/-]+)*")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _may_override_display(usage_class: str | None) -> bool:
|
|
434
|
+
"""Whether a call site's class attribute could change the rendered display.
|
|
435
|
+
|
|
436
|
+
The value may be a PulsePoint expression rather than a plain class list, so
|
|
437
|
+
every class-like word in it is checked, variant prefix stripped: `sm:flex`
|
|
438
|
+
could override at that breakpoint, `flex-1` is flex-grow and cannot. If no
|
|
439
|
+
word can set a display, the call site provably cannot override the
|
|
440
|
+
component's own; if one can, the answer is unknown.
|
|
441
|
+
"""
|
|
442
|
+
if not usage_class:
|
|
443
|
+
return False
|
|
444
|
+
for word in _CLASS_WORD.findall(usage_class):
|
|
445
|
+
if word.rsplit(":", 1)[-1] in _DISPLAY_TOKENS:
|
|
446
|
+
return True
|
|
447
|
+
return False
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _preserves_whitespace(class_value: str | None) -> bool:
|
|
451
|
+
"""Whether a class list turns off whitespace collapsing.
|
|
452
|
+
|
|
453
|
+
Every rule here assumes collapsible text. Under `white-space: pre` a run of
|
|
454
|
+
spaces renders literally, and even a whitespace-only flex item is drawn, so
|
|
455
|
+
an element carrying one of these utilities has to fall back to knowing
|
|
456
|
+
nothing about its children.
|
|
457
|
+
"""
|
|
458
|
+
if not class_value:
|
|
459
|
+
return False
|
|
460
|
+
return any(token in PRE_WHITESPACE_CLASSES for token in class_value.split())
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _as_child_state(attr_map: dict[str, str | None]) -> str:
|
|
464
|
+
"""`"yes"` / `"no"` / `"unknown"` for a tag's as-child attribute."""
|
|
465
|
+
for name in AS_CHILD_ATTRS:
|
|
466
|
+
if name not in attr_map:
|
|
467
|
+
continue
|
|
468
|
+
value = (attr_map[name] or "").strip().lower()
|
|
469
|
+
if "{" in value:
|
|
470
|
+
# A runtime expression: the component may or may not be transparent,
|
|
471
|
+
# and guessing either way could drop whitespace that renders.
|
|
472
|
+
return "unknown"
|
|
473
|
+
return "yes" if value in _TRUTHY_AS_CHILD else "no"
|
|
474
|
+
return "no"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class _Frame:
|
|
478
|
+
"""One open element, reduced to how it treats its children's whitespace.
|
|
479
|
+
|
|
480
|
+
``collapses`` -- a text child's edge whitespace cannot render.
|
|
481
|
+
``drops_ws`` -- a child made only of whitespace cannot render at all.
|
|
482
|
+
``transparent`` -- the element owns no box (`as-child`); defer to the
|
|
483
|
+
nearest ancestor that does.
|
|
484
|
+
``opaque`` -- nothing is known; every rule must stay conservative.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
__slots__ = (
|
|
488
|
+
"tag",
|
|
489
|
+
"collapses",
|
|
490
|
+
"drops_ws",
|
|
491
|
+
"block_level",
|
|
492
|
+
"flex_container",
|
|
493
|
+
"transparent",
|
|
494
|
+
"opaque",
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def __init__(
|
|
498
|
+
self,
|
|
499
|
+
tag: str,
|
|
500
|
+
attrs: tuple,
|
|
501
|
+
in_flex_parent: bool = False,
|
|
502
|
+
jinja_classes: dict | None = None,
|
|
503
|
+
) -> None:
|
|
504
|
+
self.tag = tag
|
|
505
|
+
attr_map = dict(attrs)
|
|
506
|
+
state = _as_child_state(attr_map)
|
|
507
|
+
self.transparent = state == "yes"
|
|
508
|
+
self.opaque = state == "unknown"
|
|
509
|
+
self.block_level = False
|
|
510
|
+
self.flex_container = False
|
|
511
|
+
if self.transparent or self.opaque:
|
|
512
|
+
self.collapses = self.drops_ws = False
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
# A component behaves as the element it renders, plus that element's
|
|
516
|
+
# own classes; an ordinary tag is simply itself.
|
|
517
|
+
html_tag = tag
|
|
518
|
+
classes = attr_map.get("class") or ""
|
|
519
|
+
if classes and jinja_classes:
|
|
520
|
+
# `class="{helper()}"` -- a helper with a single literal body.
|
|
521
|
+
classes = jinja_classes.get(classes, classes)
|
|
522
|
+
if not classes and jinja_classes:
|
|
523
|
+
# `<div {{ attributes }}>` -- the class is behind a forwarded value.
|
|
524
|
+
for name in attr_map:
|
|
525
|
+
if name in jinja_classes:
|
|
526
|
+
classes = jinja_classes[name]
|
|
527
|
+
break
|
|
528
|
+
if tag.startswith("x-"):
|
|
529
|
+
html_tag = resolve_tag(tag)
|
|
530
|
+
if _may_override_display(classes):
|
|
531
|
+
# The call site could win the display over the component's base
|
|
532
|
+
# classes, and which one wins is a runtime `twMerge` decision.
|
|
533
|
+
self.collapses = self.drops_ws = False
|
|
534
|
+
return
|
|
535
|
+
classes = str(component_root(tag).get("classes") or "")
|
|
536
|
+
|
|
537
|
+
class_tokens = set(classes.split())
|
|
538
|
+
if CONTENTS_CLASS in class_tokens:
|
|
539
|
+
# No box of its own: the children belong to the parent's context.
|
|
540
|
+
self.transparent = True
|
|
541
|
+
self.collapses = self.drops_ws = False
|
|
542
|
+
return
|
|
543
|
+
if not html_tag or _preserves_whitespace(classes) or INLINE_CLASS in class_tokens:
|
|
544
|
+
self.collapses = self.drops_ws = False
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
flex = bool(class_tokens & FLEX_DISPLAY_CLASSES)
|
|
548
|
+
self.flex_container = flex
|
|
549
|
+
self.drops_ws = flex or html_tag in WS_DROPPING_TAGS or html_tag in VOID_TAGS
|
|
550
|
+
self.collapses = (
|
|
551
|
+
flex
|
|
552
|
+
or self.drops_ws
|
|
553
|
+
or html_tag in BLOCK_TAGS
|
|
554
|
+
or html_tag in CONTENT_BOX_TAGS
|
|
555
|
+
or bool(class_tokens & BLOCK_CONTAINER_CLASSES)
|
|
556
|
+
)
|
|
557
|
+
# An element's own display utility outranks its tag's default: a
|
|
558
|
+
# `<label class="block">` is a block-level box, a `<div class="inline">`
|
|
559
|
+
# is not one any more.
|
|
560
|
+
if HIDDEN_CLASS in class_tokens:
|
|
561
|
+
self.block_level = False
|
|
562
|
+
return
|
|
563
|
+
if in_flex_parent:
|
|
564
|
+
# A flex or grid item is blockified: its computed display becomes
|
|
565
|
+
# the block-level equivalent whatever the element's default was
|
|
566
|
+
# (CSS Display 2.7). So an inline `<label>` inside a grid really is
|
|
567
|
+
# a block box, and the whitespace at its edges collapses.
|
|
568
|
+
self.block_level = True
|
|
569
|
+
self.collapses = True
|
|
570
|
+
return
|
|
571
|
+
self.block_level = bool(class_tokens & BLOCK_LEVEL_CLASSES) or (
|
|
572
|
+
html_tag in BLOCK_TAGS
|
|
573
|
+
and not (class_tokens & BLOCK_CONTAINER_CLASSES - BLOCK_LEVEL_CLASSES)
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
91
577
|
class _Tokens(HTMLParser):
|
|
92
578
|
"""Reduce markup to a token stream where equality implies equal rendering."""
|
|
93
579
|
|
|
94
|
-
def __init__(self) -> None:
|
|
580
|
+
def __init__(self, jinja_classes: dict | None = None) -> None:
|
|
95
581
|
super().__init__(convert_charrefs=False)
|
|
582
|
+
self.jinja_classes = jinja_classes or {}
|
|
96
583
|
self.out: list[tuple] = []
|
|
97
|
-
self._open: list[
|
|
584
|
+
self._open: list[_Frame] = []
|
|
98
585
|
self._raw_stack: list[str] = []
|
|
99
586
|
self._raw_buf: list[str] = []
|
|
100
587
|
|
|
101
588
|
def _in_raw(self) -> str | None:
|
|
102
589
|
return self._raw_stack[-1] if self._raw_stack else None
|
|
103
590
|
|
|
591
|
+
def _parent_is_flex(self) -> bool:
|
|
592
|
+
"""Whether the element about to open becomes a flex or grid item."""
|
|
593
|
+
frame = self._context()
|
|
594
|
+
return frame is not None and frame.flex_container
|
|
595
|
+
|
|
596
|
+
def _context(self) -> "_Frame | None":
|
|
597
|
+
"""The nearest open element that actually owns a box.
|
|
598
|
+
|
|
599
|
+
A transparent element (`as-child`) renders its child in its own place,
|
|
600
|
+
so whitespace written inside it belongs to whichever ancestor lays the
|
|
601
|
+
child out. An opaque one stops the walk with nothing known.
|
|
602
|
+
"""
|
|
603
|
+
for frame in reversed(self._open):
|
|
604
|
+
if frame.transparent:
|
|
605
|
+
continue
|
|
606
|
+
return frame
|
|
607
|
+
return None
|
|
608
|
+
|
|
104
609
|
def _attrs(self, attrs) -> tuple:
|
|
105
610
|
return tuple(sorted((k, _canon_attr_value(v)) for k, v in attrs))
|
|
106
611
|
|
|
@@ -111,15 +616,19 @@ class _Tokens(HTMLParser):
|
|
|
111
616
|
if tag in LITERAL_TAGS | CODE_TAGS:
|
|
112
617
|
self._raw_stack.append(tag)
|
|
113
618
|
self._raw_buf = []
|
|
114
|
-
|
|
115
|
-
self.
|
|
619
|
+
attrs_tuple = self._attrs(attrs)
|
|
620
|
+
frame = _Frame(tag, attrs_tuple, self._parent_is_flex(), self.jinja_classes)
|
|
621
|
+
self.out.append(("start", tag, attrs_tuple, frame.block_level))
|
|
622
|
+
self._open.append(frame)
|
|
116
623
|
|
|
117
624
|
def handle_startendtag(self, tag, attrs):
|
|
118
625
|
if self._in_raw():
|
|
119
626
|
self._raw_buf.append(self.get_starttag_text() or "")
|
|
120
627
|
return
|
|
121
|
-
|
|
122
|
-
self.
|
|
628
|
+
attrs_tuple = self._attrs(attrs)
|
|
629
|
+
frame = _Frame(tag, attrs_tuple, self._parent_is_flex(), self.jinja_classes)
|
|
630
|
+
self.out.append(("start", tag, attrs_tuple, frame.block_level))
|
|
631
|
+
self.out.append(("end", tag, frame.block_level))
|
|
123
632
|
|
|
124
633
|
def handle_endtag(self, tag):
|
|
125
634
|
raw = self._in_raw()
|
|
@@ -134,9 +643,10 @@ class _Tokens(HTMLParser):
|
|
|
134
643
|
self.out.append(("raw", raw, body))
|
|
135
644
|
self._raw_stack.pop()
|
|
136
645
|
self._raw_buf = []
|
|
137
|
-
self.
|
|
138
|
-
|
|
139
|
-
|
|
646
|
+
closing = next((f for f in reversed(self._open) if f.tag == tag), None)
|
|
647
|
+
self.out.append(("end", tag, closing.block_level if closing else False))
|
|
648
|
+
if any(frame.tag == tag for frame in self._open):
|
|
649
|
+
while self._open and self._open.pop().tag != tag:
|
|
140
650
|
pass
|
|
141
651
|
|
|
142
652
|
def handle_data(self, data):
|
|
@@ -146,18 +656,24 @@ class _Tokens(HTMLParser):
|
|
|
146
656
|
collapsed = re.sub(r"\s+", " ", _canon_jinja(data))
|
|
147
657
|
if collapsed == "":
|
|
148
658
|
return
|
|
149
|
-
|
|
659
|
+
frame = self._context()
|
|
660
|
+
parent = frame.tag if frame is not None else ""
|
|
150
661
|
if collapsed.strip() == "":
|
|
151
|
-
# A pure-whitespace node
|
|
152
|
-
#
|
|
153
|
-
|
|
662
|
+
# A pure-whitespace node. Inside a flex or grid container it becomes
|
|
663
|
+
# an anonymous item holding only white space, which is never
|
|
664
|
+
# rendered; anywhere else its presence can separate two inline
|
|
665
|
+
# elements, so it is kept as a token whose length is irrelevant.
|
|
666
|
+
if frame is not None and frame.drops_ws:
|
|
667
|
+
return
|
|
668
|
+
self.out.append(("ws", frame is not None and frame.collapses))
|
|
154
669
|
return
|
|
670
|
+
collapses = frame is not None and frame.collapses
|
|
155
671
|
self.out.append(
|
|
156
672
|
(
|
|
157
673
|
"text",
|
|
158
674
|
collapsed.strip(),
|
|
159
|
-
collapsed[0].isspace(),
|
|
160
|
-
collapsed[-1].isspace(),
|
|
675
|
+
collapsed[0].isspace() and not collapses,
|
|
676
|
+
collapsed[-1].isspace() and not collapses,
|
|
161
677
|
parent,
|
|
162
678
|
)
|
|
163
679
|
)
|
|
@@ -185,10 +701,16 @@ class _Tokens(HTMLParser):
|
|
|
185
701
|
|
|
186
702
|
|
|
187
703
|
def _is_block_boundary(token: tuple | None) -> bool:
|
|
704
|
+
"""Whether whitespace touching this token collapses.
|
|
705
|
+
|
|
706
|
+
Only a genuinely block-level box qualifies. An inline-block (`<button>`) or
|
|
707
|
+
an inline replaced element (`<svg>`) does not: two of them separated by a
|
|
708
|
+
newline really do render a space between them.
|
|
709
|
+
"""
|
|
188
710
|
if token is None:
|
|
189
711
|
return True # the fragment's own edge
|
|
190
712
|
if token[0] in ("start", "end"):
|
|
191
|
-
return token[1]
|
|
713
|
+
return bool(token[-1])
|
|
192
714
|
return False
|
|
193
715
|
|
|
194
716
|
|
|
@@ -196,32 +718,52 @@ def _drop_insignificant_ws(tokens: list[tuple]) -> list[tuple]:
|
|
|
196
718
|
"""Remove whitespace nodes that provably cannot render."""
|
|
197
719
|
out: list[tuple] = []
|
|
198
720
|
for i, tok in enumerate(tokens):
|
|
199
|
-
if tok !=
|
|
721
|
+
if tok[0] != "ws":
|
|
200
722
|
out.append(tok)
|
|
201
723
|
continue
|
|
202
724
|
prev = out[-1] if out else None
|
|
203
|
-
nxt = next((t for t in tokens[i + 1 :] if t !=
|
|
725
|
+
nxt = next((t for t in tokens[i + 1 :] if t[0] != "ws"), None)
|
|
204
726
|
if _is_block_boundary(prev) and _is_block_boundary(nxt):
|
|
205
727
|
continue
|
|
206
|
-
|
|
728
|
+
# Whitespace at the first or last position inside a container that lays
|
|
729
|
+
# its content out as a block: a leading space is removed at the start of
|
|
730
|
+
# the first line box, a trailing one at the end of the last.
|
|
731
|
+
at_open = prev is not None and prev[0] == "start"
|
|
732
|
+
at_close = nxt is not None and nxt[0] == "end"
|
|
733
|
+
if tok[1] and (at_open or at_close):
|
|
734
|
+
continue
|
|
735
|
+
out.append(("ws",))
|
|
207
736
|
return out
|
|
208
737
|
|
|
209
738
|
|
|
210
739
|
def _canon_text_edges(tokens: list[tuple]) -> list[tuple]:
|
|
211
|
-
"""Drop edge-whitespace flags
|
|
740
|
+
"""Drop edge-whitespace flags that provably cannot render.
|
|
741
|
+
|
|
742
|
+
Three separate reasons a text node's edge whitespace collapses:
|
|
212
743
|
|
|
213
|
-
`<h1
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
744
|
+
* its parent lays its content out as a block -- `<h1>\n Title\n</h1>`
|
|
745
|
+
is `<h1>Title</h1>`, because whitespace at the edge of a block container
|
|
746
|
+
always collapses;
|
|
747
|
+
* the neighbour on that side is a block-level box -- whitespace next to one
|
|
748
|
+
is removed, even inside an inline parent;
|
|
749
|
+
* both, which is the common case.
|
|
750
|
+
|
|
751
|
+
Inside a `<span>` between two inline siblings none of that applies, and the
|
|
752
|
+
flags survive to be reported as a difference.
|
|
218
753
|
"""
|
|
219
754
|
out: list[tuple] = []
|
|
220
|
-
for tok in tokens:
|
|
221
|
-
if tok[0]
|
|
222
|
-
out.append(("text", tok[1], False, False, tok[4]))
|
|
223
|
-
else:
|
|
755
|
+
for i, tok in enumerate(tokens):
|
|
756
|
+
if tok[0] != "text":
|
|
224
757
|
out.append(tok)
|
|
758
|
+
continue
|
|
759
|
+
_, body, lead, trail, parent = tok
|
|
760
|
+
if parent in BLOCK_TAGS:
|
|
761
|
+
lead = trail = False
|
|
762
|
+
if lead and _is_block_boundary(out[-1] if out else None):
|
|
763
|
+
lead = False
|
|
764
|
+
if trail and _is_block_boundary(tokens[i + 1] if i + 1 < len(tokens) else None):
|
|
765
|
+
trail = False
|
|
766
|
+
out.append(("text", body, lead, trail, parent))
|
|
225
767
|
return out
|
|
226
768
|
|
|
227
769
|
|
|
@@ -243,8 +785,8 @@ def _squeeze_jinja(markup: str) -> str:
|
|
|
243
785
|
return JINJA.sub(sub, markup)
|
|
244
786
|
|
|
245
787
|
|
|
246
|
-
def tokenize(markup: str) -> list[tuple] | None:
|
|
247
|
-
parser = _Tokens()
|
|
788
|
+
def tokenize(markup: str, jinja_classes: dict | None = None) -> list[tuple] | None:
|
|
789
|
+
parser = _Tokens(jinja_classes)
|
|
248
790
|
try:
|
|
249
791
|
parser.feed(_squeeze_jinja(markup))
|
|
250
792
|
parser.close()
|
|
@@ -253,13 +795,17 @@ def tokenize(markup: str) -> list[tuple] | None:
|
|
|
253
795
|
return _canon_text_edges(_drop_insignificant_ws(parser.out))
|
|
254
796
|
|
|
255
797
|
|
|
256
|
-
def equivalent(before: str, after: str) -> tuple[bool, str]:
|
|
798
|
+
def equivalent(before: str, after: str, jinja_classes: dict | None = None) -> tuple[bool, str]:
|
|
257
799
|
"""True when `after` is guaranteed to render exactly like `before`.
|
|
258
800
|
|
|
801
|
+
`jinja_classes` maps `{{name}}` attribute variables to the class list they
|
|
802
|
+
forward, from `jinja_attr_classes` on the owning Python file. Without it a
|
|
803
|
+
`<div {{ attributes }}>` simply has an unknown display, as before.
|
|
804
|
+
|
|
259
805
|
The second element is a short explanation of the first difference, for the
|
|
260
806
|
skip report.
|
|
261
807
|
"""
|
|
262
|
-
ta, tb = tokenize(before), tokenize(after)
|
|
808
|
+
ta, tb = tokenize(before, jinja_classes), tokenize(after, jinja_classes)
|
|
263
809
|
if ta is None or tb is None:
|
|
264
810
|
return False, "markup could not be parsed"
|
|
265
811
|
if ta == tb:
|
package/dist/settings/format.py
CHANGED
|
@@ -53,6 +53,7 @@ import subprocess
|
|
|
53
53
|
import sys
|
|
54
54
|
import tempfile
|
|
55
55
|
from dataclasses import dataclass, field
|
|
56
|
+
from functools import lru_cache
|
|
56
57
|
from pathlib import Path
|
|
57
58
|
|
|
58
59
|
import _markup_equivalence as eq
|
|
@@ -345,6 +346,20 @@ def render_literal(block: Block, markup: str) -> str | None:
|
|
|
345
346
|
return f"{block.prefix}{body}{block.quote}"
|
|
346
347
|
|
|
347
348
|
|
|
349
|
+
@lru_cache(maxsize=None)
|
|
350
|
+
def jinja_classes(path: Path) -> dict:
|
|
351
|
+
"""Class lists this file hides behind an expression.
|
|
352
|
+
|
|
353
|
+
Recovered from the file's own source so the oracle can see the display of a
|
|
354
|
+
root written as `<div {{ attributes }}>` or `class="{helper()}"`. Cached
|
|
355
|
+
because every block in a file shares one answer.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
return eq.class_hints(path.read_text(encoding="utf-8"))
|
|
359
|
+
except OSError:
|
|
360
|
+
return {}
|
|
361
|
+
|
|
362
|
+
|
|
348
363
|
@dataclass
|
|
349
364
|
class Skip:
|
|
350
365
|
path: str
|
|
@@ -402,7 +417,7 @@ def format_markup_blocks(*, write: bool) -> MarkupReport:
|
|
|
402
417
|
if formatted.strip("\n") == block.source.strip("\n"):
|
|
403
418
|
report.already += 1
|
|
404
419
|
continue
|
|
405
|
-
same, why = eq.equivalent(block.source, formatted)
|
|
420
|
+
same, why = eq.equivalent(block.source, formatted, jinja_classes(path))
|
|
406
421
|
if not same:
|
|
407
422
|
report.skips.append(Skip(rel, block.lineno, why))
|
|
408
423
|
continue
|