web-ext-run
Version:
A tool to open and run web extensions
208 lines (200 loc) • 6.96 kB
JavaScript
import path from 'path';
import { createWriteStream } from 'fs';
import fs from 'fs/promises';
import parseJSON from 'parse-json';
import stripBom from 'strip-bom';
import defaultFromEvent from 'promise-toolbox/fromEvent';
import zipDir from 'zip-dir';
import defaultSourceWatcher from '../watcher.js';
import getValidatedManifest, { getManifestId } from '../util/manifest.js';
import { prepareArtifactsDir } from '../util/artifacts.js';
import { createLogger } from '../util/logger.js';
import { UsageError, isErrorWithCode } from '../errors.js';
import { createFileFilter as defaultFileFilterCreator } from '../util/file-filter.js';
const log = createLogger(import.meta.url);
const DEFAULT_FILENAME_TEMPLATE = '{name}-{version}.zip';
export function safeFileName(name) {
return name.toLowerCase().replace(/[^a-z0-9.-]+/g, '_');
}
// defaultPackageCreator types and implementation.
// This defines the _locales/messages.json type. See:
// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Internationalization#Providing_localized_strings_in__locales
export async function getDefaultLocalizedName({
messageFile,
manifestData
}) {
let messageData;
let messageContents;
let extensionName = manifestData.name;
try {
messageContents = await fs.readFile(messageFile, {
encoding: 'utf-8'
});
} catch (error) {
throw new UsageError(`Error reading messages.json file at ${messageFile}: ${error}`);
}
messageContents = stripBom(messageContents);
const {
default: stripJsonComments
} = await import('strip-json-comments');
try {
messageData = parseJSON(stripJsonComments(messageContents));
} catch (error) {
throw new UsageError(`Error parsing messages.json file at ${messageFile}: ${error}`);
}
extensionName = manifestData.name.replace(/__MSG_([A-Za-z0-9@_]+?)__/g, (match, messageName) => {
if (!(messageData[messageName] && messageData[messageName].message)) {
const error = new UsageError(`The locale file ${messageFile} ` + `is missing key: ${messageName}`);
throw error;
} else {
return messageData[messageName].message;
}
});
return Promise.resolve(extensionName);
}
// https://stackoverflow.com/a/22129960
export function getStringPropertyValue(prop, obj) {
const properties = prop.split('.');
const value = properties.reduce((prev, curr) => prev && prev[curr], obj);
if (!['string', 'number'].includes(typeof value)) {
throw new UsageError(`Manifest key "${prop}" is missing or has an invalid type: ${value}`);
}
const stringValue = `${value}`;
if (!stringValue.length) {
throw new UsageError(`Manifest key "${prop}" value is an empty string`);
}
return stringValue;
}
function getPackageNameFromTemplate(filenameTemplate, manifestData) {
const packageName = filenameTemplate.replace(/{([A-Za-z0-9._]+?)}/g, (match, manifestProperty) => {
return safeFileName(getStringPropertyValue(manifestProperty, manifestData));
});
// Validate the resulting packageName string, after interpolating the manifest property
// specified in the template string.
const parsed = path.parse(packageName);
if (parsed.dir) {
throw new UsageError(`Invalid filename template "${filenameTemplate}". ` + `Filename "${packageName}" should not contain a path`);
}
if (!['.zip', '.xpi'].includes(parsed.ext)) {
throw new UsageError(`Invalid filename template "${filenameTemplate}". ` + `Filename "${packageName}" should have a zip or xpi extension`);
}
return packageName;
}
export async function defaultPackageCreator({
manifestData,
sourceDir,
fileFilter,
artifactsDir,
overwriteDest,
showReadyMessage,
filename = DEFAULT_FILENAME_TEMPLATE
}, {
fromEvent = defaultFromEvent
} = {}) {
let id;
if (manifestData) {
id = getManifestId(manifestData);
log.debug(`Using manifest id=${id || '[not specified]'}`);
} else {
manifestData = await getValidatedManifest(sourceDir);
}
const buffer = await zipDir(sourceDir, {
filter: (...args) => fileFilter.wantFile(...args)
});
let filenameTemplate = filename;
let {
default_locale
} = manifestData;
if (default_locale) {
default_locale = default_locale.replace(/-/g, '_');
const messageFile = path.join(sourceDir, '_locales', default_locale, 'messages.json');
log.debug('Manifest declared default_locale, localizing extension name');
const extensionName = await getDefaultLocalizedName({
messageFile,
manifestData
});
// allow for a localized `{name}`, without mutating `manifestData`
filenameTemplate = filenameTemplate.replace(/{name}/g, extensionName);
}
const packageName = safeFileName(getPackageNameFromTemplate(filenameTemplate, manifestData));
const extensionPath = path.join(artifactsDir, packageName);
// Added 'wx' flags to avoid overwriting of existing package.
const stream = createWriteStream(extensionPath, {
flags: 'wx'
});
stream.write(buffer, () => {
stream.end();
});
try {
await fromEvent(stream, 'close');
} catch (error) {
if (!isErrorWithCode('EEXIST', error)) {
throw error;
}
if (!overwriteDest) {
throw new UsageError(`Extension exists at the destination path: ${extensionPath}\n` + 'Use --overwrite-dest to enable overwriting.');
}
log.info(`Destination exists, overwriting: ${extensionPath}`);
const overwriteStream = createWriteStream(extensionPath);
overwriteStream.write(buffer, () => {
overwriteStream.end();
});
await fromEvent(overwriteStream, 'close');
}
if (showReadyMessage) {
log.info(`Your web extension is ready: ${extensionPath}`);
}
return {
extensionPath
};
}
// Build command types and implementation.
export default async function build({
sourceDir,
artifactsDir,
asNeeded = false,
overwriteDest = false,
ignoreFiles = [],
filename = DEFAULT_FILENAME_TEMPLATE
}, {
manifestData,
createFileFilter = defaultFileFilterCreator,
fileFilter = createFileFilter({
sourceDir,
artifactsDir,
ignoreFiles
}),
onSourceChange = defaultSourceWatcher,
packageCreator = defaultPackageCreator,
showReadyMessage = true
} = {}) {
const rebuildAsNeeded = asNeeded; // alias for `build --as-needed`
log.info(`Building web extension from ${sourceDir}`);
const createPackage = () => packageCreator({
manifestData,
sourceDir,
fileFilter,
artifactsDir,
overwriteDest,
showReadyMessage,
filename
});
await prepareArtifactsDir(artifactsDir);
const result = await createPackage();
if (rebuildAsNeeded) {
log.info('Rebuilding when files change...');
onSourceChange({
sourceDir,
artifactsDir,
onChange: () => {
return createPackage().catch(error => {
log.error(error.stack);
throw error;
});
},
shouldWatchFile: (...args) => fileFilter.wantFile(...args)
});
}
return result;
}
//# sourceMappingURL=build.js.map