/ extension / scripts / package-release.mjs
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();