UNPKG

react-native-bootsplash-cli-fork

Version:

Fork of CLI for generating assets for react-native-bootsplash.

612 lines (545 loc) 18 kB
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")); } };