react-native-bootsplash-cli-fork
Version:
Fork of CLI for generating assets for react-native-bootsplash.
612 lines (545 loc) • 18 kB
text/typescript
import chalk from "chalk";
import fs from "fs-extra";
import path from "path";
import pc from "picocolors";
import sharp from "sharp";
const lightLogoFileName = "bootsplash_logo";
const darkLogoFileName = "bootsplash_logo_dark";
const logoAssetName = "BootSplashLogo";
const colorAssetName = "SplashColor";
const androidColorName = "bootsplash_background";
const androidColorRegex = /<color name="bootsplash_background">#\w+<\/color>/g;
const toFullHexadecimal = (hex: string) => {
const prefixed = hex[0] === "#" ? hex : `#${hex}`;
const up = prefixed.toUpperCase();
return up.length === 4
? "#" + up[1] + up[1] + up[2] + up[2] + up[3] + up[3]
: up;
};
const colorToRGB = (hex: string) => {
const fullHexColor = toFullHexadecimal(hex);
return {
r: (parseInt(fullHexColor[1] + fullHexColor[2], 16) / 255).toPrecision(15),
g: (parseInt(fullHexColor[3] + fullHexColor[4], 16) / 255).toPrecision(15),
b: (parseInt(fullHexColor[5] + fullHexColor[6], 16) / 255).toPrecision(15),
};
};
const getLogoContentsJson = (includeDarkLogo: boolean) => `{
"images": [
{
"idiom": "universal",
"filename": "${lightLogoFileName}.png",
"scale": "1x"
},
{
"idiom": "universal",
"filename": "${lightLogoFileName}@2x.png",
"scale": "2x"
},
{
"idiom": "universal",
"filename": "${lightLogoFileName}@3x.png",
"scale": "3x"
}${includeDarkLogo ? DarkImagesContentsJson : ""}
],
"info": {
"version": 1,
"author": "xcode"
}
}
`;
const DarkImagesContentsJson = `,
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom": "universal",
"filename": "${darkLogoFileName}.png",
"scale": "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom": "universal",
"filename": "${darkLogoFileName}@2x.png",
"scale": "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom": "universal",
"filename": "${darkLogoFileName}@3x.png",
"scale": "3x"
}
`;
const getDarkColorsContentsJson = (darkColor: string) => {
const rgb = colorToRGB(darkColor);
return `,
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "${rgb.b}",
"green" : "${rgb.g}",
"red" : "${rgb.r}"
}
},
"idiom" : "universal"
}
`;
};
const getColorsContentsJson = (lightColor: string, darkColor?: string) => {
const rgb = colorToRGB(lightColor);
return `{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "${rgb.b}",
"green" : "${rgb.g}",
"red" : "${rgb.r}"
}
},
"idiom" : "universal"
}${darkColor ? getDarkColorsContentsJson(darkColor) : ""}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}`;
};
const getStoryboard = ({
height,
width,
}: {
height: number;
width: number;
}) => {
return `xml version="1.0" encoding="UTF-8"
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17147" 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="17120"/>
<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 id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" autoresizesSubviews="NO" userInteractionEnabled="NO" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView autoresizesSubviews="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="BootSplashLogo" translatesAutoresizingMaskIntoConstraints="NO" id="3lX-Ut-9ad">
<rect key="frame" x="${(375 - width) / 2}" y="${
(667 - height) / 2
}" width="${width}" height="${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="SplashColor"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" notEnabled="YES"/>
</accessibility>
<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="${logoAssetName}" width="${width}" height="${height}"/>
<namedColor name="SplashColor" />
</resources>
</document>
`;
};
const log = {
error: (text: string) => console.log(pc.red(text)),
text: (text: string) => console.log(text),
warn: (text: string) => console.log(pc.yellow(text)),
};
const logWriteGlobal = (
emoji: string,
filePath: string,
workingPath: string,
dimensions?: { width: number; height: number },
) =>
log.text(
`${emoji} ${path.relative(workingPath, filePath)}` +
(dimensions != null ? ` (${dimensions.width}x${dimensions.height})` : ""),
);
const isValidHexadecimal = (value: string) =>
/^#?([0-9A-F]{3}){1,2}$/i.test(value);
export const generate = async ({
android,
ios,
workingPath,
logoPath,
darkLogoPath,
backgroundColor,
darkBackgroundColor,
logoWidth,
flavor,
assetsPath,
}: {
android: {
sourceDir: string;
appName: string;
} | null;
ios: {
projectPath: string;
} | null;
workingPath: string;
logoPath: string;
darkLogoPath?: string;
assetsPath?: string;
backgroundColor: string;
darkBackgroundColor?: string;
flavor: string;
logoWidth: number;
}) => {
await generateSingle({
android,
ios,
workingPath,
logoPath,
backgroundColor,
logoWidth,
flavor,
assetsPath,
theme: "light",
});
if (darkLogoPath && darkBackgroundColor) {
await generateSingle({
android,
ios,
workingPath,
logoPath: darkLogoPath,
backgroundColor: darkBackgroundColor,
logoWidth,
flavor,
assetsPath,
theme: "dark",
});
}
if (ios) {
createIosAssets({
projectPath: ios.projectPath,
workingPath,
includeDarkLogo: !!darkLogoPath,
lightBackgroundColor: backgroundColor,
darkBackgroundColor: darkBackgroundColor,
});
}
log.text(`
${chalk.blue("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓")}
${chalk.blue("┃")} 💖 ${chalk.bold(
"Love this library? Consider sponsoring!",
)} ${chalk.blue("┃")}
${chalk.blue("┃")} One-time amounts are available. ${chalk.blue(
"┃",
)}
${chalk.blue("┃")} ${chalk.underline(
"https://github.com/sponsors/zoontek",
)} ${chalk.blue("┃")}
${chalk.blue("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")}
`);
log.text(
`✅ Done! Thanks for using ${chalk.underline("react-native-bootsplash")}.`,
);
};
const generateSingle = async ({
android,
ios,
workingPath,
logoPath,
backgroundColor,
logoWidth,
flavor,
assetsPath,
theme,
}: {
android: {
sourceDir: string;
appName: string;
} | null;
ios: {
projectPath: string;
} | null;
workingPath: string;
logoPath: string;
assetsPath?: string;
backgroundColor: string;
flavor: string;
logoWidth: number;
theme: "light" | "dark";
}) => {
if (!isValidHexadecimal(backgroundColor)) {
log.error("--background-color value is not a valid hexadecimal color.");
process.exit(1);
}
const logoFileName = theme === "light" ? lightLogoFileName : darkLogoFileName;
const image = sharp(logoPath);
const backgroundColorHex = toFullHexadecimal(backgroundColor);
const { format } = await image.metadata();
if (format !== "png" && format !== "svg") {
log.error("Input file is an unsupported image format");
process.exit(1);
}
const logoHeight = await image
.clone()
.resize(logoWidth)
.toBuffer()
.then((buffer) => sharp(buffer).metadata())
.then(({ height = 0 }) => height);
const shouldSkipAndroid = logoWidth > 288 || logoHeight > 288;
const logAbove288 = (dimension: "height" | "width") => {
const message = `⚠️ Logo ${dimension} exceed 288dp. As it will be cropped by Android, we skip generation for this platform.`;
log.warn(message);
};
const logAbove192 = (dimension: "height" | "width") => {
const message = `⚠️ Logo ${dimension} exceed 192dp. It might be cropped by Android.`;
log.warn(message);
};
if (logoWidth > 288) {
logAbove288("width");
} else if (logoHeight > 288) {
logAbove288("height");
} else if (logoWidth > 192) {
logAbove192("width");
} else if (logoHeight > 192) {
logAbove192("height");
}
const logWrite = (
emoji: string,
filePath: string,
dimensions?: { width: number; height: number },
) => logWriteGlobal(emoji, filePath, workingPath, dimensions);
if (assetsPath && fs.existsSync(assetsPath)) {
log.text(`\n ${chalk.underline("Assets")}`);
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 fileName = `${logoFileName}${suffix}.png`;
const filePath = path.resolve(assetsPath, fileName);
return image
.clone()
.resize(logoWidth * ratio)
.png({ quality: 100 })
.toFile(filePath)
.then(({ width, height }) => {
logWrite("✨", filePath, { width, height });
});
}),
);
}
if (android && !shouldSkipAndroid) {
log.text(`\n ${pc.underline("Android")}`);
const appPath = android.appName
? path.resolve(android.sourceDir, android.appName)
: path.resolve(android.sourceDir); // @react-native-community/cli 2.x & 3.x support
const resPath = path.resolve(appPath, "src", flavor, "res");
const valuesPath = path.resolve(
resPath,
theme === "light" ? "values" : "values-night",
);
fs.ensureDirSync(valuesPath);
const colorsXmlPath = path.resolve(valuesPath, "colors.xml");
const colorsXmlEntry = `<color name="${androidColorName}">${backgroundColorHex}</color>`;
if (fs.existsSync(colorsXmlPath)) {
const colorsXml = fs.readFileSync(colorsXmlPath, "utf-8");
if (colorsXml.match(androidColorRegex)) {
fs.writeFileSync(
colorsXmlPath,
colorsXml.replace(androidColorRegex, colorsXmlEntry),
"utf-8",
);
} else {
fs.writeFileSync(
colorsXmlPath,
colorsXml.replace(
/<\/resources>/g,
` ${colorsXmlEntry}\n</resources>`,
),
"utf-8",
);
}
logWrite("✏️ ", colorsXmlPath);
} else {
fs.writeFileSync(
colorsXmlPath,
`<resources>\n ${colorsXmlEntry}\n</resources>\n`,
"utf-8",
);
logWrite("✨", colorsXmlPath);
}
await Promise.all(
[
{ ratio: 1, directory: "mipmap-mdpi" },
{ ratio: 1.5, directory: "mipmap-hdpi" },
{ ratio: 2, directory: "mipmap-xhdpi" },
{ ratio: 3, directory: "mipmap-xxhdpi" },
{ ratio: 4, directory: "mipmap-xxxhdpi" },
].map(({ ratio, directory }) => {
const fileName = `${logoFileName}.png`;
const filePath = path.resolve(resPath, directory, fileName);
// https://github.com/androidx/androidx/blob/androidx-main/core/core-splashscreen/src/main/res/values/dimens.xml#L22
const canvasSize = 288 * ratio;
// https://sharp.pixelplumbing.com/api-constructor
const canvas = sharp({
create: {
width: canvasSize,
height: canvasSize,
channels: 4,
background: {
r: 255,
g: 255,
b: 255,
alpha: 0,
},
},
});
return image
.clone()
.resize(logoWidth * ratio)
.toBuffer()
.then((input) =>
canvas
.composite([{ input }])
.png({ quality: 100 })
.toFile(filePath),
)
.then(() => {
logWrite("✨", filePath, { width: canvasSize, height: canvasSize });
});
}),
);
}
if (ios) {
log.text(`\n ${chalk.underline("iOS")}`);
const projectPath = ios.projectPath;
const imagesPath = path.resolve(projectPath, "Images.xcassets");
if (fs.existsSync(projectPath)) {
const storyboardPath = path.resolve(projectPath, "BootSplash.storyboard");
fs.writeFileSync(
storyboardPath,
getStoryboard({
height: logoHeight,
width: logoWidth,
}),
"utf-8",
);
logWrite("✨", storyboardPath);
} else {
log.text(
`No "${projectPath}" directory found. Skipping iOS storyboard generation…`,
);
}
if (fs.existsSync(imagesPath)) {
const imageSetPath = path.resolve(
imagesPath,
logoAssetName + ".imageset",
);
fs.ensureDirSync(imageSetPath);
await Promise.all(
[
{ ratio: 1, suffix: "" },
{ ratio: 2, suffix: "@2x" },
{ ratio: 3, suffix: "@3x" },
].map(({ ratio, suffix }) => {
const fileName = `${logoFileName}${suffix}.png`;
const filePath = path.resolve(imageSetPath, fileName);
return image
.clone()
.resize(logoWidth * ratio)
.png({ quality: 100 })
.toFile(filePath)
.then(({ width, height }) => {
logWrite("✨", filePath, { width, height });
});
}),
);
} else {
log.text(
`No "${imagesPath}" directory found. Skipping iOS images generation…`,
);
}
}
};
const createIosAssets = ({
projectPath,
workingPath,
includeDarkLogo,
lightBackgroundColor,
darkBackgroundColor,
}: {
projectPath: string;
workingPath: string;
includeDarkLogo: boolean;
lightBackgroundColor: string;
darkBackgroundColor?: string;
}) => {
const logWrite = (
emoji: string,
filePath: string,
dimensions?: { width: number; height: number },
) => logWriteGlobal(emoji, filePath, workingPath, dimensions);
const imagesPath = path.resolve(projectPath, "Images.xcassets");
if (fs.existsSync(imagesPath)) {
const imageSetPath = path.resolve(imagesPath, logoAssetName + ".imageset");
fs.ensureDirSync(imageSetPath);
fs.writeFileSync(
path.resolve(imageSetPath, "Contents.json"),
getLogoContentsJson(includeDarkLogo),
"utf-8",
);
logWrite("✨", path.resolve(imageSetPath, "Contents.json"));
const colorSetPath = path.resolve(imagesPath, colorAssetName + ".colorset");
fs.ensureDirSync(colorSetPath);
fs.writeFileSync(
path.resolve(colorSetPath, "Contents.json"),
getColorsContentsJson(lightBackgroundColor, darkBackgroundColor),
"utf-8",
);
logWrite("✨", path.resolve(colorSetPath, "Contents.json"));
}
};