@keymanapp/kmc-keyboard-info
Version:
Keyman Developer .keyboard_info compiler
503 lines (501 loc) • 22.8 kB
JavaScript
/**
* Merges a source .keyboard_info file with metadata extracted from .kps file and
* compiled files to produce a comprehensive .keyboard_info file.
*/
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{},n=(new Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="ecd98b99-6b08-53ad-a65b-51a7abb4f5fc")}catch(e){}}();
import { minKeymanVersion } from "./min-keyman-version.js";
import { KeymanFileTypes, KmxFileReader, KeymanTargets } from "@keymanapp/common-types";
import { KeyboardInfoCompilerMessages } from "./keyboard-info-compiler-messages.js";
import langtags from "./imports/langtags.js";
import { KeymanUrls, validateMITLicense } from "@keymanapp/developer-utils";
import { KmpCompiler } from "@keymanapp/kmc-package";
import { SchemaValidators } from "@keymanapp/common-types";
import { getFontFamily } from "./font-family.js";
const regionNames = new Intl.DisplayNames(['en'], { type: "region" });
const scriptNames = new Intl.DisplayNames(['en'], { type: "script" });
const langtagsByTag = {};
/**
* Build a dictionary of language tags from langtags.json
*/
function preinit() {
if (langtagsByTag['en']) {
// Already initialized, we can reasonably assume that 'en' will always be in
// langtags.json.
return;
}
for (const tag of langtags) {
langtagsByTag[tag.tag] = tag;
langtagsByTag[tag.full] = tag;
if (tag.tags) {
for (const t of tag.tags) {
langtagsByTag[t] = tag;
}
}
}
}
;
;
;
;
/**
* @public
* Compiles source data from a keyboard project to a .keyboard_info. The
* compiler does not read or write from filesystem or network directly, but
* relies on callbacks for all external IO.
*/
export class KeyboardInfoCompiler {
callbacks;
options;
constructor() {
preinit();
}
/**
* Initialize the compiler.
* Copies options.
* @param callbacks - Callbacks for external interfaces, including message
* reporting and file io
* @param options - Compiler options
* @returns false if initialization fails
*/
async init(callbacks, options) {
this.callbacks = callbacks;
this.options = { ...options };
return true;
}
/**
* @public
* Builds a .keyboard_info file with metadata from the keyboard and package
* source file. Returns an object containing binary artifacts on success. The
* files are passed in by name, and the compiler will use callbacks as passed
* to the {@link KeyboardInfoCompiler.init} function to read any input files
* by disk.
*
* This function is intended for use within the keyboards repository. While
* many of the parameters could be deduced from each other, they are specified
* here to reduce the number of places the filenames are constructed. For full
* documentation, see: https://help.keyman.com/developer/cloud/keyboard_info/
*
* @param infile - Path to source file. Path will be parsed to find relative
* references in the .kpj file, such as .model.ts or
* .model.kps file
* @param outfile - Path to output file. The file will not be written to, but
* will be included in the result for use by
* {@link KeyboardInfoCompiler.write}.
* @returns Binary artifacts on success, null on failure.
*/
async run(inputFilename, outputFilename) {
const sources = this.options.sources;
// TODO(lowpri): work from .kpj and nothing else as input. Blocked because
// .kpj work is largely in kmc at present, so that would need to move to
// a separate module.
const kmpCompiler = new KmpCompiler();
if (!await kmpCompiler.init(this.callbacks, {})) {
// Errors will have been emitted by KmpCompiler
return null;
}
const kmpJsonData = kmpCompiler.transformKpsToKmpObject(sources.kpsFilename);
if (!kmpJsonData) {
// Errors will have been emitted by KmpCompiler
return null;
}
if (!sources.kmpFilename) {
// We can't build any metadata without a .kmp file
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_CannotBuildWithoutKmpFile());
return null;
}
const keyboard_info = {};
let jsFile = null;
if (sources.jsFilename) {
jsFile = this.loadJsFile(sources.jsFilename);
if (!jsFile) {
return null;
}
}
const kmxFiles = this.loadKmxFiles(sources.kpsFilename, kmpJsonData);
//
// Build .keyboard_info file
// https://api.keyman.com/schemas/keyboard_info.schema.json
// https://help.keyman.com/developer/cloud/keyboard_info/2.0
//
keyboard_info.id = this.callbacks.path.basename(sources.kmpFilename, '.kmp');
keyboard_info.name = kmpJsonData.info.name.description;
// License
if (sources.forPublishing) {
// We will only verify the license if asked to do so, so that all keyboard
// projects can be built even if license is not present. Keyboards
// repository will always verify license
if (!kmpJsonData.options?.licenseFile) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_NoLicenseFound());
return null;
}
if (!this.isLicenseMIT(this.callbacks.resolveFilename(sources.kpsFilename, kmpJsonData.options.licenseFile))) {
return null;
}
}
// Even if license is not verified, we set the .keyboard_info license to
// 'mit' to meet the schema requirements. The .keyboard_info file is only
// used by the keyboards repository, so this is a fair assumption to make.
keyboard_info.license = 'mit';
// isRTL
if (jsFile?.match(/this\.KRTL=1/)) {
keyboard_info.isRTL = true;
}
// author
const author = kmpJsonData.info.author;
if (author?.description || author?.url) {
keyboard_info.authorName = author.description;
if (author.url) {
// we strip the mailto: from the .kps file for the .keyboard_info
const match = author.url.match(/^(mailto\:)?(.+)$/);
/* c8 ignore next 4 */
if (match === null) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_InvalidAuthorEmail({ email: author.url }));
return null;
}
keyboard_info.authorEmail = match[2];
}
}
// description
if (kmpJsonData.info.description?.description) {
keyboard_info.description = kmpJsonData.info.description.description.trim();
}
else {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_DescriptionIsMissing({ filename: sources.kpsFilename }));
return null;
}
// extract the language identifiers from the language metadata arrays for
// each of the keyboards in the kmp.json file, and merge into a single array
// of identifiers in the .keyboard_info file.
if (!await this.fillLanguages(sources.kpsFilename, keyboard_info, kmpJsonData)) {
return null;
}
// If a last commit date is not given, then just use the current time
keyboard_info.lastModifiedDate = sources.lastCommitDate ?? (new Date).toISOString();
keyboard_info.packageFilename = this.callbacks.path.basename(sources.kmpFilename);
// Always overwrite with actual file size
if (!this.callbacks.fs.existsSync(sources.kmpFilename)) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_FileDoesNotExist({ filename: sources.kmpFilename }));
return null;
}
keyboard_info.packageFileSize = this.callbacks.fileSize(sources.kmpFilename);
if (sources.jsFilename) {
keyboard_info.jsFilename = this.callbacks.path.basename(sources.jsFilename);
// Always overwrite with actual file size
/* c8 ignore next 5 */
keyboard_info.jsFileSize = this.callbacks.fileSize(sources.jsFilename);
if (keyboard_info.jsFileSize === undefined) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_FileDoesNotExist({ filename: sources.jsFilename }));
return null;
}
}
const includes = new Set();
keyboard_info.packageIncludes = [];
for (const file of kmpJsonData.files) {
if (file.name.match(/\.(otf|ttf|ttc)$/)) {
includes.add('fonts');
}
else if (file.name.match(/welcome\.htm$/)) {
includes.add('welcome');
}
else if (file.name.match(/\.kvk$/)) {
includes.add('visualKeyboard');
}
else if (file.name.match(/\.(rtf|html|htm|pdf)$/)) {
includes.add('documentation');
}
}
keyboard_info.packageIncludes = [...includes];
keyboard_info.version = kmpJsonData.info.version.description;
let minVersion = minKeymanVersion;
const m = jsFile?.match(/this.KMINVER\s*=\s*(['"])(.*?)\1/);
if (m) {
if (parseFloat(m[2]) > parseFloat(minVersion)) {
minVersion = m[2];
}
}
for (const file of kmxFiles) {
const v = this.kmxFileVersionToString(file.data.fileVersion);
if (parseFloat(v) > parseFloat(minVersion)) {
minVersion = v;
}
}
// Only legacy keyboards supprt non-Unicode encodings, and we no longer
// rewrite the .keyboard_info for those.
keyboard_info.encodings = ['unicode'];
// platformSupport
const platforms = new Set();
for (const file of kmxFiles) {
const targets = KeymanTargets.keymanTargetsFromString(file.data.targets, { expandTargets: true });
for (const target of targets) {
this.mapKeymanTargetToPlatform(target).forEach(platform => platforms.add(platform));
}
}
if (jsFile) {
if (platforms.size == 0) {
// In this case, there was no .kmx metadata available. We need to
// make an assumption that this keyboard is both desktop+mobile web,
// and if the .js is in the package, that it is mobile native as well,
// because the targets metadata is not available in the .js.
platforms.add('mobileWeb').add('desktopWeb');
if (kmpJsonData.files.find(file => file.name.match(/\.js$/))) {
platforms.add('android').add('ios');
}
}
// Special case for determining desktopWeb and mobileWeb support: we use
// &targets to determine which platforms the .js is actually compatible
// with. The presence of the .js file itself determines whether there is
// supposed to be any web support. The presence of the .js file in the
// package (which is a separate check) does not determine whether or not
// the keyboard itself actually supports mobile, although it must be
// included in the package in order to actually be delivered to mobile
// apps.
if (platforms.has('android') || platforms.has('ios')) {
platforms.add('mobileWeb');
}
if (platforms.has('linux') || platforms.has('macos') || platforms.has('windows')) {
platforms.add('desktopWeb');
}
}
keyboard_info.platformSupport = {};
for (const platform of platforms) {
keyboard_info.platformSupport[platform] = 'full';
}
keyboard_info.minKeymanVersion = minVersion;
keyboard_info.sourcePath = sources.sourcePath;
keyboard_info.helpLink = KeymanUrls.HELP_KEYBOARD(keyboard_info.id);
// Related packages
if (kmpJsonData.relatedPackages?.length) {
keyboard_info.related = {};
for (const p of kmpJsonData.relatedPackages) {
keyboard_info.related[p.id] = {
deprecates: p.relationship == 'deprecates'
};
}
}
const jsonOutput = JSON.stringify(keyboard_info, null, 2);
if (!SchemaValidators.default.keyboard_info(keyboard_info)) {
// This is an internal fatal error; we should not be capable of producing
// invalid output, so it is best to throw and die
throw new Error(JSON.stringify({
keyboard_info: keyboard_info,
error: SchemaValidators.default.keyboard_info.errors
}, null, 2));
}
const data = new TextEncoder().encode(jsonOutput);
const result = {
artifacts: {
keyboard_info: {
data,
filename: outputFilename ?? inputFilename.replace(/\.kpj$/, '.keyboard_info')
}
}
};
return result;
}
/**
* Write artifacts from a successful compile to disk, via callbacks methods.
* The artifacts written may include:
*
* - .keyboard_info file - metadata file used by keyman.com
*
* @param artifacts - object containing artifact binary data to write out
* @returns true on success
*/
async write(artifacts) {
this.callbacks.fs.writeFileSync(artifacts.keyboard_info.filename, artifacts.keyboard_info.data);
return true;
}
mapKeymanTargetToPlatform(target) {
const map = {
any: [],
androidphone: ['android'],
androidtablet: ['android'],
desktop: [],
ipad: ['ios'],
iphone: ['ios'],
linux: ['linux'],
macosx: ['macos'],
mobile: [],
tablet: [],
web: ['desktopWeb'],
windows: ['windows']
};
return map[target] ?? [];
}
kmxFileVersionToString(version) {
return ((version & 0xFF00) >> 8).toString() + '.' + (version & 0xFF).toString();
}
isLicenseMIT(filename) {
const data = this.callbacks.loadFile(filename);
if (!data) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_LicenseFileIsMissing({ filename }));
return false;
}
let license = null;
try {
license = new TextDecoder().decode(data);
}
catch (e) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_LicenseFileIsDamaged({ filename }));
return false;
}
if (!license) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_LicenseFileIsDamaged({ filename }));
return false;
}
const message = validateMITLicense(license);
if (message != null) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_LicenseIsNotValid({ filename, message }));
return false;
}
return true;
}
loadKmxFiles(kpsFilename, kmpJsonData) {
const reader = new KmxFileReader();
return kmpJsonData.files
.filter(file => KeymanFileTypes.filenameIs(file.name, ".kmx" /* KeymanFileTypes.Binary.Keyboard */))
.map(file => ({
filename: this.callbacks.path.basename(file.name),
data: reader.read(this.callbacks.loadFile(this.callbacks.resolveFilename(kpsFilename, file.name)))
}));
}
loadJsFile(filename) {
const data = this.callbacks.loadFile(filename);
if (!data) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_FileDoesNotExist({ filename }));
return null;
}
const text = new TextDecoder('utf-8', { fatal: true }).decode(data);
return text;
}
async fillLanguages(kpsFilename, keyboard_info, kmpJsonData) {
// Collapse language data from multiple keyboards
const languages = kmpJsonData.keyboards.reduce((a, e) => [].concat(a, (e.languages ?? []).map((f) => f.id)), []);
const examples = kmpJsonData.keyboards.reduce((a, e) => [].concat(a, e.examples ?? []), []);
// Transform array into object
keyboard_info.languages = {};
for (const language of languages) {
keyboard_info.languages[language] = {};
}
const fontSource = [].concat(...kmpJsonData.keyboards.map(e => e.displayFont ? [e.displayFont] : []), ...kmpJsonData.keyboards.map(e => e.webDisplayFonts ?? []));
const oskFontSource = [].concat(...kmpJsonData.keyboards.map(e => e.oskFont ? [e.oskFont] : []), ...kmpJsonData.keyboards.map(e => e.webOskFonts ?? []));
for (const bcp47 of Object.keys(keyboard_info.languages)) {
const language = keyboard_info.languages[bcp47];
//
// Add examples
//
language.examples = [];
for (const example of examples) {
if (example.id == bcp47) {
language.examples.push({
// we don't copy over example.id
keys: example.keys,
note: example.note,
text: example.text
});
}
}
//
// Add fonts -- which are duplicated for each language; we'll mark this as a future
// optimization, but it's another keyboard_info breaking change so don't want to
// do it right now.
//
if (fontSource.length) {
language.font = await this.fontSourceToKeyboardInfoFont(kpsFilename, kmpJsonData, fontSource);
if (language.font == null) {
return false;
}
}
if (oskFontSource.length) {
language.oskFont = await this.fontSourceToKeyboardInfoFont(kpsFilename, kmpJsonData, oskFontSource);
if (language.oskFont == null) {
return false;
}
}
//
// Add locale description
//
const locale = new Intl.Locale(bcp47);
// DisplayNames.prototype.of will throw a RangeError if it doesn't understand
// the format of the bcp47 tag. This happens with Node 18.14.1, for example, with:
// new Intl.DisplayNames(['en'], {type: 'language'}).of('und-fonipa');
const mapName = (code, dict) => {
try {
return dict.of(code);
}
catch (e) {
if (e instanceof RangeError) {
return code;
}
else {
throw e;
}
}
};
const tag = langtagsByTag[bcp47] ?? langtagsByTag[locale.language];
language.languageName = tag ? tag.name : bcp47;
language.regionName = mapName(locale.region, regionNames);
language.scriptName = mapName(locale.script, scriptNames);
language.displayName = language.languageName + ((language.scriptName && language.regionName) ?
` (${language.scriptName}, ${language.regionName})` :
language.scriptName ?
` (${language.scriptName})` :
language.regionName ?
` (${language.regionName})` :
'');
}
return true;
}
/**
* @internal
*/
async fontSourceToKeyboardInfoFont(kpsFilename, kmpJsonData, source) {
// locate a .ttf, .otf, or .woff font file
const ttf = source.find(file => file.endsWith('.ttf') || file.endsWith('.otf') || file.endsWith('.woff'));
if (!ttf) {
return {
// If we can't find a matching font, we'll just use the filename of the first font
family: this.callbacks.path.basename(source[0]),
source: source
};
}
// The font sources already have path information stripped, but we can
// find the matching file from the list of files in the package.
const sourcePath = kmpJsonData.files.find(file => ('/' + file.name.replaceAll('\\', '/')).endsWith('/' + ttf))?.name;
if (!sourcePath) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_FileDoesNotExist({ filename: ttf }));
return null;
}
const fontData = this.callbacks.loadFile(this.callbacks.resolveFilename(kpsFilename, sourcePath));
if (!fontData) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_FileDoesNotExist({ filename: sourcePath }));
return null;
}
let fontFamily = null;
try {
fontFamily = await getFontFamily(fontData);
}
catch (e) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_FontFileMetaDataIsInvalid({ filename: sourcePath, message: e }));
return null;
}
const result = {
family: fontFamily,
source
};
/* c8 ignore next 4 */
if (!result.family) {
this.callbacks.reportMessage(KeyboardInfoCompilerMessages.Error_FontFileCannotBeRead({ filename: sourcePath }));
return null;
}
return result;
}
}
/**
* these are exported only for unit tests, do not use
*/
export const unitTestEndpoints = {
langtagsByTag,
};
//# sourceMappingURL=keyboard-info-compiler.js.map
//# debugId=ecd98b99-6b08-53ad-a65b-51a7abb4f5fc