From f0a73cf8751e3b2fb1e61521b197878d53e80301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20S=C3=A1nchez?= Date: Tue, 12 Dec 2023 18:29:34 +0100 Subject: [PATCH] feat!: refactor api (#25) * feat!: refactor api * chore: remove log from generate-assets * chore: include original image name in the instruction * chore: cleanup generate-assets * chore: include pwa manifest icons + move some tests * chore: split instructor resolver * chore: rename icons resolver module * chore: add manifest CLI option --- build.config.ts | 4 + package.json | 26 +- playground/package.json | 7 +- src/api/apple-icons-helper.ts | 201 +++++++++ src/api/generate-assets.ts | 43 ++ src/api/generate-html-markup.ts | 13 + src/api/generate-manifest-icons-entry.ts | 30 ++ src/api/html.ts | 19 +- src/api/icons-resolver-helper.ts | 166 +++++++ src/api/instructions-resolver.ts | 104 +++++ src/api/instructions.ts | 5 + src/api/types.ts | 162 +++++++ src/build.ts | 412 ------------------ src/cli-start.ts | 185 ++++---- src/config.ts | 18 + src/index.ts | 4 +- src/splash.ts | 8 +- test/__snapshots__/instructions.test.ts.snap | 248 +++++++++++ .../__snapshots__/manifest-icons.test.ts.snap | 29 ++ test/instructions.test.ts | 51 +++ test/manifest-icons.test.ts | 25 ++ 21 files changed, 1217 insertions(+), 543 deletions(-) create mode 100644 src/api/apple-icons-helper.ts create mode 100644 src/api/generate-assets.ts create mode 100644 src/api/generate-html-markup.ts create mode 100644 src/api/generate-manifest-icons-entry.ts create mode 100644 src/api/icons-resolver-helper.ts create mode 100644 src/api/instructions-resolver.ts create mode 100644 src/api/instructions.ts delete mode 100644 src/build.ts create mode 100644 test/__snapshots__/instructions.test.ts.snap create mode 100644 test/__snapshots__/manifest-icons.test.ts.snap create mode 100644 test/instructions.test.ts create mode 100644 test/manifest-icons.test.ts diff --git a/build.config.ts b/build.config.ts index 3374cd8..b70b26c 100644 --- a/build.config.ts +++ b/build.config.ts @@ -8,6 +8,10 @@ export default defineBuildConfig({ { input: 'src/presets/minimal', name: 'presets/minimal' }, { input: 'src/presets/minimal-2023', name: 'presets/minimal-2023' }, { input: 'src/api/index', name: 'api' }, + { input: 'src/api/instructions', name: 'api/instructions' }, + { input: 'src/api/generate-assets', name: 'api/generate-assets' }, + { input: 'src/api/generate-html-markup', name: 'api/generate-html-markup' }, + { input: 'src/api/generate-manifest-icons-entry', name: 'api/generate-manifest-icons-entry' }, { input: 'src/cli', name: 'cli' }, ], clean: true, diff --git a/package.json b/package.json index 1f2b62d..2225cc6 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,6 @@ "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, - "./api": { - "import": "./dist/api.mjs", - "require": "./dist/api.cjs" - }, "./config": { "import": "./dist/config.mjs", "require": "./dist/config.cjs" @@ -46,6 +42,26 @@ "import": "./dist/presets/minimal-2023.mjs", "require": "./dist/presets/minimal-2023.cjs" }, + "./api": { + "import": "./dist/api.mjs", + "require": "./dist/api.cjs" + }, + "./api/instructions": { + "import": "./dist/api/instructions.mjs", + "require": "./dist/api/instructions.cjs" + }, + "./api/generate-html-markup": { + "import": "./dist/api/generate-html-markup.mjs", + "require": "./dist/api/generate-html-markup.cjs" + }, + "./api/generate-manifest-icons-entry": { + "import": "./dist/api/generate-manifest-icons-entry.mjs", + "require": "./dist/api/generate-manifest-icons-entry.cjs" + }, + "./api/generate-assets": { + "import": "./dist/api/generate-assets.mjs", + "require": "./dist/api/generate-assets.cjs" + }, "./*": "./*" }, "main": "dist/index.cjs", @@ -76,7 +92,7 @@ "prepublishOnly": "npm run build", "release": "bumpp && npm publish", "run-playground": "pnpm -C playground run build", - "test": "vitest" + "test": "vitest run" }, "dependencies": { "cac": "^6.7.14", diff --git a/playground/package.json b/playground/package.json index f85f83e..6f9b69a 100644 --- a/playground/package.json +++ b/playground/package.json @@ -1,7 +1,12 @@ { "private": true, "scripts": { - "build": "pwa-assets-generator" + "build": "pwa-assets-generator", + "build-no-override": "pwa-assets-generator --override=false", + "build-no-manifest": "pwa-assets-generator --manifest=false", + "build-n-override": "pwa-assets-generator -o=false", + "build-n-manifest": "pwa-assets-generator -m=false", + "help": "pwa-assets-generator -h" }, "devDependencies": { "@vite-pwa/assets-generator": "workspace:*" diff --git a/src/api/apple-icons-helper.ts b/src/api/apple-icons-helper.ts new file mode 100644 index 0000000..0dc0eda --- /dev/null +++ b/src/api/apple-icons-helper.ts @@ -0,0 +1,201 @@ +import type { PngOptions, ResizeOptions } from 'sharp' +import type { AppleDeviceSize, AppleSplashScreens, ResolvedAppleSplashScreens } from '../types.ts' +import { defaultSplashScreenName } from '../splash.ts' +import { createPngCompressionOptions, createResizeOptions } from './defaults.ts' +import type { ImageAssets, ImageAssetsInstructions } from './types.ts' +import { generateMaskableAsset } from './maskable.ts' +import { createAppleSplashScreenHtmlLink } from './html.ts' + +export function resolveAppleSplashScreensInstructions( + imageAssets: ImageAssets, + instructions: ImageAssetsInstructions, + useAppleSplashScreens?: AppleSplashScreens, +) { + const appleSplashScreens = resolveAppleSplashScreens(useAppleSplashScreens) + if (!appleSplashScreens || !appleSplashScreens.sizes.length) + return + + const { linkMediaOptions, name: resolveName, sizes, png } = appleSplashScreens + + const sizesMap = new Map() + const splashScreens: SplashScreenData[] = sizes.reduce((acc, size) => { + // cleanup duplicates screen dimensions: + // should we add the links (maybe scaleFactor is different)? + if (sizesMap.get(size.width) === size.height) + return acc + + sizesMap.set(size.width, size.height) + const { width: height, height: width, ...restSize } = size + const { + width: lheight, + height: lwidth, + ...restResizeOptions + } = size.resizeOptions || {} + const landscapeSize: AppleDeviceSize = { + ...restSize, + width, + height, + resizeOptions: { + ...restResizeOptions, + width: lwidth, + height: lheight, + }, + } + acc.push({ + size, + landscape: false, + dark: size.darkResizeOptions ? false : undefined, + resizeOptions: size.resizeOptions, + padding: size.padding ?? 0.3, + png: size.png ?? png, + }) + acc.push({ + size: landscapeSize, + landscape: true, + dark: size.darkResizeOptions ? false : undefined, + resizeOptions: landscapeSize.resizeOptions, + padding: size.padding ?? 0.3, + png: size.png ?? png, + }) + if (size.darkResizeOptions) { + const { + width: dlheight, + height: dlwidth, + ...restDarkResizeOptions + } = size.darkResizeOptions + const landscapeDarkResizeOptions: ResizeOptions = { ...restDarkResizeOptions, width: dlwidth, height: dlheight } + const landscapeDarkSize: AppleDeviceSize = { + ...restSize, + width, + height, + resizeOptions: landscapeDarkResizeOptions, + darkResizeOptions: undefined, + } + acc.push({ + size, + landscape: false, + dark: true, + resizeOptions: size.darkResizeOptions, + padding: size.padding ?? 0.3, + png: size.png ?? png, + }) + acc.push({ + size: landscapeDarkSize, + landscape: true, + dark: true, + resizeOptions: landscapeDarkResizeOptions, + padding: size.padding ?? 0.3, + png: size.png ?? png, + }) + } + return acc + }, [] as SplashScreenData[]) + + sizesMap.clear() + + for (const size of splashScreens) { + const name = resolveName(size.landscape, size.size, size.dark) + const url = `${imageAssets.basePath}${name}` + const promise = () => generateMaskableAsset('png', imageAssets.imageName, size.size, { + padding: size.padding, + resizeOptions: { + ...size.resizeOptions, + background: size.resizeOptions?.background ?? (size.dark ? 'black' : 'white'), + }, + outputOptions: size.png, + }) + instructions.appleSplashScreen[url] = { + name, + url, + width: size.size.width, + height: size.size.height, + mimeType: 'image/png', + link: createAppleSplashScreenHtmlLink('string', { + size: size.size, + landscape: size.landscape, + addMediaScreen: linkMediaOptions.addMediaScreen, + xhtml: linkMediaOptions.xhtml, + name: resolveName, + basePath: linkMediaOptions.basePath, + dark: size.dark, + includeId: imageAssets.htmlLinks.includeId, + }), + linkObject: createAppleSplashScreenHtmlLink('link', { + size: size.size, + landscape: size.landscape, + addMediaScreen: linkMediaOptions.addMediaScreen, + xhtml: linkMediaOptions.xhtml, + name: resolveName, + basePath: linkMediaOptions.basePath, + dark: size.dark, + includeId: imageAssets.htmlLinks.includeId, + }), + buffer: () => promise().then(m => m.toBuffer()), + } + } +} + +interface SplashScreenData { + size: AppleDeviceSize + landscape: boolean + dark?: boolean + resizeOptions?: ResizeOptions + padding: number + png: PngOptions +} + +function resolveAppleSplashScreens( + useAppleSplashScreens?: AppleSplashScreens, +) { + let appleSplashScreens: ResolvedAppleSplashScreens | undefined + if (useAppleSplashScreens) { + const { + padding = 0.3, + resizeOptions: useResizeOptions = {}, + darkResizeOptions: useDarkResizeOptions = {}, + linkMediaOptions: useLinkMediaOptions = {}, + sizes, + name = defaultSplashScreenName, + png: usePng = {}, + } = useAppleSplashScreens + + // Initialize defaults + const resizeOptions = createResizeOptions(false, useResizeOptions) + const darkResizeOptions = createResizeOptions(true, useDarkResizeOptions) + const png: PngOptions = createPngCompressionOptions(usePng) + + for (const size of sizes) { + if (typeof size.padding === 'undefined') + size.padding = padding + + if (typeof size.png === 'undefined') + size.png = png + + if (typeof size.resizeOptions === 'undefined') + size.resizeOptions = resizeOptions + + if (typeof size.darkResizeOptions === 'undefined') + size.darkResizeOptions = darkResizeOptions + } + const { + log = true, + addMediaScreen = true, + basePath = '/', + xhtml = false, + } = useLinkMediaOptions + appleSplashScreens = { + padding, + sizes, + linkMediaOptions: { + log, + addMediaScreen, + basePath, + xhtml, + }, + name, + png, + } + } + + return appleSplashScreens +} diff --git a/src/api/generate-assets.ts b/src/api/generate-assets.ts new file mode 100644 index 0000000..3a5c106 --- /dev/null +++ b/src/api/generate-assets.ts @@ -0,0 +1,43 @@ +import { resolve } from 'node:path' +import { existsSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import type { IconAsset, ImageAssetsInstructions } from './types.ts' + +export async function generateAssets( + instruction: ImageAssetsInstructions, + overrideAssets: boolean, + folder: string, + log?: (message: string, ignored: boolean) => void, +) { + const transparent = Array.from(Object.values(instruction.transparent)) + await Promise.all(transparent.map(icon => generateAsset('PNG', icon, folder, overrideAssets, log))) + const maskable = Array.from(Object.values(instruction.maskable)) + await Promise.all(maskable.map(icon => generateAsset('PNG', icon, folder, overrideAssets, log))) + const apple = Array.from(Object.values(instruction.apple)) + await Promise.all(apple.map(icon => generateAsset('PNG', icon, folder, overrideAssets, log))) + const favicon = Array.from(Object.values(instruction.favicon)) + await Promise.all(favicon.filter(icon => !icon.name.endsWith('.svg')).map(icon => generateAsset('ICO', icon, folder, overrideAssets, log))) + const appleSplashScreen = Array.from(Object.values(instruction.appleSplashScreen)) + await Promise.all(appleSplashScreen.map(icon => generateAsset('PNG', icon, folder, overrideAssets, log))) +} + +async function generateAsset( + type: 'ICO' | 'PNG', + icon: IconAsset, + folder: string, + overrideAssets: boolean, + log?: (message: string, ignored: boolean) => void, +) { + const filePath = resolve(folder, icon.name) + if (!overrideAssets && existsSync(filePath)) { + log?.(`Skipping, ${type} file already exists: ${filePath}`, true) + return + } + + await icon + .buffer() + .then(b => writeFile(resolve(folder, icon.name), b)) + .then(() => {}) + + log?.(`Generated ${type} file: ${filePath}`, false) +} diff --git a/src/api/generate-html-markup.ts b/src/api/generate-html-markup.ts new file mode 100644 index 0000000..9f2f1b5 --- /dev/null +++ b/src/api/generate-html-markup.ts @@ -0,0 +1,13 @@ +import type { ImageAssetsInstructions } from './types.ts' + +export function generateHtmlMarkup(instruction: ImageAssetsInstructions) { + const apple = Array.from(Object.values(instruction.apple)) + const favicon = Array.from(Object.values(instruction.favicon)) + const appleSplashScreen = Array.from(Object.values(instruction.appleSplashScreen)) + const links: string[] = [] + favicon.forEach(icon => icon.link?.length && links.push(icon.link)) + apple.forEach(icon => icon.link?.length && links.push(icon.link)) + appleSplashScreen.forEach(icon => icon.link?.length && links.push(icon.link)) + + return links +} diff --git a/src/api/generate-manifest-icons-entry.ts b/src/api/generate-manifest-icons-entry.ts new file mode 100644 index 0000000..7f13687 --- /dev/null +++ b/src/api/generate-manifest-icons-entry.ts @@ -0,0 +1,30 @@ +import type { ImageAssetsInstructions, ManifestIcons, ManifestIconsOptionsType, ManifestIconsType } from './types.ts' + +export function generateManifestIconsEntry( + format: Format, + instruction: ImageAssetsInstructions, +): ManifestIconsOptionsType { + const icons: ManifestIcons = { icons: [] } + + for (const icon of Object.values(instruction.transparent)) { + icons.icons.push({ + src: icon.name, + sizes: `${icon.width}x${icon.height}`, + type: icon.mimeType, + }) + } + for (const icon of Object.values(instruction.maskable)) { + icons.icons.push({ + src: icon.name, + sizes: `${icon.width}x${icon.height}`, + type: icon.mimeType, + purpose: 'maskable', + }) + } + + return ( + format === 'string' + ? JSON.stringify(icons, null, 2) + : icons + ) as ManifestIconsOptionsType +} diff --git a/src/api/html.ts b/src/api/html.ts index f6db764..c4a2fa8 100644 --- a/src/api/html.ts +++ b/src/api/html.ts @@ -88,11 +88,13 @@ export function createAppleTouchIconHtmlLink( icon: HtmlIconLinkOptions, ): HtmlLinkReturnType { const href = `${icon.basePath ?? '/'}${icon.name}` + const { width, height } = toResolvedSize(icon.size!) + const id = `ati-${width}-${height}` if (format === 'string') - return `` as HtmlLinkReturnType + return `` as HtmlLinkReturnType return { - id: 'apple-touch-icon', + id, rel: 'apple-touch-icon', href, } as HtmlLinkReturnType @@ -230,6 +232,7 @@ if (import.meta.vitest) { it('apple touch icon generation', () => { const appleTouchIconOptions = { name: 'apple-touch-icon.png', + size: 180, } satisfies HtmlIconLinkOptions const appleTouchIconString = createAppleTouchIconHtmlLink('string', appleTouchIconOptions) expectTypeOf(appleTouchIconString).toEqualTypeOf() @@ -238,12 +241,12 @@ if (import.meta.vitest) { const appleTouchIcon = createAppleTouchIconHtmlLink('link', appleTouchIconOptions) expectTypeOf(appleTouchIcon).toEqualTypeOf() expect(appleTouchIcon).toMatchInlineSnapshot(` - { - "href": "/apple-touch-icon.png", - "id": "apple-touch-icon", - "rel": "apple-touch-icon", - } - `) + { + "href": "/apple-touch-icon.png", + "id": "ati-180-180", + "rel": "apple-touch-icon", + } + `) }) it('favicon generation', () => { const svgFaviconOptions = { diff --git a/src/api/icons-resolver-helper.ts b/src/api/icons-resolver-helper.ts new file mode 100644 index 0000000..0745a2c --- /dev/null +++ b/src/api/icons-resolver-helper.ts @@ -0,0 +1,166 @@ +import type { ResolvedAssets } from '../types.ts' +import { toResolvedSize } from '../utils.ts' +import type { ImageAssets, ImageAssetsInstructions, ImageSourceInput } from './types.ts' +import type { HtmlLinkPreset } from './html.ts' +import { createAppleTouchIconHtmlLink, createFaviconHtmlLink } from './html.ts' +import { generateTransparentAsset } from './transparent.ts' +import { generateFavicon } from './favicon.ts' +import { generateMaskableAsset } from './maskable.ts' + +export function resolveTransparentIcons( + imageAssets: ImageAssets, + image: ImageSourceInput, + assets: ResolvedAssets, + htmlPreset: HtmlLinkPreset, + instructions: ImageAssetsInstructions, +) { + const asset = assets.assets.transparent + const { sizes, padding, resizeOptions } = asset + const { basePath, htmlLinks: { xhtml, includeId } } = imageAssets + for (const size of sizes) { + const name = assets.assetName('transparent', size) + const url = `${basePath}${name}` + const promise = () => generateTransparentAsset('png', image, size, { + padding, + resizeOptions, + outputOptions: assets.png, + }) + instructions.transparent[url] = { + name, + url, + width: size.width, + height: size.height, + mimeType: 'image/png', + buffer: () => promise().then(m => m.toBuffer()), + } + } + + const favicons = asset.favicons + if (!favicons) + return + + for (const [size, name] of favicons) { + const url = `${basePath}${name}` + const promise = () => generateTransparentAsset('png', image, size, { + padding, + resizeOptions, + outputOptions: assets.png, + }).then(m => m.toBuffer()) + .then(b => generateFavicon('png', b)) + const resolvedSize = toResolvedSize(size) + instructions.favicon[url] = { + name, + url, + width: resolvedSize.width, + height: resolvedSize.height, + mimeType: 'image/x-icon', + link: createFaviconHtmlLink('string', htmlPreset, { + name, + size, + basePath, + xhtml, + includeId, + }), + linkObject: createFaviconHtmlLink('link', htmlPreset, { + name, + size, + basePath, + xhtml, + includeId, + }), + buffer: () => promise(), + } + } +} + +export function resolveMaskableIcons( + type: 'apple' | 'maskable', + imageAssets: ImageAssets, + image: ImageSourceInput, + assets: ResolvedAssets, + htmlPreset: HtmlLinkPreset, + instructions: ImageAssetsInstructions, +) { + const asset = assets.assets[type] + const { sizes, padding, resizeOptions } = asset + const { basePath, htmlLinks: { xhtml, includeId } } = imageAssets + for (const size of sizes) { + const name = assets.assetName(type, size) + const url = `${basePath}${name}` + const promise = () => generateMaskableAsset('png', image, size, { + padding, + resizeOptions, + outputOptions: assets.png, + }) + const buffer = () => promise().then(m => m.toBuffer()) + if (type === 'apple') { + instructions.apple[url] = { + name, + url, + width: size.width, + height: size.height, + mimeType: 'image/png', + link: createAppleTouchIconHtmlLink('string', { + name, + size, + basePath, + xhtml, + includeId, + }), + linkObject: createAppleTouchIconHtmlLink('link', { + name, + size, + basePath, + xhtml, + includeId, + }), + buffer, + } + } + else { + instructions.maskable[url] = { + name, + url, + width: size.width, + height: size.height, + mimeType: 'image/png', + buffer, + } + } + } + + const favicons = asset.favicons + if (!favicons) + return + + for (const [size, name] of favicons) { + const url = `${basePath}${name}` + const resolvedSize = toResolvedSize(size) + instructions.favicon[url] = { + name, + url, + width: resolvedSize.width, + height: resolvedSize.height, + mimeType: 'image/x-icon', + link: createFaviconHtmlLink('string', htmlPreset, { + name, + size, + basePath, + xhtml, + includeId, + }), + linkObject: createFaviconHtmlLink('link', htmlPreset, { + name, + size, + basePath, + xhtml, + includeId, + }), + buffer: () => generateMaskableAsset('png', image, size, { + padding, + resizeOptions, + outputOptions: assets.png, + }).then(m => m.toBuffer()).then(b => generateFavicon('png', b)), + } + } +} diff --git a/src/api/instructions-resolver.ts b/src/api/instructions-resolver.ts new file mode 100644 index 0000000..07c4705 --- /dev/null +++ b/src/api/instructions-resolver.ts @@ -0,0 +1,104 @@ +import type { + BuiltInPreset, + Preset, + ResolvedAssets, +} from '../config.ts' +import { + defaultAssetName, + defaultPngCompressionOptions, +} from '../config.ts' +import { toResolvedAsset } from '../utils.ts' +import type { HtmlLinkPreset } from './html.ts' +import { + createFaviconHtmlLink, +} from './html.ts' +import type { ImageAssets, ImageAssetsInstructions } from './types.ts' +import { resolveAppleSplashScreensInstructions } from './apple-icons-helper.ts' +import { resolveMaskableIcons, resolveTransparentIcons } from './icons-resolver-helper.ts' + +export async function resolveInstructions(imageAssets: ImageAssets) { + const { + imageResolver, + imageName, + originalName, + preset = 'minimal', + faviconPreset, + } = imageAssets + + const [usePreset, htmlPreset] = await resolvePreset(preset, faviconPreset) + + const { + assetName = defaultAssetName, + png = defaultPngCompressionOptions, + appleSplashScreens: useAppleSplashScreens, + } = usePreset + + const assets: ResolvedAssets = { + assets: { + transparent: toResolvedAsset('transparent', usePreset.transparent), + maskable: toResolvedAsset('maskable', usePreset.maskable), + apple: toResolvedAsset('apple', usePreset.apple), + }, + png, + assetName, + } + + const instructions = { + image: imageName, + originalName, + favicon: {}, + transparent: {}, + maskable: {}, + apple: {}, + appleSplashScreen: {}, + } as ImageAssetsInstructions + + const image = await imageResolver() + + resolveTransparentIcons(imageAssets, image, assets, htmlPreset, instructions) + resolveMaskableIcons('maskable', imageAssets, image, assets, htmlPreset, instructions) + resolveMaskableIcons('apple', imageAssets, image, assets, htmlPreset, instructions) + + if (imageName.endsWith('.svg')) { + const name = imageAssets.resolveSvgName(imageName) + const url = `${imageAssets.basePath}${name}` + instructions.favicon[url] = { + name, + url, + width: 0, + height: 0, + mimeType: 'image/svg+xml', + link: createFaviconHtmlLink('string', htmlPreset, { + name, + basePath: imageAssets.basePath, + xhtml: imageAssets.htmlLinks.xhtml, + includeId: imageAssets.htmlLinks.includeId, + }), + linkObject: createFaviconHtmlLink('link', htmlPreset, { + name, + basePath: imageAssets.basePath, + xhtml: imageAssets.htmlLinks.xhtml, + includeId: imageAssets.htmlLinks.includeId, + }), + buffer: () => Promise.resolve(image), + } + } + + resolveAppleSplashScreensInstructions(imageAssets, instructions, useAppleSplashScreens) + + return instructions +} + +async function resolvePreset(preset: BuiltInPreset | Preset, faviconPreset?: HtmlLinkPreset): Promise<[Preset, HtmlLinkPreset]> { + if (typeof preset === 'object') + return [preset, faviconPreset ?? 'default'] + + switch (preset) { + case 'minimal': + return [await import('../presets/minimal.ts').then(m => m.minimalPreset), 'default'] + case 'minimal-2023': + return [await import('../presets/minimal-2023.ts').then(m => m.minimal2023Preset), '2023'] + default: + throw new Error(`Preset ${preset} not yet implemented`) + } +} diff --git a/src/api/instructions.ts b/src/api/instructions.ts new file mode 100644 index 0000000..bc20c7a --- /dev/null +++ b/src/api/instructions.ts @@ -0,0 +1,5 @@ +import type { ImageAssets } from './types.ts' + +export async function instructions(imageAssets: ImageAssets) { + return await import('./instructions-resolver.ts').then(({ resolveInstructions }) => resolveInstructions(imageAssets)) +} diff --git a/src/api/types.ts b/src/api/types.ts index 1ec99a3..20328da 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,5 +1,7 @@ import type { PngOptions, WebpOptions } from 'sharp' import type sharp from 'sharp' +import type { BuiltInPreset, Preset } from '../config.ts' +import type { AppleSplashScreenLink, FaviconLink, HtmlLink, HtmlLinkPreset } from './html.ts' export type ImageSourceInput = // eslint-disable-next-line n/prefer-global/buffer @@ -40,3 +42,163 @@ export type GenerateFaviconOptionsType = T extends 'png' ? PngOptions : T extends 'webp' ? WebpOptions : never + +/** + * PWA web manifest icon. + * + * @see https://developer.mozilla.org/en-US/docs/Web/Manifest/icons + * @see https://w3c.github.io/manifest/#icons-member + */ +export interface ManifestIcon { + src: string + type?: string + sizes?: string + purpose?: string +} + +/** + * PWA web manifest icons. + * + * @see https://developer.mozilla.org/en-US/docs/Web/Manifest/icons + * @see https://w3c.github.io/manifest/#icons-member + */ +export interface ManifestIcons { + icons: ManifestIcon[] +} + +export type ManifestIconsType = 'string' | 'object' +export type ManifestIconsOptionsType = + T extends 'string' ? string : + T extends 'object' ? ManifestIcons : + never + +export interface IconAsset { + /** + * The name of the icon asset. + */ + name: string + /** + * The icon asset url. + */ + url: string + /** + * The icon asset width. + * + * For the SVG image favicon icon, it will be set to 0. + */ + width: number + /** + * The icon asset height. + * + * For the SVG image favicon icon, it will be set to 0. + */ + height: number + /** + * The icon asset mime type. + */ + mimeType: 'image/png' | 'image/webp' | 'image/svg+xml' | 'image/x-icon' + /** + * The html head link. + */ + link?: string + /** + * The html head link. + */ + linkObject?: T + /** + * Creates the icon asset. + */ + // eslint-disable-next-line n/prefer-global/buffer + buffer: () => Promise +} + +/** + * PWA assets generation and injection options. + */ +export interface ImageAssets { + /** + * The image to use for generating the icon assets. + */ + // eslint-disable-next-line n/prefer-global/buffer + imageResolver: () => Buffer | Promise + /** + * The name of the image. + */ + imageName: string + /** + * The original name of the image. + */ + originalName?: string + /** + * The preset to use. + */ + preset: BuiltInPreset | Preset + /** + * The preset for the favicons. + * + * If using the built-in preset option (`minimal` or `minimal-2023`), this option will be ignored (will be set to `default` or `2023` for `minimal` and `minimal-2023` respectively). + * + * @default 'default' + */ + faviconPreset?: HtmlLinkPreset + /** + * Html link options. + */ + htmlLinks: { + xhtml: boolean + includeId: boolean + } + /** + * Base path to generate the html head links. + */ + basePath: string + /** + * By default, the SVG favicon will use the SVG file name as the name. + * + * For example, if you provide `public/logo.svg` as the image source, the href in the link will be `logo.svg`. + * + * @param name The name of the SVG icons. + */ + resolveSvgName: (name: string) => string +} + +export interface ImageAssetsInstructions { + /** + * The image path when providing an absolute path, otherwise the image name provided to `instructions`. + */ + image: string + /** + * The original name of the image. + */ + originalName?: string + /** + * The favicon icons instructions. + * + * The key is the favicon url of the icon asset. + */ + favicon: Record> + /** + * The transparents icons instructions. + * + * The key is the icon url of the icon asset. + */ + transparent: Record> + /** + * The maskable icons instructions. + * + * The key is the icon url of the icon asset. + */ + maskable: Record> + /** + * The apple icons instructions. + * + * The key is the icon url of the icon asset. + */ + apple: Record> + /** + * The apple splash screens icons instructions. + * + * The key is the favicon icons of the icon asset. + */ + appleSplashScreen: Record> +} diff --git a/src/build.ts b/src/build.ts deleted file mode 100644 index d5b63df..0000000 --- a/src/build.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { existsSync } from 'node:fs' -import { rm, writeFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import { consola } from 'consola' -import { green, yellow } from 'colorette' -import type { PngOptions, ResizeOptions } from 'sharp' -import type { - AppleDeviceSize, - AssetType, - Favicon, - ResolvedAppleSplashScreens, - ResolvedAssetSize, - ResolvedAssets, - ResolvedBuildOptions, -} from './types.ts' -import { - cloneResolvedAssetsSizes, - defaultAssetName, - sameAssetSize, - toResolvedAsset, - toResolvedSize, -} from './utils.ts' -import { - createAppleSplashScreenHtmlLink, - createAppleTouchIconHtmlLink, - createFaviconHtmlLink, - defaultPngCompressionOptions, - defaultPngOptions, - generateFavicon, - generateMaskableAsset, - generateTransparentAsset, -} from './api' - -export * from './types' -export { - defaultAssetName, - defaultPngCompressionOptions, - defaultPngOptions, - toResolvedAsset, -} - -export async function generatePWAImageAssets( - buildOptions: ResolvedBuildOptions, - image: string, - assets: ResolvedAssets, - appleSplashScreens?: ResolvedAppleSplashScreens, -) { - const imagePath = resolve(buildOptions.root, image) - const folder = dirname(imagePath) - - const pngFilesToDelete: string[] = [] - - const newAssets = collectMissingFavicons(assets, pngFilesToDelete, folder) - - const links: string[] = [] - await Promise.all([ - generateTransparentAssets(buildOptions, newAssets, imagePath, folder, links), - generateMaskableAssets('maskable', buildOptions, newAssets, imagePath, folder, undefined), - generateMaskableAssets('apple', buildOptions, newAssets, imagePath, folder, links), - ]) - - if (links.length) { - if (image.endsWith('.svg')) { - const { basePath, preset, resolveSvgName } = buildOptions.headLinkOptions - links.push(createFaviconHtmlLink('string', preset, { - name: resolveSvgName(image), - basePath, - })) - } - consola.start('Head Links:') - - links.sort((i1, i2) => { - if (i1.includes('apple-touch-icon')) - return i2.includes('apple-touch-icon') ? 0 : 1 - - return i2.includes('apple-touch-icon') ? -1 : 0 - }).forEach((link) => { - // eslint-disable-next-line no-console - console.log(link) - }) - } - - if (pngFilesToDelete.length) { - consola.start('Deleting unused PNG files') - await Promise.all(pngFilesToDelete.map((png) => { - consola.ready(green(`Deleting PNG file: ${png}`)) - return rm(png, { force: true }) - })) - consola.ready('Unused PNG files deleted') - } - - if (appleSplashScreens && appleSplashScreens.sizes.length) { - const appleLinks: string[] = [] - consola.start('Generating Apple Splash Screens...') - await generateAppleSplashScreens(buildOptions, appleSplashScreens, imagePath, folder, appleLinks) - if (buildOptions.logLevel !== 'silent' && appleLinks.length && appleSplashScreens.linkMediaOptions.log) { - consola.start('Apple Splash Screens Links:') - // eslint-disable-next-line no-console - appleLinks.forEach(link => console.log(link)) - } - consola.ready('Apple Splash Screens generated') - } -} - -export async function generatePWAAssets( - images: string[], - assets: ResolvedAssets, - buildOptions: ResolvedBuildOptions, - appleSplashScreens?: ResolvedAppleSplashScreens, -) { - for (const image of images) - await generatePWAImageAssets(buildOptions, image, assets, appleSplashScreens) -} - -function collectMissingFavicons( - resolvedAssets: ResolvedAssets, - pngFilesToDelete: string[], - folder: string, -) { - const missingFavicons = new Map() - const newAssets = cloneResolvedAssetsSizes(resolvedAssets) - // generate missing favicon icons - Object.entries(newAssets.assets).forEach(([type, asset]) => { - const favicons = asset.favicons - if (!favicons) - return - - const entriesToAdd: ResolvedAssetSize[] = [] - - favicons.forEach(([size, name]) => { - const generate = !asset.sizes.some(s => sameAssetSize(size, s)) - if (generate) { - const resolvedSize = toResolvedSize(size) - entriesToAdd.push(resolvedSize) - pngFilesToDelete.push(resolve(folder, newAssets.assetName(type as AssetType, resolvedSize))) - let entry = missingFavicons.get(type as AssetType) - if (!entry) { - entry = [] - missingFavicons.set(type as AssetType, entry) - } - entry.push([size, name]) - } - }) - - if (entriesToAdd.length) - asset.sizes.push(...entriesToAdd) - }) - - return newAssets -} - -async function generateFaviconFile( - buildOptions: ResolvedBuildOptions, - folder: string, - type: AssetType, - assets: ResolvedAssets, - assetSize: ResolvedAssetSize, - headLinks?: string[], -) { - const asset = assets.assets[type] - const favicons = asset?.favicons?.filter(([size]) => sameAssetSize(size, assetSize)) - if (!favicons) - return - - await Promise.all(favicons.map(async ([_size, name]) => { - const favicon = resolve(folder, name) - if (!buildOptions.overrideAssets && existsSync(favicon)) { - if (buildOptions.logLevel !== 'silent') - consola.log(yellow(`Skipping, ICO file already exists: ${favicon}`)) - - return - } - - const png = resolve(folder, assets.assetName(type, assetSize)) - if (!existsSync(png)) { - if (buildOptions.logLevel !== 'silent') - consola.log(yellow(`Skipping: ${favicon}, missing PNG source file: ${png}`)) - - return - } - - // const pngBuffer = await sharp(png).toFormat('png').toBuffer() - // await writeFile(favicon, encode([pngBuffer])) - await writeFile(favicon, await generateFavicon('png', png)) - if (buildOptions.logLevel !== 'silent') { - if (headLinks) { - const { basePath, preset } = buildOptions.headLinkOptions - headLinks.push(createFaviconHtmlLink('string', preset, { - name, - size: assetSize.original, - basePath, - })) - } - consola.ready(green(`Generated ICO file: ${favicon}`)) - } - })) -} - -async function generateTransparentAssets( - buildOptions: ResolvedBuildOptions, - assets: ResolvedAssets, - image: string, - folder: string, - headLinks: string[], -) { - const asset = assets.assets.transparent - const { sizes, padding, resizeOptions } = asset - await Promise.all(sizes.map(async (size) => { - const filePath = resolve(folder, assets.assetName('transparent', size)) - if (!buildOptions.overrideAssets && existsSync(filePath)) { - if (buildOptions.logLevel !== 'silent') - consola.log(yellow(`Skipping, PNG file already exists: ${filePath}`)) - - return - } - - const result = await generateTransparentAsset('png', image, size, { - padding, - resizeOptions, - outputOptions: assets.png, - }) - - await result.toFile(filePath) - if (buildOptions.logLevel !== 'silent') - consola.ready(green(`Generated PNG file: ${filePath.replace(/-temp\.png$/, '.png')}`)) - - await generateFaviconFile( - buildOptions, - folder, - 'transparent', - assets, - size, - headLinks, - ) - })) -} - -async function generateMaskableAssets( - type: AssetType, - buildOptions: ResolvedBuildOptions, - assets: ResolvedAssets, - image: string, - folder: string, - headLinks?: string[], -) { - const asset = assets.assets[type] - const addAppleTouchIcon = type === 'apple' - const { sizes, padding, resizeOptions } = asset - const { basePath } = buildOptions.headLinkOptions - await Promise.all(sizes.map(async (size) => { - const name = assets.assetName(type, size) - const filePath = resolve(folder, name) - if (!buildOptions.overrideAssets && existsSync(filePath)) { - if (buildOptions.logLevel !== 'silent') - consola.log(yellow(`Skipping, PNG file already exists: ${filePath}`)) - - return - } - - const result = await generateMaskableAsset('png', image, size, { - padding, - resizeOptions, - outputOptions: assets.png, - }) - - await result.toFile(filePath) - if (buildOptions.logLevel !== 'silent') { - if (addAppleTouchIcon && headLinks) { - headLinks.push(createAppleTouchIconHtmlLink('string', { - name, - basePath, - })) - } - consola.ready(green(`Generated PNG file: ${filePath.replace(/-temp\.png$/, '.png')}`)) - } - - await generateFaviconFile( - buildOptions, - folder, - type, - assets, - size, - undefined, - ) - })) -} - -interface SplashScreenData { - size: AppleDeviceSize - landscape: boolean - dark?: boolean - resizeOptions?: ResizeOptions - padding: number - png: PngOptions -} - -async function generateAppleSplashScreens( - buildOptions: ResolvedBuildOptions, - { linkMediaOptions, name, sizes, png }: ResolvedAppleSplashScreens, - image: string, - folder: string, - links: string[], -) { - const sizesMap = new Map() - const splashScreens: SplashScreenData[] = sizes.reduce((acc, size) => { - // cleanup duplicates screen dimensions: - // should we add the links (maybe scaleFactor is different)? - if (sizesMap.get(size.width) === size.height) - return acc - - sizesMap.set(size.width, size.height) - const { width: height, height: width, ...restSize } = size - const { - width: lheight, - height: lwidth, - ...restResizeOptions - } = size.resizeOptions || {} - const landscapeSize: AppleDeviceSize = { - ...restSize, - width, - height, - resizeOptions: { - ...restResizeOptions, - width: lwidth, - height: lheight, - }, - } - acc.push({ - size, - landscape: false, - dark: size.darkResizeOptions ? false : undefined, - resizeOptions: size.resizeOptions, - padding: size.padding ?? 0.3, - png: size.png ?? png, - }) - acc.push({ - size: landscapeSize, - landscape: true, - dark: size.darkResizeOptions ? false : undefined, - resizeOptions: landscapeSize.resizeOptions, - padding: size.padding ?? 0.3, - png: size.png ?? png, - }) - if (size.darkResizeOptions) { - const { - width: dlheight, - height: dlwidth, - ...restDarkResizeOptions - } = size.darkResizeOptions - const landscapeDarkResizeOptions: ResizeOptions = { ...restDarkResizeOptions, width: dlwidth, height: dlheight } - const landscapeDarkSize: AppleDeviceSize = { - ...restSize, - width, - height, - resizeOptions: landscapeDarkResizeOptions, - darkResizeOptions: undefined, - } - acc.push({ - size, - landscape: false, - dark: true, - resizeOptions: size.darkResizeOptions, - padding: size.padding ?? 0.3, - png: size.png ?? png, - }) - acc.push({ - size: landscapeDarkSize, - landscape: true, - dark: true, - resizeOptions: landscapeDarkResizeOptions, - padding: size.padding ?? 0.3, - png: size.png ?? png, - }) - } - return acc - }, [] as SplashScreenData[]) - - sizesMap.clear() - - await Promise.all(splashScreens.map(async (size) => { - const filePath = resolve(folder, name(size.landscape, size.size, size.dark)) - if (!buildOptions.overrideAssets && existsSync(filePath)) { - if (buildOptions.logLevel !== 'silent') - consola.log(yellow(`Skipping, PNG file already exists: ${filePath}`)) - - return - } - - await (await generateMaskableAsset('png', image, size.size, { - padding: size.padding, - resizeOptions: { - ...size.resizeOptions, - background: size.resizeOptions?.background ?? (size.dark ? 'black' : 'white'), - }, - outputOptions: size.png, - })).toFile(filePath) - if (buildOptions.logLevel !== 'silent') { - consola.ready(green(`Generated PNG file: ${filePath.replace(/-temp\.png$/, '.png')}`)) - if (linkMediaOptions.log) { - links.push(createAppleSplashScreenHtmlLink('string', { - size: size.size, - landscape: size.landscape, - addMediaScreen: linkMediaOptions.addMediaScreen, - xhtml: linkMediaOptions.xhtml, - name, - basePath: linkMediaOptions.basePath, - dark: size.dark, - })) - } - } - })) -} diff --git a/src/cli-start.ts b/src/cli-start.ts index ce431ef..40ecd7d 100644 --- a/src/cli-start.ts +++ b/src/cli-start.ts @@ -1,20 +1,22 @@ import process from 'node:process' -import { basename } from 'node:path' +import { basename, dirname, resolve } from 'node:path' +import { readFile } from 'node:fs/promises' import cac from 'cac' import { consola } from 'consola' -import { green } from 'colorette' -import type { PngOptions } from 'sharp' +import { green, yellow } from 'colorette' import { version } from '../package.json' -import { defaultSplashScreenName, loadConfig } from './config.ts' -import type { BuiltInPreset, HeadLinkOptions, Preset, ResolvedAppleSplashScreens, ResolvedAssets, UserConfig } from './config.ts' -import { defaultAssetName, toResolvedAsset } from './utils.ts' -import { generatePWAAssets } from './build.ts' -import { createPngCompressionOptions, createResizeOptions, defaultPngCompressionOptions } from './api/defaults.ts' -import type { HtmlLinkPreset } from './api' +import { loadConfig } from './config.ts' +import type { BuiltInPreset, HeadLinkOptions, UserConfig } from './config.ts' +import { resolveInstructions } from './api/instructions-resolver.ts' +import { generateHtmlMarkup } from './api/generate-html-markup.ts' +import { generateAssets } from './api/generate-assets.ts' +import { generateManifestIconsEntry } from './api/generate-manifest-icons-entry.ts' interface CliOptions extends Omit { preset?: BuiltInPreset headLinkOptions?: HeadLinkOptions + override?: boolean + manifest?: boolean } export async function startCli(args: string[] = process.argv) { @@ -26,6 +28,7 @@ export async function startCli(args: string[] = process.argv) { .option('-c, --config ', 'Path to config file') .option('-p, --preset ', 'Built-in preset name: minimal, android, windows, ios or all') .option('-o, --override', 'Override assets? Defaults to true') + .option('-m, --manifest', 'Generate generate PWA web manifest icons entry? Defaults to true') .help() .command( '[...images]', @@ -55,118 +58,76 @@ async function run(images: string[] = [], cliOptions: CliOptions = {}) { const { logLevel = 'info', overrideAssets = true, - preset = 'minimal', + preset, images: configImages, headLinkOptions: userHeadLinkOptions, + manifestIconsEntry = true, } = config - const useImages = Array.isArray(configImages) ? configImages : [configImages] - - let usePreset: Preset - let htmlPreset: HtmlLinkPreset | undefined - if (typeof preset === 'object') { - usePreset = preset - } - else { - switch (preset) { - case 'minimal': - usePreset = await import('./presets/minimal.ts').then(m => m.minimalPreset) - htmlPreset = 'default' - break - case 'minimal-2023': - usePreset = await import('./presets/minimal-2023.ts').then(m => m.minimal2023Preset) - htmlPreset = '2023' - break - default: - throw new Error(`Preset ${preset} not yet implemented`) - } - } - - const { - assetName = defaultAssetName, - png = defaultPngCompressionOptions, - appleSplashScreens: useAppleSplashScreens, - } = usePreset - - let appleSplashScreens: ResolvedAppleSplashScreens | undefined - if (useAppleSplashScreens) { - const { - padding = 0.3, - resizeOptions: useResizeOptions = {}, - darkResizeOptions: useDarkResizeOptions = {}, - linkMediaOptions: useLinkMediaOptions = {}, - sizes, - name = defaultSplashScreenName, - png: usePng = {}, - } = useAppleSplashScreens - - // Initialize defaults - const resizeOptions = createResizeOptions(false, useResizeOptions) - const darkResizeOptions = createResizeOptions(true, useDarkResizeOptions) - const png: PngOptions = createPngCompressionOptions(usePng) - - sizes.forEach((size) => { - if (typeof size.padding === 'undefined') - size.padding = padding - - if (typeof size.png === 'undefined') - size.png = png - - if (typeof size.resizeOptions === 'undefined') - size.resizeOptions = resizeOptions - - if (typeof size.darkResizeOptions === 'undefined') - size.darkResizeOptions = darkResizeOptions - }) - const { - log = true, - addMediaScreen = true, - basePath = '/', - xhtml = false, - } = useLinkMediaOptions - appleSplashScreens = { - padding, - sizes, - linkMediaOptions: { - log, - addMediaScreen, - basePath, - xhtml, - }, - name, - png, - } - } + const useOverrideAssets = cliOptions.override === false + ? false + : overrideAssets + const useManifestIconsEntry = cliOptions.manifest === false + ? false + : manifestIconsEntry - const assets: ResolvedAssets = { - assets: { - transparent: toResolvedAsset('transparent', usePreset.transparent), - maskable: toResolvedAsset('maskable', usePreset.maskable), - apple: toResolvedAsset('apple', usePreset.apple), - }, - png, - assetName, - } + const useImages = Array.isArray(configImages) ? configImages : [configImages] - const headLinkOptions: Required = { - preset: htmlPreset ?? userHeadLinkOptions?.preset ?? 'default', - resolveSvgName: userHeadLinkOptions?.resolveSvgName ?? (name => basename(name)), + const xhtml = userHeadLinkOptions?.xhtml === true + const includeId = userHeadLinkOptions?.includeId === true + + consola.start('Resolving instructions...') + // 1. resolve instructions + const instructions = await Promise.all(useImages.map(i => resolveInstructions({ + imageResolver: () => readFile(resolve(root, i)), + imageName: resolve(root, i), + originalName: i, + preset, + faviconPreset: userHeadLinkOptions?.preset, + htmlLinks: { xhtml, includeId }, basePath: userHeadLinkOptions?.basePath ?? '/', - } + resolveSvgName: userHeadLinkOptions?.resolveSvgName ?? (name => basename(name)), + }))) - consola.ready('PWA assets ready to be generated') + consola.ready('PWA assets ready to be generated, instructions resolved') consola.start(`Generating PWA assets from ${useImages.join(', ')} image${useImages.length > 1 ? 's' : ''}`) - await generatePWAAssets( - useImages, - assets, - { - root, - logLevel, - overrideAssets, - headLinkOptions, - }, - appleSplashScreens, - ) + const log = logLevel !== 'silent' + for (const instruction of instructions) { + // 2. generate assets + consola.start(`Generating assets for ${instruction.originalName}...`) + await generateAssets( + instruction, + useOverrideAssets, + dirname(instruction.image), + log + ? (message, ignored) => { + if (ignored) + consola.log(yellow(message)) + else + consola.ready(green(message)) + } + : undefined, + ) + consola.ready(`Assets generated for ${instruction.originalName}`) + if (logLevel !== 'silent') { + // 3. html markup + const links = generateHtmlMarkup(instruction) + if (links.length) { + consola.start('Generating Html Head Links...') + // eslint-disable-next-line no-console + links.forEach(link => console.log(link)) + consola.ready('Html Head Links generated') + } + // 4. web manifest icons entry + if (useManifestIconsEntry) { + consola.start('Generating PWA web manifest icons entry...') + // eslint-disable-next-line no-console + console.log(generateManifestIconsEntry('string', instruction)) + consola.ready('PWA web manifest icons entry generated') + } + } + } + consola.ready('PWA assets generated') } diff --git a/src/config.ts b/src/config.ts index 98b8ce9..1230395 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,18 @@ export interface HeadLinkOptions { * @param name The name of the SVG icons. */ resolveSvgName?: (name: string) => string + /** + * Generate an id when generating the html head links. + * + * @default false + */ + xhtml?: boolean + /** + * Include the id when generating the html head links. + * + * @default false + */ + includeId?: boolean } export interface UserConfig { @@ -96,6 +108,12 @@ export interface UserConfig { * Options for generating the html head links for `apple-touch-icon` and favicons. */ headLinkOptions?: HeadLinkOptions + /** + * Show the PWA web manifest icons' entry. + * + * @default true + */ + manifestIconsEntry?: boolean } export interface ResolvedConfig extends Required> { diff --git a/src/index.ts b/src/index.ts index b92ef91..fcb1c8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,3 @@ -export * from './build.ts' +export * from './types' +export * from './utils.ts' +export * from './api/defaults.ts' diff --git a/src/splash.ts b/src/splash.ts index 2ec8839..9a8254a 100644 --- a/src/splash.ts +++ b/src/splash.ts @@ -113,7 +113,7 @@ export function createAppleSplashScreens( name, } = options - return { + return { sizes: devices.map(deviceName => appleSplashScreenSizes[deviceName]), padding, resizeOptions, @@ -121,7 +121,7 @@ export function createAppleSplashScreens( linkMediaOptions, png, name, - } + } satisfies AppleSplashScreens } export function combinePresetAndAppleSplashScreens( @@ -136,8 +136,8 @@ export function combinePresetAndAppleSplashScreens( } = {}, devices: AppleDeviceName[] = AllAppleDeviceNames, ) { - return { + return { ...preset, appleSplashScreens: createAppleSplashScreens(options, devices), - } + } satisfies Preset } diff --git a/test/__snapshots__/instructions.test.ts.snap b/test/__snapshots__/instructions.test.ts.snap new file mode 100644 index 0000000..d5f5bff --- /dev/null +++ b/test/__snapshots__/instructions.test.ts.snap @@ -0,0 +1,248 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`instructions > resolve instructions 1`] = ` +{ + "apple": { + "/apple-touch-icon-180x180.png": { + "buffer": [Function], + "height": 180, + "link": "", + "linkObject": { + "href": "/apple-touch-icon-180x180.png", + "id": "ati-180-180", + "rel": "apple-touch-icon", + }, + "mimeType": "image/png", + "name": "apple-touch-icon-180x180.png", + "url": "/apple-touch-icon-180x180.png", + "width": 180, + }, + }, + "appleSplashScreen": {}, + "favicon": { + "/favicon.ico": { + "buffer": [Function], + "height": 48, + "link": "", + "linkObject": { + "href": "/favicon.ico", + "id": "fav-48x48", + "rel": "icon", + "sizes": "48x48", + }, + "mimeType": "image/x-icon", + "name": "favicon.ico", + "url": "/favicon.ico", + "width": 48, + }, + "/favicon.svg": { + "buffer": [Function], + "height": 0, + "link": "", + "linkObject": { + "href": "/favicon.svg", + "id": "fav-svg", + "rel": "icon", + "sizes": "any", + "type": "image/svg+xml", + }, + "mimeType": "image/svg+xml", + "name": "favicon.svg", + "url": "/favicon.svg", + "width": 0, + }, + }, + "image": "playground/pwa/public/favicon.svg", + "maskable": { + "/maskable-icon-512x512.png": { + "buffer": [Function], + "height": 512, + "mimeType": "image/png", + "name": "maskable-icon-512x512.png", + "url": "/maskable-icon-512x512.png", + "width": 512, + }, + }, + "originalName": undefined, + "transparent": { + "/pwa-192x192.png": { + "buffer": [Function], + "height": 192, + "mimeType": "image/png", + "name": "pwa-192x192.png", + "url": "/pwa-192x192.png", + "width": 192, + }, + "/pwa-512x512.png": { + "buffer": [Function], + "height": 512, + "mimeType": "image/png", + "name": "pwa-512x512.png", + "url": "/pwa-512x512.png", + "width": 512, + }, + "/pwa-64x64.png": { + "buffer": [Function], + "height": 64, + "mimeType": "image/png", + "name": "pwa-64x64.png", + "url": "/pwa-64x64.png", + "width": 64, + }, + }, +} +`; + +exports[`instructions > resolve instructions with apple splash screen icons 1`] = ` +{ + "apple": { + "/apple-touch-icon-180x180.png": { + "buffer": [Function], + "height": 180, + "link": "", + "linkObject": { + "href": "/apple-touch-icon-180x180.png", + "id": "ati-180-180", + "rel": "apple-touch-icon", + }, + "mimeType": "image/png", + "name": "apple-touch-icon-180x180.png", + "url": "/apple-touch-icon-180x180.png", + "width": 180, + }, + }, + "appleSplashScreen": { + "/apple-splash-landscape-dark-2048x1536.png": { + "buffer": [Function], + "height": 1536, + "link": "", + "linkObject": { + "href": "/apple-splash-landscape-dark-2048x1536.png", + "id": "atsi-1536-2048-2-dark", + "media": "screen and (prefers-color-scheme: dark) and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + "rel": "apple-touch-startup-image", + }, + "mimeType": "image/png", + "name": "apple-splash-landscape-dark-2048x1536.png", + "url": "/apple-splash-landscape-dark-2048x1536.png", + "width": 2048, + }, + "/apple-splash-landscape-light-2048x1536.png": { + "buffer": [Function], + "height": 1536, + "link": "", + "linkObject": { + "href": "/apple-splash-landscape-light-2048x1536.png", + "id": "atsi-1536-2048-2-light", + "media": "screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + "rel": "apple-touch-startup-image", + }, + "mimeType": "image/png", + "name": "apple-splash-landscape-light-2048x1536.png", + "url": "/apple-splash-landscape-light-2048x1536.png", + "width": 2048, + }, + "/apple-splash-portrait-dark-1536x2048.png": { + "buffer": [Function], + "height": 2048, + "link": "", + "linkObject": { + "href": "/apple-splash-portrait-dark-1536x2048.png", + "id": "atsi-1536-2048-2-dark", + "media": "screen and (prefers-color-scheme: dark) and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + "rel": "apple-touch-startup-image", + }, + "mimeType": "image/png", + "name": "apple-splash-portrait-dark-1536x2048.png", + "url": "/apple-splash-portrait-dark-1536x2048.png", + "width": 1536, + }, + "/apple-splash-portrait-light-1536x2048.png": { + "buffer": [Function], + "height": 2048, + "link": "", + "linkObject": { + "href": "/apple-splash-portrait-light-1536x2048.png", + "id": "atsi-1536-2048-2-light", + "media": "screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + "rel": "apple-touch-startup-image", + }, + "mimeType": "image/png", + "name": "apple-splash-portrait-light-1536x2048.png", + "url": "/apple-splash-portrait-light-1536x2048.png", + "width": 1536, + }, + }, + "favicon": { + "/favicon.ico": { + "buffer": [Function], + "height": 48, + "link": "", + "linkObject": { + "href": "/favicon.ico", + "id": "fav-48x48", + "rel": "icon", + "sizes": "48x48", + }, + "mimeType": "image/x-icon", + "name": "favicon.ico", + "url": "/favicon.ico", + "width": 48, + }, + "/favicon.svg": { + "buffer": [Function], + "height": 0, + "link": "", + "linkObject": { + "href": "/favicon.svg", + "id": "fav-svg", + "rel": "icon", + "sizes": "any", + "type": "image/svg+xml", + }, + "mimeType": "image/svg+xml", + "name": "favicon.svg", + "url": "/favicon.svg", + "width": 0, + }, + }, + "image": "playground/pwa/public/favicon.svg", + "maskable": { + "/maskable-icon-512x512.png": { + "buffer": [Function], + "height": 512, + "mimeType": "image/png", + "name": "maskable-icon-512x512.png", + "url": "/maskable-icon-512x512.png", + "width": 512, + }, + }, + "originalName": undefined, + "transparent": { + "/pwa-192x192.png": { + "buffer": [Function], + "height": 192, + "mimeType": "image/png", + "name": "pwa-192x192.png", + "url": "/pwa-192x192.png", + "width": 192, + }, + "/pwa-512x512.png": { + "buffer": [Function], + "height": 512, + "mimeType": "image/png", + "name": "pwa-512x512.png", + "url": "/pwa-512x512.png", + "width": 512, + }, + "/pwa-64x64.png": { + "buffer": [Function], + "height": 64, + "mimeType": "image/png", + "name": "pwa-64x64.png", + "url": "/pwa-64x64.png", + "width": 64, + }, + }, +} +`; diff --git a/test/__snapshots__/manifest-icons.test.ts.snap b/test/__snapshots__/manifest-icons.test.ts.snap new file mode 100644 index 0000000..1dd3241 --- /dev/null +++ b/test/__snapshots__/manifest-icons.test.ts.snap @@ -0,0 +1,29 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`should generate manifest icons entry 1`] = ` +{ + "icons": [ + { + "sizes": "64x64", + "src": "pwa-64x64.png", + "type": "image/png", + }, + { + "sizes": "192x192", + "src": "pwa-192x192.png", + "type": "image/png", + }, + { + "sizes": "512x512", + "src": "pwa-512x512.png", + "type": "image/png", + }, + { + "purpose": "maskable", + "sizes": "512x512", + "src": "maskable-icon-512x512.png", + "type": "image/png", + }, + ], +} +`; diff --git a/test/instructions.test.ts b/test/instructions.test.ts new file mode 100644 index 0000000..6179208 --- /dev/null +++ b/test/instructions.test.ts @@ -0,0 +1,51 @@ +import { readFile } from 'node:fs/promises' +import { basename } from 'node:path' +import { describe, expect, it } from 'vitest' +import { minimal2023Preset } from '../src/presets' +import { createAppleSplashScreens } from '../src/splash' +import { resolveInstructions } from '../src/api/instructions-resolver' + +describe('instructions', () => { + it ('resolve instructions', async () => { + const instructions = await resolveInstructions({ + imageResolver: () => readFile('playground/pwa/public/favicon.svg'), + imageName: 'playground/pwa/public/favicon.svg', + preset: 'minimal-2023', + htmlLinks: { + xhtml: false, + includeId: false, + }, + basePath: '/', + resolveSvgName: name => basename(name), + }) + expect(instructions).toMatchSnapshot() + }) + it ('resolve instructions with apple splash screen icons', async () => { + const instructions = await resolveInstructions({ + imageResolver: () => readFile('playground/pwa/public/favicon.svg'), + imageName: 'playground/pwa/public/favicon.svg', + faviconPreset: '2023', + preset: { + ...minimal2023Preset, + appleSplashScreens: createAppleSplashScreens({ + padding: 0.3, + resizeOptions: { fit: 'contain', background: 'white' }, + darkResizeOptions: { fit: 'contain', background: 'black' }, + linkMediaOptions: { + log: true, + addMediaScreen: true, + basePath: '/', + xhtml: true, + }, + }, ['iPad Air 9.7"']), + }, + htmlLinks: { + xhtml: false, + includeId: false, + }, + basePath: '/', + resolveSvgName: name => basename(name), + }) + expect(instructions).toMatchSnapshot() + }) +}) diff --git a/test/manifest-icons.test.ts b/test/manifest-icons.test.ts new file mode 100644 index 0000000..6e7d7ae --- /dev/null +++ b/test/manifest-icons.test.ts @@ -0,0 +1,25 @@ +import { readFile } from 'node:fs/promises' +import { basename } from 'node:path' +import { expect, expectTypeOf, it } from 'vitest' +import { resolveInstructions } from '../src/api/instructions-resolver' +import { generateManifestIconsEntry } from '../src/api/generate-manifest-icons-entry' +import type { ManifestIcons } from '../src/api' + +it('should generate manifest icons entry', async () => { + const instructions = await resolveInstructions({ + imageResolver: () => readFile('playground/pwa/public/favicon.svg'), + imageName: 'playground/pwa/public/favicon.svg', + preset: 'minimal-2023', + htmlLinks: { + xhtml: false, + includeId: false, + }, + basePath: '/', + resolveSvgName: name => basename(name), + }) + const iconsString = generateManifestIconsEntry('string', instructions) + expectTypeOf(iconsString).toEqualTypeOf() + const icons = generateManifestIconsEntry('object', instructions) + expectTypeOf(icons).toEqualTypeOf() + expect(icons).toMatchSnapshot() +})