@keymanapp/kmc-model-info
Version:
Keyman Developer .model_info compiler
240 lines • 10.2 kB
JavaScript
/**
* Builds a source .model_info file with metadata extracted from .kps file and
* compiled files to produce a comprehensive .model_info file.
*/
import { minKeymanVersion } from "./min-keyman-version.js";
import { ModelInfoCompilerMessages } from "./model-info-compiler-messages.js";
import { KeymanUrls, isValidEmail, validateMITLicense } from "@keymanapp/developer-utils";
/* c8 ignore start */
/**
* @public
* Description of sources and metadata required to build a .model_info file
*/
export class ModelInfoSources {
/** The identifier for the model */
model_id;
/** The data from the .kps file, transformed to kmp.json */
kmpJsonData;
/** The path in the keymanapp/lexical-models repo where this model may be found */
sourcePath;
/** The compiled model filename and relative path (.js) */
modelFileName;
/** The compiled package filename and relative path (.kmp) */
kmpFileName;
/** The source package filename and relative path (.kps) */
kpsFilename;
/** Last modification date for files in the project folder 'YYYY-MM-DDThh:mm:ssZ' */
lastCommitDate;
/** Return an error if project does not meet requirements of lexical-models repository */
forPublishing;
}
;
;
;
;
/**
* @public
* Compiles source data from a lexical model project to a .model_info. The
* compiler does not read or write from filesystem or network directly, but
* relies on callbacks for all external IO.
*/
export class ModelInfoCompiler {
callbacks;
options;
constructor() {
}
/**
* 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;
}
/**
* Builds .model_info file with metadata from the model 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 ModelInfoCompiler.init} function to read any input files by disk.
*
* This function is intended for use within the lexical-models 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.
*
* @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 ModelInfoCompiler.write}.
* @returns Binary artifacts on success, null on failure.
*
* @param sources - Details on files from which to extract additional metadata
*/
async run(inputFilename, outputFilename) {
const sources = this.options.sources;
/*
* Model info looks like this:
*
* {
* "name": "Example Template Model"
* "license": "mit",
* "version": "1.0.0",
* "languages": ["en"],
* "authorName": "Example Author",
* "authorEmail": "nobody@example.com",
* "description": "Example wordlist model"
* }
*
* For full documentation, see:
* https://help.keyman.com/developer/cloud/model_info/1.0/
*/
let jsFile = null;
if (sources.modelFileName) {
jsFile = this.loadJsFile(sources.modelFileName);
if (!jsFile) {
return null;
}
}
let model_info = {
languages: [],
};
//
// Build .model_info file -- some fields have "special" behaviours -- see below
// https://api.keyman.com/schemas/model_info.source.json and
// https://api.keyman.com/schemas/model_info.distribution.json
// https://help.keyman.com/developer/cloud/model_info/1.0
//
model_info.id = sources.model_id;
model_info.name = sources.kmpJsonData.info.name.description;
// License
if (!sources.kmpJsonData.options?.licenseFile) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_NoLicenseFound());
return null;
}
if (!this.isLicenseMIT(this.callbacks.resolveFilename(sources.kpsFilename, sources.kmpJsonData.options.licenseFile))) {
return null;
}
model_info.license = 'mit';
const author = sources.kmpJsonData.info.author;
model_info.authorName = author?.description ?? '';
if (author?.url) {
// we strip the mailto: from the .kps file for the .model_info
const match = author.url.match(/^(mailto\:)?(.+)$/);
/* c8 ignore next 3 */
if (match === null) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_InvalidAuthorEmail({ email: author.url }));
return null;
}
if (!isValidEmail(match[2])) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_InvalidAuthorEmail({ email: author.url }));
return null;
}
model_info.authorEmail = match[2];
}
// description
if (sources.kmpJsonData.info.description?.description) {
model_info.description = sources.kmpJsonData.info.description.description.trim();
}
else {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_DescriptionIsMissing({ filename: sources.kpsFilename }));
return null;
}
// isRTL -- this is a little bit of a heuristic from a compiled .js
// which may need modification if compilers change
if (jsFile?.match(/("?)isRTL("?):\s*true/)) {
model_info.isRTL = true;
}
// extract the language identifiers from the language metadata
// arrays for each of the lexical models in the kmp.json file,
// and merge into a single array of identifiers in the
// .model_info file.
model_info.languages = sources.kmpJsonData.lexicalModels.reduce((a, e) => [].concat(a, e.languages.map((f) => f.id)), []);
// If a last commit date is not given, then just use the current time
model_info.lastModifiedDate = sources.lastCommitDate ?? (new Date).toISOString();
model_info.packageFilename = this.callbacks.path.basename(sources.kmpFileName);
model_info.packageFileSize = this.callbacks.fileSize(sources.kmpFileName);
if (model_info.packageFileSize === undefined) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_FileDoesNotExist({ filename: sources.kmpFileName }));
return null;
}
model_info.jsFilename = this.callbacks.path.basename(sources.modelFileName);
model_info.jsFileSize = this.callbacks.fileSize(sources.modelFileName);
if (model_info.jsFileSize === undefined) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_FileDoesNotExist({ filename: sources.modelFileName }));
return null;
}
model_info.packageIncludes = sources.kmpJsonData.files.filter((e) => !!e.name.match(/.[ot]tf$/i)).length ? ['fonts'] : [];
model_info.version = sources.kmpJsonData.info.version.description;
model_info.minKeymanVersion = minKeymanVersion;
model_info.helpLink = KeymanUrls.HELP_MODEL(model_info.id);
if (sources.sourcePath) {
model_info.sourcePath = sources.sourcePath;
}
const jsonOutput = JSON.stringify(model_info, null, 2);
const data = new TextEncoder().encode(jsonOutput);
const result = {
artifacts: {
model_info: {
data,
filename: outputFilename ?? inputFilename.replace(/\.kpj$/, '.model_info')
}
}
};
return result;
}
/**
* Write artifacts from a successful compile to disk, via callbacks methods.
* The artifacts written may include:
*
* - .model_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.model_info.filename, artifacts.model_info.data);
return true;
}
isLicenseMIT(filename) {
const data = this.callbacks.loadFile(filename);
if (!data) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_LicenseFileIsMissing({ filename }));
return false;
}
let license = null;
try {
license = new TextDecoder().decode(data);
}
catch (e) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_LicenseFileIsDamaged({ filename }));
return false;
}
if (!license) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_LicenseFileIsDamaged({ filename }));
return false;
}
const message = validateMITLicense(license);
if (message != null) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_LicenseIsNotValid({ filename, message }));
return false;
}
return true;
}
loadJsFile(filename) {
const data = this.callbacks.loadFile(filename);
if (!data) {
this.callbacks.reportMessage(ModelInfoCompilerMessages.Error_FileDoesNotExist({ filename }));
return null;
}
const text = new TextDecoder('utf-8', { fatal: true }).decode(data);
return text;
}
}
//# sourceMappingURL=model-info-compiler.js.map