#!/usr/bin/env node /** * Build script for Firefox Add-ons (AMO) production package * Usage: node scripts/build-firefox.mjs * * This script: * 1. Sets ALLOW_INSTANCE_CONFIG = false in sidepanel.js * 2. Restricts host_permissions to memento-note.com only * 3. Removes side_panel (Chrome-only) from manifest.json * 4. Keeps sidebar_action + browser_specific_settings (Firefox-specific) * 5. Copies and generates icons from public/icons/ * 6. Creates a Firefox-ready .xpi 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-firefox') 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 or a function (relPath, isDirectory) -> boolean. 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 (lock URL to memento-note.com) function processSidepanelJs(content) { return content.replace( /const ALLOW_INSTANCE_CONFIG = true/, 'const ALLOW_INSTANCE_CONFIG = false' ) } // Read and modify manifest.json for Firefox production function processManifestJson(content) { const manifest = JSON.parse(content) // Firefox doesn't support chrome.sidePanel โ€” use sidebarAction (already declared). delete manifest.side_panel // Firefox MV3 service workers are supported, but background events are also // reliable with background.scripts. Keep service_worker for simplicity. // Restrict host_permissions to production. Keep optional_host_permissions as-is. manifest.host_permissions = [ 'https://memento-note.com/*', 'https://www.memento-note.com/*', ] if (manifest.content_scripts?.[0]) { manifest.content_scripts[0].matches = ['https://memento-note.com/*', 'https://www.memento-note.com/*'] } 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] 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') fs.copyFileSync(icon512Svg, path.join(distDir, 'icon-512.svg')) fs.copyFileSync(icon192Svg, path.join(distDir, 'icon-192.svg')) return } 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') } fs.copyFileSync(icon512Svg, path.join(distDir, 'icon-512.svg')) fs.copyFileSync(icon192Svg, path.join(distDir, 'icon-192.svg')) } // Create XPI (ZIP) package using AdmZip async function createXpiPackage() { log('๐Ÿ“ฆ Creating XPI package...', 'blue') const xpiPath = path.join(extRoot, 'memento-web-clipper-firefox.xpi') try { const zip = new AdmZip() 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) zip.writeZip(xpiPath) const stats = fs.statSync(xpiPath) log(`โœ“ XPI package created: ${xpiPath}`, 'green') log(` Size: ${(stats.size / 1024).toFixed(2)} KB`, 'green') } catch (error) { throw new Error(`Failed to create XPI: ${error.message}`) } } async function build() { log('๐ŸฆŠ Starting Firefox (AMO) build...', 'blue') log('') try { log('๐Ÿงน Cleaning dist directory...', 'blue') if (fs.existsSync(distDir)) { fs.rmSync(distDir, { recursive: true, force: true }) } ensureDir(distDir) log('๐Ÿ“‹ Copying extension files...', 'blue') copyFiles(extRoot, distDir, [ (rel, isDir) => isDir && /^dist-/i.test(rel), (rel, isDir) => isDir && (rel === 'scripts' || rel === 'i18n' || rel === 'store-assets'), /\.md$/, /^node_modules$/, /^diagnose\.js$/, /^test-sidepanel\.html$/, /^memento-web-clipper-chrome-store\.zip$/, /^memento-web-clipper-firefox\.xpi$/, ]) 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') log('โš™๏ธ Processing manifest.json...', 'blue') const manifestPath = path.join(distDir, 'manifest.json') let manifestContent = fs.readFileSync(manifestPath, 'utf8') manifestContent = processManifestJson(manifestContent) const manifest = JSON.parse(manifestContent) manifest.icons = { "16": "icon-16.png", "48": "icon-48.png", "128": "icon-128.png" } // Firefox uses browser_action icon OR sidebar_action icon manifest.action = { ...manifest.action, "default_icon": { "16": "icon-16.png", "48": "icon-48.png", "128": "icon-128.png" } } if (manifest.sidebar_action) { manifest.sidebar_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 side_panel (Chrome-only)', 'green') log(' โœ“ Restricted host_permissions to memento-note.com', 'green') log(' โœ“ Kept sidebar_action + browser_specific_settings for Firefox', 'green') log(' โœ“ Added icon definitions', 'green') await generateIcons() await createXpiPackage() log('') log('โœ… Build completed successfully!', 'green') log('') log('๐Ÿ“ฆ Output files:', 'blue') log(` โ€ข Package: ${path.join(extRoot, 'memento-web-clipper-firefox.xpi')}`, 'reset') log(` โ€ข Dist dir: ${distDir}`, 'reset') log('') log('๐Ÿ“ Next steps:', 'blue') log(' 1. Test: web-ext run --source-dir ' + distDir, 'reset') log(' 2. Sign: web-ext sign --api-key= --api-secret= --source-dir ' + distDir, 'reset') log(' 3. Submit signed .xpi to https://addons.mozilla.org/developers/', 'reset') log('') } catch (error) { log('') log('โŒ Build failed!', 'red') log(` Error: ${error.message}`, 'red') process.exit(1) } } build()