Skip to content

Commit

Permalink
feat(general): initial implementation
Browse files Browse the repository at this point in the history
Generates icons for Android (except rounded for now) and iOS in all applicable sizes. release-npm
  • Loading branch information
tobua committed Jun 29, 2022
0 parents commit 19355c1
Show file tree
Hide file tree
Showing 9 changed files with 342 additions and 0 deletions.
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: release

on:
push:
branches: [main]

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm install --legacy-peer-deps
- name: 🧪 Test
run: npm test
- name: 🚧 Build
run: npm run build
- uses: tobua/release-npm-action@v1
with:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
package-lock.json
tsconfig.json
dist
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# icon-numic-plugin

<img align="right" src="https://github.com/tobua/icon-numic-plugin/raw/main/logo.png" width="20%" alt="Icon Numic Plugin Logo" />

Numic plugin for React Native to automatically generate iOS and Android app icons from a single file. Commit only one 1024x1024 file of your app icon but get all sizes automatically.

## Installation

```
npm i --save-dev icon-numic-plugin
```

## Usage

Numic automatically picks up the plugin once installed and adds the various icons to the native folders in `/android` and `/ios` without any changes to commit. The only thing **required is an icon** of the recommended size 1024x1024. The plugin will look for icons in the following locations and pick the first match:

- icon.png
- app-icon.png
- asset/icon.png
- logo.png (also used as Avatar in SourceTree)
120 changes: 120 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'fs'
import { join, dirname } from 'path'
import sharp from 'sharp'
import { contentsWithLinks } from './ios'

// Sharp Docs: https://sharp.pixelplumbing.com/api-constructor
// Alternative: https://github.com/silvia-odwyer/photon
// https://github.com/aeirola/react-native-svg-app-icon
// https://www.npmjs.com/package/app-icon (requires imagemagik)
// https://docs.expo.dev/guides/app-icons/

type Input = {
cwd?: string
log?: (message: string, type?: string) => void
}

const iconSourcePaths = (cwd: string) => [
join(cwd, 'icon.png'),
join(cwd, 'app-icon.png'),
join(cwd, 'asset/icon.png'),
join(cwd, 'logo.png'),
]

const getInput = (cwd: string) => {
const paths = iconSourcePaths(cwd)
let match: string | undefined

paths.forEach((path) => {
if (!match && existsSync(path)) {
match = path
}
})

return match
}

const getAndroidFolders = () => [
{ path: 'android/app/src/main/res/mipmap-mdpi/ic_launcher.png', size: 48 },
{ path: 'android/app/src/main/res/mipmap-hdpi/ic_launcher.png', size: 72 },
{ path: 'android/app/src/main/res/mipmap-xhdpi/ic_launcher.png', size: 96 },
{ path: 'android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png', size: 144 },
{ path: 'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png', size: 192 },
// TODO rounded icons
]

const getIOSFolders = (iosImageDirectory: string) => {
if (!iosImageDirectory) {
return []
}

return [
{ path: `${iosImageDirectory}/AppIcon.appiconset/Icon-40.png`, size: 40 },
{ path: `${iosImageDirectory}/AppIcon.appiconset/Icon-58.png`, size: 58 },
{ path: `${iosImageDirectory}/AppIcon.appiconset/Icon-60.png`, size: 60 },
{ path: `${iosImageDirectory}/AppIcon.appiconset/Icon-80.png`, size: 80 },
{ path: `${iosImageDirectory}/AppIcon.appiconset/Icon-87.png`, size: 87 },
{ path: `${iosImageDirectory}/AppIcon.appiconset/Icon-120.png`, size: 120 },
{ path: `${iosImageDirectory}/AppIcon.appiconset/Icon-121.png`, size: 121 },
{ path: `${iosImageDirectory}/AppIcon.appiconset/Icon-180.png`, size: 180 },
{ path: `${iosImageDirectory}/AppIcon.appiconset/Icon-1024.png`, size: 1024 },
]
}

const getSizes = ({ cwd, log }: Input) => {
const iosDirectories = readdirSync(join(cwd, 'ios'), { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.filter((dirent) => existsSync(join(cwd, 'ios', dirent.name, 'Images.xcassets')))
.map((dirent) => dirent.name)
const iosImageDirectory =
iosDirectories.length > 0 ? join('ios', iosDirectories[0], 'Images.xcassets') : null

if (!iosImageDirectory) {
log('iOS project directory with "Images.xcassets" not found', 'warning')
}

return {
android: getAndroidFolders(),
ios: getIOSFolders(iosImageDirectory),
iosDirectory: iosImageDirectory,
}
}

export default async ({
cwd = process.cwd(),
// eslint-disable-next-line no-console
log = console.log,
}: Input) => {
const inputFile = getInput(cwd)
const sizes = getSizes({ cwd, log })

const androidPromises = sizes.android.map((icon) => {
const destinationFile = join(cwd, icon.path)
const directory = dirname(destinationFile)
if (!existsSync(directory)) {
mkdirSync(directory, { recursive: true })
}
return sharp(inputFile).resize(icon.size, icon.size).toFile(destinationFile)
})

await Promise.all(androidPromises)

const iosPromises = sizes.ios.map((icon) => {
const destinationFile = join(cwd, icon.path)
const directory = dirname(destinationFile)
if (!existsSync(directory)) {
mkdirSync(directory, { recursive: true })
}
return sharp(inputFile).resize(icon.size, icon.size).toFile(destinationFile)
})

await Promise.all(iosPromises)

// Link ios icons in Contents.json.
writeFileSync(
join(cwd, sizes.iosDirectory, 'AppIcon.appiconset/Contents.json'),
JSON.stringify(contentsWithLinks, null, 2)
)

log('App icons created')
}
62 changes: 62 additions & 0 deletions ios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export const contentsWithLinks = {
images: [
{
filename: 'Icon-40.png',
idiom: 'iphone',
scale: '2x',
size: '20x20',
},
{
filename: 'Icon-60.png',
idiom: 'iphone',
scale: '3x',
size: '20x20',
},
{
filename: 'Icon-58.png',
idiom: 'iphone',
scale: '2x',
size: '29x29',
},
{
filename: 'Icon-87.png',
idiom: 'iphone',
scale: '3x',
size: '29x29',
},
{
filename: 'Icon-80.png',
idiom: 'iphone',
scale: '2x',
size: '40x40',
},
{
filename: 'Icon-120.png',
idiom: 'iphone',
scale: '3x',
size: '40x40',
},
{
filename: 'Icon-121.png',
idiom: 'iphone',
scale: '2x',
size: '60x60',
},
{
filename: 'Icon-180.png',
idiom: 'iphone',
scale: '3x',
size: '60x60',
},
{
filename: 'Icon-1024.png',
idiom: 'ios-marketing',
scale: '1x',
size: '1024x1024',
},
],
info: {
author: 'xcode',
version: 1,
},
}
Binary file added logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 71 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"name": "icon-numic-plugin",
"description": "Generate icons for Android and iOS in React Native.",
"version": "0.0.0-development",
"repository": "github:tobua/icon-numic-plugin",
"license": "MIT",
"author": "Matthias Giger",
"scripts": {
"build": "padua build",
"start": "padua watch",
"test": "vitest run"
},
"padua": {
"esbuild": {
"platform": "node",
"format": "esm",
"target": "node16"
},
"tsconfig": {
"compilerOptions": {
"target": "es2022"
}
}
},
"dependencies": {
"sharp": "^0.30.7"
},
"peerDependencies": {
"numic": ">= 0.3"
},
"type": "module",
"sideEffects": false,
"main": "dist/index.js",
"exports": {
"default": "./dist/index.js"
},
"types": "dist/index.d.ts",
"files": [
"dist"
],
"keywords": [
"numic",
"plugin",
"app-icon",
"icon",
"react-native"
],
"devDependencies": {
"@types/sharp": "^0.30.4",
"jest-fixture": "^3.0.1",
"padua": "^0.6.1",
"vitest": "^0.16.0"
},
"prettier": "padua/configuration/.prettierrc.json",
"eslintConfig": {
"extends": "./node_modules/padua/configuration/eslint.cjs"
},
"jest": {
"globals": {
"ts-jest": {
"tsconfig": "./tsconfig.json"
}
},
"transform": {
"^.+\\.tsx?$": "ts-jest"
}
},
"engines": {
"node": ">= 14"
}
}
Binary file added test/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 46 additions & 0 deletions test/logo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { cpSync, existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import { expect, test, beforeEach, afterEach, vi } from 'vitest'
import { prepare, environment, packageJson, listFilesMatching, readFile } from 'jest-fixture'
import plugin from '../index'

const initialCwd = process.cwd()

// @ts-ignore
global.jest = { spyOn: vi.spyOn }
// @ts-ignore
global.beforeEach = beforeEach
// @ts-ignore
global.afterEach = afterEach

environment('logo')

test('Properly configures empty project.', async () => {
prepare([packageJson('logo')])

const logoPath = join(process.cwd(), 'logo.png')

cpSync(join(initialCwd, 'test/logo.png'), logoPath)
mkdirSync(join(process.cwd(), 'ios/numic/Images.xcassets'), { recursive: true })

expect(existsSync(logoPath)).toBe(true)

await plugin({})

const files = listFilesMatching('**/*.png')

expect(files.length).toBe(15)
expect(files.includes('android/app/src/main/res/mipmap-mdpi/ic_launcher.png')).toBe(true)
expect(files.includes('ios/numic/Images.xcassets/AppIcon.appiconset/Icon-80.png')).toBe(true)

const iosContentsPath = join(
process.cwd(),
'ios/numic/Images.xcassets/AppIcon.appiconset/Contents.json'
)

expect(existsSync(iosContentsPath)).toBe(true)

const iconContentsSpecification = readFile(iosContentsPath)

expect(iconContentsSpecification.images[0].filename).toBe('Icon-40.png')
})

0 comments on commit 19355c1

Please sign in to comment.