expo-font
Version:
Load fonts at runtime and use them in React Native components.
175 lines (147 loc) • 5.73 kB
text/typescript
import { addImports } from '@expo/config-plugins/build/android/codeMod';
import { mergeContents } from '@expo/config-plugins/build/utils/generateCode';
import { type ExpoConfig } from '@expo/config-types';
import { type ConfigPlugin, withDangerousMod, withMainApplication, XML } from 'expo/config-plugins';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { resolveFontPaths, toValidAndroidResourceName } from './utils';
import type { Font, FontObject } from './withFonts';
const assetsFontsFir = 'app/src/main/assets/fonts';
const resourcesFontsDir = 'app/src/main/res/font';
export const withFontsAndroid: ConfigPlugin<Font[]> = (config, fonts) => {
const assetFontPaths = fonts.filter((it) => typeof it === 'string');
config = copyFontsToDir(config, assetFontPaths, assetsFontsFir);
const xmlFonts = fonts.filter((it) => typeof it === 'object');
config = addXmlFonts(config, xmlFonts);
return config;
};
type GroupedFontObject = Record<string, FontObject['fontDefinitions']>;
export function groupByFamily(array: FontObject[]): GroupedFontObject {
return array.reduce<GroupedFontObject>((result, item) => {
const keyValue = item['fontFamily'];
result[keyValue] ||= [];
result[keyValue].push(...item.fontDefinitions);
return result;
}, {});
}
function addXmlFonts(config: ExpoConfig, xmlFontObjects: FontObject[]) {
const fontsByFamily = groupByFamily(xmlFontObjects);
const fontPaths = Object.values(fontsByFamily)
.map((font) => font.map((it) => it.path))
.flat();
config = copyFontsToDir(config, fontPaths, resourcesFontsDir, (filenameWithExt) => {
const filename = toValidAndroidResourceName(filenameWithExt);
const ext = path.extname(filenameWithExt);
return `${filename}${ext}`;
});
config = addFontXmlToMainApplication(config, fontsByFamily);
return withDangerousMod(config, [
'android',
async (config) => {
const fontsDir = path.join(config.modRequest.platformProjectRoot, 'app/src/main/res/font');
const xmlSpecs = getXmlSpecs(fontsDir, fontsByFamily);
await Promise.all(xmlSpecs.map(XML.writeXMLAsync));
return config;
},
]);
}
const resourceNameConflictAvoidancePrefix = 'xml_';
export function getXmlSpecs(fontsDir: string, xmlFontObjects: GroupedFontObject) {
return Object.entries(xmlFontObjects).map(([fontFamily, fontDefinitions]) => {
const filePath = path.join(
fontsDir,
`${resourceNameConflictAvoidancePrefix + toValidAndroidResourceName(fontFamily)}.xml`
);
// each font family has one xml resource file with potentially multiple font definitions
// the font files (e.g. ttf) at `path` are copied to res/font
// with their name changed to be a valid resource and referenced in the xml file
return {
path: filePath,
xml: {
'font-family': {
// using `app` namespace for better compat:
// https://developer.android.com/develop/ui/views/text-and-emoji/fonts-in-xml#using-support-lib
$: {
'xmlns:app': 'http://schemas.android.com/apk/res-auto',
},
font: fontDefinitions.map((definition) => {
return {
$: {
'app:font': `@font/${toValidAndroidResourceName(definition.path)}`,
'app:fontStyle': definition.style || 'normal',
'app:fontWeight': String(definition.weight),
},
};
}),
},
},
};
});
}
function addFontXmlToMainApplication(config: ExpoConfig, xmlFontObjects: GroupedFontObject) {
return withMainApplication(config, (config) => {
const {
modResults,
modResults: { language },
} = config;
modResults.contents = addImports(
modResults.contents,
['com.facebook.react.common.assets.ReactFontManager'],
language === 'java'
);
const fontManagerCalls = generateFontManagerCalls(xmlFontObjects, language).join(os.EOL);
const withInit = mergeContents({
src: modResults.contents,
comment: ' //',
tag: 'xml-fonts-init',
offset: 1,
anchor: /super\.onCreate\(\)/,
newSrc: fontManagerCalls,
});
return {
...config,
modResults: {
...modResults,
contents: withInit.contents,
},
};
});
}
export function generateFontManagerCalls(
xmlFontObjects: GroupedFontObject,
language: 'java' | 'kt'
) {
const lineEnding = language === 'java' ? ';' : '';
const indent = ' ';
return Object.keys(xmlFontObjects).map(
(family) =>
`${indent}ReactFontManager.getInstance().addCustomFont(this, "${family}", R.font.${resourceNameConflictAvoidancePrefix + toValidAndroidResourceName(family)})${lineEnding}`
);
}
function copyFontsToDir(
config: ExpoConfig,
paths: string[],
inAppDestination: typeof assetsFontsFir | typeof resourcesFontsDir,
filenameProcessor = (filenameWithExt: string) => filenameWithExt
) {
return withDangerousMod(config, [
'android',
async (config) => {
const fontsDir = path.join(config.modRequest.platformProjectRoot, inAppDestination);
await fs.mkdir(fontsDir, { recursive: true });
const resolvedFonts = await resolveFontPaths(paths, config.modRequest.projectRoot);
await Promise.all(
resolvedFonts.map(async (asset) => {
const filenameWithExt = path.basename(asset);
const outputFileName = filenameProcessor(filenameWithExt);
const output = path.join(fontsDir, outputFileName);
if (output.endsWith('.ttf') || output.endsWith('.otf')) {
await fs.copyFile(asset, output);
}
})
);
return config;
},
]);
}