diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba2df76 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.github +node_modules +**/node_modules +dist +**/dist +coverage +**/.vite +**/.env +**/.env.* +**/*.log +**/*.pem +**/*.key +**/*.p12 +**/*.pfx +**/.npmrc +vault +supabase +apps/web/plugins +apps/web/tools +.vscode +.idea diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..3de0ffe --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,46 @@ +name: Verificar despliegue Docker + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Obtener repositorio + uses: actions/checkout@v4 + - name: Configurar Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Compilar imagen para Coolify + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + load: true + tags: infinitus-web:ci + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Verificar servidor y rutas de React + run: | + docker run -d --name infinitus-web -p 8080:8080 infinitus-web:ci + trap 'docker logs infinitus-web; docker rm -f infinitus-web' EXIT + for intento in $(seq 1 30); do + if curl --fail --silent http://localhost:8080/healthz; then break; fi + sleep 1 + done + curl --fail --silent http://localhost:8080/healthz + for ruta in / /contact /blog/2 /services/tsia2; do + curl --fail --silent "http://localhost:8080$ruta" | grep -q 'id="root"' + done + test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://localhost:8080/assets/inexistente.js)" = 404 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28c21b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Dependencias y artefactos +node_modules/ +dist/ +build/ +coverage/ +.vite/ +*.log + +# No publicar claves, variables locales ni credenciales +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +*.pfx +.npmrc + +# Archivos locales y del entorno de edición +.DS_Store +Thumbs.db +.idea/ +.vscode/ +vault/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.version b/.version new file mode 100644 index 0000000..e373ee6 --- /dev/null +++ b/.version @@ -0,0 +1 @@ +50 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5feeb68 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Coolify: repositorio Git, contexto /, Dockerfile /Dockerfile, puerto 8080. +# Asigna tu dominio y habilita HTTPS en el proxy de Coolify. +# Supabase permanece alojado en el proyecto existente; no necesita otra base de datos. +# No añadas service_role ni claves de correo como argumentos de compilación. +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY apps/web/package.json ./apps/web/package.json +RUN npm ci --include=dev --no-audit --no-fund +COPY apps/web ./apps/web +# Configuración independiente de producción, sin herramientas del editor Horizons. +RUN cd apps/web && ../../node_modules/.bin/vite build --config vite.production.config.js --outDir ../../dist/apps/web + +FROM nginx:stable-alpine AS runtime +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist/apps/web /usr/share/nginx/html +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD busybox wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/web/.version b/apps/web/.version new file mode 100644 index 0000000..82cced2 --- /dev/null +++ b/apps/web/.version @@ -0,0 +1 @@ +51 diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..d376401 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..3ffacac --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,61 @@ +import importPlugin from 'eslint-plugin-import'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; +import unicodeEscapePlugin from './eslint.unicode-escapes-plugin.mjs'; + +export default [ + { ignores: ['node_modules/**', 'dist/**', 'build/**', 'vite.config.js'] }, + { + files: ['**/*.js', '**/*.jsx'], + plugins: { react, 'react-hooks': reactHooks, 'import': importPlugin }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { ecmaFeatures: { jsx: true } }, + globals: { ...globals.browser, React: 'readonly', Intl: 'readonly' }, + }, + settings: { + 'react': { version: 'detect' }, + 'import/extensions': ['.js', '.jsx'], + 'import/resolver': { + node: { extensions: ['.js', '.jsx'] }, + alias: { map: [['@', './src']], extensions: ['.js', '.jsx'] }, + }, + }, + rules: { + ...react.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + ...importPlugin.flatConfigs.recommended.rules, + + // Non-critical rules - disabled since code works fine without them + 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', + 'react/display-name': 'off', // Non-critical, component works without displayName + 'react/jsx-uses-react': 'off', // Not needed in React 17+, non-critical + 'react/react-in-jsx-scope': 'off', // Not needed in React 17+, non-critical + 'react/jsx-uses-vars': 'off', // Non-critical, code works fine + 'react/jsx-no-comment-textnodes': 'off', // Non-critical, comments could be visible if put inside the JSX, most cases are just rendering text like '///' + + 'no-unused-vars': 'off', // Non-critical, code works fine with unused vars + 'import/no-named-as-default': 'off', // Can cause runtime import errors, usually fine to leave as is + 'import/no-named-as-default-member': 'off', // Can cause runtime import errors + + // Critical rules that prevent runtime errors + 'no-undef': 'error', // Undefined variables cause runtime errors + 'no-empty': ['error', { allowEmptyCatch: true }], // Empty blocks often signal a bug (e.g. missing body) + + // Override recommended import rules for stricter checking + 'import/no-self-import': 'error', // Extremely fast rule, breaking results in infinite loop/bundling error + + // Disable expensive rules for performance + 'import/no-cycle': 'off', // AI rarely makes this error, and the rule is very slow to run + }, + }, + { + files: ['**/*.jsx'], + plugins: { horizons: unicodeEscapePlugin }, + rules: { 'horizons/no-unicode-escapes-in-jsx': 'warn' }, + }, + { files: ['tools/**/*.js', 'tailwind.config.js'], languageOptions: { globals: globals.node } }, +]; diff --git a/apps/web/eslint.unicode-escapes-formatter.mjs b/apps/web/eslint.unicode-escapes-formatter.mjs new file mode 100644 index 0000000..5e9cb92 --- /dev/null +++ b/apps/web/eslint.unicode-escapes-formatter.mjs @@ -0,0 +1,61 @@ +const RULE_ID = 'horizons/no-unicode-escapes-in-jsx'; +const MAX_DETAILED_FINDINGS = 25; +const ESCAPE_FROM_MESSAGE_PATTERN = /^Literal Unicode escape "(\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\}))"/; +const GUIDANCE = 'Unicode escapes in JSX text or quoted attributes may be displayed verbatim to website visitors. Replace every escape in affected JSX in place with its intended literal character (for example, use "ç", not "\\u00e7"). Do not move the text into variables, objects, or JSX expressions, and do not wrap escapes in JSX braces ({}).'; + +function selectFindingsAcrossFiles(findings) { + const findingsByFile = new Map(); + for (const finding of findings) { + const fileFindings = findingsByFile.get(finding.filePath) ?? []; + fileFindings.push(finding); + findingsByFile.set(finding.filePath, fileFindings); + } + + const shown = []; + for (let findingIndex = 0; shown.length < MAX_DETAILED_FINDINGS; findingIndex++) { + let addedFinding = false; + + for (const fileFindings of findingsByFile.values()) { + const finding = fileFindings[findingIndex]; + if (finding) { + shown.push(finding); + addedFinding = true; + } + + if (shown.length === MAX_DETAILED_FINDINGS) { + break; + } + } + + if (!addedFinding) { + break; + } + } + + return shown; +} + +export default function format(results) { + const findings = results.flatMap(result => + result.messages + .filter(message => message.ruleId === RULE_ID) + .map(message => ({ filePath: result.filePath, ...message })), + ); + + if (findings.length === 0) { + return ''; + } + + const shown = selectFindingsAcrossFiles(findings); + const lines = [ + GUIDANCE, + ...shown.map((finding) => { + const escape = finding.message.match(ESCAPE_FROM_MESSAGE_PATTERN)?.[1] ?? 'Unicode escape'; + return `${finding.filePath}:${finding.line}:${finding.column} warning ${escape} ${RULE_ID}`; + }), + ]; + const omitted = findings.length - shown.length; + lines.push(`Found ${findings.length} Unicode escapes; ${omitted} additional findings omitted. Replace all escapes in affected JSX. [horizons-unicode-escapes] total=${findings.length} shown=${shown.length} omitted=${omitted}`); + + return `${lines.join('\n')}\n`; +} diff --git a/apps/web/eslint.unicode-escapes-plugin.mjs b/apps/web/eslint.unicode-escapes-plugin.mjs new file mode 100644 index 0000000..e0236b9 --- /dev/null +++ b/apps/web/eslint.unicode-escapes-plugin.mjs @@ -0,0 +1,64 @@ +const CODE_LIKE_ELEMENTS = new Set(['code', 'pre', 'kbd', 'samp']); +const UNICODE_ESCAPE_PATTERN = /\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\})/g; + +function isInsideCodeLikeElement(node) { + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type !== 'JSXElement') { + continue; + } + + const name = parent.openingElement.name; + if (name.type === 'JSXIdentifier' && CODE_LIKE_ELEMENTS.has(name.name)) { + return true; + } + } + + return false; +} + +export default { + rules: { + 'no-unicode-escapes-in-jsx': { + meta: { + type: 'problem', + docs: { + description: 'Warn when Unicode escapes in JSX text or quoted attributes would be shown verbatim to website visitors instead of rendering the intended character', + }, + schema: [], + messages: { + found: 'Literal Unicode escape "{{escape}}" in JSX text or a quoted attribute may be displayed verbatim to users. Replace only the escape in place with the intended literal character (for example, use "ç", not "\\u00e7"). Do not move the text into variables, objects, or JSX expressions, and do not wrap the escape in JSX braces ({}).', + }, + }, + create(context) { + function check(node) { + if (isInsideCodeLikeElement(node)) { + return; + } + + const text = context.sourceCode.getText(node); + for (const match of text.matchAll(UNICODE_ESCAPE_PATTERN)) { + const startIndex = node.range[0] + match.index; + context.report({ + node, + loc: { + start: context.sourceCode.getLocFromIndex(startIndex), + end: context.sourceCode.getLocFromIndex(startIndex + match[0].length), + }, + messageId: 'found', + data: { escape: match[0] }, + }); + } + } + + return { + JSXText: check, + JSXAttribute(node) { + if (node.value?.type === 'Literal') { + check(node.value); + } + }, + }; + }, + }, + }, +}; diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..4c545c0 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,35 @@ + + +
+ + + + + + +