first commit
This commit is contained in:
commit
b583aaf404
25 changed files with 13978 additions and 0 deletions
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
android
|
||||
.idea
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
.env.staging
|
||||
.env.production
|
||||
src/
|
||||
47
Dockerfile
Normal file
47
Dockerfile
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# ================================
|
||||
# Base
|
||||
# ================================
|
||||
FROM node:22 AS base
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# ================================
|
||||
# DEV
|
||||
# ================================
|
||||
FROM base AS dev
|
||||
ENV NODE_ENV=development
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
# ================================
|
||||
# STAGING
|
||||
# ================================
|
||||
FROM base AS staging
|
||||
ARG VITE_API_URL
|
||||
ENV NODE_ENV=staging
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "preview"]
|
||||
|
||||
# ================================
|
||||
# PROD
|
||||
# ================================
|
||||
FROM base AS builder
|
||||
ARG VITE_API_URL
|
||||
ENV NODE_ENV=production
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
WORKDIR /usr/share/nginx/html
|
||||
RUN rm -rf ./*
|
||||
COPY --from=builder /app/dist .
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
17
docker-compose.dev.yml
Normal file
17
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
services:
|
||||
frontend:
|
||||
container_name: dev_SAGE
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
args:
|
||||
VITE_API_URL: "https://prod.dataven.fr/api"
|
||||
env_file: .env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
restart: unless-stopped
|
||||
14
docker-compose.prod.yml
Normal file
14
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
services:
|
||||
frontend:
|
||||
container_name: prod_SAGE
|
||||
build:
|
||||
context: .
|
||||
target: runner
|
||||
args:
|
||||
VITE_API_URL: "http://51.91.76.136:8004"
|
||||
env_file: .env.production
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
restart: always
|
||||
14
docker-compose.staging.yml
Normal file
14
docker-compose.staging.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
services:
|
||||
frontend:
|
||||
container_name: staging_SAGE
|
||||
build:
|
||||
context: .
|
||||
target: staging
|
||||
args:
|
||||
VITE_API_URL: "https://uat.dataven.fr/api"
|
||||
env_file: .env.staging
|
||||
ports:
|
||||
- "3002:3000"
|
||||
environment:
|
||||
NODE_ENV: staging
|
||||
restart: unless-stopped
|
||||
4
docker-compose.yml
Normal file
4
docker-compose.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
14
index.html
Normal file
14
index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="generator" content="Hostinger Horizons" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dataven</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10995
package-lock.json
generated
Normal file
10995
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
71
package.json
Normal file
71
package.json
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"name": "bijou-erp",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host :: --port 3000",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host :: --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hello-pangea/dnd": "^16.3.0",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@mui/material": "^7.3.6",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@reduxjs/toolkit": "^2.11.0",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"framer-motion": "^10.16.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jspdf": "^3.0.4",
|
||||
"lucide-react": "^0.292.0",
|
||||
"react": "^18.2.0",
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"recharts": "^2.9.3",
|
||||
"redux-persist": "^6.0.0",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^4.5.0"
|
||||
}
|
||||
}
|
||||
430
plugins/selection-mode/selection-mode-script.js
Normal file
430
plugins/selection-mode/selection-mode-script.js
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
const ALLOWED_PARENT_ORIGINS = [
|
||||
'https://horizons.hostinger.com',
|
||||
'https://horizons.hostinger.dev',
|
||||
'https://horizons-frontend-local.hostinger.dev',
|
||||
'http://localhost:4000',
|
||||
];
|
||||
|
||||
const IMPORTANT_STYLES = [
|
||||
'display',
|
||||
'position',
|
||||
'flex-direction',
|
||||
'justify-content',
|
||||
'align-items',
|
||||
'width',
|
||||
'height',
|
||||
'padding',
|
||||
'margin',
|
||||
'border',
|
||||
'background-color',
|
||||
'color',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-family',
|
||||
'border-radius',
|
||||
'box-shadow',
|
||||
'gap',
|
||||
'grid-template-columns',
|
||||
];
|
||||
|
||||
const PRIMARY_400_COLOR = '#7B68EE';
|
||||
const TEXT_CONTEXT_MAX_LENGTH = 500;
|
||||
const DATA_SELECTION_MODE_ENABLED_ATTRIBUTE = 'data-selection-mode-enabled';
|
||||
const MESSAGE_TYPE_ENABLE_SELECTION_MODE = 'enableSelectionMode';
|
||||
const MESSAGE_TYPE_DISABLE_SELECTION_MODE = 'disableSelectionMode';
|
||||
|
||||
let selectionModeEnabled = false;
|
||||
let currentHoverElement = null;
|
||||
let overlayDiv = null;
|
||||
let selectedOverlayDiv = null;
|
||||
let selectedElement = null;
|
||||
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('selection-mode-styles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'selection-mode-styles';
|
||||
style.textContent = `
|
||||
#selection-mode-overlay {
|
||||
position: absolute;
|
||||
border: 2px dashed ${PRIMARY_400_COLOR};
|
||||
pointer-events: none;
|
||||
z-index: 999999;
|
||||
}
|
||||
#selection-mode-selected-overlay {
|
||||
position: absolute;
|
||||
border: 3px solid ${PRIMARY_400_COLOR};
|
||||
pointer-events: none;
|
||||
z-index: 999998;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function getParentOrigin() {
|
||||
if (
|
||||
window.location.ancestorOrigins
|
||||
&& window.location.ancestorOrigins.length > 0
|
||||
) {
|
||||
return window.location.ancestorOrigins[0];
|
||||
}
|
||||
|
||||
if (document.referrer) {
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch {
|
||||
console.warn('[SELECTION MODE] Invalid referrer URL:', document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file path from React Fiber metadata (simplified - only for filePath)
|
||||
* @param {*} node - DOM node
|
||||
* @returns {string|null} - File path if found, null otherwise
|
||||
*/
|
||||
function getFilePathFromNode(node) {
|
||||
const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber'));
|
||||
if (!fiberKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fiber = node[fiberKey];
|
||||
if (!fiber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Traverse up the fiber tree to find source metadata
|
||||
let currentFiber = fiber;
|
||||
while (currentFiber) {
|
||||
const source = currentFiber._debugSource
|
||||
|| currentFiber.memoizedProps?.__source
|
||||
|| currentFiber.pendingProps?.__source;
|
||||
|
||||
if (source?.fileName) {
|
||||
return source.fileName;
|
||||
}
|
||||
|
||||
currentFiber = currentFiber.return;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CSS selector path to uniquely identify the element
|
||||
* @param {*} element
|
||||
* @returns {string} CSS selector path
|
||||
*/
|
||||
function getPathToElement(element) {
|
||||
const path = [];
|
||||
let current = element;
|
||||
let depth = 0;
|
||||
const maxDepth = 20; // Prevent infinite loops
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
||||
let selector = current.nodeName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
path.unshift(selector);
|
||||
break; // ID is unique, stop here
|
||||
}
|
||||
|
||||
if (current.className && typeof current.className === 'string') {
|
||||
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||
if (classes.length > 0) {
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.parentElement) {
|
||||
const siblings = Array.from(current.parentElement.children);
|
||||
const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName);
|
||||
if (sameTypeSiblings.length > 1) {
|
||||
const index = sameTypeSiblings.indexOf(current) + 1;
|
||||
selector += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
function getComputedStyles(element) {
|
||||
const computedStyles = window.getComputedStyle(element);
|
||||
|
||||
return Object.fromEntries(IMPORTANT_STYLES.map((style) => {
|
||||
const styleValue = computedStyles.getPropertyValue(style)?.trim();
|
||||
|
||||
return styleValue && styleValue !== 'none' && styleValue !== 'normal'
|
||||
? [style, styleValue]
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean));
|
||||
}
|
||||
|
||||
function extractDOMContext(element) {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textContent = element.textContent?.trim();
|
||||
|
||||
return {
|
||||
outerHTML: element.outerHTML,
|
||||
selector: getPathToElement(element),
|
||||
attributes: (element.attributes && element.attributes.length > 0)
|
||||
? Object.fromEntries(Array.from(element.attributes).map((attr) => [attr.name, attr.value]))
|
||||
: {},
|
||||
computedStyles: getComputedStyles(element),
|
||||
textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH)
|
||||
? element.textContent?.trim()
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function createOverlay() {
|
||||
if (overlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
overlayDiv = document.createElement('div');
|
||||
overlayDiv.id = 'selection-mode-overlay';
|
||||
document.body.appendChild(overlayDiv);
|
||||
}
|
||||
|
||||
function createSelectedOverlay() {
|
||||
if (selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
selectedOverlayDiv = document.createElement('div');
|
||||
selectedOverlayDiv.id = 'selection-mode-selected-overlay';
|
||||
document.body.appendChild(selectedOverlayDiv);
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
if (overlayDiv && overlayDiv.parentNode) {
|
||||
overlayDiv.parentNode.removeChild(overlayDiv);
|
||||
overlayDiv = null;
|
||||
}
|
||||
if (selectedOverlayDiv && selectedOverlayDiv.parentNode) {
|
||||
selectedOverlayDiv.parentNode.removeChild(selectedOverlayDiv);
|
||||
selectedOverlayDiv = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showOverlay(element) {
|
||||
if (!overlayDiv) {
|
||||
createOverlay();
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
overlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||
overlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||
overlayDiv.style.width = `${rect.width}px`;
|
||||
overlayDiv.style.height = `${rect.height}px`;
|
||||
overlayDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function showSelectedOverlay(element) {
|
||||
if (!selectedOverlayDiv) {
|
||||
createSelectedOverlay();
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
selectedOverlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||
selectedOverlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||
selectedOverlayDiv.style.width = `${rect.width}px`;
|
||||
selectedOverlayDiv.style.height = `${rect.height}px`;
|
||||
selectedOverlayDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
if (overlayDiv) {
|
||||
overlayDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!element) {
|
||||
hideOverlay();
|
||||
currentHoverElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if we're hovering a different element
|
||||
if (currentHoverElement !== element) {
|
||||
currentHoverElement = element;
|
||||
|
||||
// Show outline on the element
|
||||
showOverlay(element);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchStart(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
if (!element) {
|
||||
currentHoverElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentHoverElement = element;
|
||||
|
||||
showOverlay(element);
|
||||
}
|
||||
|
||||
function stripFilePath(filePath) {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const publicHtmlIndex = filePath.indexOf('public_html/');
|
||||
if (publicHtmlIndex !== -1) {
|
||||
return filePath.substring(publicHtmlIndex + 'public_html/'.length);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function handleClick(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentHoverElement) {
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
|
||||
if (!element || element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentHoverElement = element;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const domContext = extractDOMContext(currentHoverElement);
|
||||
|
||||
if (!domContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedElement = currentHoverElement;
|
||||
if (selectedElement) {
|
||||
showSelectedOverlay(selectedElement);
|
||||
}
|
||||
|
||||
// Extract file path from React Fiber (if available)
|
||||
const filePath = getFilePathFromNode(currentHoverElement);
|
||||
const strippedFilePath = filePath ? stripFilePath(filePath) : undefined;
|
||||
|
||||
// Send domContext and filePath to parent window
|
||||
const parentOrigin = getParentOrigin();
|
||||
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'elementSelected',
|
||||
payload: {
|
||||
filePath: strippedFilePath,
|
||||
domContext,
|
||||
},
|
||||
},
|
||||
parentOrigin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
hideOverlay();
|
||||
currentHoverElement = null;
|
||||
}
|
||||
|
||||
function enableSelectionMode() {
|
||||
if (selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionModeEnabled = true;
|
||||
document.getElementById('root')?.setAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE, 'true');
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
createOverlay();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
document.addEventListener('touchstart', handleTouchStart, true);
|
||||
document.addEventListener('click', handleClick, true);
|
||||
document.addEventListener('mouseleave', handleMouseLeave, true);
|
||||
}
|
||||
|
||||
function disableSelectionMode() {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionModeEnabled = false;
|
||||
document.getElementById('root')?.removeAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE);
|
||||
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
hideOverlay();
|
||||
removeOverlay();
|
||||
currentHoverElement = null;
|
||||
selectedElement = null;
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('touchstart', handleTouchStart, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
document.removeEventListener('mouseleave', handleMouseLeave, true);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data?.type === MESSAGE_TYPE_ENABLE_SELECTION_MODE) {
|
||||
enableSelectionMode();
|
||||
}
|
||||
if (event.data?.type === MESSAGE_TYPE_DISABLE_SELECTION_MODE) {
|
||||
disableSelectionMode();
|
||||
}
|
||||
});
|
||||
27
plugins/selection-mode/vite-plugin-selection-mode.js
Normal file
27
plugins/selection-mode/vite-plugin-selection-mode.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = resolve(__filename, '..');
|
||||
|
||||
export default function selectionModePlugin() {
|
||||
return {
|
||||
name: 'vite:selection-mode',
|
||||
apply: 'serve',
|
||||
|
||||
transformIndexHtml() {
|
||||
const scriptPath = resolve(__dirname, 'selection-mode-script.js');
|
||||
const scriptContent = readFileSync(scriptPath, 'utf-8');
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: scriptContent,
|
||||
injectTo: 'body',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
279
plugins/utils/ast-utils.js
Normal file
279
plugins/utils/ast-utils.js
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import generate from '@babel/generator';
|
||||
import { parse } from '@babel/parser';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import {
|
||||
isJSXIdentifier,
|
||||
isJSXMemberExpression,
|
||||
} from '@babel/types';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const VITE_PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
// Blacklist of components that should not be extracted (utility/non-visual components)
|
||||
const COMPONENT_BLACKLIST = new Set([
|
||||
'Helmet',
|
||||
'HelmetProvider',
|
||||
'Head',
|
||||
'head',
|
||||
'Meta',
|
||||
'meta',
|
||||
'Script',
|
||||
'script',
|
||||
'NoScript',
|
||||
'noscript',
|
||||
'Style',
|
||||
'style',
|
||||
'title',
|
||||
'Title',
|
||||
'link',
|
||||
'Link',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validates that a file path is safe to access
|
||||
* @param {string} filePath - Relative file path
|
||||
* @returns {{ isValid: boolean, absolutePath?: string, error?: string }} - Object containing validation result
|
||||
*/
|
||||
export function validateFilePath(filePath) {
|
||||
if (!filePath) {
|
||||
return { isValid: false, error: 'Missing filePath' };
|
||||
}
|
||||
|
||||
const absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
|
||||
|
||||
if (filePath.includes('..')
|
||||
|| !absoluteFilePath.startsWith(VITE_PROJECT_ROOT)
|
||||
|| absoluteFilePath.includes('node_modules')) {
|
||||
return { isValid: false, error: 'Invalid path' };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absoluteFilePath)) {
|
||||
return { isValid: false, error: 'File not found' };
|
||||
}
|
||||
|
||||
return { isValid: true, absolutePath: absoluteFilePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a file into a Babel AST
|
||||
* @param {string} absoluteFilePath - Absolute path to file
|
||||
* @returns {object} Babel AST
|
||||
*/
|
||||
export function parseFileToAST(absoluteFilePath) {
|
||||
const content = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||
|
||||
return parse(content, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
errorRecovery: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a JSX opening element at a specific line and column
|
||||
* @param {object} ast - Babel AST
|
||||
* @param {number} line - Line number (1-indexed)
|
||||
* @param {number} column - Column number (0-indexed for get-code-block, 1-indexed for apply-edit)
|
||||
* @returns {object | null} Babel path to the JSX opening element
|
||||
*/
|
||||
export function findJSXElementAtPosition(ast, line, column) {
|
||||
let targetNodePath = null;
|
||||
let closestNodePath = null;
|
||||
let closestDistance = Infinity;
|
||||
const allNodesOnLine = [];
|
||||
|
||||
const visitor = {
|
||||
JSXOpeningElement(path) {
|
||||
const node = path.node;
|
||||
if (node.loc) {
|
||||
// Exact match (with tolerance for off-by-one column differences)
|
||||
if (node.loc.start.line === line
|
||||
&& Math.abs(node.loc.start.column - column) <= 1) {
|
||||
targetNodePath = path;
|
||||
path.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Track all nodes on the same line
|
||||
if (node.loc.start.line === line) {
|
||||
allNodesOnLine.push({
|
||||
path,
|
||||
column: node.loc.start.column,
|
||||
distance: Math.abs(node.loc.start.column - column),
|
||||
});
|
||||
}
|
||||
|
||||
// Track closest match on the same line for fallback
|
||||
if (node.loc.start.line === line) {
|
||||
const distance = Math.abs(node.loc.start.column - column);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Also check JSXElement nodes that contain the position
|
||||
JSXElement(path) {
|
||||
const node = path.node;
|
||||
if (!node.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this element spans the target line (for multi-line elements)
|
||||
if (node.loc.start.line > line || node.loc.end.line < line) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're inside this element's range, consider its opening element
|
||||
if (!path.node.openingElement?.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openingLine = path.node.openingElement.loc.start.line;
|
||||
const openingCol = path.node.openingElement.loc.start.column;
|
||||
|
||||
// Prefer elements that start on the exact line
|
||||
if (openingLine === line) {
|
||||
const distance = Math.abs(openingCol - column);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path.get('openingElement');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle elements that start before the target line
|
||||
if (openingLine < line) {
|
||||
const distance = (line - openingLine) * 100; // Penalize by line distance
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path.get('openingElement');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
traverseBabel.default(ast, visitor);
|
||||
|
||||
// Return exact match if found, otherwise return closest match if within reasonable distance
|
||||
// Use larger threshold (50 chars) for same-line elements, 5 lines for multi-line elements
|
||||
const threshold = closestDistance < 100 ? 50 : 500;
|
||||
return targetNodePath || (closestDistance <= threshold ? closestNodePath : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a JSX element name is blacklisted
|
||||
* @param {object} jsxOpeningElement - Babel JSX opening element node
|
||||
* @returns {boolean} True if blacklisted
|
||||
*/
|
||||
function isBlacklistedComponent(jsxOpeningElement) {
|
||||
if (!jsxOpeningElement || !jsxOpeningElement.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle JSXIdentifier (e.g., <Helmet>)
|
||||
if (isJSXIdentifier(jsxOpeningElement.name)) {
|
||||
return COMPONENT_BLACKLIST.has(jsxOpeningElement.name.name);
|
||||
}
|
||||
|
||||
// Handle JSXMemberExpression (e.g., <React.Fragment>)
|
||||
if (isJSXMemberExpression(jsxOpeningElement.name)) {
|
||||
let current = jsxOpeningElement.name;
|
||||
while (isJSXMemberExpression(current)) {
|
||||
current = current.property;
|
||||
}
|
||||
if (isJSXIdentifier(current)) {
|
||||
return COMPONENT_BLACKLIST.has(current.name);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates code from an AST node
|
||||
* @param {object} node - Babel AST node
|
||||
* @param {object} options - Generator options
|
||||
* @returns {string} Generated code
|
||||
*/
|
||||
export function generateCode(node, options = {}) {
|
||||
const generateFunction = generate.default || generate;
|
||||
const output = generateFunction(node, options);
|
||||
return output.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a full source file from AST with source maps
|
||||
* @param {object} ast - Babel AST
|
||||
* @param {string} sourceFileName - Source file name for source map
|
||||
* @param {string} originalCode - Original source code
|
||||
* @returns {{code: string, map: object}} - Object containing generated code and source map
|
||||
*/
|
||||
export function generateSourceWithMap(ast, sourceFileName, originalCode) {
|
||||
const generateFunction = generate.default || generate;
|
||||
return generateFunction(ast, {
|
||||
sourceMaps: true,
|
||||
sourceFileName,
|
||||
}, originalCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts code blocks from a JSX element at a specific location
|
||||
* @param {string} filePath - Relative file path
|
||||
* @param {number} line - Line number
|
||||
* @param {number} column - Column number
|
||||
* @param {object} [domContext] - Optional DOM context to return on failure
|
||||
* @returns {{success: boolean, filePath?: string, specificLine?: string, error?: string, domContext?: object}} - Object with metadata for LLM
|
||||
*/
|
||||
export function extractCodeBlocks(filePath, line, column, domContext) {
|
||||
try {
|
||||
// Validate file path
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.isValid) {
|
||||
return { success: false, error: validation.error, domContext };
|
||||
}
|
||||
|
||||
// Parse AST
|
||||
const ast = parseFileToAST(validation.absolutePath);
|
||||
|
||||
// Find target node
|
||||
const targetNodePath = findJSXElementAtPosition(ast, line, column);
|
||||
|
||||
if (!targetNodePath) {
|
||||
return { success: false, error: 'Target node not found at specified line/column', domContext };
|
||||
}
|
||||
|
||||
// Check if the target node is a blacklisted component
|
||||
const isBlacklisted = isBlacklistedComponent(targetNodePath.node);
|
||||
|
||||
if (isBlacklisted) {
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
specificLine: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Get specific line code
|
||||
const specificLine = generateCode(targetNodePath.parentPath?.node || targetNodePath.node);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
specificLine,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ast-utils] Error extracting code blocks:', error);
|
||||
return { success: false, error: 'Failed to extract code blocks', domContext };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project root path
|
||||
*/
|
||||
export { VITE_PROJECT_ROOT };
|
||||
356
plugins/visual-editor/edit-mode-script.js
Normal file
356
plugins/visual-editor/edit-mode-script.js
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import { POPUP_STYLES } from "./plugins/visual-editor/visual-editor-config.js";
|
||||
|
||||
const PLUGIN_APPLY_EDIT_API_URL = "/api/apply-edit";
|
||||
|
||||
const ALLOWED_PARENT_ORIGINS = [
|
||||
"https://horizons.hostinger.com",
|
||||
"https://horizons.hostinger.dev",
|
||||
"https://horizons-frontend-local.hostinger.dev",
|
||||
"http://localhost:4000",
|
||||
];
|
||||
|
||||
let disabledTooltipElement = null;
|
||||
let currentDisabledHoverElement = null;
|
||||
|
||||
let translations = {
|
||||
disabledTooltipText: "This text can be changed only through chat.",
|
||||
disabledTooltipTextImage: "This image can only be changed through chat.",
|
||||
};
|
||||
|
||||
let areStylesInjected = false;
|
||||
|
||||
let globalEventHandlers = null;
|
||||
|
||||
let currentEditingInfo = null;
|
||||
|
||||
function injectPopupStyles() {
|
||||
if (areStylesInjected) return;
|
||||
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = "inline-editor-styles";
|
||||
styleElement.textContent = POPUP_STYLES;
|
||||
document.head.appendChild(styleElement);
|
||||
areStylesInjected = true;
|
||||
}
|
||||
|
||||
function findEditableElementAtPoint(event) {
|
||||
let editableElement = event.target.closest("[data-edit-id]");
|
||||
|
||||
if (editableElement) {
|
||||
return editableElement;
|
||||
}
|
||||
|
||||
const elementsAtPoint = document.elementsFromPoint(
|
||||
event.clientX,
|
||||
event.clientY
|
||||
);
|
||||
|
||||
const found = elementsAtPoint.find(
|
||||
(el) => el !== event.target && el.hasAttribute("data-edit-id")
|
||||
);
|
||||
if (found) return found;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findDisabledElementAtPoint(event) {
|
||||
const direct = event.target.closest("[data-edit-disabled]");
|
||||
if (direct) return direct;
|
||||
const elementsAtPoint = document.elementsFromPoint(
|
||||
event.clientX,
|
||||
event.clientY
|
||||
);
|
||||
const found = elementsAtPoint.find(
|
||||
(el) => el !== event.target && el.hasAttribute("data-edit-disabled")
|
||||
);
|
||||
if (found) return found;
|
||||
return null;
|
||||
}
|
||||
|
||||
function showPopup(targetElement, editId, currentContent, isImage = false) {
|
||||
currentEditingInfo = { editId, targetElement };
|
||||
|
||||
const parentOrigin = getParentOrigin();
|
||||
|
||||
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
const eventType = isImage ? "imageEditEnter" : "editEnter";
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: eventType,
|
||||
payload: { currentText: currentContent },
|
||||
},
|
||||
parentOrigin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalEvent(event) {
|
||||
if (
|
||||
!document.getElementById("root")?.getAttribute("data-edit-mode-enabled")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't handle if selection mode is active
|
||||
if (document.getElementById("root")?.getAttribute("data-selection-mode-enabled") === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("#inline-editor-popup")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editableElement = findEditableElementAtPoint(event);
|
||||
|
||||
if (editableElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
if (event.type === "click") {
|
||||
const editId = editableElement.getAttribute("data-edit-id");
|
||||
if (!editId) {
|
||||
console.warn("[INLINE EDITOR] Clicked element missing data-edit-id");
|
||||
return;
|
||||
}
|
||||
|
||||
const isImage = editableElement.tagName.toLowerCase() === "img";
|
||||
let currentContent = "";
|
||||
|
||||
if (isImage) {
|
||||
currentContent = editableElement.getAttribute("src") || "";
|
||||
} else {
|
||||
currentContent = editableElement.textContent || "";
|
||||
}
|
||||
|
||||
showPopup(editableElement, editId, currentContent, isImage);
|
||||
}
|
||||
} else {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function getParentOrigin() {
|
||||
if (
|
||||
window.location.ancestorOrigins &&
|
||||
window.location.ancestorOrigins.length > 0
|
||||
) {
|
||||
return window.location.ancestorOrigins[0];
|
||||
}
|
||||
|
||||
if (document.referrer) {
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch (e) {
|
||||
console.warn("Invalid referrer URL:", document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleEditSave(updatedText) {
|
||||
const newText = updatedText
|
||||
// Replacing characters that cause Babel parser to crash
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/{/g, "{")
|
||||
.replace(/}/g, "}");
|
||||
|
||||
const { editId } = currentEditingInfo;
|
||||
|
||||
try {
|
||||
const response = await fetch(PLUGIN_APPLY_EDIT_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
editId: editId,
|
||||
newFullText: newText,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const parentOrigin = getParentOrigin();
|
||||
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "editApplied",
|
||||
payload: {
|
||||
editId: editId,
|
||||
fileContent: result.newFileContent,
|
||||
beforeCode: result.beforeCode,
|
||||
afterCode: result.afterCode,
|
||||
},
|
||||
},
|
||||
parentOrigin
|
||||
);
|
||||
} else {
|
||||
console.error("Unauthorized parent origin:", parentOrigin);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`[vite][visual-editor] Error saving changes: ${result.error}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[vite][visual-editor] Error during fetch for ${editId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createDisabledTooltip() {
|
||||
if (disabledTooltipElement) return;
|
||||
|
||||
disabledTooltipElement = document.createElement("div");
|
||||
disabledTooltipElement.id = "inline-editor-disabled-tooltip";
|
||||
document.body.appendChild(disabledTooltipElement);
|
||||
}
|
||||
|
||||
function showDisabledTooltip(targetElement, isImage = false) {
|
||||
if (!disabledTooltipElement) createDisabledTooltip();
|
||||
|
||||
disabledTooltipElement.textContent = isImage
|
||||
? translations.disabledTooltipTextImage
|
||||
: translations.disabledTooltipText;
|
||||
|
||||
if (!disabledTooltipElement.isConnected) {
|
||||
document.body.appendChild(disabledTooltipElement);
|
||||
}
|
||||
disabledTooltipElement.classList.add("tooltip-active");
|
||||
|
||||
const tooltipWidth = disabledTooltipElement.offsetWidth;
|
||||
const tooltipHeight = disabledTooltipElement.offsetHeight;
|
||||
const rect = targetElement.getBoundingClientRect();
|
||||
|
||||
// Ensures that tooltip is not off the screen with 5px margin
|
||||
let newLeft = rect.left + window.scrollX + rect.width / 2 - tooltipWidth / 2;
|
||||
let newTop = rect.bottom + window.scrollY + 5;
|
||||
|
||||
if (newLeft < window.scrollX) {
|
||||
newLeft = window.scrollX + 5;
|
||||
}
|
||||
if (newLeft + tooltipWidth > window.innerWidth + window.scrollX) {
|
||||
newLeft = window.innerWidth + window.scrollX - tooltipWidth - 5;
|
||||
}
|
||||
if (newTop + tooltipHeight > window.innerHeight + window.scrollY) {
|
||||
newTop = rect.top + window.scrollY - tooltipHeight - 5;
|
||||
}
|
||||
if (newTop < window.scrollY) {
|
||||
newTop = window.scrollY + 5;
|
||||
}
|
||||
|
||||
disabledTooltipElement.style.left = `${newLeft}px`;
|
||||
disabledTooltipElement.style.top = `${newTop}px`;
|
||||
}
|
||||
|
||||
function hideDisabledTooltip() {
|
||||
if (disabledTooltipElement) {
|
||||
disabledTooltipElement.classList.remove("tooltip-active");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisabledElementHover(event) {
|
||||
const isImage = event.currentTarget.tagName.toLowerCase() === "img";
|
||||
|
||||
showDisabledTooltip(event.currentTarget, isImage);
|
||||
}
|
||||
|
||||
function handleDisabledElementLeave() {
|
||||
hideDisabledTooltip();
|
||||
}
|
||||
|
||||
function handleDisabledGlobalHover(event) {
|
||||
const disabledElement = findDisabledElementAtPoint(event);
|
||||
if (disabledElement) {
|
||||
if (currentDisabledHoverElement !== disabledElement) {
|
||||
currentDisabledHoverElement = disabledElement;
|
||||
const isImage = disabledElement.tagName.toLowerCase() === "img";
|
||||
showDisabledTooltip(disabledElement, isImage);
|
||||
}
|
||||
} else {
|
||||
if (currentDisabledHoverElement) {
|
||||
currentDisabledHoverElement = null;
|
||||
hideDisabledTooltip();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enableEditMode() {
|
||||
// Don't enable if selection mode is active
|
||||
if (document.getElementById("root")?.getAttribute("data-selection-mode-enabled") === "true") {
|
||||
console.warn("[EDIT MODE] Cannot enable edit mode while selection mode is active");
|
||||
return;
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("root")
|
||||
?.setAttribute("data-edit-mode-enabled", "true");
|
||||
|
||||
injectPopupStyles();
|
||||
|
||||
if (!globalEventHandlers) {
|
||||
globalEventHandlers = {
|
||||
mousedown: handleGlobalEvent,
|
||||
pointerdown: handleGlobalEvent,
|
||||
click: handleGlobalEvent,
|
||||
};
|
||||
|
||||
Object.entries(globalEventHandlers).forEach(([eventType, handler]) => {
|
||||
document.addEventListener(eventType, handler, true);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleDisabledGlobalHover, true);
|
||||
|
||||
document.querySelectorAll("[data-edit-disabled]").forEach((el) => {
|
||||
el.removeEventListener("mouseenter", handleDisabledElementHover);
|
||||
el.addEventListener("mouseenter", handleDisabledElementHover);
|
||||
el.removeEventListener("mouseleave", handleDisabledElementLeave);
|
||||
el.addEventListener("mouseleave", handleDisabledElementLeave);
|
||||
});
|
||||
}
|
||||
|
||||
function disableEditMode() {
|
||||
document.getElementById("root")?.removeAttribute("data-edit-mode-enabled");
|
||||
|
||||
hideDisabledTooltip();
|
||||
|
||||
if (globalEventHandlers) {
|
||||
Object.entries(globalEventHandlers).forEach(([eventType, handler]) => {
|
||||
document.removeEventListener(eventType, handler, true);
|
||||
});
|
||||
globalEventHandlers = null;
|
||||
}
|
||||
|
||||
document.removeEventListener("mousemove", handleDisabledGlobalHover, true);
|
||||
currentDisabledHoverElement = null;
|
||||
|
||||
document.querySelectorAll("[data-edit-disabled]").forEach((el) => {
|
||||
el.removeEventListener("mouseenter", handleDisabledElementHover);
|
||||
el.removeEventListener("mouseleave", handleDisabledElementLeave);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
if (event.data?.type === "edit-save") {
|
||||
handleEditSave(event.data?.payload?.newText);
|
||||
}
|
||||
if (event.data?.type === "enable-edit-mode") {
|
||||
if (event.data?.translations) {
|
||||
translations = { ...translations, ...event.data.translations };
|
||||
}
|
||||
|
||||
enableEditMode();
|
||||
}
|
||||
if (event.data?.type === "disable-edit-mode") {
|
||||
disableEditMode();
|
||||
}
|
||||
});
|
||||
137
plugins/visual-editor/visual-editor-config.js
Normal file
137
plugins/visual-editor/visual-editor-config.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
export const POPUP_STYLES = `
|
||||
#inline-editor-popup {
|
||||
width: 360px;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: #161718;
|
||||
color: white;
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 16px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#inline-editor-popup {
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
}
|
||||
|
||||
#inline-editor-popup.is-active {
|
||||
display: flex;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
#inline-editor-popup.is-disabled-view {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
#inline-editor-popup textarea {
|
||||
height: 100px;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.42;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#inline-editor-popup .button-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#inline-editor-popup .popup-button {
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
height: 34px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#inline-editor-popup .save-button {
|
||||
background: #673de6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#inline-editor-popup .cancel-button {
|
||||
background: transparent;
|
||||
border: 1px solid #3b3d4a;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background:#474958;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function getPopupHTMLTemplate(saveLabel, cancelLabel) {
|
||||
return `
|
||||
<textarea></textarea>
|
||||
<div class="button-container">
|
||||
<button class="popup-button cancel-button">${cancelLabel}</button>
|
||||
<button class="popup-button save-button">${saveLabel}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export const EDIT_MODE_STYLES = `
|
||||
#root[data-edit-mode-enabled="true"] [data-edit-id] {
|
||||
cursor: pointer;
|
||||
outline: 2px dashed #357DF9;
|
||||
outline-offset: 2px;
|
||||
min-height: 1em;
|
||||
}
|
||||
#root[data-edit-mode-enabled="true"] img[data-edit-id] {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
#root[data-edit-mode-enabled="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
#root[data-edit-mode-enabled="true"] [data-edit-id]:hover {
|
||||
background-color: #357DF933;
|
||||
outline-color: #357DF9;
|
||||
}
|
||||
|
||||
@keyframes fadeInTooltip {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
#inline-editor-disabled-tooltip {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
background-color: #1D1E20;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
z-index: 10001;
|
||||
font-size: 14px;
|
||||
border: 1px solid #3B3D4A;
|
||||
max-width: 184px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#inline-editor-disabled-tooltip.tooltip-active {
|
||||
display: block;
|
||||
animation: fadeInTooltip 0.2s ease-out forwards;
|
||||
}
|
||||
`;
|
||||
32
plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
32
plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { EDIT_MODE_STYLES } from './visual-editor-config';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = resolve(__filename, '..');
|
||||
|
||||
export default function inlineEditDevPlugin() {
|
||||
return {
|
||||
name: 'vite:inline-edit-dev',
|
||||
apply: 'serve',
|
||||
transformIndexHtml() {
|
||||
const scriptPath = resolve(__dirname, 'edit-mode-script.js');
|
||||
const scriptContent = readFileSync(scriptPath, 'utf-8');
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: scriptContent,
|
||||
injectTo: 'body'
|
||||
},
|
||||
{
|
||||
tag: 'style',
|
||||
children: EDIT_MODE_STYLES,
|
||||
injectTo: 'head'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
365
plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
365
plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import path from 'path';
|
||||
import { parse } from '@babel/parser';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
validateFilePath,
|
||||
parseFileToAST,
|
||||
findJSXElementAtPosition,
|
||||
generateCode,
|
||||
generateSourceWithMap,
|
||||
VITE_PROJECT_ROOT
|
||||
} from '../utils/ast-utils.js';
|
||||
|
||||
const EDITABLE_HTML_TAGS = ["a", "Button", "button", "p", "span", "h1", "h2", "h3", "h4", "h5", "h6", "label", "Label", "img"];
|
||||
|
||||
function parseEditId(editId) {
|
||||
const parts = editId.split(':');
|
||||
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const column = parseInt(parts.at(-1), 10);
|
||||
const line = parseInt(parts.at(-2), 10);
|
||||
const filePath = parts.slice(0, -2).join(':');
|
||||
|
||||
if (!filePath || isNaN(line) || isNaN(column)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { filePath, line, column };
|
||||
}
|
||||
|
||||
function checkTagNameEditable(openingElementNode, editableTagsList) {
|
||||
if (!openingElementNode || !openingElementNode.name) return false;
|
||||
const nameNode = openingElementNode.name;
|
||||
|
||||
// Check 1: Direct name (for <p>, <Button>)
|
||||
if (nameNode.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 2: Property name of a member expression (for <motion.h1>, check if "h1" is in editableTagsList)
|
||||
if (nameNode.type === 'JSXMemberExpression' && nameNode.property && nameNode.property.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.property.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateImageSrc(openingNode) {
|
||||
if (!openingNode || !openingNode.name || openingNode.name.name !== 'img') {
|
||||
return { isValid: true, reason: null }; // Not an image, skip validation
|
||||
}
|
||||
|
||||
const hasPropsSpread = openingNode.attributes.some(attr =>
|
||||
t.isJSXSpreadAttribute(attr) &&
|
||||
attr.argument &&
|
||||
t.isIdentifier(attr.argument) &&
|
||||
attr.argument.name === 'props'
|
||||
);
|
||||
|
||||
if (hasPropsSpread) {
|
||||
return { isValid: false, reason: 'props-spread' };
|
||||
}
|
||||
|
||||
const srcAttr = openingNode.attributes.find(attr =>
|
||||
t.isJSXAttribute(attr) &&
|
||||
attr.name &&
|
||||
attr.name.name === 'src'
|
||||
);
|
||||
|
||||
if (!srcAttr) {
|
||||
return { isValid: false, reason: 'missing-src' };
|
||||
}
|
||||
|
||||
if (!t.isStringLiteral(srcAttr.value)) {
|
||||
return { isValid: false, reason: 'dynamic-src' };
|
||||
}
|
||||
|
||||
if (!srcAttr.value.value || srcAttr.value.value.trim() === '') {
|
||||
return { isValid: false, reason: 'empty-src' };
|
||||
}
|
||||
|
||||
return { isValid: true, reason: null };
|
||||
}
|
||||
|
||||
export default function inlineEditPlugin() {
|
||||
return {
|
||||
name: 'vite-inline-edit-plugin',
|
||||
enforce: 'pre',
|
||||
|
||||
transform(code, id) {
|
||||
if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeFilePath = path.relative(VITE_PROJECT_ROOT, id);
|
||||
const webRelativeFilePath = relativeFilePath.split(path.sep).join('/');
|
||||
|
||||
try {
|
||||
const babelAst = parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
errorRecovery: true
|
||||
});
|
||||
|
||||
let attributesAdded = 0;
|
||||
|
||||
traverseBabel.default(babelAst, {
|
||||
enter(path) {
|
||||
if (path.isJSXOpeningElement()) {
|
||||
const openingNode = path.node;
|
||||
const elementNode = path.parentPath.node; // The JSXElement itself
|
||||
|
||||
if (!openingNode.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyHasId = openingNode.attributes.some(
|
||||
(attr) => t.isJSXAttribute(attr) && attr.name.name === 'data-edit-id'
|
||||
);
|
||||
|
||||
if (alreadyHasId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Condition 1: Is the current element tag type editable?
|
||||
const isCurrentElementEditable = checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS);
|
||||
if (!isCurrentElementEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageValidation = validateImageSrc(openingNode);
|
||||
if (!imageValidation.isValid) {
|
||||
const disabledAttribute = t.jsxAttribute(
|
||||
t.jsxIdentifier('data-edit-disabled'),
|
||||
t.stringLiteral('true')
|
||||
);
|
||||
openingNode.attributes.push(disabledAttribute);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldBeDisabledDueToChildren = false;
|
||||
|
||||
// Condition 2: Does the element have dynamic or editable children
|
||||
if (t.isJSXElement(elementNode) && elementNode.children) {
|
||||
// Check if element has {...props} spread attribute - disable editing if it does
|
||||
const hasPropsSpread = openingNode.attributes.some(attr => t.isJSXSpreadAttribute(attr)
|
||||
&& attr.argument
|
||||
&& t.isIdentifier(attr.argument)
|
||||
&& attr.argument.name === 'props'
|
||||
);
|
||||
|
||||
const hasDynamicChild = elementNode.children.some(child =>
|
||||
t.isJSXExpressionContainer(child)
|
||||
);
|
||||
|
||||
if (hasDynamicChild || hasPropsSpread) {
|
||||
shouldBeDisabledDueToChildren = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldBeDisabledDueToChildren && t.isJSXElement(elementNode) && elementNode.children) {
|
||||
const hasEditableJsxChild = elementNode.children.some(child => {
|
||||
if (t.isJSXElement(child)) {
|
||||
return checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasEditableJsxChild) {
|
||||
shouldBeDisabledDueToChildren = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldBeDisabledDueToChildren) {
|
||||
const disabledAttribute = t.jsxAttribute(
|
||||
t.jsxIdentifier('data-edit-disabled'),
|
||||
t.stringLiteral('true')
|
||||
);
|
||||
|
||||
openingNode.attributes.push(disabledAttribute);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Condition 3: Parent is non-editable if AT LEAST ONE child JSXElement is a non-editable type.
|
||||
if (t.isJSXElement(elementNode) && elementNode.children && elementNode.children.length > 0) {
|
||||
let hasNonEditableJsxChild = false;
|
||||
for (const child of elementNode.children) {
|
||||
if (t.isJSXElement(child)) {
|
||||
if (!checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS)) {
|
||||
hasNonEditableJsxChild = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasNonEditableJsxChild) {
|
||||
const disabledAttribute = t.jsxAttribute(
|
||||
t.jsxIdentifier('data-edit-disabled'),
|
||||
t.stringLiteral("true")
|
||||
);
|
||||
openingNode.attributes.push(disabledAttribute);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Condition 4: Is any ancestor JSXElement also editable?
|
||||
let currentAncestorCandidatePath = path.parentPath.parentPath;
|
||||
while (currentAncestorCandidatePath) {
|
||||
const ancestorJsxElementPath = currentAncestorCandidatePath.isJSXElement()
|
||||
? currentAncestorCandidatePath
|
||||
: currentAncestorCandidatePath.findParent(p => p.isJSXElement());
|
||||
|
||||
if (!ancestorJsxElementPath) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (checkTagNameEditable(ancestorJsxElementPath.node.openingElement, EDITABLE_HTML_TAGS)) {
|
||||
return;
|
||||
}
|
||||
currentAncestorCandidatePath = ancestorJsxElementPath.parentPath;
|
||||
}
|
||||
|
||||
const line = openingNode.loc.start.line;
|
||||
const column = openingNode.loc.start.column + 1;
|
||||
const editId = `${webRelativeFilePath}:${line}:${column}`;
|
||||
|
||||
const idAttribute = t.jsxAttribute(
|
||||
t.jsxIdentifier('data-edit-id'),
|
||||
t.stringLiteral(editId)
|
||||
);
|
||||
|
||||
openingNode.attributes.push(idAttribute);
|
||||
attributesAdded++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (attributesAdded > 0) {
|
||||
const output = generateSourceWithMap(babelAst, webRelativeFilePath, code);
|
||||
return { code: output.code, map: output.map };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[vite][visual-editor] Error transforming ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Updates source code based on the changes received from the client
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/apply-edit', async (req, res, next) => {
|
||||
if (req.method !== 'POST') return next();
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk.toString(); });
|
||||
|
||||
req.on('end', async () => {
|
||||
let absoluteFilePath = '';
|
||||
try {
|
||||
const { editId, newFullText } = JSON.parse(body);
|
||||
|
||||
if (!editId || typeof newFullText === 'undefined') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Missing editId or newFullText' }));
|
||||
}
|
||||
|
||||
const parsedId = parseEditId(editId);
|
||||
if (!parsedId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Invalid editId format (filePath:line:column)' }));
|
||||
}
|
||||
|
||||
const { filePath, line, column } = parsedId;
|
||||
|
||||
// Validate file path
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.isValid) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: validation.error }));
|
||||
}
|
||||
absoluteFilePath = validation.absolutePath;
|
||||
|
||||
// Parse AST
|
||||
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||
const babelAst = parseFileToAST(absoluteFilePath);
|
||||
|
||||
// Find target node (note: apply-edit uses column+1)
|
||||
const targetNodePath = findJSXElementAtPosition(babelAst, line, column + 1);
|
||||
|
||||
if (!targetNodePath) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Target node not found by line/column', editId }));
|
||||
}
|
||||
|
||||
const targetOpeningElement = targetNodePath.node;
|
||||
const parentElementNode = targetNodePath.parentPath?.node;
|
||||
|
||||
const isImageElement = targetOpeningElement.name && targetOpeningElement.name.name === 'img';
|
||||
|
||||
let beforeCode = '';
|
||||
let afterCode = '';
|
||||
let modified = false;
|
||||
|
||||
if (isImageElement) {
|
||||
// Handle image src attribute update
|
||||
beforeCode = generateCode(targetOpeningElement);
|
||||
|
||||
const srcAttr = targetOpeningElement.attributes.find(attr =>
|
||||
t.isJSXAttribute(attr) && attr.name && attr.name.name === 'src'
|
||||
);
|
||||
|
||||
if (srcAttr && t.isStringLiteral(srcAttr.value)) {
|
||||
srcAttr.value = t.stringLiteral(newFullText);
|
||||
modified = true;
|
||||
afterCode = generateCode(targetOpeningElement);
|
||||
}
|
||||
} else {
|
||||
if (parentElementNode && t.isJSXElement(parentElementNode)) {
|
||||
beforeCode = generateCode(parentElementNode);
|
||||
|
||||
parentElementNode.children = [];
|
||||
if (newFullText && newFullText.trim() !== '') {
|
||||
const newTextNode = t.jsxText(newFullText);
|
||||
parentElementNode.children.push(newTextNode);
|
||||
}
|
||||
modified = true;
|
||||
afterCode = generateCode(parentElementNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (!modified) {
|
||||
res.writeHead(409, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Could not apply changes to AST.' }));
|
||||
}
|
||||
|
||||
const webRelativeFilePath = path.relative(VITE_PROJECT_ROOT, absoluteFilePath).split(path.sep).join('/');
|
||||
const output = generateSourceWithMap(babelAst, webRelativeFilePath, originalContent);
|
||||
const newContent = output.code;
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
newFileContent: newContent,
|
||||
beforeCode,
|
||||
afterCode,
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Internal server error during edit application.' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
125
plugins/vite-plugin-iframe-route-restoration.js
Normal file
125
plugins/vite-plugin-iframe-route-restoration.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
export default function iframeRouteRestorationPlugin() {
|
||||
return {
|
||||
name: 'vite:iframe-route-restoration',
|
||||
apply: 'serve',
|
||||
transformIndexHtml() {
|
||||
const script = `
|
||||
const ALLOWED_PARENT_ORIGINS = [
|
||||
"https://horizons.hostinger.com",
|
||||
"https://horizons.hostinger.dev",
|
||||
"https://horizons-frontend-local.hostinger.dev",
|
||||
];
|
||||
|
||||
// Check to see if the page is in an iframe
|
||||
if (window.self !== window.top) {
|
||||
const STORAGE_KEY = 'horizons-iframe-saved-route';
|
||||
|
||||
const getCurrentRoute = () => location.pathname + location.search + location.hash;
|
||||
|
||||
const save = () => {
|
||||
try {
|
||||
const currentRoute = getCurrentRoute();
|
||||
sessionStorage.setItem(STORAGE_KEY, currentRoute);
|
||||
window.parent.postMessage({message: 'route-changed', route: currentRoute}, '*');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const replaceHistoryState = (url) => {
|
||||
try {
|
||||
history.replaceState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
|
||||
return true;
|
||||
} catch {}
|
||||
return false;
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
|
||||
if (!saved.startsWith('/')) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = getCurrentRoute();
|
||||
if (current !== saved) {
|
||||
if (!replaceHistoryState(saved)) {
|
||||
replaceHistoryState('/');
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => setTimeout(() => {
|
||||
try {
|
||||
const text = (document.body?.innerText || '').trim();
|
||||
|
||||
// If the restored route results in too little content, assume it is invalid and navigate home
|
||||
if (text.length < 50) {
|
||||
replaceHistoryState('/');
|
||||
}
|
||||
} catch {}
|
||||
}, 1000));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const originalPushState = history.pushState;
|
||||
history.pushState = function(...args) {
|
||||
originalPushState.apply(this, args);
|
||||
save();
|
||||
};
|
||||
|
||||
const originalReplaceState = history.replaceState;
|
||||
history.replaceState = function(...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
save();
|
||||
};
|
||||
|
||||
const getParentOrigin = () => {
|
||||
if (
|
||||
window.location.ancestorOrigins &&
|
||||
window.location.ancestorOrigins.length > 0
|
||||
) {
|
||||
return window.location.ancestorOrigins[0];
|
||||
}
|
||||
|
||||
if (document.referrer) {
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch (e) {
|
||||
console.warn("Invalid referrer URL:", document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', save);
|
||||
window.addEventListener('hashchange', save);
|
||||
window.addEventListener("message", function (event) {
|
||||
const parentOrigin = getParentOrigin();
|
||||
|
||||
if (event.data?.type === "redirect-home" && parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if(saved && saved !== '/') {
|
||||
replaceHistoryState('/')
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
restore();
|
||||
}
|
||||
`;
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: script,
|
||||
injectTo: 'head'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
19
public/.htaccess
Normal file
19
public/.htaccess
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.html [L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Powered-By "Hostinger Horizons"
|
||||
|
||||
# Cache everything on CDN by default
|
||||
Header set Cache-Control "public, s-maxage=604800, max-age=0"
|
||||
|
||||
# Cache in browser all assets
|
||||
<If "%{REQUEST_URI} =~ m#^/assets/.*$#">
|
||||
Header set Cache-Control "public, max-age=604800"
|
||||
</If>
|
||||
</IfModule>
|
||||
1
public/logo.svg
Normal file
1
public/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 135 KiB |
3
rest.http
Normal file
3
rest.http
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
### Get embed artifact
|
||||
GET https://api.sage-ai-studio.webexpr.dev/api/embed/artifact/968ba24a-47d4-4ac4-b838-f1f7a60c310d?mode=light&theme=dataven
|
||||
Authorization: Bearer sk_live_bef70d4dd6bc671fd815d21104d5dce8
|
||||
86
tailwind.config.js
Normal file
86
tailwind.config.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx}',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
redDataven: '#C94635',
|
||||
redDarkDataven: '#941403',
|
||||
grayDataven: '#6f6f6f',
|
||||
blackDataven: '#000000',
|
||||
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: 0 },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
fontFamily: {
|
||||
'headline': ['Sage Headline', 'sans-serif'],
|
||||
'sage': ['Sage Text', 'sans-serif'],
|
||||
'ui': ['Sage UI', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
181
tools/generate-llms.js
Normal file
181
tools/generate-llms.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CLEAN_CONTENT_REGEX = {
|
||||
comments: /\/\*[\s\S]*?\*\/|\/\/.*$/gm,
|
||||
templateLiterals: /`[\s\S]*?`/g,
|
||||
strings: /'[^']*'|"[^"]*"/g,
|
||||
jsxExpressions: /\{.*?\}/g,
|
||||
htmlEntities: {
|
||||
quot: /"/g,
|
||||
amp: /&/g,
|
||||
lt: /</g,
|
||||
gt: />/g,
|
||||
apos: /'/g
|
||||
}
|
||||
};
|
||||
|
||||
const EXTRACTION_REGEX = {
|
||||
route: /<Route\s+[^>]*>/g,
|
||||
path: /path=["']([^"']+)["']/,
|
||||
element: /element=\{<(\w+)[^}]*\/?\s*>\}/,
|
||||
helmet: /<Helmet[^>]*?>([\s\S]*?)<\/Helmet>/i,
|
||||
helmetTest: /<Helmet[\s\S]*?<\/Helmet>/i,
|
||||
title: /<title[^>]*?>\s*(.*?)\s*<\/title>/i,
|
||||
description: /<meta\s+name=["']description["']\s+content=["'](.*?)["']/i
|
||||
};
|
||||
|
||||
function cleanContent(content) {
|
||||
return content
|
||||
.replace(CLEAN_CONTENT_REGEX.comments, '')
|
||||
.replace(CLEAN_CONTENT_REGEX.templateLiterals, '""')
|
||||
.replace(CLEAN_CONTENT_REGEX.strings, '""');
|
||||
}
|
||||
|
||||
function cleanText(text) {
|
||||
if (!text) return text;
|
||||
|
||||
return text
|
||||
.replace(CLEAN_CONTENT_REGEX.jsxExpressions, '')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.quot, '"')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.amp, '&')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.lt, '<')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.gt, '>')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.apos, "'")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractRoutes(appJsxPath) {
|
||||
if (!fs.existsSync(appJsxPath)) return new Map();
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(appJsxPath, 'utf8');
|
||||
const routes = new Map();
|
||||
const routeMatches = [...content.matchAll(EXTRACTION_REGEX.route)];
|
||||
|
||||
for (const match of routeMatches) {
|
||||
const routeTag = match[0];
|
||||
const pathMatch = routeTag.match(EXTRACTION_REGEX.path);
|
||||
const elementMatch = routeTag.match(EXTRACTION_REGEX.element);
|
||||
const isIndex = routeTag.includes('index');
|
||||
|
||||
if (elementMatch) {
|
||||
const componentName = elementMatch[1];
|
||||
let routePath;
|
||||
|
||||
if (isIndex) {
|
||||
routePath = '/';
|
||||
} else if (pathMatch) {
|
||||
routePath = pathMatch[1].startsWith('/') ? pathMatch[1] : `/${pathMatch[1]}`;
|
||||
}
|
||||
|
||||
routes.set(componentName, routePath);
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
} catch (error) {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function findReactFiles(dir) {
|
||||
return fs.readdirSync(dir).map(item => path.join(dir, item));
|
||||
}
|
||||
|
||||
function extractHelmetData(content, filePath, routes) {
|
||||
const cleanedContent = cleanContent(content);
|
||||
|
||||
if (!EXTRACTION_REGEX.helmetTest.test(cleanedContent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const helmetMatch = content.match(EXTRACTION_REGEX.helmet);
|
||||
if (!helmetMatch) return null;
|
||||
|
||||
const helmetContent = helmetMatch[1];
|
||||
const titleMatch = helmetContent.match(EXTRACTION_REGEX.title);
|
||||
const descMatch = helmetContent.match(EXTRACTION_REGEX.description);
|
||||
|
||||
const title = cleanText(titleMatch?.[1]);
|
||||
const description = cleanText(descMatch?.[1]);
|
||||
|
||||
const fileName = path.basename(filePath, path.extname(filePath));
|
||||
const url = routes.length && routes.has(fileName)
|
||||
? routes.get(fileName)
|
||||
: generateFallbackUrl(fileName);
|
||||
|
||||
return {
|
||||
url,
|
||||
title: title || 'Untitled Page',
|
||||
description: description || 'No description available'
|
||||
};
|
||||
}
|
||||
|
||||
function generateFallbackUrl(fileName) {
|
||||
const cleanName = fileName.replace(/Page$/, '').toLowerCase();
|
||||
return cleanName === 'app' ? '/' : `/${cleanName}`;
|
||||
}
|
||||
|
||||
function generateLlmsTxt(pages) {
|
||||
const sortedPages = pages.sort((a, b) => a.title.localeCompare(b.title));
|
||||
const pageEntries = sortedPages.map(page =>
|
||||
`- [${page.title}](${page.url}): ${page.description}`
|
||||
).join('\n');
|
||||
|
||||
return `## Pages\n${pageEntries}`;
|
||||
}
|
||||
|
||||
function ensureDirectoryExists(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function processPageFile(filePath, routes) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return extractHelmetData(content, filePath, routes);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error processing ${filePath}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const pagesDir = path.join(process.cwd(), 'src', 'pages');
|
||||
const appJsxPath = path.join(process.cwd(), 'src', 'App.jsx');
|
||||
|
||||
let pages = [];
|
||||
|
||||
if (!fs.existsSync(pagesDir)) {
|
||||
pages.push(processPageFile(appJsxPath, []));
|
||||
} else {
|
||||
const routes = extractRoutes(appJsxPath);
|
||||
const reactFiles = findReactFiles(pagesDir);
|
||||
|
||||
pages = reactFiles
|
||||
.map(filePath => processPageFile(filePath, routes))
|
||||
.filter(Boolean);
|
||||
|
||||
if (pages.length === 0) {
|
||||
console.error('❌ No pages with Helmet components found!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const llmsTxtContent = generateLlmsTxt(pages);
|
||||
const outputPath = path.join(process.cwd(), 'public', 'llms.txt');
|
||||
|
||||
ensureDirectoryExists(path.dirname(outputPath));
|
||||
fs.writeFileSync(outputPath, llmsTxtContent, 'utf8');
|
||||
}
|
||||
|
||||
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
|
||||
if (isMainModule) {
|
||||
main();
|
||||
}
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"strict": true,
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
707
vite.config.js
Normal file
707
vite.config.js
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { createLogger, defineConfig } from "vite";
|
||||
import inlineEditPlugin from "./plugins/visual-editor/vite-plugin-react-inline-editor.js";
|
||||
import editModeDevPlugin from "./plugins/visual-editor/vite-plugin-edit-mode.js";
|
||||
import iframeRouteRestorationPlugin from "./plugins/vite-plugin-iframe-route-restoration.js";
|
||||
import selectionModePlugin from "./plugins/selection-mode/vite-plugin-selection-mode.js";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
// Configuration des origines autorisées pour postMessage
|
||||
// En production, définir via variable d'environnement
|
||||
const ALLOWED_PARENT_ORIGINS = isDev
|
||||
? [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"https://prod.dataven.fr",
|
||||
]
|
||||
: (
|
||||
process.env.VITE_ALLOWED_PARENT_ORIGINS || "https://prod.dataven.fr"
|
||||
).split(",");
|
||||
|
||||
// Utilitaire pour postMessage sécurisé avec handshake
|
||||
const configSecurePostMessage = `
|
||||
(function() {
|
||||
const ALLOWED_ORIGINS = ${JSON.stringify(ALLOWED_PARENT_ORIGINS)};
|
||||
let verifiedParentOrigin = null;
|
||||
let isEmbedded = false;
|
||||
|
||||
// Détecter si on est dans une iframe
|
||||
try {
|
||||
isEmbedded = window.self !== window.top;
|
||||
} catch (e) {
|
||||
isEmbedded = true; // Erreur de cross-origin = on est dans une iframe
|
||||
}
|
||||
|
||||
// Écouter le handshake du parent
|
||||
window.addEventListener('message', (event) => {
|
||||
if (!ALLOWED_ORIGINS.includes(event.origin)) {
|
||||
return; // Ignorer les origines non autorisées
|
||||
}
|
||||
|
||||
if (event.data?.type === 'HORIZONS_HANDSHAKE') {
|
||||
verifiedParentOrigin = event.origin;
|
||||
// Confirmer le handshake
|
||||
window.parent.postMessage({ type: 'HORIZONS_HANDSHAKE_ACK' }, event.origin);
|
||||
}
|
||||
});
|
||||
|
||||
// Fonction sécurisée pour envoyer des messages au parent
|
||||
window.__securePostMessage = function(payload) {
|
||||
if (!isEmbedded) return;
|
||||
|
||||
if (!verifiedParentOrigin) {
|
||||
// En attente de handshake, stocker temporairement (optionnel)
|
||||
console.debug('[SecurePostMessage] No verified parent origin yet');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitiser le payload avant envoi
|
||||
const sanitizedPayload = sanitizePayload(payload);
|
||||
window.parent.postMessage(sanitizedPayload, verifiedParentOrigin);
|
||||
};
|
||||
|
||||
// Sanitisation du payload
|
||||
function sanitizePayload(payload) {
|
||||
const sanitized = { ...payload };
|
||||
|
||||
${
|
||||
isDev
|
||||
? `
|
||||
// En DEV: conserver les détails pour le debugging
|
||||
return sanitized;
|
||||
`
|
||||
: `
|
||||
// En PROD: supprimer les infos sensibles
|
||||
if (sanitized.error) {
|
||||
// Remplacer la stack trace par un ID de corrélation
|
||||
sanitized.errorId = generateCorrelationId();
|
||||
sanitized.error = sanitized.error.split('\\n')[0]; // Garder uniquement la première ligne
|
||||
delete sanitized.stack;
|
||||
}
|
||||
|
||||
// Ne pas exposer les URLs internes ou les détails de réponse
|
||||
if (sanitized.url) {
|
||||
sanitized.url = '[REDACTED]';
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
function generateCorrelationId() {
|
||||
return 'err_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
// Exposer pour vérification
|
||||
window.__isSecureMessagingReady = () => !!verifiedParentOrigin;
|
||||
})();
|
||||
`;
|
||||
|
||||
// Handler d'erreurs Vite sécurisé
|
||||
const configHorizonsViteErrorHandler = `
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const addedNode of mutation.addedNodes) {
|
||||
if (
|
||||
addedNode.nodeType === Node.ELEMENT_NODE &&
|
||||
(addedNode.tagName?.toLowerCase() === 'vite-error-overlay' ||
|
||||
addedNode.classList?.contains('backdrop'))
|
||||
) {
|
||||
handleViteOverlay(addedNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
function handleViteOverlay(node) {
|
||||
if (!node.shadowRoot) return;
|
||||
|
||||
const backdrop = node.shadowRoot.querySelector('.backdrop');
|
||||
if (!backdrop) return;
|
||||
|
||||
const messageBodyElement = backdrop.querySelector('.message-body');
|
||||
const messageText = messageBodyElement ? messageBodyElement.textContent.trim() : '';
|
||||
|
||||
// Utiliser la fonction sécurisée
|
||||
window.__securePostMessage?.({
|
||||
type: 'horizons-vite-error',
|
||||
error: messageText,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
// Handler d'erreurs runtime sécurisé
|
||||
const configHorizonsRuntimeErrorHandler = `
|
||||
window.onerror = (message, source, lineno, colno, errorObj) => {
|
||||
${
|
||||
isDev
|
||||
? `
|
||||
// DEV: Détails complets pour debugging
|
||||
const errorDetails = errorObj ? {
|
||||
name: errorObj.name,
|
||||
message: errorObj.message,
|
||||
stack: errorObj.stack,
|
||||
source,
|
||||
lineno,
|
||||
colno,
|
||||
} : null;
|
||||
`
|
||||
: `
|
||||
// PROD: Informations minimales
|
||||
const errorDetails = errorObj ? {
|
||||
name: errorObj.name,
|
||||
message: errorObj.message?.substring(0, 100), // Tronquer
|
||||
} : null;
|
||||
`
|
||||
}
|
||||
|
||||
window.__securePostMessage?.({
|
||||
type: 'horizons-runtime-error',
|
||||
message: typeof message === 'string' ? message.substring(0, 200) : 'Unknown error',
|
||||
error: errorDetails,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
};
|
||||
`;
|
||||
|
||||
// Handler console.error sécurisé
|
||||
const configHorizonsConsoleErrorHandler = `
|
||||
const originalConsoleError = console.error;
|
||||
console.error = function(...args) {
|
||||
originalConsoleError.apply(console, args);
|
||||
|
||||
${
|
||||
isDev
|
||||
? `
|
||||
// DEV: Logger les détails
|
||||
let errorString = '';
|
||||
for (const arg of args) {
|
||||
if (arg instanceof Error) {
|
||||
errorString = arg.stack || \`\${arg.name}: \${arg.message}\`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!errorString) {
|
||||
errorString = args.map(arg =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
|
||||
).join(' ');
|
||||
}
|
||||
`
|
||||
: `
|
||||
// PROD: Message générique uniquement
|
||||
let errorString = 'An error occurred';
|
||||
for (const arg of args) {
|
||||
if (arg instanceof Error) {
|
||||
errorString = arg.name + ': ' + (arg.message || '').substring(0, 50);
|
||||
break;
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
window.__securePostMessage?.({
|
||||
type: 'horizons-console-error',
|
||||
error: errorString,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
};
|
||||
`;
|
||||
|
||||
// Monkey patch fetch sécurisé
|
||||
const configWindowFetchMonkeyPatch = `
|
||||
const originalFetch = window.fetch;
|
||||
|
||||
window.fetch = function(...args) {
|
||||
const url = args[0] instanceof Request ? args[0].url : args[0];
|
||||
|
||||
// Skip WebSocket URLs
|
||||
if (url.startsWith('ws:') || url.startsWith('wss:')) {
|
||||
return originalFetch.apply(this, args);
|
||||
}
|
||||
|
||||
return originalFetch.apply(this, args)
|
||||
.then(async response => {
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
const isDocumentResponse =
|
||||
contentType.includes('text/html') ||
|
||||
contentType.includes('application/xhtml+xml');
|
||||
|
||||
if (!response.ok && !isDocumentResponse) {
|
||||
${
|
||||
isDev
|
||||
? `
|
||||
// DEV: Logger le contenu de l'erreur
|
||||
const responseClone = response.clone();
|
||||
const errorFromRes = await responseClone.text();
|
||||
console.error(\`Fetch error from \${response.url}: \${errorFromRes}\`);
|
||||
`
|
||||
: `
|
||||
// PROD: Ne pas exposer le contenu de la réponse
|
||||
console.error(\`Fetch error: \${response.status}\`);
|
||||
`
|
||||
}
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
if (!url.match(/\\.html?$/i)) {
|
||||
${
|
||||
isDev
|
||||
? `
|
||||
console.error(error);
|
||||
`
|
||||
: `
|
||||
// PROD: Ne pas exposer l'URL ou les détails
|
||||
console.error('Network request failed');
|
||||
`
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
`;
|
||||
|
||||
// Handler de navigation sécurisé
|
||||
const configNavigationHandler = `
|
||||
if (window.navigation && window.self !== window.top) {
|
||||
window.navigation.addEventListener('navigate', (event) => {
|
||||
const url = event.destination.url;
|
||||
|
||||
try {
|
||||
const destinationUrl = new URL(url);
|
||||
const currentOrigin = window.location.origin;
|
||||
|
||||
if (destinationUrl.origin === currentOrigin) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__securePostMessage?.({
|
||||
type: 'horizons-navigation-error',
|
||||
${isDev ? `url,` : `url: '[external]',`}
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
const addTransformIndexHtml = {
|
||||
name: "add-transform-index-html",
|
||||
transformIndexHtml(html) {
|
||||
const tags = [
|
||||
// Le script de postMessage sécurisé doit être chargé EN PREMIER
|
||||
{
|
||||
tag: "script",
|
||||
attrs: { type: "module" },
|
||||
children: configSecurePostMessage,
|
||||
injectTo: "head-prepend", // Avant tous les autres scripts
|
||||
},
|
||||
{
|
||||
tag: "script",
|
||||
attrs: { type: "module" },
|
||||
children: configHorizonsRuntimeErrorHandler,
|
||||
injectTo: "head",
|
||||
},
|
||||
{
|
||||
tag: "script",
|
||||
attrs: { type: "module" },
|
||||
children: configHorizonsViteErrorHandler,
|
||||
injectTo: "head",
|
||||
},
|
||||
{
|
||||
tag: "script",
|
||||
attrs: { type: "module" },
|
||||
children: configHorizonsConsoleErrorHandler,
|
||||
injectTo: "head",
|
||||
},
|
||||
{
|
||||
tag: "script",
|
||||
attrs: { type: "module" },
|
||||
children: configWindowFetchMonkeyPatch,
|
||||
injectTo: "head",
|
||||
},
|
||||
{
|
||||
tag: "script",
|
||||
attrs: { type: "module" },
|
||||
children: configNavigationHandler,
|
||||
injectTo: "head",
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
!isDev &&
|
||||
process.env.TEMPLATE_BANNER_SCRIPT_URL &&
|
||||
process.env.TEMPLATE_REDIRECT_URL
|
||||
) {
|
||||
tags.push({
|
||||
tag: "script",
|
||||
attrs: {
|
||||
src: process.env.TEMPLATE_BANNER_SCRIPT_URL,
|
||||
"template-redirect-url": process.env.TEMPLATE_REDIRECT_URL,
|
||||
},
|
||||
injectTo: "head",
|
||||
});
|
||||
}
|
||||
|
||||
return { html, tags };
|
||||
},
|
||||
};
|
||||
|
||||
console.warn = () => {};
|
||||
|
||||
const logger = createLogger();
|
||||
const loggerError = logger.error;
|
||||
|
||||
logger.error = (msg, options) => {
|
||||
if (options?.error?.toString().includes("CssSyntaxError: [postcss]")) {
|
||||
return;
|
||||
}
|
||||
loggerError(msg, options);
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
customLogger: logger,
|
||||
plugins: [
|
||||
...(isDev
|
||||
? [
|
||||
inlineEditPlugin(),
|
||||
editModeDevPlugin(),
|
||||
iframeRouteRestorationPlugin(),
|
||||
selectionModePlugin(),
|
||||
]
|
||||
: []),
|
||||
react(),
|
||||
addTransformIndexHtml,
|
||||
],
|
||||
server: {
|
||||
cors: true,
|
||||
headers: {
|
||||
"Cross-Origin-Embedder-Policy": "credentialless",
|
||||
// Ajouter CSP pour frame-ancestors si l'app ne doit pas être iframée
|
||||
// ou limiter aux origines autorisées
|
||||
...(isDev
|
||||
? {}
|
||||
: {
|
||||
"Content-Security-Policy": `frame-ancestors ${ALLOWED_PARENT_ORIGINS.join(
|
||||
" "
|
||||
)}`,
|
||||
"X-Frame-Options": "SAMEORIGIN", // Fallback pour anciens navigateurs
|
||||
}),
|
||||
},
|
||||
allowedHosts: true,
|
||||
hmr: { overlay: false },
|
||||
watch: { usePolling: true, ignored: ["**/.git/**"] },
|
||||
},
|
||||
optimizeDeps: { force: true },
|
||||
// esbuild: {
|
||||
// loader: 'jsx',
|
||||
// include: /\.js$/,
|
||||
// },
|
||||
resolve: {
|
||||
extensions: [".jsx", ".js", ".tsx", ".ts", ".json"],
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"@babel/parser",
|
||||
"@babel/traverse",
|
||||
"@babel/generator",
|
||||
"@babel/types",
|
||||
],
|
||||
},
|
||||
},
|
||||
cacheDir: "node_modules/.vite",
|
||||
});
|
||||
|
||||
// import path from 'node:path';
|
||||
// import react from '@vitejs/plugin-react';
|
||||
// import { createLogger, defineConfig } from 'vite';
|
||||
// import inlineEditPlugin from './plugins/visual-editor/vite-plugin-react-inline-editor.js';
|
||||
// import editModeDevPlugin from './plugins/visual-editor/vite-plugin-edit-mode.js';
|
||||
// import iframeRouteRestorationPlugin from './plugins/vite-plugin-iframe-route-restoration.js';
|
||||
// import selectionModePlugin from './plugins/selection-mode/vite-plugin-selection-mode.js';
|
||||
|
||||
// const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
// const configHorizonsViteErrorHandler = `
|
||||
// const observer = new MutationObserver((mutations) => {
|
||||
// for (const mutation of mutations) {
|
||||
// for (const addedNode of mutation.addedNodes) {
|
||||
// if (
|
||||
// addedNode.nodeType === Node.ELEMENT_NODE &&
|
||||
// (
|
||||
// addedNode.tagName?.toLowerCase() === 'vite-error-overlay' ||
|
||||
// addedNode.classList?.contains('backdrop')
|
||||
// )
|
||||
// ) {
|
||||
// handleViteOverlay(addedNode);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
// observer.observe(document.documentElement, {
|
||||
// childList: true,
|
||||
// subtree: true
|
||||
// });
|
||||
|
||||
// function handleViteOverlay(node) {
|
||||
// if (!node.shadowRoot) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const backdrop = node.shadowRoot.querySelector('.backdrop');
|
||||
|
||||
// if (backdrop) {
|
||||
// const overlayHtml = backdrop.outerHTML;
|
||||
// const parser = new DOMParser();
|
||||
// const doc = parser.parseFromString(overlayHtml, 'text/html');
|
||||
// const messageBodyElement = doc.querySelector('.message-body');
|
||||
// const fileElement = doc.querySelector('.file');
|
||||
// const messageText = messageBodyElement ? messageBodyElement.textContent.trim() : '';
|
||||
// const fileText = fileElement ? fileElement.textContent.trim() : '';
|
||||
// const error = messageText + (fileText ? ' File:' + fileText : '');
|
||||
|
||||
// window.parent.postMessage({
|
||||
// type: 'horizons-vite-error',
|
||||
// error,
|
||||
// }, '*');
|
||||
// }
|
||||
// }
|
||||
// `;
|
||||
|
||||
// const configHorizonsRuntimeErrorHandler = `
|
||||
// window.onerror = (message, source, lineno, colno, errorObj) => {
|
||||
// const errorDetails = errorObj ? JSON.stringify({
|
||||
// name: errorObj.name,
|
||||
// message: errorObj.message,
|
||||
// stack: errorObj.stack,
|
||||
// source,
|
||||
// lineno,
|
||||
// colno,
|
||||
// }) : null;
|
||||
|
||||
// window.parent.postMessage({
|
||||
// type: 'horizons-runtime-error',
|
||||
// message,
|
||||
// error: errorDetails
|
||||
// }, '*');
|
||||
// };
|
||||
// `;
|
||||
|
||||
// const configHorizonsConsoleErrroHandler = `
|
||||
// const originalConsoleError = console.error;
|
||||
// console.error = function(...args) {
|
||||
// originalConsoleError.apply(console, args);
|
||||
|
||||
// let errorString = '';
|
||||
|
||||
// for (let i = 0; i < args.length; i++) {
|
||||
// const arg = args[i];
|
||||
// if (arg instanceof Error) {
|
||||
// errorString = arg.stack || \`\${arg.name}: \${arg.message}\`;
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (!errorString) {
|
||||
// errorString = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ');
|
||||
// }
|
||||
|
||||
// window.parent.postMessage({
|
||||
// type: 'horizons-console-error',
|
||||
// error: errorString
|
||||
// }, '*');
|
||||
// };
|
||||
// `;
|
||||
|
||||
// const configWindowFetchMonkeyPatch = `
|
||||
// const originalFetch = window.fetch;
|
||||
|
||||
// window.fetch = function(...args) {
|
||||
// const url = args[0] instanceof Request ? args[0].url : args[0];
|
||||
|
||||
// // Skip WebSocket URLs
|
||||
// if (url.startsWith('ws:') || url.startsWith('wss:')) {
|
||||
// return originalFetch.apply(this, args);
|
||||
// }
|
||||
|
||||
// return originalFetch.apply(this, args)
|
||||
// .then(async response => {
|
||||
// const contentType = response.headers.get('Content-Type') || '';
|
||||
|
||||
// // Exclude HTML document responses
|
||||
// const isDocumentResponse =
|
||||
// contentType.includes('text/html') ||
|
||||
// contentType.includes('application/xhtml+xml');
|
||||
|
||||
// if (!response.ok && !isDocumentResponse) {
|
||||
// const responseClone = response.clone();
|
||||
// const errorFromRes = await responseClone.text();
|
||||
// const requestUrl = response.url;
|
||||
// console.error(\`Fetch error from \${requestUrl}: \${errorFromRes}\`);
|
||||
// }
|
||||
|
||||
// return response;
|
||||
// })
|
||||
// .catch(error => {
|
||||
// if (!url.match(/\.html?$/i)) {
|
||||
// console.error(error);
|
||||
// }
|
||||
|
||||
// throw error;
|
||||
// });
|
||||
// };
|
||||
// `;
|
||||
|
||||
// const configNavigationHandler = `
|
||||
// if (window.navigation && window.self !== window.top) {
|
||||
// window.navigation.addEventListener('navigate', (event) => {
|
||||
// const url = event.destination.url;
|
||||
|
||||
// try {
|
||||
// const destinationUrl = new URL(url);
|
||||
// const destinationOrigin = destinationUrl.origin;
|
||||
// const currentOrigin = window.location.origin;
|
||||
|
||||
// if (destinationOrigin === currentOrigin) {
|
||||
// return;
|
||||
// }
|
||||
// } catch (error) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// window.parent.postMessage({
|
||||
// type: 'horizons-navigation-error',
|
||||
// url,
|
||||
// }, '*');
|
||||
// });
|
||||
// }
|
||||
// `;
|
||||
|
||||
// const addTransformIndexHtml = {
|
||||
// name: 'add-transform-index-html',
|
||||
// transformIndexHtml(html) {
|
||||
// const tags = [
|
||||
// {
|
||||
// tag: 'script',
|
||||
// attrs: { type: 'module' },
|
||||
// children: configHorizonsRuntimeErrorHandler,
|
||||
// injectTo: 'head',
|
||||
// },
|
||||
// {
|
||||
// tag: 'script',
|
||||
// attrs: { type: 'module' },
|
||||
// children: configHorizonsViteErrorHandler,
|
||||
// injectTo: 'head',
|
||||
// },
|
||||
// {
|
||||
// tag: 'script',
|
||||
// attrs: {type: 'module'},
|
||||
// children: configHorizonsConsoleErrroHandler,
|
||||
// injectTo: 'head',
|
||||
// },
|
||||
// {
|
||||
// tag: 'script',
|
||||
// attrs: { type: 'module' },
|
||||
// children: configWindowFetchMonkeyPatch,
|
||||
// injectTo: 'head',
|
||||
// },
|
||||
// {
|
||||
// tag: 'script',
|
||||
// attrs: { type: 'module' },
|
||||
// children: configNavigationHandler,
|
||||
// injectTo: 'head',
|
||||
// },
|
||||
// ];
|
||||
|
||||
// if (!isDev && process.env.TEMPLATE_BANNER_SCRIPT_URL && process.env.TEMPLATE_REDIRECT_URL) {
|
||||
// tags.push(
|
||||
// {
|
||||
// tag: 'script',
|
||||
// attrs: {
|
||||
// src: process.env.TEMPLATE_BANNER_SCRIPT_URL,
|
||||
// 'template-redirect-url': process.env.TEMPLATE_REDIRECT_URL,
|
||||
// },
|
||||
// injectTo: 'head',
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
|
||||
// return {
|
||||
// html,
|
||||
// tags,
|
||||
// };
|
||||
// },
|
||||
// };
|
||||
|
||||
// console.warn = () => {};
|
||||
|
||||
// const logger = createLogger()
|
||||
// const loggerError = logger.error
|
||||
|
||||
// logger.error = (msg, options) => {
|
||||
// if (options?.error?.toString().includes('CssSyntaxError: [postcss]')) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// loggerError(msg, options);
|
||||
// }
|
||||
|
||||
// export default defineConfig({
|
||||
// customLogger: logger,
|
||||
// plugins: [
|
||||
// ...(isDev ? [inlineEditPlugin(), editModeDevPlugin(), iframeRouteRestorationPlugin(), selectionModePlugin()] : []),
|
||||
// react(),
|
||||
// addTransformIndexHtml
|
||||
// ],
|
||||
// server: {
|
||||
// cors: true,
|
||||
// headers: {
|
||||
// 'Cross-Origin-Embedder-Policy': 'credentialless',
|
||||
// },
|
||||
// allowedHosts: true,
|
||||
// // watch: {
|
||||
// // ignored: ['**/.git/**']
|
||||
// // },
|
||||
// hmr: {
|
||||
// overlay: true, // Afficher les erreurs en overlay
|
||||
// },
|
||||
// watch: {
|
||||
// usePolling: true, // Utile sur certains systèmes
|
||||
// }
|
||||
// },
|
||||
// optimizeDeps: {
|
||||
// force: true, // Force la ré-optimisation des dépendances
|
||||
// },
|
||||
// resolve: {
|
||||
// extensions: ['.jsx', '.js', '.tsx', '.ts', '.json', ],
|
||||
// alias: {
|
||||
// '@': path.resolve(__dirname, './src'),
|
||||
// },
|
||||
// },
|
||||
// build: {
|
||||
// rollupOptions: {
|
||||
// external: [
|
||||
// '@babel/parser',
|
||||
// '@babel/traverse',
|
||||
// '@babel/generator',
|
||||
// '@babel/types'
|
||||
// ]
|
||||
// }
|
||||
// },
|
||||
// // optimizeDeps: {
|
||||
// // exclude: ['.git']
|
||||
// // },
|
||||
// // Vider le cache au démarrage si nécessaire
|
||||
// cacheDir: 'node_modules/.vite',
|
||||
// });
|
||||
Loading…
Reference in a new issue