import { fileURLToPath } from 'node:url'; import { styleText } from 'node:util'; import { join } from 'node:path'; import fs from 'fs'; import Validator, { type SchemaObject as Schema } from 'ajv'; import betterAjvErrors from 'better-ajv-errors'; import path from 'path'; import { dump as dumpYaml } from 'js-yaml'; import marky from 'marky'; import { LocationConflation } from '@rapideditor/location-conflation'; import { createRequire } from 'module'; import { compile, toSafeIdentifier } from 'json-schema-to-typescript-lite'; import type * as Taginfo from 'taginfo-projects'; import { isReference, dereferencedTranslatableContent, dereferenceUntranslatedContent } from './references.ts'; import fetchTranslations, { expandTStrings, sortObject } from './translations.ts'; import type { Field, Geometry, Preset, AllFields, AllPresets, References, TStrings, Options, AllCategories, TaginfoTag, TaginfoSchema, PresetDefaults, Deprecated, Discarded, SourceStrings, PresetCategory } from './types.def.ts'; import { printErrorsForCI, type SchemaError } from './ci.ts'; const require = createRequire(import.meta.url); const fieldSchema = require('../../schemas/field.json'); const presetSchema = require('../../schemas/preset.json'); const categorySchema = require('../../schemas/preset_category.json'); const defaultsSchema = require('../../schemas/preset_defaults.json'); const deprecatedSchema = require('../../schemas/deprecated.json'); const discardedSchema = require('../../schemas/discarded.json'); let _currBuild: Promise | null = null; const jsonschema = new Validator({ allErrors: true, schemas: [ fieldSchema, presetSchema, categorySchema, defaultsSchema, deprecatedSchema, discardedSchema, ], }); const locationConflation = new LocationConflation(); async function buildDev(options?: Options) { if (_currBuild) return _currBuild; const START = '🏗 ' + styleText('yellow', 'Validating and building for development...'); const END = '👍 ' + styleText('green', 'built for development'); process.stdout.write('\n'); process.stdout.write(START + '\n'); marky.mark(END); await processData(options, 'build-interim'); marky.stop(END); process.stdout.write('\n'); } function buildDist(options?: Partial) { if (_currBuild) return _currBuild; const START = '🏗 ' + styleText('yellow', 'Validating and building dist files...'); const END = '👍 ' + styleText('green', 'dist files built'); process.stdout.write('\n'); process.stdout.write(START + '\n'); marky.mark(END); return _currBuild = processData(options, 'build-dist') .then(() => { marky.stop(END); process.stdout.write('\n'); _currBuild = null; }) .catch((err) => { console.error(err); _currBuild = null; process.exit(1); }); } async function processData(_options: Partial | undefined, type: 'build-interim' | 'build-dist') { const options: Options = { inDirectory: 'data', interimDirectory: 'interim', outDirectory: 'dist', sourceLocale: 'en', processCategories: null, processFields: null, processPresets: null, listReusedIcons: false, ..._options, }; const dataDir = './' + options.inDirectory; // Translation strings let tstrings: TStrings = { categories: {}, fields: {}, presets: {} }; // all fields searchable under "add field" let searchableFieldIDs: Record = {}; const deprecated = read(dataDir + '/deprecated.json'); if (deprecated) { validateSchema(dataDir + '/deprecated.json', deprecated, deprecatedSchema); } const discarded = read(dataDir + '/discarded.json'); if (discarded) { validateSchema(dataDir + '/discarded.json', discarded, discardedSchema); } let categories = generateCategories(dataDir, tstrings); if (options.processCategories) options.processCategories(categories); const references: References = { fields: {}, presets: {} }; let fields = generateFields(dataDir, tstrings, searchableFieldIDs, references); if (options.processFields) options.processFields(fields); let presets = generatePresets(dataDir, tstrings, searchableFieldIDs, options.listReusedIcons, references); if (options.processPresets) options.processPresets(presets); const defaults = read(dataDir + '/preset_defaults.json'); if (defaults) { validateSchema(dataDir + '/preset_defaults.json', defaults, defaultsSchema); } if (schemaErrors.length) { printErrorsForCI(schemaErrors); process.exit(1); } // Additional consistency checks validateCategoryPresets(categories, presets); validatePresetFields(presets, fields); dereferenceUntranslatedContent(presets, fields, references); if (defaults) { validateDefaults(defaults, categories, presets); } const sourceLocale = options.sourceLocale; const interimDir = './' + options.interimDirectory; fs.rmSync(interimDir, { recursive: true, force: true }); fs.mkdirSync(interimDir); let translations = generateTranslations(fields, presets, tstrings, searchableFieldIDs); let translationsForYaml: SourceStrings = {}; translationsForYaml[sourceLocale] = { presets: translations }; fs.writeFileSync(interimDir + '/source_strings.yaml', translationsToYAML(translationsForYaml)); let icons = generateIconsList(presets, fields, categories); fs.writeFileSync(interimDir + '/icons.json', JSON.stringify(icons, null, 4) + '\n'); dereferencedTranslatableContent(tstrings, references, true); if (type !== 'build-dist') return; const doFetchTranslations = options.translOrgId && options.translProjectId; const distDir = './' + options.outDirectory; if (!fs.existsSync(distDir)) fs.mkdirSync(distDir); // clean directory for (const entry of await fs.promises.readdir(distDir, { withFileTypes: true })) { if (entry.isFile()) { await fs.promises.rm(join(distDir, entry.name), { force: true }); } } if (doFetchTranslations) { await fs.promises.rm(join(distDir, 'translations'), { recursive: true, force: true }); } categories = sortObject(categories); fields = sortObject(fields); presets = sortObject(presets); fs.writeFileSync(distDir + '/preset_categories.json', JSON.stringify(categories, null, 4)); fs.writeFileSync(distDir + '/fields.json', JSON.stringify(fields, null, 4)); fs.writeFileSync(distDir + '/presets.json', JSON.stringify(presets, null, 4)); let taginfo = generateTaginfo(presets, fields, deprecated, discarded, tstrings, options.taginfoProjectInfo!); if (taginfo) fs.writeFileSync(distDir + '/taginfo.json', JSON.stringify(taginfo, null, 4)); if (defaults) fs.writeFileSync(distDir + '/preset_defaults.json', JSON.stringify(defaults, null, 4)); if (deprecated) fs.writeFileSync(distDir + '/deprecated.json', JSON.stringify(deprecated, null, 4)); if (discarded) fs.writeFileSync(distDir + '/discarded.json', JSON.stringify(discarded, null, 4)); expandTStrings(sourceLocale, tstrings); let translationsForJson: typeof translationsForYaml = {}; translationsForJson[sourceLocale] = { presets: tstrings }; if (!fs.existsSync(distDir + '/translations')) fs.mkdirSync(distDir + '/translations'); fs.writeFileSync(distDir + '/translations/' + sourceLocale + '.json', JSON.stringify(translationsForJson, null, 4)); const tasks = [ // Minify files minifyJSON(distDir + '/preset_categories.json', distDir + '/preset_categories.min.json'), minifyJSON(distDir + '/fields.json', distDir + '/fields.min.json'), minifyJSON(distDir + '/presets.json', distDir + '/presets.min.json'), minifyJSON(distDir + '/taginfo.json', distDir + '/taginfo.min.json'), minifyJSON(distDir + '/preset_defaults.json', distDir + '/preset_defaults.min.json'), minifyJSON(distDir + '/deprecated.json', distDir + '/deprecated.min.json'), minifyJSON(distDir + '/discarded.json', distDir + '/discarded.min.json'), minifyJSON(distDir + '/translations/' + sourceLocale + '.json', distDir + '/translations/' + sourceLocale + '.min.json'), generateTypeDefs(distDir), ]; if (doFetchTranslations) { tasks.push(fetchTranslations(options, references, tstrings)); } return Promise.all(tasks); } function read(f: string): T { return fs.existsSync(f) && JSON.parse(fs.readFileSync(f, 'utf8')); } let schemaErrors: SchemaError[] = []; function validateSchema(file: string, instance: unknown, schema: Schema) { let validate = jsonschema.getSchema(schema.$id!)!; if (!validate(instance) && validate.errors) { process.stderr.write(`${file}: \n`); const json = fs.readFileSync(file, 'utf8'); const errors = betterAjvErrors(schema, instance, validate.errors, { json, format: 'js' }); const output = betterAjvErrors(schema, instance, validate.errors, { json, format: 'cli' }); schemaErrors.push(...errors.map(error => ({ error, file }))); process.stderr.write(output); process.stdout.write('\n'); } } function generateCategories(dataDir: string, tstrings: TStrings) { let categories: AllCategories = {}; const categoriesDir = path.posix.join(dataDir, 'preset_categories'); fs.globSync(categoriesDir + '/*.json').forEach(file => { let category = read(file); const id = 'category-' + extractIdFromPath(categoriesDir, file); validateSchema(file, category, categorySchema); tstrings.categories[id] = { name: category.name }; // @ts-expect-error -- deleting a non-optional property delete category.name; categories[id] = category; }); return categories; } function generateFields(dataDir: string, tstrings: TStrings, searchableFieldIDs: Record, references: References) { let fields: AllFields = {}; const fieldsDir = path.posix.join(dataDir, 'fields'); fs.globSync(fieldsDir + '/**/*.json').forEach(file => { let field = read(file); const id = extractIdFromPath(fieldsDir, file); validateSchema(file, field, fieldSchema); tstrings.fields[id] = {}; let t = tstrings.fields[id]; const label = field.label; if (isReference(label)) { references.fields[id] ||= {}; references.fields[id].labelAndTerms = label; } else { t.label = label; } // @ts-expect-error -- deleting a non-optional property delete field.label; tstrings.fields[id].terms = Array.from(new Set( (field.terms || []) .map(t => t.toLowerCase().trim()) .filter(Boolean) )).join(','); delete field.terms; if (field.universal) { searchableFieldIDs[id] = true; } if (field.placeholder) { if (isReference(field.placeholder)) { references.fields[id] ||= {}; references.fields[id].placeholder = field.placeholder; } else { t.placeholder = field.placeholder; } delete field.placeholder; } if (field.strings) { for (let _prop in field.strings) { const prop = _prop as keyof typeof field.strings; t[prop] = {}; for (const [key, value] of Object.entries(field.strings[prop]!)) { if (typeof value === 'string' && isReference(value)) { references.fields[id] ||= {}; references.fields[id].options ||= {}; references.fields[id].options[prop] ||= {}; references.fields[id].options[prop][key] = value; } else { t[prop][key] = value; } } } if (!field.options && field.strings.options) { field.options = Object.keys(field.strings.options); } delete field.strings; } if (field.stringsCrossReference) { references.fields[id] ||= {}; references.fields[id].stringsCrossReference = field.stringsCrossReference; delete field.stringsCrossReference; } if (field.locationSet) { if (!field.locationSet.include) { field.locationSet.include = [ 'Planet' ]; } locationConflation.validateLocationSet(field.locationSet); } fields[id] = field; }); return fields; } /** * @example `data/presets/addr/_interpolation.json` -> `addr/interpolation` */ function extractIdFromPath(parentDir: string, file: string) { // Strip the leading directory const relativePath = path.relative(parentDir, file); const dirName = path.posix.dirname(relativePath); // Strip the extension, i.e. `.json` const extension = path.extname(relativePath); const fileName = path.posix.basename(relativePath, extension); // Build the id from only the subdirectories and base file name const id = path.posix.join(dirName, fileName); // Remove leading underscores from any directory or file name // Also enforce posix path separator '/' in case the path was with Windows '\' path separator return path.posix.join(...id.split(path.sep).map(s => s.replace(/^_/, ''))); } function generatePresets( dataDir: string, tstrings: TStrings, searchableFieldIDs: Record, listReusedIcons: boolean | number | undefined, references: References, ) { let presets: AllPresets = {}; let icons: Record = {}; const presetsDir = path.posix.join(dataDir, 'presets'); fs.globSync(presetsDir + '/**/*.json').forEach(file => { let preset = read(file); const id = extractIdFromPath(presetsDir, file); if (presets[id] !== undefined) { process.stderr.write(`Preset with id "${id}" defined multiple times\n`); process.stdout.write('\n'); process.exit(1); } validateSchema(file, preset, presetSchema); let names = new Set([]); tstrings.presets[id] = {}; if (isReference(preset.name)) { references.presets[id] ||= {}; references.presets[id].nameTermsAliases = preset.name; } else { tstrings.presets[id].name = preset.name; names.add(preset.name.toLowerCase()); } preset.aliases = Array.from(new Set( (preset.aliases || []) .map(t => t.trim()) .filter(Boolean) .filter(t => !names.has(t.toLowerCase())) )); preset.aliases.forEach(a => names.add(a.toLowerCase())); preset.terms = Array.from(new Set( (preset.terms || []) .map(t => t.toLowerCase().trim()) .filter(Boolean) .filter(t => !names.has(t)) )); if (preset.aliases.length) tstrings.presets[id].aliases = preset.aliases.join('\n'); if (preset.terms.length) tstrings.presets[id].terms = preset.terms.join(','); // don't include localized strings in the presets dist file since they're already in the locale file delete preset.aliases; delete preset.terms; // @ts-expect-error -- deleting a non-optional prop delete preset.name; if (preset.moreFields) { preset.moreFields.forEach(fieldID => { searchableFieldIDs[fieldID] = true; }); } presets[id] = preset; if (preset.searchable !== false) { let icon = preset.icon || '(none)'; if (!icons[icon]) icons[icon] = []; icons[icon].push(id); } if (preset.relation) { tstrings.presets[id].relation = { role_labels: preset.relation.role_labels }; // @ts-expect-error -- deleting a non-optional prop delete preset.relation.role_labels; } if (preset.relationCrossReference) { references.presets[id] ||= {}; references.presets[id].relation = preset.relationCrossReference; delete preset.relationCrossReference; } if (preset.locationSet) { if (!preset.locationSet.include) { preset.locationSet.include = [ 'Planet' ]; } locationConflation.validateLocationSet(preset.locationSet); } }); if (listReusedIcons) { const reuseLimit = typeof listReusedIcons === 'number' && listReusedIcons > 0 ? listReusedIcons : 1; let reusedIconPresetCount = 0; const reusedIcons = Object.keys(icons).filter(function(iconID) { const presetIDs = icons[iconID]; if (presetIDs.length > reuseLimit) { reusedIconPresetCount += presetIDs.length; return true; } return false; }); if (reusedIcons.length > 0) { process.stdout.write(reusedIcons.length + ' icon(s), including (none), are each used more than ' + reuseLimit + ' time(s), affecting ' + reusedIconPresetCount + ' presets\n'); reusedIcons.sort(function(iconID1, iconID2) { return icons[iconID2].length - icons[iconID1].length; }).forEach(function(iconID) { const presetIDs = icons[iconID]; process.stdout.write(iconID + ', ' + presetIDs.length + '\n'); for (let i in presetIDs) { process.stdout.write('-' + presetIDs[i] + '\n'); } process.stdout.write('\n'); }); } else { process.stdout.write(styleText('green', 'No icon is used more than ' + reuseLimit + ' time(s) across all searchable presets\n')); } } return presets; } function generateTranslations(fields: AllFields, presets: AllPresets, tstrings: TStrings, searchableFieldIDs: Record) { let yamlStrings: TStrings = JSON.parse(JSON.stringify(tstrings)); // deep clone for (let fieldId in yamlStrings.fields) { let yamlField = yamlStrings.fields[fieldId]; let field = fields[fieldId]; let options = yamlField.options || {}; let optkeys = Object.keys(options); if (field.keys) { yamlField['#label'] = field.keys.map(k => `${k}=*`).join(', '); } else if (field.key) { yamlField['#label'] = `${field.key}=*`; } optkeys.forEach(k => { if (typeof options[k] === 'string'){ options['#' + k] = field.key ? `${field.key}=${k}` : `field "${fieldId}" with value "${k}"`; } else { options[k]['#description'] = `description for ${field.key}=${k}`; options[k]['#title'] = `title for ${field.key}=${k}`; } }); if (field.locationSet) { if (field.locationSet.include) { yamlField['#label'] += ` | Local preset for countries ${field.locationSet.include.map(country => `"${country.toUpperCase()}"`).join(', ')}`; } if (field.locationSet.exclude) { yamlField['#label'] += ` | Hidden in some countries: ${field.locationSet.exclude.map(country => `"${country.toUpperCase()}"`).join(', ')}`; } } if (yamlField.placeholder) { yamlField['#placeholder'] = `${fieldId} field placeholder`; } if (searchableFieldIDs[fieldId]) { if (yamlField.terms) { yamlField['#terms'] = 'terms: ' + yamlField.terms; } else { delete tstrings.fields[fieldId].terms; } if (yamlField.label) { yamlField.terms = `[translate with synonyms or related terms for '${yamlField.label}', separated by commas]`; } else { delete yamlField.terms; } } else { delete tstrings.fields[fieldId].terms; delete yamlField.terms; } } for (let presetId in yamlStrings.presets) { let yamlPreset = yamlStrings.presets[presetId]; let preset = presets[presetId]; let tags = preset.tags || {}; let keys = Object.keys(tags); const tagsString = keys.map(k => `${k}=${tags[k]}`).join(' + '); if (keys.length) { yamlPreset['#name'] = tagsString; if (yamlPreset.aliases) { yamlPreset['#name'] += ' | ' + (yamlPreset.aliases as string).split('\n').join(', '); } yamlPreset['#name'] += ' | Translate the primary name. Optionally, add equivalent synonyms on newlines in order of preference (press the Return key).'; } if (preset.locationSet?.include) { yamlPreset['#name'] += ` | Local preset for countries ${preset.locationSet.include.map(country => `"${country.toUpperCase()}"`).join(', ')}`; } if (yamlPreset.relation?.role_labels) { for (const role in yamlPreset.relation.role_labels) { yamlPreset.relation.role_labels[`#${role}`] = `Relation role “${role}” when used with ${tagsString}`; } } if (preset.searchable !== false) { if (yamlPreset.terms) { yamlPreset['#terms'] = 'terms: ' + yamlPreset.terms; } else { delete yamlPreset.terms; } if (yamlPreset.name) { yamlPreset.terms = ``; } } else { delete tstrings.presets[presetId].terms; delete yamlPreset.terms; } delete yamlPreset.aliases; } return yamlStrings; } function getIconUrlFromIdentifier(identifier: string) { if (identifier?.startsWith('maki-')) { return 'https://cdn.jsdelivr.net/gh/mapbox/maki/icons/' + identifier.replace(/^maki-/, '') + '.svg'; } else if (identifier?.startsWith('temaki-')) { return 'https://cdn.jsdelivr.net/gh/rapideditor/temaki/icons/' + identifier.replace(/^temaki-/, '') + '.svg'; } else if (identifier && /^fa[srb]-/.test(identifier)) { return 'https://cdn.jsdelivr.net/gh/openstreetmap/iD@develop/svg/fontawesome/' + identifier + '.svg'; } else if (identifier?.startsWith('roentgen-')) { return 'https://cdn.jsdelivr.net/gh/enzet/Roentgen@main/icons/' + identifier.replace(/^roentgen-/, '') + '.svg'; } else if (identifier?.startsWith('pinhead-')) { return 'https://pinhead.ink/latest/' + identifier.replace(/^pinhead-/, '') + '.svg'; } else if (identifier?.startsWith('iD-')) { return 'https://cdn.jsdelivr.net/gh/openstreetmap/iD@develop/svg/iD-sprite/presets/' + identifier.replace(/^iD-/, '') + '.svg'; } process.stderr.write(`Unknown icon set for: ${identifier}\n`); process.stdout.write('\n'); process.exit(1); } function generateTaginfo( presets: AllPresets, fields: AllFields, deprecated: Deprecated, discarded: Discarded, tstrings: TStrings, projectInfo: Taginfo.Project, ) { const packageInfo = JSON.parse(fs.readFileSync('./package.json', 'utf8')); if (!projectInfo.name) projectInfo.name = packageInfo.name; if (!projectInfo.description) projectInfo.description = packageInfo.description; projectInfo.description += ' The following mnemonics are used in individual tag descriptions to annotate in which context the respective tag is supported:' + ' 🄿: preset, 🄵 field, 🄵🅅: field value, 🄳: deprecated tag, 🄳🄳: discarded tag'; const requiredProps = ['name', 'description', 'project_url', 'contact_name', 'contact_email']; for (let i in requiredProps) { if (!(requiredProps[i] in projectInfo)) { process.stdout.write(styleText('yellow', 'Cannot compile taginfo.json: missing required project property `' + requiredProps[i] + '`') + '\n'); return null; } } let taginfo: TaginfoSchema = { data_format: 1, project: projectInfo, tags: [] }; Object.keys(presets).forEach(id => { let preset = presets[id]; if (id.startsWith('@')) return; const everyTag: Record> = {}; for (const group of [preset.tags, preset.addTags, preset.removeTags]) { for (const key in group) { everyTag[key] ||= new Set(); everyTag[key].add(group[key]); } } for (const key in everyTag) { for (const value of everyTag[key]) { let tag: TaginfoTag = { key }; if (value !== '*') tag.value = value; let name = tstrings.presets[id].name; if (!name && isReference(preset.name)) { name = tstrings.presets[preset.name.slice(1, -1)].name; } let legacy = (preset.searchable === false) ? ' (unsearchable)' : ''; tag._description = [ `🄿 ${name}${legacy}` ]; if (preset.geometry) { setObjectType(tag, preset); } // add icon if (preset.icon !== undefined) { tag.icon_url = getIconUrlFromIdentifier(preset.icon); } coalesceTags(taginfo, tag); } } }); Object.keys(fields).forEach(id => { const field = fields[id]; const keys = field.keys || (field.key && [field.key]) || []; const isRadio = (field.type === 'radio' || field.type === 'structureRadio'); if (field.type === 'directionalCombo') { // for directionalCombo, only :left and :right are included in field.keys, // so we need to explicitly add the two other possibilities. const base = field.key!.replace(/:both$/, ''); keys.push(base, `${base}:both`); } keys.forEach(key => { let tag: TaginfoTag = { key: key }; let label = tstrings.fields[id].label; if (!label && field.label && isReference(field.label)) { label = tstrings.fields[field.label.slice(1, -1)].label; } tag._description = [ `🄵 ${label}` ]; if (areAnyFreeFormTagsAcceptedInField(field)) { coalesceTags(taginfo, tag); } let yesTag = { ...tag, value: 'yes', _description: [`🄵🅅 ${label}: yes`] }; let noTag = { ...tag, value: 'no', _description: [`🄵🅅 ${label}: no`] }; if (field.type === 'defaultCheck') { coalesceTags(taginfo, yesTag); } if (field.type === 'check' || field.type === 'onewayCheck') { coalesceTags(taginfo, yesTag); coalesceTags(taginfo, noTag); } if (field.options && !isRadio && field.type !== 'manyCombo') { field.options.forEach(value => { if (value === 'undefined' || value === '*' || value === '') return; let tag: TaginfoTag; if (field.type === 'multiCombo') { tag = { key: key + value }; } else { tag = { key: key, value: value }; } let valueLabel = tstrings.fields[id].options?.[value]; if (valueLabel && typeof valueLabel === 'string') { tag._description = [ `🄵🅅 ${label}: ${valueLabel}` ]; } else { tag._description = [ `🄵🅅 ${label}: \`${value}\`` ]; } coalesceTags(taginfo, tag); }); } }); }); if (!deprecated) deprecated = []; deprecated.forEach(elem => { let old = elem.old; let oldKeys = Object.keys(old); if (oldKeys.length === 1) { let oldKey = oldKeys[0]; let tag: TaginfoTag = { key: oldKey }; let oldValue = old[oldKey]; if (oldValue !== '*') tag.value = oldValue; let replacementStrings = []; for (let replaceKey in elem.replace) { let replaceValue = elem.replace[replaceKey]; if (replaceValue === '$1') replaceValue = '*'; replacementStrings.push(`${replaceKey}=${replaceValue}`); } let description = '🄳 (deprecated tag)'; if (replacementStrings.length > 0) { description += ' ➜ ' + replacementStrings.join(' + '); } tag._description = [description]; coalesceTags(taginfo, tag); } }); if (!discarded) discarded = {}; for (const key in discarded) { let description = '🄳🄳 (discarded tag)'; if (discarded[key] === true) { // key=* let tag = { key, _description: [description] }; coalesceTags(taginfo, tag); } else { // key=value for (const value in discarded[key]) { const tag = { key, value, _description: [description] }; coalesceTags(taginfo, tag); } } } taginfo.tags.forEach(elem => { if (elem._description) { elem.description = elem._description.join(', '); delete elem._description; } }); function areAnyFreeFormTagsAcceptedInField(field: Field) { if (['multiCombo', 'manyCombo', 'check', 'defaultCheck', 'onewayCheck'].includes(field.type)) { return false; } if (field.autoSuggestions === false && field.customValues === false) { return false; } return true; } function coalesceTags(taginfo: TaginfoSchema, tag: TaginfoTag) { if (!tag.key) return; let currentTaginfoEntries = taginfo.tags .filter(t => (t.key === tag.key && t.value === tag.value)); if (currentTaginfoEntries.length === 0) { taginfo.tags.push(tag); return; } if (!tag._description) return; if (!currentTaginfoEntries[0]._description) { currentTaginfoEntries[0]._description = tag._description; return; } let isNewDescription = currentTaginfoEntries[0]._description .indexOf(tag._description[0]) === -1; if (isNewDescription) { currentTaginfoEntries[0]._description!.push(tag._description[0]); } } function setObjectType(tag: TaginfoTag, input: Field | Preset) { if (!input.geometry) return; tag.object_types = []; const mapping: Record = { 'point' : 'node', 'vertex' : 'node', 'line' : 'way', 'relation' : 'relation', 'area' : 'area' }; input.geometry.forEach(geom => { if (tag.object_types!.indexOf(mapping[geom]) === -1) { tag.object_types!.push(mapping[geom]); } }); } return taginfo; } function generateIconsList(presets: AllPresets, fields: AllFields, categories: AllCategories) { const icons: Record = {}; [ ...Object.values(presets).map(p => p.icon).filter(Boolean), ...Object.values(categories).map(c => c.icon).filter(Boolean), ...Object.values(fields).flatMap(f => Object.values(f.icons || {})) ].forEach(icon => icons[icon!] = true); return Object.keys(icons).sort(); } const toPascalCase = (string: string) => string.replace(/(_.|^.)/g, match => match.at(-1)!.toUpperCase()); const createTypeIdentifier = (string: string) => toPascalCase(toSafeIdentifier(string)); async function generateTypeDefs(distDir: string) { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const inputFolder = path.join(__dirname, '../../schemas'); /** * Some generated files use plural names because they * export an object, e.g. `Fields = Record` */ const KEY_MAP: Record = { field: 'fields', preset: 'presets', preset_category: 'preset_categories', }; const fileNames = fs.globSync(path.join(inputFolder, '/**/*.json')); async function processFile(fileName: string) { const key = path.parse(fileName).name; const pluralKey = KEY_MAP[key]; const mainExport = createTypeIdentifier(pluralKey || key); const output = ['']; if (pluralKey) { output.push( `export interface ${createTypeIdentifier(pluralKey)} {`, ` [id: string]: ${createTypeIdentifier(key)}`, '}' ); } output.push( `declare const json: ${mainExport};`, 'export default json;' ); const fileContent = JSON.parse(await fs.promises.readFile(fileName, 'utf8')); if (key === 'field') delete fileContent.anyOf; const tsFile = await compile(fileContent, mainExport, { additionalProperties: false, ignoreMinAndMaxItems: true, cwd: path.join(__dirname, '../../schemas'), // ensure that the default export uses a consistent name customName: (schema, fallback) => schema.$schema && schema.$id ? createTypeIdentifier(path.parse(schema.$id).name) : (schema.$id || schema.title || fallback || ''), }); output.unshift(tsFile); output.push(''); await fs.promises.writeFile( path.join(distDir, `${pluralKey || key}.d.json.ts`), output.join('\n'), ); } await Promise.all(fileNames.map(processFile)); // finally, create the index file which re-exports everything // as named types. const indexFile = fileNames .map((fileName) => { const key = path.parse(fileName).name; return `export type * from './${KEY_MAP[key] || key}.d.json.ts';`; }) .join('\n'); await fs.promises.writeFile( path.join(distDir, 'index.d.ts'), indexFile + '\n', ); } function validateCategoryPresets(categories: AllCategories, presets: AllPresets) { Object.keys(categories).forEach(id => { const category = categories[id]; if (!category.members) return; category.members.forEach(preset => { if (presets[preset] === undefined) { process.stderr.write(`Unknown preset: ${preset} in category ${category.name}\n`); process.stdout.write('\n'); process.exit(1); } }); }); } function validatePresetFields(presets: AllPresets, fields: AllFields) { const betweenBracketsRegex = /([^{]*?)(?=\})/; const maxFieldsBeforeError = 10; let usedFieldIDs = new Set(); for (let presetID in presets) { let preset = presets[presetID]; if (preset.replacement) { let replacementPreset = presets[preset.replacement]; let p1geometry = preset.geometry.slice().sort().toString(); if (replacementPreset === undefined) { process.stderr.write(`Unknown preset "${preset.replacement}" referenced as replacement of preset "${presetID}" (${preset.name})\n`); process.stdout.write('\n'); process.exit(1); } let p2geometry = replacementPreset.geometry.slice().sort().toString(); if (p1geometry !== p2geometry) { process.stderr.write(`The preset "${presetID}" has different geometry than its replacement preset, "${preset.replacement}". They must match for tag upgrades to work.\n`); process.stdout.write('\n'); process.exit(1); } } // the keys for properties that contain arrays of field ids let fieldKeys = ['fields', 'moreFields'] as const; for (let fieldsKeyIndex in fieldKeys) { let fieldsKey = fieldKeys[fieldsKeyIndex]; if (!preset[fieldsKey]) continue; // no fields are referenced, okay for (let fieldIndex in preset[fieldsKey]) { let fieldID = preset[fieldsKey][fieldIndex]; usedFieldIDs.add(fieldID); let field = fields[fieldID]; if (field) { if (field.geometry) { let sharedGeometry = field.geometry.filter(value => preset.geometry.includes(value)); if (!sharedGeometry.length) { process.stderr.write(`The preset "${presetID}" (${preset.name}) will never display the field "${fieldID}" since they don't share geometry types.\n`); process.stdout.write('\n'); process.exit(1); } } } else { // no field found with this ID... let regexResult = betweenBracketsRegex.exec(fieldID); if (regexResult) { let foreignPresetID = regexResult[0]; if (presets[foreignPresetID] === undefined) { process.stderr.write(`Unknown preset "${foreignPresetID}" referenced in "${fieldsKey}" array of preset "${presetID}" (${preset.name})\n`); process.stdout.write('\n'); process.exit(1); } } else { process.stderr.write(`Unknown preset field "${fieldID}" in "${fieldsKey}" array of preset "${presetID}" (${preset.name})\n`); process.stdout.write('\n'); process.exit(1); } } } } if (preset.fields) { // since `moreFields` is available, check that `fields` doesn't get too cluttered let fieldCount = preset.fields.length; if (fieldCount > maxFieldsBeforeError) { // Fields with `prerequisiteTag` or `geometry` may not always be shown, // so don't count them against the limits. const alwaysShownFields = preset.fields.filter(fieldID => { if (fields[fieldID]?.prerequisiteTag || fields[fieldID]?.geometry) return false; return true; }); fieldCount = alwaysShownFields.length; } if (fieldCount > maxFieldsBeforeError) { process.stderr.write(`${fieldCount} values in "fields" of "${preset.name}" (${presetID}). Limit: ${maxFieldsBeforeError}. Please move lower-priority fields to "moreFields".\n`); process.stdout.write('\n'); process.exit(1); } } } for (let fieldID in fields) { if (!usedFieldIDs.has(fieldID) && fields[fieldID].universal !== true && (fields[fieldID].usage || 'preset') === 'preset') { process.stdout.write('Field "' + fields[fieldID].label + '" (' + fieldID + ') isn\'t used by any presets.\n'); } } } function validateDefaults(defaults: PresetDefaults, categories: AllCategories, presets: AllPresets) { Object.keys(defaults).forEach(name => { const members = defaults[name as keyof PresetDefaults]; members.forEach(id => { if (!presets[id] && !categories[id]) { process.stderr.write(`Unknown category or preset: ${id} in default ${name}\n`); process.stdout.write('\n'); process.exit(1); } }); }); } function translationsToYAML(translations: SourceStrings) { // comment keys start with '#' and should sort immediately before their related key. function commentFirst(a: string, b: string) { if (a === '#' + b) return -1; if (b === '#' + a) return 1; if (a[0] !== b[0]) { if (a[0] === '#') a = a.substr(1); if (b[0] === '#') b = b.substr(1); } return (a > b ? 1 : a < b ? -1 : 0); } return dumpYaml(translations, { sortKeys: commentFirst, lineWidth: -1 }) .replace(/'?#.*?'?:/g, '#'); } function minifyJSON(inPath: string, outPath: string) { return new Promise((resolve, reject) => { if (!fs.existsSync(inPath)) { resolve(); return; } fs.readFile(inPath, 'utf8', (err, data) => { if (err) return reject(err); const minified = JSON.stringify(JSON.parse(data)); fs.writeFile(outPath, minified, (err) => { if (err) return reject(err); resolve(); }); }); }); } export { buildDev, buildDist, };