UNPKG

@keymanapp/kmc-keyboard-info

Version:

Keyman Developer .keyboard_info compiler

503 lines (501 loc) 22.8 kB
/** * 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