diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..89f88a4 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,36 @@ +name: Checks + +on: + pull_request: + push: + branches: [main] + +jobs: + lint-format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci || npm install + - run: npm run lint + - run: npm run format + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm install --no-save + - name: Build extension zip + run: npm run package + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: tweai-extension + path: tweai-v*.zip + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dbecd27 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm install --no-save + - name: Build extension zip + run: npm run package + - name: Capture zip path + id: zip + run: | + ZIP=$(ls tweai-v*.zip | head -1) + echo "path=$ZIP" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$ZIP")" >> "$GITHUB_OUTPUT" + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + gh release create "$TAG" "${{ steps.zip.outputs.path }}" \ + --title "$TAG" \ + --generate-notes diff --git a/.gitignore b/.gitignore index 29ff4b5..4b88d34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ node_modules/ env.json + +# Build artifacts +dist/ +*.zip + +# Tooling +.eslintcache diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..22c4b2d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +node_modules +dist +*.zip +docs +tweai-mcp-server +_locales +LICENSE +.git diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..8fd548f --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..bccea90 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,94 @@ +// Flat config for ESLint 9.x. WebExtensions globals + browser env. +// Намеренно мягкие правила: цель — поймать опечатки и явные баги, не переписать +// весь codebase. Style-issues идут через prettier. + +export default [ + { + ignores: [ + 'node_modules/**', + 'dist/**', + '*.zip', + 'tweai-mcp-server/**', + 'docs/**', + '_locales/**', + ], + }, + { + files: ['**/*.js', '**/*.mjs'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'script', + globals: { + // WebExtensions + chrome: 'readonly', + browser: 'readonly', + // Browser + window: 'readonly', + document: 'readonly', + navigation: 'readonly', + location: 'readonly', + localStorage: 'readonly', + history: 'readonly', + console: 'readonly', + fetch: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + Promise: 'readonly', + Map: 'readonly', + Set: 'readonly', + Boolean: 'readonly', + Number: 'readonly', + String: 'readonly', + Object: 'readonly', + Array: 'readonly', + Math: 'readonly', + Date: 'readonly', + JSON: 'readonly', + RegExp: 'readonly', + Error: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + MutationObserver: 'readonly', + IntersectionObserver: 'readonly', + InputEvent: 'readonly', + Event: 'readonly', + CustomEvent: 'readonly', + HTMLElement: 'readonly', + Element: 'readonly', + Node: 'readonly', + getComputedStyle: 'readonly', + requestAnimationFrame: 'readonly', + addEventListener: 'readonly', + // own globals (shared between content scripts in same isolated world) + TTASelectors: 'writable', + TTALogger: 'writable', + }, + }, + rules: { + 'no-undef': 'error', + 'no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }], + 'no-constant-condition': ['error', { checkLoops: false }], + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-prototype-builtins': 'off', + 'no-cond-assign': ['error', 'except-parens'], + 'no-control-regex': 'off', + 'no-useless-escape': 'warn', + }, + }, + { + // Build/tools — Node environment + files: ['tools/**/*.mjs', '*.mjs'], + languageOptions: { + sourceType: 'module', + globals: { + process: 'readonly', + console: 'readonly', + URL: 'readonly', + }, + }, + }, +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..0154665 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "tweai", + "version": "1.8.1", + "private": true, + "description": "TweAI — AI Reply Assistant for X / Twitter. Chrome extension.", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --check .", + "format:fix": "prettier --write .", + "package": "node tools/build.mjs", + "check": "npm run lint && npm run format" + }, + "devDependencies": { + "eslint": "^9.0.0", + "prettier": "^3.0.0" + } +} diff --git a/tools/build.mjs b/tools/build.mjs new file mode 100644 index 0000000..696dade --- /dev/null +++ b/tools/build.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// Сборка артефакта расширения для Chrome Web Store. +// +// Текущее состояние: vanilla content scripts без bundler — просто копируем +// рантайм-файлы в dist/, исключая node-репку MCP-сервера, docs, dev-файлы. +// Когда (если) перейдём на ESM-модули в options/ — этот же скрипт будет +// запускать esbuild перед копированием. +// +// Использование: +// node tools/build.mjs # → dist/ + tweai-v.zip +// node tools/build.mjs --no-zip # только dist/ + +import { readFile, rm, mkdir, cp, readdir, stat } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const DIST = join(ROOT, 'dist'); + +const INCLUDE = [ + 'manifest.json', + 'background.js', + 'content_script.js', + 'selectors.js', + 'dev-logger.js', + 'ad-blocker.js', + 'profile-scraper.js', + 'options.html', + 'options.js', + 'options.css', + 'styles.css', + 'icons', + '_locales', + 'LICENSE', +]; + +async function readVersion() { + const m = JSON.parse(await readFile(join(ROOT, 'manifest.json'), 'utf8')); + return m.version; +} + +async function copyTree(src, dst) { + await cp(src, dst, { recursive: true }); +} + +async function zipDir(srcDir, outZip) { + // Используем системный `zip` — он есть на macOS/Linux и GitHub Actions + // runner. Без extra deps: добавлять JS-zip-библиотеку ради одной операции + // не стоит. + await new Promise((resolve, reject) => { + const proc = spawn('zip', ['-r', outZip, '.'], { cwd: srcDir, stdio: 'inherit' }); + proc.on('exit', code => (code === 0 ? resolve() : reject(new Error(`zip exit ${code}`)))); + proc.on('error', reject); + }); +} + +async function main() { + const version = await readVersion(); + const wantZip = !process.argv.includes('--no-zip'); + + await rm(DIST, { recursive: true, force: true }); + await mkdir(DIST, { recursive: true }); + + for (const name of INCLUDE) { + const src = join(ROOT, name); + const dst = join(DIST, name); + try { + await stat(src); + } catch { + console.warn(`[build] skip missing: ${name}`); + continue; + } + await copyTree(src, dst); + } + + console.log(`[build] dist/ ready — version ${version}`); + const entries = await readdir(DIST); + console.log(`[build] ${entries.length} top-level entries`); + + if (wantZip) { + const zipPath = join(ROOT, `tweai-v${version}.zip`); + await rm(zipPath, { force: true }); + await zipDir(DIST, zipPath); + const s = await stat(zipPath); + console.log(`[build] wrote ${zipPath} (${(s.size / 1024).toFixed(1)} KB)`); + } +} + +main().catch(e => { + console.error('[build] failed:', e); + process.exit(1); +});