package-release.mjs
1 import * as fs from 'node:fs/promises'; 2 import * as path from 'node:path'; 3 import { fileURLToPath } from 'node:url'; 4 5 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 const extensionDir = path.resolve(__dirname, '..'); 7 const repoRoot = path.resolve(extensionDir, '..'); 8 9 function parseArgs(argv) { 10 const args = { outDir: path.join(repoRoot, 'extension-package') }; 11 for (let i = 0; i < argv.length; i++) { 12 const arg = argv[i]; 13 if (arg === '--out' && argv[i + 1]) { 14 const outDir = argv[++i]; 15 args.outDir = path.isAbsolute(outDir) 16 ? outDir 17 : path.resolve(process.cwd(), outDir); 18 } 19 } 20 return args; 21 } 22 23 async function exists(targetPath) { 24 try { 25 await fs.access(targetPath); 26 return true; 27 } catch { 28 return false; 29 } 30 } 31 32 function isLocalAsset(ref) { 33 return typeof ref === 'string' 34 && ref.length > 0 35 && !ref.startsWith('http://') 36 && !ref.startsWith('https://') 37 && !ref.startsWith('//') 38 && !ref.startsWith('chrome://') 39 && !ref.startsWith('chrome-extension://') 40 && !ref.startsWith('data:') 41 && !ref.startsWith('#'); 42 } 43 44 function addLocalAsset(files, ref) { 45 if (isLocalAsset(ref)) files.add(ref); 46 } 47 48 function collectManifestEntrypoints(manifest) { 49 const files = new Set(['manifest.json']); 50 51 addLocalAsset(files, manifest.background?.service_worker); 52 addLocalAsset(files, manifest.action?.default_popup); 53 addLocalAsset(files, manifest.options_page); 54 addLocalAsset(files, manifest.devtools_page); 55 addLocalAsset(files, manifest.side_panel?.default_path); 56 57 for (const ref of Object.values(manifest.icons ?? {})) addLocalAsset(files, ref); 58 for (const ref of Object.values(manifest.action?.default_icon ?? {})) addLocalAsset(files, ref); 59 for (const contentScript of manifest.content_scripts ?? []) { 60 for (const jsFile of contentScript.js ?? []) addLocalAsset(files, jsFile); 61 for (const cssFile of contentScript.css ?? []) addLocalAsset(files, cssFile); 62 } 63 for (const page of manifest.sandbox?.pages ?? []) addLocalAsset(files, page); 64 for (const overridePage of Object.values(manifest.chrome_url_overrides ?? {})) addLocalAsset(files, overridePage); 65 for (const entry of manifest.web_accessible_resources ?? []) { 66 for (const resource of entry.resources ?? []) addLocalAsset(files, resource); 67 } 68 if (manifest.default_locale) files.add('_locales'); 69 70 return [...files]; 71 } 72 73 async function collectHtmlDependencies(relativeHtmlPath, files, visited) { 74 if (visited.has(relativeHtmlPath)) return; 75 visited.add(relativeHtmlPath); 76 77 const htmlPath = path.join(extensionDir, relativeHtmlPath); 78 const html = await fs.readFile(htmlPath, 'utf8'); 79 const attrRe = /\b(?:src|href)=["']([^"'#?]+(?:\?[^"']*)?)["']/gi; 80 81 for (const match of html.matchAll(attrRe)) { 82 const rawRef = match[1]; 83 const cleanRef = rawRef.split('?')[0]; 84 if (!isLocalAsset(cleanRef)) continue; 85 86 const resolvedRelativePath = cleanRef.startsWith('/') 87 ? cleanRef.slice(1) 88 : path.posix.normalize(path.posix.join(path.posix.dirname(relativeHtmlPath), cleanRef)); 89 90 addLocalAsset(files, resolvedRelativePath); 91 if (resolvedRelativePath.endsWith('.html')) { 92 await collectHtmlDependencies(resolvedRelativePath, files, visited); 93 } 94 } 95 } 96 97 async function collectManifestAssets(manifest) { 98 const files = new Set(collectManifestEntrypoints(manifest)); 99 const htmlPages = []; 100 101 if (manifest.action?.default_popup) { 102 htmlPages.push(manifest.action.default_popup); 103 } 104 if (manifest.options_page) htmlPages.push(manifest.options_page); 105 if (manifest.devtools_page) htmlPages.push(manifest.devtools_page); 106 if (manifest.side_panel?.default_path) htmlPages.push(manifest.side_panel.default_path); 107 for (const page of manifest.sandbox?.pages ?? []) htmlPages.push(page); 108 for (const overridePage of Object.values(manifest.chrome_url_overrides ?? {})) htmlPages.push(overridePage); 109 110 const visited = new Set(); 111 for (const htmlPage of htmlPages) { 112 if (isLocalAsset(htmlPage)) { 113 await collectHtmlDependencies(htmlPage, files, visited); 114 } 115 } 116 117 return [...files]; 118 } 119 120 async function copyEntry(relativePath, outDir) { 121 const fromPath = path.join(extensionDir, relativePath); 122 const toPath = path.join(outDir, relativePath); 123 const stats = await fs.stat(fromPath); 124 125 if (stats.isDirectory()) { 126 await fs.cp(fromPath, toPath, { recursive: true }); 127 return; 128 } 129 130 await fs.mkdir(path.dirname(toPath), { recursive: true }); 131 await fs.copyFile(fromPath, toPath); 132 } 133 134 async function findMissingEntries(baseDir, entries) { 135 const missingEntries = []; 136 for (const relativePath of entries) { 137 const absolutePath = path.join(baseDir, relativePath); 138 if (!(await exists(absolutePath))) { 139 missingEntries.push(relativePath); 140 } 141 } 142 return missingEntries; 143 } 144 145 async function main() { 146 const { outDir } = parseArgs(process.argv.slice(2)); 147 const manifestPath = path.join(extensionDir, 'manifest.json'); 148 const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); 149 150 const requiredEntries = await collectManifestAssets(manifest); 151 const missingEntries = await findMissingEntries(extensionDir, requiredEntries); 152 153 if (missingEntries.length > 0) { 154 console.error('Missing files referenced by the extension package:'); 155 for (const missingEntry of missingEntries) console.error(`- ${missingEntry}`); 156 process.exit(1); 157 } 158 159 await fs.rm(outDir, { recursive: true, force: true }); 160 await fs.mkdir(outDir, { recursive: true }); 161 162 for (const relativePath of requiredEntries) { 163 await copyEntry(relativePath, outDir); 164 } 165 166 // Guard against regressions where manifest entry files (e.g. action.default_popup) 167 // are accidentally omitted from the packaged directory. 168 const packagedEntrypoints = collectManifestEntrypoints(manifest); 169 const missingPackagedEntrypoints = await findMissingEntries(outDir, packagedEntrypoints); 170 if (missingPackagedEntrypoints.length > 0) { 171 console.error('Packaged extension is missing files referenced by manifest.json:'); 172 for (const missingEntry of missingPackagedEntrypoints) console.error(`- ${missingEntry}`); 173 process.exit(1); 174 } 175 176 console.log(`Extension package prepared at ${path.relative(repoRoot, outDir) || outDir}`); 177 } 178 179 await main();