#!/usr/bin/env node /** * Build script for Chrome Web Store production package * Usage: node scripts/build-chrome-store.mjs * * This script: * 1. Sets ALLOW_INSTANCE_CONFIG = false in sidepanel.js * 2. Removes localhost permissions from manifest.json * 3. Copies and generates icons from public/icons/ * 4. Creates a production-ready .zip package */ import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import AdmZip from 'adm-zip' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const extRoot = path.resolve(__dirname, '..') const projectRoot = path.resolve(extRoot, '..') const publicIconsDir = path.join(projectRoot, 'public', 'icons') const distDir = path.join(extRoot, 'dist-chrome-store') // Colors for terminal output const colors = { reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', red: '\x1b[31m' } function log(message, color = 'reset') { console.log(`${colors[color]}${message}${colors.reset}`) } function ensureDir(dirPath) { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }) } } // Copy all files from source to destination, excluding specified patterns. // `exclude` accepts RegExp (matched against the relative path) or a function // returning true when the entry (relative path, isDirectory flag) should be skipped. function copyFiles(src, dest, exclude = []) { ensureDir(dest) const entries = fs.readdirSync(src, { withFileTypes: true }) const isExcluded = (relPath, isDir) => exclude.some(rule => { if (typeof rule === 'function') return rule(relPath, isDir) return relPath.match(rule) !== null }) for (const entry of entries) { const srcPath = path.join(src, entry.name) const relPath = path.relative(extRoot, srcPath) if (isExcluded(relPath, entry.isDirectory())) { continue } const destPath = path.join(dest, entry.name) if (entry.isDirectory()) { copyFiles(srcPath, destPath, exclude) } else { fs.copyFileSync(srcPath, destPath) } } } // Read and modify sidepanel.js for production function processSidepanelJs(content) { return content .replace( /const ALLOW_INSTANCE_CONFIG = true/, 'const ALLOW_INSTANCE_CONFIG = false' ) .replace( /const ALLOW_DIAGNOSTICS = true/, 'const ALLOW_DIAGNOSTICS = false' ) } // Read and modify manifest.json for production function processManifestJson(content) { const manifest = JSON.parse(content) // Un Web Clipper doit pouvoir s'exécuter sur n'importe quel site http(s). // Le Chrome Web Store accepte ces permissions quand elles sont justifiées // par la fonctionnalité (extraction de pages web). manifest.host_permissions = [ 'http://*/*', 'https://*/*', ] if (manifest.content_scripts?.[0]) { manifest.content_scripts[0].matches = ['http://*/*', 'https://*/*'] } // Strip Firefox-only fields (Chrome Store rejects unknown keys on strict review) delete manifest.sidebar_action delete manifest.browser_specific_settings delete manifest.optional_host_permissions return JSON.stringify(manifest, null, 2) } // Generate PNG icons from SVG using sharp async function generateIcons() { log('📦 Generating PNG icons from SVG...', 'blue') const sharp = (await import('sharp')).default const sizes = [16, 48, 128] // Source SVG files const icon512Svg = path.join(publicIconsDir, 'icon-512.svg') const icon192Svg = path.join(publicIconsDir, 'icon-192.svg') if (!fs.existsSync(icon512Svg) || !fs.existsSync(icon192Svg)) { log('⚠️ Source SVG icons not found. Copying SVG files only.', 'yellow') // Copy SVG files as fallback fs.copyFileSync(icon512Svg, path.join(distDir, 'icon-512.svg')) fs.copyFileSync(icon192Svg, path.join(distDir, 'icon-192.svg')) return } // Generate PNG icons for (const size of sizes) { const sourceSvg = size >= 128 ? icon512Svg : icon192Svg const outputPath = path.join(distDir, `icon-${size}.png`) await sharp(sourceSvg) .resize(size, size) .png() .toFile(outputPath) log(` ✓ Generated icon-${size}.png`, 'green') } // Also copy SVG files for reference fs.copyFileSync(icon512Svg, path.join(distDir, 'icon-512.svg')) fs.copyFileSync(icon192Svg, path.join(distDir, 'icon-192.svg')) } // Create ZIP package using AdmZip async function createZipPackage() { log('📦 Creating ZIP package...', 'blue') const zipPath = path.join(extRoot, 'memento-web-clipper-chrome-store.zip') try { const zip = new AdmZip() // Add all files from dist directory const addFiles = (dir, base = '') => { const entries = fs.readdirSync(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(dir, entry.name) const relativePath = path.join(base, entry.name) if (entry.isDirectory()) { addFiles(fullPath, relativePath) } else { zip.addLocalFile(fullPath, base) } } } addFiles(distDir) // Write the zip file zip.writeZip(zipPath) // Get file size const stats = fs.statSync(zipPath) log(`✓ ZIP package created: ${zipPath}`, 'green') log(` Size: ${(stats.size / 1024).toFixed(2)} KB`, 'green') } catch (error) { throw new Error(`Failed to create ZIP: ${error.message}`) } } // Main build process async function build() { log('🚀 Starting Chrome Web Store build...', 'blue') log('') try { // Clean dist directory log('🧹 Cleaning dist directory...', 'blue') if (fs.existsSync(distDir)) { fs.rmSync(distDir, { recursive: true, force: true }) } ensureDir(distDir) // Copy extension files (excluding build scripts, internal sources and dist) log('📋 Copying extension files...', 'blue') copyFiles(extRoot, distDir, [ // Top-level directories to skip entirely (do not even create empty folders) (rel, isDir) => isDir && /^dist-/i.test(rel), (rel, isDir) => isDir && (rel === 'scripts' || rel === 'i18n' || rel === 'store-assets'), // File-level patterns /\.md$/, /^node_modules$/, /^diagnose\.js$/, // dev diagnostic script /^test-sidepanel\.html$/, // dev test page /^memento-web-clipper-chrome-store\.zip$/, // avoid recursive zip-in-zip /^memento-web-clipper-firefox\.xpi$/, // exclude sibling Firefox build artifacts ]) // Process sidepanel.js log('⚙️ Processing sidepanel.js...', 'blue') const sidepanelPath = path.join(distDir, 'sidepanel.js') let sidepanelContent = fs.readFileSync(sidepanelPath, 'utf8') sidepanelContent = processSidepanelJs(sidepanelContent) fs.writeFileSync(sidepanelPath, sidepanelContent) log(' ✓ Set ALLOW_INSTANCE_CONFIG = false', 'green') // Process manifest.json log('⚙️ Processing manifest.json...', 'blue') const manifestPath = path.join(distDir, 'manifest.json') let manifestContent = fs.readFileSync(manifestPath, 'utf8') manifestContent = processManifestJson(manifestContent) // Add icons to manifest const manifest = JSON.parse(manifestContent) manifest.icons = { "16": "icon-16.png", "48": "icon-48.png", "128": "icon-128.png" } manifest.action = { ...manifest.action, "default_icon": { "16": "icon-16.png", "48": "icon-48.png", "128": "icon-128.png" } } fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) log(' ✓ Removed localhost permissions', 'green') log(' ✓ Added icon definitions', 'green') // Generate icons await generateIcons() // Create ZIP package await createZipPackage() log('') log('✅ Build completed successfully!', 'green') log('') log('📦 Output files:', 'blue') log(` • Package: ${path.join(extRoot, 'memento-web-clipper-chrome-store.zip')}`, 'reset') log(` • Dist dir: ${distDir}`, 'reset') log('') log('📝 Next steps:', 'blue') log(' 1. Test the extension by loading the dist-chrome-store folder in Chrome (chrome://extensions)', 'reset') log(' 2. Upload the .zip file to Chrome Web Store Developer Dashboard', 'reset') log('') } catch (error) { log('') log('❌ Build failed!', 'red') log(` Error: ${error.message}`, 'red') process.exit(1) } } // Run the build build()