create-prisma-php-app 2.0.0-alpha.9 → 2.0.0-beta.10

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/composer.json CHANGED
@@ -22,6 +22,6 @@
22
22
  "guzzlehttp/guzzle": "^7.9.2",
23
23
  "ezyang/htmlpurifier": "^4.18.0",
24
24
  "symfony/uid": "^7.2.0",
25
- "brick/math": "^0.12.1"
25
+ "brick/math": "^0.13.0"
26
26
  }
27
27
  }
package/dist/.htaccess CHANGED
@@ -1,11 +1,11 @@
1
1
  # Turn on rewrite engine
2
2
  RewriteEngine On
3
3
 
4
- # Deny access to .env file for security
5
- <Files .env>
4
+ # Prevent access to sensitive files
5
+ <FilesMatch "(^\.htaccess|\.git|\.env|composer\.(json|lock)|package(-lock)?\.json|phpunit\.xml)$">
6
6
  Order allow,deny
7
7
  Deny from all
8
- </Files>
8
+ </FilesMatch>
9
9
 
10
10
  # Allow cross-origin requests (CORS) for all routes
11
11
  <IfModule mod_headers.c>
@@ -32,11 +32,38 @@ RewriteEngine On
32
32
  </FilesMatch>
33
33
  </IfModule>
34
34
 
35
+ # Add important security headers
36
+ <IfModule mod_headers.c>
37
+ # Enforce HTTPS and prevent protocol downgrade attacks
38
+ Header set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
39
+
40
+ # Protect against Cross-Site Scripting (XSS) attacks
41
+ Header set X-XSS-Protection "1; mode=block"
42
+
43
+ # Prevent MIME-type sniffing
44
+ Header set X-Content-Type-Options "nosniff"
45
+
46
+ # Clickjacking protection
47
+ Header always set X-Frame-Options "DENY"
48
+
49
+ # Implement a basic Content Security Policy (CSP)
50
+ Header set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:;"
51
+
52
+ # Restrict form submissions
53
+ Header set Content-Security-Policy "form-action 'self'"
54
+
55
+ # Set a strict Referrer Policy
56
+ Header set Referrer-Policy "strict-origin-when-cross-origin"
57
+
58
+ # Control browser permissions (optional but recommended)
59
+ Header set Permissions-Policy "geolocation=(), microphone=(), camera=(), autoplay=()"
60
+ </IfModule>
61
+
35
62
  # Exclude static files from being redirected
36
63
  RewriteCond %{REQUEST_URI} !\.(css|js|png|jpe?g|gif|svg|webp|woff2?|ttf|eot|ico|pdf|mp4|webm|mp3|ogg)$ [NC]
37
64
  RewriteCond %{REQUEST_URI} !^/bootstrap.php
38
65
  RewriteRule ^(.*)$ bootstrap.php [QSA,L]
39
66
 
40
- # Add this to ensure OPTIONS requests are handled correctly
67
+ # Ensure OPTIONS requests are handled correctly
41
68
  RewriteCond %{REQUEST_METHOD} OPTIONS
42
69
  RewriteRule ^ - [R=200,L]
@@ -627,7 +627,6 @@ final class Bootstrap
627
627
 
628
628
  public static function getLoadingsFiles(): string
629
629
  {
630
- // Gather all loading.php files
631
630
  $loadingFiles = array_filter(PrismaPHPSettings::$routeFiles, function ($route) {
632
631
  $normalizedRoute = str_replace('\\', '/', $route);
633
632
  return preg_match('/\/loading\.php$/', $normalizedRoute);
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import{execSync}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);let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.php","metadata.php","not-found.php"],dockerFiles=[".dockerignore","docker-compose.yml","Dockerfile","apache.conf"];function bsConfigUrls(e){const s=e.indexOf("\\htdocs\\");if(-1===s)return{bsTarget:"",bsPathRewrite:{}};const t=e.substring(0,s+"\\htdocs\\".length).replace(/\\/g,"\\\\"),n=e.replace(new RegExp(`^${t}`),"").replace(/\\/g,"/");let c=`http://localhost/${n}`;c=c.endsWith("/")?c.slice(0,-1):c;const i=c.replace(/(?<!:)(\/\/+)/g,"/"),o=n.replace(/\/\/+/g,"/");return{bsTarget:`${i}/`,bsPathRewrite:{"^/":`/${o.startsWith("/")?o.substring(1):o}/`}}}async function updatePackageJson(e,s){const t=path.join(e,"package.json");if(checkExcludeFiles(t))return;const n=JSON.parse(fs.readFileSync(t,"utf8"));n.scripts={...n.scripts,projectName:"tsx settings/project-name.ts"};let c=[];if(s.tailwindcss&&(n.scripts={...n.scripts,tailwind:"postcss src/app/css/tailwind.css -o src/app/css/styles.css --watch"},c.push("tailwind")),s.websocket&&(n.scripts={...n.scripts,websocket:"tsx settings/restart-websocket.ts"},c.push("websocket")),s.docker&&(n.scripts={...n.scripts,docker:"docker-compose up"},c.push("docker")),s.swaggerDocs){const e=s.prisma?"tsx settings/auto-swagger-docs.ts":"tsx settings/swagger-config.ts";n.scripts={...n.scripts,"create-swagger-docs":e}}let i={...n.scripts};i.browserSync="tsx settings/bs-config.ts",i.dev=`npm-run-all projectName -p browserSync ${c.join(" ")}`,n.scripts=i,n.type="module",s.prisma&&(n.prisma={seed:"tsx prisma/seed.ts"}),fs.writeFileSync(t,JSON.stringify(n,null,2))}async function updateComposerJson(e,s){const t=path.join(e,"composer.json");if(checkExcludeFiles(t))return;let n;if(fs.existsSync(t)){{const e=fs.readFileSync(t,"utf8");n=JSON.parse(e)}s.websocket&&(n.require={...n.require,"cboden/ratchet":"^0.4.4"}),s.prisma&&(n.require={...n.require,"calicastle/cuid":"^2.0.0"}),fs.writeFileSync(t,JSON.stringify(n,null,2))}}async function updateIndexJsForWebSocket(e,s){if(!s.websocket)return;const t=path.join(e,"src","app","js","index.js");if(checkExcludeFiles(t))return;let n=fs.readFileSync(t,"utf8");n+='\n// WebSocket initialization\nvar ws = new WebSocket("ws://localhost:8080");\n',fs.writeFileSync(t,n,"utf8")}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateLocalStoreKey(){return randomBytes(16).toString("hex")}function copyRecursiveSync(e,s,t){const n=fs.existsSync(e),c=n&&fs.statSync(e);if(n&&c&&c.isDirectory()){const n=s.toLowerCase();if(!t.websocket&&n.includes("src\\lib\\websocket"))return;if(!t.prisma&&n.includes("src\\lib\\prisma"))return;if(t.backendOnly&&n.includes("src\\app\\js")||t.backendOnly&&n.includes("src\\app\\css")||t.backendOnly&&n.includes("src\\app\\assets"))return;if(!t.swaggerDocs&&n.includes("src\\app\\swagger-docs"))return;const c=s.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(c))return;fs.existsSync(s)||fs.mkdirSync(s,{recursive:!0}),fs.readdirSync(e).forEach((n=>{copyRecursiveSync(path.join(e,n),path.join(s,n),t)}))}else{if(checkExcludeFiles(s))return;if(!t.tailwindcss&&(s.includes("tailwind.css")||s.includes("styles.css")))return;if(!t.websocket&&(s.includes("restart-websocket.ts")||s.includes("restart-websocket.bat")))return;if(!t.docker&&dockerFiles.some((e=>s.includes(e))))return;if(t.backendOnly&&nonBackendFiles.some((e=>s.includes(e))))return;if(!t.backendOnly&&s.includes("route.php"))return;if(t.backendOnly&&!t.swaggerDocs&&s.includes("layout.php"))return;if(!t.swaggerDocs&&s.includes("swagger-config.ts"))return;if(t.tailwindcss&&s.includes("index.css"))return;if(!t.prisma&&(s.includes("prisma-sdk.ts")||s.includes("prisma-schema.json")))return;if((!t.swaggerDocs||!t.prisma)&&(s.includes("auto-swagger-docs.ts")||s.includes("prisma-schema-config.json")))return;fs.copyFileSync(e,s,0)}}async function executeCopy(e,s,t){s.forEach((({src:s,dest:n})=>{copyRecursiveSync(path.join(__dirname,s),path.join(e,n),t)}))}function modifyPostcssConfig(e){const s=path.join(e,"postcss.config.js");if(checkExcludeFiles(s))return;fs.writeFileSync(s,'export default {\n plugins: {\n "@tailwindcss/postcss": {},\n },\n};',{flag:"w"})}function modifyLayoutPHP(e,s){const t=path.join(e,"src","app","layout.php");if(!checkExcludeFiles(t))try{let e=fs.readFileSync(t,"utf8"),n="";s.backendOnly||(s.tailwindcss||(n='\n <link href="<?= Request::baseUrl; ?>/css/index.css" rel="stylesheet" />'),n+='\n <script src="<?= Request::baseUrl; ?>/js/json5.min.js"><\/script>\n <script src="<?= Request::baseUrl; ?>/js/index.js"><\/script>');let c="";s.backendOnly||(c=s.tailwindcss?` <link href="<?= Request::baseUrl; ?>/css/styles.css" rel="stylesheet" /> ${n}`:n),e=e.replace("</head>",`${c}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){}}async function createOrUpdateEnvFile(e,s){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,s,{flag:"w"})}function checkExcludeFiles(e){return!!updateAnswer?.isUpdate&&(updateAnswer?.excludeFilePath?.includes(e.replace(/\\/g,"/"))??!1)}async function createDirectoryStructure(e,s){const t=[{src:"/bootstrap.php",dest:"/bootstrap.php"},{src:"/.htaccess",dest:"/.htaccess"},{src:"/../composer.json",dest:"/composer.json"},{src:"/tsconfig.json",dest:"/tsconfig.json"},{src:"/app-gitignore",dest:"/.gitignore"}];s.tailwindcss&&t.push({src:"/postcss.config.js",dest:"/postcss.config.js"});const n=[{src:"/settings",dest:"/settings"},{src:"/src",dest:"/src"}];s.prisma&&n.push({src:"/prisma",dest:"/prisma"}),s.docker&&n.push({src:"/.dockerignore",dest:"/.dockerignore"},{src:"/docker-compose.yml",dest:"/docker-compose.yml"},{src:"/Dockerfile",dest:"/Dockerfile"},{src:"/apache.conf",dest:"/apache.conf"}),t.forEach((({src:s,dest:t})=>{const n=path.join(__dirname,s),c=path.join(e,t);if(checkExcludeFiles(c))return;const i=fs.readFileSync(n,"utf8");fs.writeFileSync(c,i,{flag:"w"})})),await executeCopy(e,n,s),await updatePackageJson(e,s),await updateComposerJson(e,s),s.backendOnly||await updateIndexJsForWebSocket(e,s),s.tailwindcss&&modifyPostcssConfig(e),(s.tailwindcss||!s.backendOnly||s.swaggerDocs)&&modifyLayoutPHP(e,s);const c=`# Prisma PHP Auth Secret Key For development only - Change this in production\nAUTH_SECRET="${generateAuthSecret()}"\n\n# PHPMailer\n# SMTP_HOST="smtp.gmail.com" or your SMTP host\n# SMTP_USERNAME="john.doe@gmail.com" or your SMTP username\n# SMTP_PASSWORD="123456"\n# SMTP_PORT="587" for TLS, 465 for SSL or your SMTP port\n# SMTP_ENCRYPTION="ssl" or tls\n# MAIL_FROM="john.doe@gmail.com"\n# MAIL_FROM_NAME="John Doe"\n\n# SHOW ERRORS - Set to true to show errors in the browser for development only - Change this in production to false\nSHOW_ERRORS="true"\n\n# APP TIMEZONE - Set your application timezone - Default is "UTC"\nAPP_TIMEZONE="UTC"\n\n# APP ENV - Set your application environment - Default is "development" - Change this in production to "production"\nAPP_ENV="development"\n\n# APP CACHE ENABLED - Set to true to enable caching - Default is false\nCACHE_ENABLED="false"\n# APP CACHE TTL - Set the cache time to live in seconds - Default is 600 seconds (10 minutes)\nCACHE_TTL="600"\n\n# LOCAL STORAGE KEY - Define a custom key for local storage.\n# If not set, it defaults to "pphp_local_store_59e13".\n# Spaces in the value will be replaced with underscores, and the key will be converted to lowercase automatically.\nLOCALSTORE_KEY="${generateLocalStoreKey()}"`;if(s.prisma){const s=`${'# Environment variables declared in this file are automatically made available to Prisma.\n# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema\n\n# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.\n# See the documentation for all the connection string options: https://pris.ly/d/connection-strings\n\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"'}\n\n${c}`;await createOrUpdateEnvFile(e,s)}else await createOrUpdateEnvFile(e,c)}async function getAnswer(e={}){const s=[];e.projectName||s.push({type:"text",name:"projectName",message:"What is your project named?",initial:"my-app"}),e.backendOnly||s.push({type:"toggle",name:"backendOnly",message:`Would you like to create a ${chalk.blue("backend-only project")}?`,initial:!1,active:"Yes",inactive:"No"});const t=()=>{process.exit(0)},n=await prompts(s,{onCancel:t}),c=[];n.backendOnly||e.backendOnly?(e.swaggerDocs||c.push({type:"toggle",name:"swaggerDocs",message:`Would you like to use ${chalk.blue("Swagger Docs")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("Websocket")}?`,initial:!0,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma PHP ORM")}?`,initial:!0,active:"Yes",inactive:"No"}),e.docker||c.push({type:"toggle",name:"docker",message:`Would you like to use ${chalk.blue("Docker")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.swaggerDocs||c.push({type:"toggle",name:"swaggerDocs",message:`Would you like to use ${chalk.blue("Swagger Docs")}?`,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.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("Websocket")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma PHP ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.docker||c.push({type:"toggle",name:"docker",message:`Would you like to use ${chalk.blue("Docker")}?`,initial:!1,active:"Yes",inactive:"No"}));const i=await prompts(c,{onCancel:t});return{projectName:n.projectName?String(n.projectName).trim().replace(/ /g,"-"):e.projectName??"my-app",backendOnly:n.backendOnly??e.backendOnly??!1,swaggerDocs:i.swaggerDocs??e.swaggerDocs??!1,tailwindcss:i.tailwindcss??e.tailwindcss??!1,websocket:i.websocket??e.websocket??!1,prisma:i.prisma??e.prisma??!1,docker:i.docker??e.docker??!1}}async function uninstallDependencies(e,s,t=!1){s.forEach((e=>{}));const n=`npm uninstall ${t?"--save-dev":"--save"} ${s.join(" ")}`;execSync(n,{stdio:"inherit",cwd:e})}function fetchPackageVersion(e){return new Promise(((s,t)=>{https.get(`https://registry.npmjs.org/${e}`,(e=>{let n="";e.on("data",(e=>n+=e)),e.on("end",(()=>{try{const e=JSON.parse(n);s(e["dist-tags"].latest)}catch(e){t(new Error("Failed to parse JSON response"))}}))})).on("error",(e=>t(e)))}))}const readJsonFile=e=>{const s=fs.readFileSync(e,"utf8");return JSON.parse(s)};function compareVersions(e,s){const t=e.split(".").map(Number),n=s.split(".").map(Number);for(let e=0;e<t.length;e++){if(t[e]>n[e])return 1;if(t[e]<n[e])return-1}return 0}function getInstalledPackageVersion(e){try{const s=execSync(`npm list -g ${e} --depth=0`).toString().match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+)`));return s?s[1]:null}catch(e){return null}}
2
+ import{execSync}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);let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.php","metadata.php","not-found.php","error.php"],dockerFiles=[".dockerignore","docker-compose.yml","Dockerfile","apache.conf"];function bsConfigUrls(e){const s=e.indexOf("\\htdocs\\");if(-1===s)return{bsTarget:"",bsPathRewrite:{}};const t=e.substring(0,s+"\\htdocs\\".length).replace(/\\/g,"\\\\"),n=e.replace(new RegExp(`^${t}`),"").replace(/\\/g,"/");let c=`http://localhost/${n}`;c=c.endsWith("/")?c.slice(0,-1):c;const o=c.replace(/(?<!:)(\/\/+)/g,"/"),i=n.replace(/\/\/+/g,"/");return{bsTarget:`${o}/`,bsPathRewrite:{"^/":`/${i.startsWith("/")?i.substring(1):i}/`}}}async function updatePackageJson(e,s){const t=path.join(e,"package.json");if(checkExcludeFiles(t))return;const n=JSON.parse(fs.readFileSync(t,"utf8"));n.scripts={...n.scripts,projectName:"tsx settings/project-name.ts"};let c=[];if(s.tailwindcss&&(n.scripts={...n.scripts,tailwind:"postcss src/app/css/tailwind.css -o src/app/css/styles.css --watch"},c.push("tailwind")),s.websocket&&(n.scripts={...n.scripts,websocket:"tsx settings/restart-websocket.ts"},c.push("websocket")),s.docker&&(n.scripts={...n.scripts,docker:"docker-compose up"},c.push("docker")),s.swaggerDocs){const e=s.prisma?"tsx settings/auto-swagger-docs.ts":"tsx settings/swagger-config.ts";n.scripts={...n.scripts,"create-swagger-docs":e}}let o={...n.scripts};o.browserSync="tsx settings/bs-config.ts",o.dev=`npm-run-all projectName -p browserSync ${c.join(" ")}`,n.scripts=o,n.type="module",s.prisma&&(n.prisma={seed:"tsx prisma/seed.ts"}),fs.writeFileSync(t,JSON.stringify(n,null,2))}async function updateComposerJson(e,s){const t=path.join(e,"composer.json");if(checkExcludeFiles(t))return;let n;if(fs.existsSync(t)){{const e=fs.readFileSync(t,"utf8");n=JSON.parse(e)}s.websocket&&(n.require={...n.require,"cboden/ratchet":"^0.4.4"}),s.prisma&&(n.require={...n.require,"calicastle/cuid":"^2.0.0"}),fs.writeFileSync(t,JSON.stringify(n,null,2))}}async function updateIndexJsForWebSocket(e,s){if(!s.websocket)return;const t=path.join(e,"src","app","js","index.js");if(checkExcludeFiles(t))return;let n=fs.readFileSync(t,"utf8");n+='\n// WebSocket initialization\nvar ws = new WebSocket("ws://localhost:8080");\n',fs.writeFileSync(t,n,"utf8")}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateLocalStoreKey(){return randomBytes(16).toString("hex")}function copyRecursiveSync(e,s,t){const n=fs.existsSync(e),c=n&&fs.statSync(e);if(n&&c&&c.isDirectory()){const n=s.toLowerCase();if(!t.websocket&&n.includes("src\\lib\\websocket"))return;if(t.backendOnly&&n.includes("src\\app\\js")||t.backendOnly&&n.includes("src\\app\\css")||t.backendOnly&&n.includes("src\\app\\assets"))return;if(!t.swaggerDocs&&n.includes("src\\app\\swagger-docs"))return;const c=s.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(c))return;fs.existsSync(s)||fs.mkdirSync(s,{recursive:!0}),fs.readdirSync(e).forEach((n=>{copyRecursiveSync(path.join(e,n),path.join(s,n),t)}))}else{if(checkExcludeFiles(s))return;if(!t.tailwindcss&&(s.includes("tailwind.css")||s.includes("styles.css")))return;if(!t.websocket&&(s.includes("restart-websocket.ts")||s.includes("restart-websocket.bat")))return;if(!t.docker&&dockerFiles.some((e=>s.includes(e))))return;if(t.backendOnly&&nonBackendFiles.some((e=>s.includes(e))))return;if(!t.backendOnly&&s.includes("route.php"))return;if(t.backendOnly&&!t.swaggerDocs&&s.includes("layout.php"))return;if(!t.swaggerDocs&&s.includes("swagger-config.ts"))return;if(t.tailwindcss&&s.includes("index.css"))return;if((!t.swaggerDocs||!t.prisma)&&(s.includes("auto-swagger-docs.ts")||s.includes("prisma-schema-config.json")))return;fs.copyFileSync(e,s,0)}}async function executeCopy(e,s,t){s.forEach((({src:s,dest:n})=>{copyRecursiveSync(path.join(__dirname,s),path.join(e,n),t)}))}function modifyPostcssConfig(e){const s=path.join(e,"postcss.config.js");if(checkExcludeFiles(s))return;fs.writeFileSync(s,'export default {\n plugins: {\n "@tailwindcss/postcss": {},\n cssnano: {},\n },\n};',{flag:"w"})}function modifyLayoutPHP(e,s){const t=path.join(e,"src","app","layout.php");if(!checkExcludeFiles(t))try{let e=fs.readFileSync(t,"utf8"),n="";s.backendOnly||(s.tailwindcss||(n='\n <link href="<?= Request::baseUrl; ?>/css/index.css" rel="stylesheet" />'),n+='\n <script src="<?= Request::baseUrl; ?>/js/json5.min.js"><\/script>\n <script src="<?= Request::baseUrl; ?>/js/index.js"><\/script>');let c="";s.backendOnly||(c=s.tailwindcss?` <link href="<?= Request::baseUrl; ?>/css/styles.css" rel="stylesheet" /> ${n}`:n),e=e.replace("</head>",`${c}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){}}async function createOrUpdateEnvFile(e,s){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,s,{flag:"w"})}function checkExcludeFiles(e){return!!updateAnswer?.isUpdate&&(updateAnswer?.excludeFilePath?.includes(e.replace(/\\/g,"/"))??!1)}async function createDirectoryStructure(e,s){const t=[{src:"/bootstrap.php",dest:"/bootstrap.php"},{src:"/.htaccess",dest:"/.htaccess"},{src:"/../composer.json",dest:"/composer.json"},{src:"/tsconfig.json",dest:"/tsconfig.json"},{src:"/app-gitignore",dest:"/.gitignore"}];s.tailwindcss&&t.push({src:"/postcss.config.js",dest:"/postcss.config.js"});const n=[{src:"/settings",dest:"/settings"},{src:"/src",dest:"/src"}];s.docker&&n.push({src:"/.dockerignore",dest:"/.dockerignore"},{src:"/docker-compose.yml",dest:"/docker-compose.yml"},{src:"/Dockerfile",dest:"/Dockerfile"},{src:"/apache.conf",dest:"/apache.conf"}),t.forEach((({src:s,dest:t})=>{const n=path.join(__dirname,s),c=path.join(e,t);if(checkExcludeFiles(c))return;const o=fs.readFileSync(n,"utf8");fs.writeFileSync(c,o,{flag:"w"})})),await executeCopy(e,n,s),await updatePackageJson(e,s),await updateComposerJson(e,s),s.backendOnly||await updateIndexJsForWebSocket(e,s),s.tailwindcss&&modifyPostcssConfig(e),(s.tailwindcss||!s.backendOnly||s.swaggerDocs)&&modifyLayoutPHP(e,s);const c=`# Prisma PHP Auth Secret Key\nAUTH_SECRET="${generateAuthSecret()}"\n\n# PHPMailer\n# SMTP_HOST="smtp.gmail.com" or your SMTP host\n# SMTP_USERNAME="john.doe@gmail.com" or your SMTP username\n# SMTP_PASSWORD="123456"\n# SMTP_PORT="587" for TLS, 465 for SSL or your SMTP port\n# SMTP_ENCRYPTION="ssl" or tls\n# MAIL_FROM="john.doe@gmail.com"\n# MAIL_FROM_NAME="John Doe"\n\n# SHOW ERRORS - Set to true to show errors in the browser for development only - Change this in production to false\nSHOW_ERRORS="true"\n\n# APP TIMEZONE - Set your application timezone - Default is "UTC"\nAPP_TIMEZONE="UTC"\n\n# APP ENV - Set your application environment - Default is "development" - Change this in production to "production"\nAPP_ENV="development"\n\n# APP CACHE ENABLED - Set to true to enable caching - Default is false\nCACHE_ENABLED="false"\n# APP CACHE TTL - Set the cache time to live in seconds - Default is 600 seconds (10 minutes)\nCACHE_TTL="600"\n\n# LOCAL STORAGE KEY - Define a custom key for local storage.\n# If not set, it defaults to "pphp_local_store_59e13".\n# Spaces in the value will be replaced with underscores, and the key will be converted to lowercase automatically.\nLOCALSTORE_KEY="${generateLocalStoreKey()}"`;if(s.prisma){const s=`${'# Environment variables declared in this file are automatically made available to Prisma.\n# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema\n\n# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.\n# See the documentation for all the connection string options: https://pris.ly/d/connection-strings\n\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"'}\n\n${c}`;await createOrUpdateEnvFile(e,s)}else await createOrUpdateEnvFile(e,c)}async function getAnswer(e={}){const s=[];e.projectName||s.push({type:"text",name:"projectName",message:"What is your project named?",initial:"my-app"}),e.backendOnly||s.push({type:"toggle",name:"backendOnly",message:`Would you like to create a ${chalk.blue("backend-only project")}?`,initial:!1,active:"Yes",inactive:"No"});const t=()=>{process.exit(0)},n=await prompts(s,{onCancel:t}),c=[];n.backendOnly||e.backendOnly?(e.swaggerDocs||c.push({type:"toggle",name:"swaggerDocs",message:`Would you like to use ${chalk.blue("Swagger Docs")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("Websocket")}?`,initial:!0,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma PHP ORM")}?`,initial:!0,active:"Yes",inactive:"No"}),e.docker||c.push({type:"toggle",name:"docker",message:`Would you like to use ${chalk.blue("Docker")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.swaggerDocs||c.push({type:"toggle",name:"swaggerDocs",message:`Would you like to use ${chalk.blue("Swagger Docs")}?`,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.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("Websocket")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma PHP ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.docker||c.push({type:"toggle",name:"docker",message:`Would you like to use ${chalk.blue("Docker")}?`,initial:!1,active:"Yes",inactive:"No"}));const o=await prompts(c,{onCancel:t});return{projectName:n.projectName?String(n.projectName).trim().replace(/ /g,"-"):e.projectName??"my-app",backendOnly:n.backendOnly??e.backendOnly??!1,swaggerDocs:o.swaggerDocs??e.swaggerDocs??!1,tailwindcss:o.tailwindcss??e.tailwindcss??!1,websocket:o.websocket??e.websocket??!1,prisma:o.prisma??e.prisma??!1,docker:o.docker??e.docker??!1}}async function uninstallDependencies(e,s,t=!1){s.forEach((e=>{}));const n=`npm uninstall ${t?"--save-dev":"--save"} ${s.join(" ")}`;execSync(n,{stdio:"inherit",cwd:e})}function fetchPackageVersion(e){return new Promise(((s,t)=>{https.get(`https://registry.npmjs.org/${e}`,(e=>{let n="";e.on("data",(e=>n+=e)),e.on("end",(()=>{try{const e=JSON.parse(n);s(e["dist-tags"].latest)}catch(e){t(new Error("Failed to parse JSON response"))}}))})).on("error",(e=>t(e)))}))}const readJsonFile=e=>{const s=fs.readFileSync(e,"utf8");return JSON.parse(s)};function compareVersions(e,s){const t=e.split(".").map(Number),n=s.split(".").map(Number);for(let e=0;e<t.length;e++){if(t[e]>n[e])return 1;if(t[e]<n[e])return-1}return 0}function getInstalledPackageVersion(e){try{const s=execSync(`npm list -g ${e} --depth=0`).toString().match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+)`));return s?s[1]:null}catch(e){return null}}
3
3
  /**
4
4
  * Install dependencies in the specified directory.
5
5
  * @param {string} baseDir - The base directory where to install the dependencies.
@@ -32,26 +32,23 @@ async function installDependencies(baseDir, dependencies, isDev = false) {
32
32
  });
33
33
  }
34
34
  const pinnedVersions = {
35
- "@prisma/client": "^6.4.0",
36
- "@prisma/internals": "^6.4.0",
37
- "@tailwindcss/postcss": "^4.0.7",
35
+ "@tailwindcss/postcss": "^4.0.12",
38
36
  "@types/browser-sync": "^2.29.0",
39
- "@types/node": "^22.13.4",
37
+ "@types/node": "^22.13.10",
40
38
  "@types/prompts": "^2.4.9",
41
- autoprefixer: "^10.4.20",
42
39
  "browser-sync": "^3.0.3",
43
40
  chalk: "^5.4.1",
44
41
  "chokidar-cli": "^3.0.0",
42
+ cssnano: "^7.0.6",
45
43
  "http-proxy-middleware": "^3.0.3",
46
44
  "npm-run-all": "^4.1.5",
47
45
  "php-parser": "^3.2.2",
48
- postcss: "^8.5.2",
46
+ postcss: "^8.5.3",
49
47
  "postcss-cli": "^11.0.0",
50
- prisma: "^6.4.0",
51
48
  prompts: "^2.4.2",
52
- tailwindcss: "^4.0.7",
49
+ tailwindcss: "^4.0.12",
53
50
  tsx: "^4.19.3",
54
- typescript: "^5.7.3",
51
+ typescript: "^5.8.2",
55
52
  };
56
53
  function pkg(name) {
57
54
  return pinnedVersions[name] ? `${name}@${pinnedVersions[name]}` : name;
@@ -84,26 +81,28 @@ async function main() {
84
81
  }
85
82
  const currentDir = process.cwd();
86
83
  const configPath = path.join(currentDir, "prisma-php.json");
87
- const localSettings = readJsonFile(configPath);
88
- let excludeFiles = [];
89
- localSettings.excludeFiles?.map((file) => {
90
- const filePath = path.join(currentDir, file);
91
- if (fs.existsSync(filePath))
92
- excludeFiles.push(filePath.replace(/\\/g, "/"));
93
- });
94
- updateAnswer = {
95
- projectName,
96
- backendOnly: answer?.backendOnly ?? false,
97
- swaggerDocs: answer?.swaggerDocs ?? false,
98
- tailwindcss: answer?.tailwindcss ?? false,
99
- websocket: answer?.websocket ?? false,
100
- prisma: answer?.prisma ?? false,
101
- docker: answer?.docker ?? false,
102
- isUpdate: true,
103
- excludeFiles: localSettings.excludeFiles ?? [],
104
- excludeFilePath: excludeFiles ?? [],
105
- filePath: currentDir,
106
- };
84
+ if (fs.existsSync(configPath)) {
85
+ const localSettings = readJsonFile(configPath);
86
+ let excludeFiles = [];
87
+ localSettings.excludeFiles?.map((file) => {
88
+ const filePath = path.join(currentDir, file);
89
+ if (fs.existsSync(filePath))
90
+ excludeFiles.push(filePath.replace(/\\/g, "/"));
91
+ });
92
+ updateAnswer = {
93
+ projectName,
94
+ backendOnly: answer?.backendOnly ?? false,
95
+ swaggerDocs: answer?.swaggerDocs ?? false,
96
+ tailwindcss: answer?.tailwindcss ?? false,
97
+ websocket: answer?.websocket ?? false,
98
+ prisma: answer?.prisma ?? false,
99
+ docker: answer?.docker ?? false,
100
+ isUpdate: true,
101
+ excludeFiles: localSettings.excludeFiles ?? [],
102
+ excludeFilePath: excludeFiles ?? [],
103
+ filePath: currentDir,
104
+ };
105
+ }
107
106
  } else {
108
107
  answer = await getAnswer();
109
108
  }
@@ -124,10 +123,10 @@ async function main() {
124
123
  latestVersionOfCreatePrismaPhpApp
125
124
  ) === -1
126
125
  ) {
127
- execSync(`npm uninstall -g create-prisma-php-app`, {
126
+ execSync("npm uninstall -g create-prisma-php-app", {
128
127
  stdio: "inherit",
129
128
  });
130
- execSync(`npm install -g create-prisma-php-app`, {
129
+ execSync("npm install -g create-prisma-php-app", {
131
130
  stdio: "inherit",
132
131
  });
133
132
  }
@@ -156,40 +155,31 @@ async function main() {
156
155
  dependencies.push(pkg("swagger-jsdoc"), pkg("@types/swagger-jsdoc"));
157
156
  }
158
157
  if (answer.swaggerDocs && answer.prisma) {
159
- dependencies.push(
160
- pkg("prompts"),
161
- pkg("@types/prompts"),
162
- pkg("@prisma/internals")
163
- );
158
+ dependencies.push(pkg("prompts"), pkg("@types/prompts"));
164
159
  }
165
160
  if (answer.tailwindcss) {
166
161
  dependencies.push(
167
162
  pkg("tailwindcss"),
168
163
  pkg("postcss"),
169
164
  pkg("postcss-cli"),
170
- pkg("@tailwindcss/postcss")
165
+ pkg("@tailwindcss/postcss"),
166
+ pkg("cssnano")
171
167
  );
172
168
  }
173
169
  if (answer.websocket) {
174
170
  dependencies.push(pkg("chokidar-cli"));
175
171
  }
176
172
  if (answer.prisma) {
177
- dependencies.push(
178
- pkg("prisma"),
179
- pkg("@prisma/client"),
180
- pkg("@prisma/internals")
181
- );
182
173
  execSync("npm install -g prisma-client-php", { stdio: "inherit" });
183
174
  }
184
175
  await installDependencies(projectPath, dependencies, true);
185
176
  if (!projectName) {
186
- execSync(`npx tsc --init`, { stdio: "inherit" });
177
+ execSync("npx tsc --init", { stdio: "inherit" });
187
178
  }
179
+ await createDirectoryStructure(projectPath, answer);
188
180
  if (answer.prisma) {
189
- if (!fs.existsSync(path.join(projectPath, "prisma")))
190
- execSync(`npx prisma init`, { stdio: "inherit" });
181
+ execSync("npx ppo init --prisma-php", { stdio: "inherit" });
191
182
  }
192
- await createDirectoryStructure(projectPath, answer);
193
183
  if (answer.swaggerDocs) {
194
184
  const swaggerDocsPath = path.join(
195
185
  projectPath,
@@ -264,8 +254,7 @@ async function main() {
264
254
  "swagger-jsdoc",
265
255
  "@types/swagger-jsdoc",
266
256
  "prompts",
267
- "@types/prompts",
268
- "@prisma/internals"
257
+ "@types/prompts"
269
258
  );
270
259
  }
271
260
  if (!updateAnswer.tailwindcss) {
@@ -283,7 +272,8 @@ async function main() {
283
272
  "tailwindcss",
284
273
  "postcss",
285
274
  "postcss-cli",
286
- "@tailwindcss/postcss"
275
+ "@tailwindcss/postcss",
276
+ "cssnano"
287
277
  );
288
278
  }
289
279
  if (!updateAnswer.websocket) {
@@ -1,5 +1,6 @@
1
1
  export default {
2
2
  plugins: {
3
3
  "@tailwindcss/postcss": {},
4
+ cssnano: {},
4
5
  },
5
6
  };
@@ -16,7 +16,7 @@ class ChatGPTClient
16
16
  private string $apiKey = '';
17
17
  private array $cache = [];
18
18
 
19
- public function __construct(Client $client = null)
19
+ public function __construct(?Client $client = null)
20
20
  {
21
21
  // Initialize the Guzzle HTTP client, allowing for dependency injection
22
22
  $this->client = $client ?: new Client();
@@ -12,6 +12,9 @@ use Lib\Validator;
12
12
  use GuzzleHttp\Client;
13
13
  use GuzzleHttp\Exception\RequestException;
14
14
  use Lib\Request;
15
+ use Exception;
16
+ use InvalidArgumentException;
17
+ use ArrayObject;
15
18
 
16
19
  class Auth
17
20
  {
@@ -70,14 +73,14 @@ class Auth
70
73
  * try {
71
74
  * $jwt = $auth->signIn('Admin', '1h');
72
75
  * echo "JWT: " . $jwt;
73
- * } catch (\InvalidArgumentException $e) {
76
+ * } catch (InvalidArgumentException $e) {
74
77
  * echo "Error: " . $e->getMessage();
75
78
  * }
76
79
  */
77
- public function signIn($data, string $tokenValidity = null): string
80
+ public function signIn($data, ?string $tokenValidity = null): string
78
81
  {
79
82
  if (!$this->secretKey) {
80
- throw new \InvalidArgumentException("Secret key is required for authentication.");
83
+ throw new InvalidArgumentException("Secret key is required for authentication.");
81
84
  }
82
85
 
83
86
  $expirationTime = $this->calculateExpirationTime($tokenValidity ?? $this->defaultTokenValidity);
@@ -131,6 +134,10 @@ class Auth
131
134
  return false;
132
135
  }
133
136
 
137
+ if (!isset($_SESSION[self::PAYLOAD_SESSION_KEY])) {
138
+ return false;
139
+ }
140
+
134
141
  return true;
135
142
  }
136
143
 
@@ -158,11 +165,11 @@ class Auth
158
165
  case 'd':
159
166
  return new DateInterval("P{$value}D");
160
167
  default:
161
- throw new \InvalidArgumentException("Invalid duration format: {$duration}");
168
+ throw new InvalidArgumentException("Invalid duration format: {$duration}");
162
169
  }
163
170
  }
164
171
 
165
- throw new \InvalidArgumentException("Invalid duration format: {$duration}");
172
+ throw new InvalidArgumentException("Invalid duration format: {$duration}");
166
173
  }
167
174
 
168
175
  /**
@@ -190,7 +197,7 @@ class Auth
190
197
  }
191
198
 
192
199
  return $token;
193
- } catch (\Exception) {
200
+ } catch (Exception) {
194
201
  return null;
195
202
  }
196
203
  }
@@ -210,12 +217,12 @@ class Auth
210
217
  *
211
218
  * @throws InvalidArgumentException Thrown if the token is invalid.
212
219
  */
213
- public function refreshToken(string $jwt, string $tokenValidity = null): string
220
+ public function refreshToken(string $jwt, ?string $tokenValidity = null): string
214
221
  {
215
222
  $decodedToken = $this->verifyToken($jwt);
216
223
 
217
224
  if (!$decodedToken) {
218
- throw new \InvalidArgumentException("Invalid token.");
225
+ throw new InvalidArgumentException("Invalid token.");
219
226
  }
220
227
 
221
228
  $expirationTime = $this->calculateExpirationTime($tokenValidity ?? $this->defaultTokenValidity);
@@ -256,7 +263,7 @@ class Auth
256
263
  *
257
264
  * @return void
258
265
  */
259
- public function signOut(string $redirect = null)
266
+ public function signOut(?string $redirect = null)
260
267
  {
261
268
  if (isset($_COOKIE[self::COOKIE_NAME])) {
262
269
  unset($_COOKIE[self::COOKIE_NAME]);
@@ -282,7 +289,7 @@ class Auth
282
289
  {
283
290
  if (isset($_SESSION[self::PAYLOAD_SESSION_KEY])) {
284
291
  $value = $_SESSION[self::PAYLOAD_SESSION_KEY][self::PAYLOAD_NAME];
285
- return is_array($value) ? new \ArrayObject($value, \ArrayObject::ARRAY_AS_PROPS) : $value;
292
+ return is_array($value) ? new ArrayObject($value, ArrayObject::ARRAY_AS_PROPS) : $value;
286
293
  }
287
294
 
288
295
  return null;
@@ -317,7 +324,7 @@ class Auth
317
324
  private function findProvider(array $providers, string $type): ?object
318
325
  {
319
326
  foreach ($providers as $provider) {
320
- if ($provider instanceof $type) {
327
+ if (is_object($provider) && get_class($provider) === $type) {
321
328
  return $provider;
322
329
  }
323
330
  }
@@ -4,6 +4,8 @@ declare(strict_types=1);
4
4
 
5
5
  namespace Lib\Auth;
6
6
 
7
+ use ArrayObject;
8
+
7
9
  enum AuthRole: string
8
10
  {
9
11
  case Admin = 'Admin';
@@ -67,13 +69,13 @@ final class AuthConfig
67
69
  /**
68
70
  * Checks if the given user role is authorized to access a set of roles.
69
71
  *
70
- * @param \ArrayObject|string $userRole The user's role to check.
72
+ * @param ArrayObject|string $userRole The user's role to check.
71
73
  * @param array<AuthRole> $roles An array of AuthRole instances specifying allowed roles.
72
74
  * @return bool Returns true if the user's role matches any of the allowed roles, false otherwise.
73
75
  */
74
- public static function checkAuthRole(\ArrayObject|string $userRole, array $roles): bool
76
+ public static function checkAuthRole(ArrayObject|string $userRole, array $roles): bool
75
77
  {
76
- if ($userRole instanceof \ArrayObject) {
78
+ if ($userRole instanceof ArrayObject) {
77
79
  $userRole = $userRole[Auth::ROLE_NAME] ?? '';
78
80
  }
79
81
 
@@ -93,6 +93,9 @@ class ErrorHandler
93
93
  }
94
94
 
95
95
  if ($errorFileExists) {
96
+ if (ob_get_level()) {
97
+ ob_end_clean();
98
+ }
96
99
  self::$content = $contentToAdd;
97
100
  if (Bootstrap::isAjaxOrXFileRequestOrRouteFile()) {
98
101
  header('Content-Type: application/json');
@@ -38,7 +38,8 @@ class Mailer
38
38
  * @param string $to The recipient's email address.
39
39
  * @param string $subject The subject of the email.
40
40
  * @param string $body The HTML body of the email.
41
- * @param array $options (optional) Additional email options like name, altBody, CC, and BCC.
41
+ * @param array $options (optional) Additional email options like name, altBody, CC, BCC, and attachments.
42
+ * - attachments: A string or an array of file paths, or an array of associative arrays with keys 'path' and 'name'.
42
43
  *
43
44
  * @return bool Returns true if the email is sent successfully, false otherwise.
44
45
  *
@@ -50,7 +51,7 @@ class Mailer
50
51
  // Validate and sanitize inputs
51
52
  $to = Validator::email($to);
52
53
  if (!$to) {
53
- throw new \Exception('Invalid email address for the main recipient');
54
+ throw new Exception('Invalid email address for the main recipient');
54
55
  }
55
56
 
56
57
  $subject = Validator::string($subject);
@@ -60,6 +61,7 @@ class Mailer
60
61
  $name = $options['name'] ?? '';
61
62
  $addCC = $options['addCC'] ?? [];
62
63
  $addBCC = $options['addBCC'] ?? [];
64
+ $attachments = $options['attachments'] ?? [];
63
65
 
64
66
  $name = Validator::string($name);
65
67
 
@@ -67,6 +69,10 @@ class Mailer
67
69
  $this->handleRecipients($addCC, 'CC');
68
70
  // Handle BCC recipients
69
71
  $this->handleRecipients($addBCC, 'BCC');
72
+ // Handle file attachments if provided
73
+ if (!empty($attachments)) {
74
+ $this->handleAttachments($attachments);
75
+ }
70
76
 
71
77
  // Set the main recipient and other email properties
72
78
  $this->mail->addAddress($to, $name);
@@ -77,8 +83,8 @@ class Mailer
77
83
 
78
84
  // Send the email
79
85
  return $this->mail->send();
80
- } catch (\Exception $e) {
81
- throw new \Exception($e->getMessage());
86
+ } catch (Exception $e) {
87
+ throw new Exception($e->getMessage());
82
88
  }
83
89
  }
84
90
 
@@ -101,7 +107,7 @@ class Mailer
101
107
  if ($recipient) {
102
108
  $this->mail->{$method}($recipient);
103
109
  } else {
104
- throw new \Exception("Invalid email address in $type");
110
+ throw new Exception("Invalid email address in $type");
105
111
  }
106
112
  }
107
113
  } else {
@@ -109,12 +115,47 @@ class Mailer
109
115
  if ($recipient) {
110
116
  $this->mail->{$method}($recipient);
111
117
  } else {
112
- throw new \Exception("Invalid email address in $type");
118
+ throw new Exception("Invalid email address in $type");
113
119
  }
114
120
  }
115
121
  }
116
122
  }
117
123
 
124
+ /**
125
+ * Handle adding file attachments.
126
+ *
127
+ * @param string|array $attachments File path(s) to attach.
128
+ * You can pass a string for a single file or an array of file paths.
129
+ * Alternatively, each attachment can be an array with keys 'path' and 'name' for custom naming.
130
+ *
131
+ * @throws Exception Throws an exception if any attachment file is not found.
132
+ */
133
+ private function handleAttachments(string|array $attachments): void
134
+ {
135
+ if (is_array($attachments)) {
136
+ foreach ($attachments as $attachment) {
137
+ if (is_array($attachment)) {
138
+ $file = $attachment['path'] ?? null;
139
+ $name = $attachment['name'] ?? '';
140
+ if (!$file || !file_exists($file)) {
141
+ throw new Exception("Attachment file does not exist: " . ($file ?? 'unknown'));
142
+ }
143
+ $this->mail->addAttachment($file, $name);
144
+ } else {
145
+ if (!file_exists($attachment)) {
146
+ throw new Exception("Attachment file does not exist: $attachment");
147
+ }
148
+ $this->mail->addAttachment($attachment);
149
+ }
150
+ }
151
+ } else {
152
+ if (!file_exists($attachments)) {
153
+ throw new Exception("Attachment file does not exist: $attachments");
154
+ }
155
+ $this->mail->addAttachment($attachments);
156
+ }
157
+ }
158
+
118
159
  /**
119
160
  * Convert HTML content to plain text.
120
161
  *
@@ -167,6 +167,20 @@ class TwMerge
167
167
  $splitClasses = preg_split("/\s+/", $item);
168
168
  foreach ($splitClasses as $individualClass) {
169
169
  $classKey = self::getClassGroup($individualClass);
170
+
171
+ // If the class is non-responsive (no colon), remove any responsive variants for the same base
172
+ if (strpos($classKey, ':') === false) {
173
+ // The base group is the class key itself (e.g. "justify")
174
+ $baseGroup = $classKey;
175
+ // Remove any entries that end with the same base group but have a prefix (e.g. "sm:justify")
176
+ foreach ($classArray as $existingKey => $existingClass) {
177
+ if ($existingKey !== $baseGroup && substr($existingKey, -strlen($baseGroup)) === $baseGroup) {
178
+ unset($classArray[$existingKey]);
179
+ }
180
+ }
181
+ }
182
+
183
+ // Get the conflicting keys and remove them
170
184
  $conflictingKeys = self::getConflictingKeys($classKey);
171
185
 
172
186
  // Remove any conflicting classes