react-native-bootsplash
Version:
Display a bootsplash on your app starts. Hide it when you want.
615 lines (610 loc) • 21.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.writeXmlLike = exports.writeWebAssets = exports.writeJson = exports.writeIOSAssets = exports.writeGenericAssets = exports.writeAndroidAssets = exports.transformProps = exports.setLoggerMode = exports.requireAddon = exports.readXmlLike = exports.log = exports.hfs = exports.PACKAGE_NAME = void 0;
var _crypto = _interopRequireDefault(require("crypto"));
var _detectIndent = _interopRequireDefault(require("detect-indent"));
var _fs = _interopRequireDefault(require("fs"));
var _nodeHtmlParser = require("node-html-parser");
var _path = _interopRequireDefault(require("path"));
var _picocolors = _interopRequireDefault(require("picocolors"));
var htmlPlugin = _interopRequireWildcard(require("prettier/plugins/html"));
var cssPlugin = _interopRequireWildcard(require("prettier/plugins/postcss"));
var prettier = _interopRequireWildcard(require("prettier/standalone"));
var _semver = _interopRequireDefault(require("semver"));
var _sharp = _interopRequireDefault(require("sharp"));
var _tsDedent = require("ts-dedent");
var _xmlFormatter = _interopRequireDefault(require("xml-formatter"));
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
const PACKAGE_NAME = exports.PACKAGE_NAME = "react-native-bootsplash";
let loggerMode = {
type: "plugin"
};
const setLoggerMode = value => {
loggerMode = value;
};
exports.setLoggerMode = setLoggerMode;
const warningMessages = new Set();
const errorMessages = new Set();
const log = exports.log = {
warn: text => {
if (loggerMode.type === "cli") {
console.log(_picocolors.default.yellow(`⚠️ ${text}`));
} else {
const message = `⚠️ [${PACKAGE_NAME}] ${text}`;
if (!warningMessages.has(message)) {
warningMessages.add(message);
console.log(_picocolors.default.yellow(message));
}
}
},
error: text => {
if (loggerMode.type === "cli") {
console.log(_picocolors.default.red(`❌ ${text}`));
} else {
const message = `❌ [${PACKAGE_NAME}] ${text}`;
if (!errorMessages.has(message)) {
errorMessages.add(message);
console.log(_picocolors.default.red(message));
}
}
},
title: (emoji, text) => {
if (loggerMode.type === "cli") {
console.log(`\n${emoji} ${_picocolors.default.underline(_picocolors.default.bold(text))}`);
}
},
write: (filePath, dimensions) => {
if (loggerMode.type === "cli") {
console.log(` ${_path.default.relative(loggerMode.cwd, filePath)}` + (dimensions != null ? ` (${dimensions.width}x${dimensions.height})` : ""));
}
}
};
// Freely inspired by https://github.com/humanwhocodes/humanfs
const hfs = exports.hfs = {
buffer: path => _fs.default.readFileSync(path),
exists: path => _fs.default.existsSync(path),
json: path => JSON.parse(_fs.default.readFileSync(path, "utf-8")),
readDir: path => _fs.default.readdirSync(path, "utf-8"),
realPath: path => _fs.default.realpathSync(path, "utf-8"),
rm: path => _fs.default.rmSync(path, {
force: true,
recursive: true
}),
text: path => _fs.default.readFileSync(path, "utf-8"),
ensureDir: dir => {
_fs.default.mkdirSync(dir, {
recursive: true
});
},
write: (path, content) => {
const trimmed = content.trim();
_fs.default.writeFileSync(path, trimmed === "" ? trimmed : trimmed + "\n", "utf-8");
}
};
const writeJson = (filePath, content) => {
hfs.write(filePath, JSON.stringify(content, null, 2));
log.write(filePath);
};
exports.writeJson = writeJson;
const readXmlLike = filePath => {
const content = hfs.text(filePath);
return {
root: (0, _nodeHtmlParser.parse)(content),
formatOptions: {
indent: (0, _detectIndent.default)(content)
}
};
};
exports.readXmlLike = readXmlLike;
const writeXmlLike = async (filePath, content, {
indent,
...formatOptions
}) => {
if (formatOptions.formatter === "prettier") {
const {
formatter,
useCssPlugin = false,
selfClosingTags = false,
...options
} = formatOptions;
const formatted = await prettier.format(content, {
parser: "html",
bracketSameLine: true,
printWidth: 10000,
plugins: [htmlPlugin, ...(useCssPlugin ? [cssPlugin] : [])],
useTabs: indent?.type === "tab",
tabWidth: (indent?.amount ?? 0) || 2,
...options
});
hfs.write(filePath, selfClosingTags ? formatted.replace(/><\/[a-z-0-9]+>/gi, " />") : formatted);
log.write(filePath);
} else {
const {
formatter,
...options
} = formatOptions;
const formatted = (0, _xmlFormatter.default)(content, {
collapseContent: true,
forceSelfClosingEmptyTag: true,
lineSeparator: "\n",
whiteSpaceAtEndOfSelfclosingTag: true,
indentation: (indent?.indent ?? "") || " ",
...options
});
hfs.write(filePath, formatted);
log.write(filePath);
}
};
exports.writeXmlLike = writeXmlLike;
const toAsset = async (filePath, width) => {
const image = (0, _sharp.default)(filePath);
const {
format
} = await image.metadata();
if (format !== "png" && format !== "svg") {
log.error(`${_path.default.basename(filePath)} image file format (${format}) is not supported`);
process.exit(1);
}
const [height, hash] = await Promise.all([image.clone().resize(width).toBuffer().then(buffer => (0, _sharp.default)(buffer).metadata()).then(({
height = 0
}) => Math.round(height)), image.clone().resize(width).png({
quality: 100
}).toBuffer().then(buffer => buffer.toString("base64"))]);
return {
path: filePath,
image,
hash,
height,
width
};
};
const parseColor = value => {
const up = value.toUpperCase().replace(/[^0-9A-F]/g, "");
if (up.length !== 3 && up.length !== 6) {
log.error(`"${value}" value is not a valid hexadecimal color.`);
process.exit(1);
}
const hex = up.length === 3 ? "#" + up[0] + up[0] + up[1] + up[1] + up[2] + up[2] : "#" + up;
const rgb = {
R: (Number.parseInt("" + hex[1] + hex[2], 16) / 255).toPrecision(15),
G: (Number.parseInt("" + hex[3] + hex[4], 16) / 255).toPrecision(15),
B: (Number.parseInt("" + hex[5] + hex[6], 16) / 255).toPrecision(15)
};
return {
hex: hex.toLowerCase(),
rgb
};
};
const transformProps = async (rootPath, {
android = {},
licenseKey,
...rawProps
}) => {
if (_semver.default.lt(process.versions.node, "20.0.0")) {
log.error("Requires Node 20 (or higher)");
process.exit(1);
}
const withDefaults = {
assetsOutput: "assets/bootsplash",
background: "#fff",
brandWidth: 80,
logoWidth: 100,
...rawProps
};
const assetsOutputPath = _path.default.resolve(rootPath, withDefaults.assetsOutput);
const logoPath = _path.default.resolve(rootPath, withDefaults.logo);
const darkLogoPath = withDefaults.darkLogo != null ? _path.default.resolve(rootPath, withDefaults.darkLogo) : undefined;
const brandPath = withDefaults.brand != null ? _path.default.resolve(rootPath, withDefaults.brand) : undefined;
const darkBrandPath = withDefaults.darkBrand != null ? _path.default.resolve(rootPath, withDefaults.darkBrand) : undefined;
const logoWidth = withDefaults.logoWidth - withDefaults.logoWidth % 2;
const brandWidth = withDefaults.brandWidth - withDefaults.brandWidth % 2;
const [logo, darkLogo, brand, darkBrand] = await Promise.all([toAsset(logoPath, logoWidth), darkLogoPath != null ? toAsset(darkLogoPath, logoWidth) : undefined, brandPath != null ? toAsset(brandPath, brandWidth) : undefined, darkBrandPath != null ? toAsset(darkBrandPath, brandWidth) : undefined]);
const background = parseColor(withDefaults.background);
const darkBackground = withDefaults.darkBackground != null ? parseColor(withDefaults.darkBackground) : undefined;
const executeAddon = brand != null || darkBackground != null || darkLogo != null || darkBrand != null;
if (licenseKey != null && !executeAddon) {
log.warn("You specified a license key but none of the options that requires it.");
}
const isPluginLoggerMode = loggerMode.type === "plugin";
const optionNames = {
brand: isPluginLoggerMode ? "brand" : "--brand",
darkBackground: isPluginLoggerMode ? "darkBackground" : "--dark-background",
darkLogo: isPluginLoggerMode ? "darkLogo" : "--dark-logo",
darkBrand: isPluginLoggerMode ? "darkBrand" : "--dark-brand"
};
if (licenseKey == null && executeAddon) {
const options = [brand != null ? optionNames.brand : "", darkBackground != null ? optionNames.darkBackground : "", darkLogo != null ? optionNames.darkLogo : "", darkBrand != null ? optionNames.darkBrand : ""].filter(option => option !== "").join(", ");
log.error(`You need to specify a license key in order to use ${options}.`);
process.exit(1);
}
if (brand == null && darkBrand != null) {
log.error(`${optionNames.darkBrand} option couldn't be used without ${optionNames.brand}.`);
process.exit(1);
}
if (logoWidth < withDefaults.logoWidth) {
log.warn(`Logo width must be a multiple of 2. It has been rounded to ${logoWidth}dp.`);
}
if (brandWidth < withDefaults.brandWidth) {
log.warn(`Brand width must be a multiple of 2. It has been rounded to ${brandWidth}dp.`);
}
const logoSizeExceeded = logo.width > 192 || logo.height > 192;
const brandSizeExceeded = brand != null && (brand.width > 200 || brand.height > 80);
const record = {
background: background.hex,
darkBackground: darkBackground?.hex ?? "",
logo: logo.hash,
darkLogo: darkLogo?.hash ?? "",
brand: brand?.hash ?? "",
darkBrand: darkBrand?.hash ?? ""
};
const stableKey = Object.keys(record).sort().map(key => record[key]).join();
const fileNameSuffix = _crypto.default.createHash("shake256", {
outputLength: 3
}).update(stableKey).digest("hex").toLowerCase();
return {
android,
assetsOutputPath,
licenseKey,
executeAddon,
background,
darkBackground,
logo,
darkLogo,
brand,
darkBrand,
logoSizeExceeded,
brandSizeExceeded,
fileNameSuffix
};
};
exports.transformProps = transformProps;
const writeAndroidAssets = async ({
androidOutputPath,
props
}) => {
const {
logo,
logoSizeExceeded
} = props;
log.title("🤖", "Android");
hfs.ensureDir(androidOutputPath);
if (logoSizeExceeded) {
return log.warn("Logo exceeds 192x192dp and will be cropped by Android. Skipping its generation.");
}
if (logo.width > 134 || logo.height > 134) {
log.warn("Logo exceeds 134x134dp. It might be cropped by Android.");
}
await Promise.all([{
ratio: 1,
suffix: "mdpi"
}, {
ratio: 1.5,
suffix: "hdpi"
}, {
ratio: 2,
suffix: "xhdpi"
}, {
ratio: 3,
suffix: "xxhdpi"
}, {
ratio: 4,
suffix: "xxxhdpi"
}].map(({
ratio,
suffix
}) => {
const drawableDirPath = _path.default.resolve(androidOutputPath, `drawable-${suffix}`);
hfs.ensureDir(drawableDirPath);
// https://developer.android.com/develop/ui/views/launch/splash-screen#dimensions
const canvasSize = 288 * ratio;
// https://sharp.pixelplumbing.com/api-constructor
const canvas = (0, _sharp.default)({
create: {
width: canvasSize,
height: canvasSize,
channels: 4,
background: {
r: 255,
g: 255,
b: 255,
alpha: 0
}
}
});
const filePath = _path.default.resolve(drawableDirPath, "bootsplash_logo.png");
return logo.image.clone().resize(logo.width * ratio).toBuffer().then(input => canvas.composite([{
input
}]).png({
quality: 100
}).toFile(filePath)).then(() => {
log.write(filePath, {
width: canvasSize,
height: canvasSize
});
});
}));
};
exports.writeAndroidAssets = writeAndroidAssets;
const getStoryboard = props => {
const {
background,
logo,
fileNameSuffix
} = props;
const {
R,
G,
B
} = background.rgb;
const frameWidth = 375;
const frameHeight = 667;
return (0, _tsDedent.dedent)`
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController modalTransitionStyle="crossDissolve" id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" autoresizesSubviews="NO" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="${frameWidth}" height="${frameHeight}"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView autoresizesSubviews="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="BootSplashLogo-${fileNameSuffix}" translatesAutoresizingMaskIntoConstraints="NO" id="3lX-Ut-9ad">
<rect key="frame" x="${(frameWidth - logo.width) / 2}" y="${(frameHeight - logo.height) / 2}" width="${logo.width}" height="${logo.height}"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
</accessibility>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" name="BootSplashBackground-${fileNameSuffix}"/>
<constraints>
<constraint firstItem="3lX-Ut-9ad" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="Fh9-Fy-1nT"/>
<constraint firstItem="3lX-Ut-9ad" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="nvB-Ic-PnI"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="0.0"/>
</scene>
</scenes>
<resources>
<image name="BootSplashLogo-${fileNameSuffix}" width="${logo.width}" height="${logo.height}"/>
<namedColor name="BootSplashBackground-${fileNameSuffix}">
<color red="${R}" green="${G}" blue="${B}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>
`;
};
const writeIOSAssets = async ({
iosOutputPath,
props
}) => {
const {
background,
logo,
fileNameSuffix
} = props;
log.title("🍏", "iOS");
hfs.ensureDir(iosOutputPath);
// clean existing assets
hfs.readDir(iosOutputPath).filter(file => file === "Colors.xcassets" || file === "Images.xcassets").map(file => _path.default.join(iosOutputPath, file)).flatMap(dir => hfs.readDir(dir).filter(file => file.startsWith("BootSplash")).map(file => _path.default.join(dir, file))).forEach(file => {
hfs.rm(file);
});
const storyboardPath = _path.default.resolve(iosOutputPath, "BootSplash.storyboard");
await writeXmlLike(storyboardPath, getStoryboard(props), {
formatter: "xmlFormatter",
whiteSpaceAtEndOfSelfclosingTag: false
});
const colorsSetPath = _path.default.resolve(iosOutputPath, "Colors.xcassets", `BootSplashBackground-${fileNameSuffix}.colorset`);
hfs.ensureDir(colorsSetPath);
writeJson(_path.default.resolve(colorsSetPath, "Contents.json"), {
colors: [{
idiom: "universal",
color: {
"color-space": "srgb",
components: {
blue: background.rgb.B,
green: background.rgb.G,
red: background.rgb.R,
alpha: "1.000"
}
}
}],
info: {
author: "xcode",
version: 1
}
});
const logoFileName = `logo-${fileNameSuffix}`;
const imagesSetPath = _path.default.resolve(iosOutputPath, "Images.xcassets", `BootSplashLogo-${fileNameSuffix}.imageset`);
hfs.ensureDir(imagesSetPath);
writeJson(_path.default.resolve(imagesSetPath, "Contents.json"), {
images: [{
idiom: "universal",
filename: `${logoFileName}.png`,
scale: "1x"
}, {
idiom: "universal",
filename: `${logoFileName}@2x.png`,
scale: "2x"
}, {
idiom: "universal",
filename: `${logoFileName}@3x.png`,
scale: "3x"
}],
info: {
author: "xcode",
version: 1
}
});
await Promise.all([{
ratio: 1,
suffix: ""
}, {
ratio: 2,
suffix: "@2x"
}, {
ratio: 3,
suffix: "@3x"
}].map(({
ratio,
suffix
}) => {
const filePath = _path.default.resolve(imagesSetPath, `${logoFileName}${suffix}.png`);
return logo.image.clone().resize(logo.width * ratio).png({
quality: 100
}).toFile(filePath).then(({
width,
height
}) => {
log.write(filePath, {
width,
height
});
});
}));
};
exports.writeIOSAssets = writeIOSAssets;
const writeWebAssets = async ({
htmlTemplatePath,
props
}) => {
const {
background,
logo
} = props;
log.title("🌐", "Web");
const htmlTemplate = readXmlLike(htmlTemplatePath);
const {
format
} = await logo.image.metadata();
const prevStyle = htmlTemplate.root.querySelector("#bootsplash-style");
const base64 = (format === "svg" ? hfs.buffer(logo.path) : await logo.image.clone().resize(Math.round(logo.width * 2)).png({
quality: 100
}).toBuffer()).toString("base64");
const dataURI = `data:image/${format ? "svg+xml" : "png"};base64,${base64}`;
const nextStyle = (0, _nodeHtmlParser.parse)((0, _tsDedent.dedent)`
<style id="bootsplash-style">
#bootsplash {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
background-color: ${background.hex};
}
#bootsplash-logo {
content: url("${dataURI}");
width: ${logo.width}px;
height: ${logo.height}px;
}
</style>
`);
if (prevStyle != null) {
prevStyle.replaceWith(nextStyle);
} else {
htmlTemplate.root.querySelector("head")?.appendChild(nextStyle);
}
const prevDiv = htmlTemplate.root.querySelector("#bootsplash");
const nextDiv = (0, _nodeHtmlParser.parse)((0, _tsDedent.dedent)`
<div id="bootsplash">
<div id="bootsplash-logo"></div>
</div>
`);
if (prevDiv != null) {
prevDiv.replaceWith(nextDiv);
} else {
htmlTemplate.root.querySelector("body")?.appendChild(nextDiv);
}
await writeXmlLike(htmlTemplatePath, htmlTemplate.root.toString(), {
...htmlTemplate.formatOptions,
formatter: "prettier",
useCssPlugin: true
});
};
exports.writeWebAssets = writeWebAssets;
const writeGenericAssets = async ({
props
}) => {
const {
assetsOutputPath,
background,
logo
} = props;
log.title("📄", "Assets");
hfs.ensureDir(assetsOutputPath);
writeJson(_path.default.resolve(assetsOutputPath, "manifest.json"), {
background: background.hex,
logo: {
width: logo.width,
height: logo.height
}
});
await Promise.all([{
ratio: 1,
suffix: ""
}, {
ratio: 1.5,
suffix: "@1,5x"
}, {
ratio: 2,
suffix: "@2x"
}, {
ratio: 3,
suffix: "@3x"
}, {
ratio: 4,
suffix: "@4x"
}].map(({
ratio,
suffix
}) => {
const filePath = _path.default.resolve(assetsOutputPath, `logo${suffix}.png`);
return logo.image.clone().resize(Math.round(logo.width * ratio)).png({
quality: 100
}).toFile(filePath).then(({
width,
height
}) => {
log.write(filePath, {
width,
height
});
});
}));
};
exports.writeGenericAssets = writeGenericAssets;
const requireAddon = ({
executeAddon,
licenseKey
}) => {
if (licenseKey != null && executeAddon) {
try {
const addon = require("./addon");
return "default" in addon ? addon.default : addon;
} catch {
return;
}
}
};
exports.requireAddon = requireAddon;
//# sourceMappingURL=utils.js.map