Skip to content

Commit e173352

Browse files
committed
dereference at build-time
1 parent 137fb3d commit e173352

File tree

4 files changed

+221
-38
lines changed

4 files changed

+221
-38
lines changed

lib/build.js

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import YAML from 'js-yaml';
88
import marky from 'marky';
99
import { createRequire } from 'module';
1010
import { compile, toSafeIdentifier } from 'json-schema-to-typescript-lite';
11-
11+
import { isReference, dereferencedTranslatableContent, dereferenceUntranslatedContent } from './references.js';
1212
import fetchTranslations, { expandTStrings, sortObject } from './translations.js';
1313

1414
const require = createRequire(import.meta.url);
@@ -117,16 +117,20 @@ function processData(options, type) {
117117
let categories = generateCategories(dataDir, tstrings);
118118
if (options.processCategories) options.processCategories(categories);
119119

120-
let fields = generateFields(dataDir, tstrings, searchableFieldIDs);
120+
/** @type {References} */
121+
const references = { fields: {}, presets: {} };
122+
let fields = generateFields(dataDir, tstrings, searchableFieldIDs, references);
121123
if (options.processFields) options.processFields(fields);
122124

123-
let presets = generatePresets(dataDir, tstrings, searchableFieldIDs, options.listReusedIcons);
125+
let presets = generatePresets(dataDir, tstrings, searchableFieldIDs, options.listReusedIcons, references);
124126
if (options.processPresets) options.processPresets(presets);
125127

126128
// Additional consistency checks
127129
validateCategoryPresets(categories, presets);
128130
validatePresetFields(presets, fields);
129131

132+
dereferenceUntranslatedContent(presets, fields);
133+
130134
const defaults = read(dataDir + '/preset_defaults.json');
131135
if (defaults) {
132136
validateSchema(dataDir + '/preset_defaults.json', defaults, defaultsSchema);
@@ -150,6 +154,8 @@ function processData(options, type) {
150154
let icons = generateIconsList(presets, fields, categories);
151155
fs.writeFileSync(interimDir + '/icons.json', JSON.stringify(icons, null, 4));
152156

157+
dereferencedTranslatableContent(tstrings, references, true);
158+
153159
if (type !== 'build-dist') return;
154160

155161
const doFetchTranslations = options.translOrgId && options.translProjectId;
@@ -198,7 +204,7 @@ function processData(options, type) {
198204
];
199205

200206
if (doFetchTranslations) {
201-
tasks.push(fetchTranslations(options));
207+
tasks.push(fetchTranslations(options, references));
202208
}
203209
return Promise.all(tasks);
204210
}
@@ -247,7 +253,7 @@ function generateCategories(dataDir, tstrings) {
247253
}
248254

249255

250-
function generateFields(dataDir, tstrings, searchableFieldIDs) {
256+
function generateFields(dataDir, tstrings, searchableFieldIDs, references) {
251257
let fields = {};
252258

253259
fs.globSync(dataDir + '/fields/**/*.json', {
@@ -262,10 +268,13 @@ function generateFields(dataDir, tstrings, searchableFieldIDs) {
262268

263269
const label = field.label;
264270

265-
if (!label.startsWith('{')) {
271+
if (isReference(label)) {
272+
references.fields[id] ||= {};
273+
references.fields[id].labelAndTerms = label;
274+
} else {
266275
t.label = label;
267-
delete field.label;
268276
}
277+
delete field.label;
269278

270279
validateTerms(field.terms, `field "${id}"`);
271280
tstrings.fields[id].terms = Array.from(new Set(
@@ -279,21 +288,42 @@ function generateFields(dataDir, tstrings, searchableFieldIDs) {
279288
searchableFieldIDs[id] = true;
280289
}
281290

282-
if (field.placeholder && !field.placeholder.startsWith('{')) {
283-
t.placeholder = field.placeholder;
291+
if (field.placeholder) {
292+
if (isReference(field.placeholder)) {
293+
references.fields[id] ||= {};
294+
references.fields[id].placeholder = field.placeholder;
295+
} else {
296+
t.placeholder = field.placeholder;
297+
}
284298
delete field.placeholder;
285299
}
286300

287301
if (field.strings) {
288-
for (let key in field.strings) {
289-
t[key] = field.strings[key];
302+
for (let prop in field.strings) {
303+
t[prop] = {};
304+
for (const [key, value] of Object.entries(field.strings[prop])) {
305+
if (typeof value === 'string' && isReference(value)) {
306+
references.fields[id] ||= {};
307+
references.fields[id].options ||= {};
308+
references.fields[id].options[prop] ||= {};
309+
references.fields[id].options[prop][key] = value;
310+
} else {
311+
t[prop][key] = value;
312+
}
313+
}
290314
}
291315
if (!field.options && field.strings.options) {
292316
field.options = Object.keys(field.strings.options);
293317
}
294318
delete field.strings;
295319
}
296320

321+
if (field.stringsCrossReference) {
322+
references.fields[id] ||= {};
323+
references.fields[id].stringsCrossReference = field.stringsCrossReference;
324+
delete field.stringsCrossReference;
325+
}
326+
297327
fields[id] = field;
298328
});
299329

@@ -308,7 +338,7 @@ function stripLeadingUnderscores(str) {
308338
}
309339

310340

311-
function generatePresets(dataDir, tstrings, searchableFieldIDs, listReusedIcons) {
341+
function generatePresets(dataDir, tstrings, searchableFieldIDs, listReusedIcons, references) {
312342
let presets = {};
313343

314344
let icons = {};
@@ -330,11 +360,12 @@ function generatePresets(dataDir, tstrings, searchableFieldIDs, listReusedIcons)
330360
let names = new Set([]);
331361
tstrings.presets[id] = {};
332362

333-
if (!preset.name.startsWith('{')) {
363+
if (isReference(preset.name)) {
364+
references.presets[id] ||= {};
365+
references.presets[id].nameTermsAliases = preset.name;
366+
} else {
334367
tstrings.presets[id].name = preset.name;
335368
names.add(preset.name.toLowerCase());
336-
// don't include localized strings in the presets dist file since they're already in the locale file
337-
delete preset.name;
338369
}
339370

340371
preset.aliases = Array.from(new Set(
@@ -359,6 +390,8 @@ function generatePresets(dataDir, tstrings, searchableFieldIDs, listReusedIcons)
359390
// don't include localized strings in the presets dist file since they're already in the locale file
360391
delete preset.aliases;
361392
delete preset.terms;
393+
delete preset.name;
394+
362395

363396
if (preset.moreFields) {
364397
preset.moreFields.forEach(fieldID => { searchableFieldIDs[fieldID] = true; });
@@ -424,13 +457,6 @@ function generateTranslations(fields, presets, tstrings, searchableFieldIDs) {
424457
yamlField['#label'] = `${field.key}=*`;
425458
}
426459
optkeys.forEach(k => {
427-
if (typeof options[k] === 'string' && options[k].startsWith('{')) {
428-
// skip, this references another field or preset, so we don't want
429-
// translators to translate it.
430-
delete options[k];
431-
return;
432-
}
433-
434460
if (typeof options[k] === 'string'){
435461
options['#' + k] = field.key ? `${field.key}=${k}` : `field "${fieldId}" with value "${k}"`;
436462
} else {
@@ -562,7 +588,7 @@ function generateTaginfo(presets, fields, deprecated, discarded, tstrings, proje
562588

563589

564590
let name = tstrings.presets[id].name;
565-
if (!name && preset.name.startsWith('{')) {
591+
if (!name && isReference(preset.name)) {
566592
name = tstrings.presets[preset.name.slice(1, -1)].name;
567593
}
568594
let legacy = (preset.searchable === false) ? ' (unsearchable)' : '';
@@ -609,7 +635,7 @@ function generateTaginfo(presets, fields, deprecated, discarded, tstrings, proje
609635
let tag = { key: key };
610636

611637
let label = tstrings.fields[id].label;
612-
if (!label && field.label.startsWith('{')) {
638+
if (!label && field.label && isReference(field.label)) {
613639
label = tstrings.fields[field.label.slice(1, -1)].label;
614640
}
615641
tag.description = [ `🄵 ${label}` ];
@@ -627,18 +653,8 @@ function generateTaginfo(presets, fields, deprecated, discarded, tstrings, proje
627653
tag = { key: key, value: value };
628654
}
629655
let valueLabel = tstrings.fields[id].options?.[value];
630-
if (!valueLabel && field.stringsCrossReference) {
631-
valueLabel = tstrings.fields[field.stringsCrossReference.slice(1, -1)].options?.[value];
632-
}
633-
if (valueLabel && typeof valueLabel === 'string') {
634-
const match = valueLabel.match(/^\{(.*)\}$/)?.[1];
635-
if (match) {
636-
const [group, ...remainder] = match.split('/');
637-
const reference = tstrings[group][remainder.join('/')];
638-
if (!reference) throw new Error(`${valueLabel} is not a valid reference`);
639656

640-
valueLabel = reference.name || reference.label;
641-
}
657+
if (valueLabel && typeof valueLabel === 'string') {
642658
tag.description = [ `🄵🅅 ${label}: ${valueLabel}` ];
643659
} else {
644660
tag.description = [ `🄵🅅 ${label}: \`${value}\`` ];

lib/index.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { buildDev, buildDist, validate } from './build.js';
2-
import fetchTranslations from './translations.js';
32

43
export default {
54
buildDev, buildDist, validate,
6-
fetchTranslations
75
};

lib/references.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/** @param {string} string */
2+
export function isReference(string) {
3+
return string.startsWith('{') && string.endsWith('}');
4+
}
5+
6+
/**
7+
* This is only used to expand references to _untranslated content_.
8+
* For example, `fields` can reference the list of field IDs from another
9+
* preset.
10+
*/
11+
export function dereferenceUntranslatedContent(presets, fields) {
12+
for (const presetID in presets) {
13+
const preset = presets[presetID];
14+
15+
// 1. fields and moreFields can reference other presets
16+
for (const prop of ['fields', 'moreFields']) {
17+
if (!preset[prop]) continue;
18+
for (let i = 0; i < preset[prop].length || 0; i++) {
19+
const otherPresetID = preset[prop][i];
20+
if (isReference(otherPresetID)) {
21+
const referencedPreset = presets[otherPresetID.slice(1, -1)];
22+
23+
if (!referencedPreset) {
24+
throw new Error(
25+
`Preset “${presetID}” references “${otherPresetID}” in ${prop}.${i}, but there is no such preset.`,
26+
);
27+
}
28+
29+
// preset (A) references the fields of preset (B), but (B) has no
30+
// fields. For now, we silently skip this to match the existing logic,
31+
// but in the future we could emit an error here. (TODO:)
32+
if (!referencedPreset[prop]) {
33+
preset[prop].splice(i--, 1);
34+
continue;
35+
}
36+
37+
// replace the reference with every field. decrement i to reprocess this array index.
38+
preset[prop].splice(i--, 1, ...referencedPreset[prop]);
39+
}
40+
}
41+
}
42+
}
43+
44+
for (const fieldID in fields) {
45+
const field = fields[fieldID];
46+
47+
// 2. fields can reference icons from other presets
48+
if (field.iconsCrossReference) {
49+
const referencedField = fields[field.iconsCrossReference.slice(1, -1)];
50+
51+
if (!referencedField) {
52+
throw new Error(
53+
`Field “${fieldID}” references “${field.iconsCrossReference}” in stringsCrossReference, but there is no such field.`,
54+
);
55+
}
56+
57+
field.icons = referencedField.icons;
58+
delete field.iconsCrossReference;
59+
}
60+
}
61+
}
62+
63+
/**
64+
* Copies translated strings to the presets that reference these strings.
65+
*/
66+
export function dereferencedTranslatableContent(tstrings, references, strict) {
67+
for (const presetID in references.presets) {
68+
// skip missing field, this language must have incomplete translations
69+
if (!tstrings.presets?.[presetID]) continue;
70+
71+
const p = references.presets[presetID];
72+
// 4. presets can reference the name + terms + aliases from other presets
73+
if (p.nameTermsAliases) {
74+
const referencedPreset =
75+
tstrings.presets[p.nameTermsAliases.slice(1, -1)];
76+
77+
if (referencedPreset) {
78+
tstrings.presets[presetID].name = referencedPreset.name;
79+
tstrings.presets[presetID].aliases = referencedPreset.aliases;
80+
tstrings.presets[presetID].terms = referencedPreset.terms;
81+
} else if (strict) {
82+
throw new Error(
83+
`Preset “${presetID}” references “${p.nameTermsAliases}” in the name, but there is no such preset.`,
84+
);
85+
}
86+
}
87+
}
88+
89+
for (const fieldID in references.fields) {
90+
// skip missing field, this language must have incomplete translations
91+
if (!tstrings.fields?.[fieldID]) continue;
92+
93+
const f = references.fields[fieldID];
94+
// 5. fields can reference the label + terms from other fields
95+
if (f.labelAndTerms) {
96+
const referencedField = tstrings.fields[f.labelAndTerms.slice(1, -1)];
97+
98+
if (referencedField) {
99+
tstrings.fields[fieldID].label = referencedField.label;
100+
tstrings.fields[fieldID].terms = referencedField.terms;
101+
} else if (strict) {
102+
throw new Error(
103+
`Field “${fieldID}” references “${f.labelAndTerms}” in the label, but there is no such field.`,
104+
);
105+
}
106+
}
107+
108+
// 6. fields can reference the placeholder from other fields
109+
if (f.placeholder) {
110+
const referencedField = tstrings.fields[f.placeholder.slice(1, -1)];
111+
112+
if (referencedField) {
113+
tstrings.fields[fieldID].placeholder = referencedField.placeholder;
114+
} else if (strict) {
115+
throw new Error(
116+
`Field “${fieldID}” references “${f.placeholder}” in the placeholder, but there is no such field.`,
117+
);
118+
}
119+
}
120+
121+
// 7. fields can reference the entire strings.options object from other fields
122+
if (f.stringsCrossReference) {
123+
const referencedField =
124+
tstrings.fields[f.stringsCrossReference.slice(1, -1)];
125+
126+
if (referencedField) {
127+
for (const prop in referencedField) {
128+
if (typeof referencedField[prop] === 'object') {
129+
tstrings.fields[fieldID][prop] = referencedField[prop];
130+
}
131+
}
132+
} else if (strict) {
133+
throw new Error(
134+
`Field “${fieldID}” references “${f.stringsCrossReference}” in stringsCrossReference, but there is no such field.`,
135+
);
136+
}
137+
}
138+
139+
// 8. specific field options can reference the label from other fields *and from other presets*
140+
if (f.options) {
141+
for (const prop in f.options) {
142+
for (const key in f.options[prop]) {
143+
const [type, ...foreignId] = f.options[prop][key]
144+
.slice(1, -1)
145+
.split('/');
146+
const referenced =
147+
type === 'presets'
148+
? tstrings.presets[foreignId.join('/')].name
149+
: type === 'fields'
150+
? tstrings.fields[foreignId.join('/')].label
151+
: undefined;
152+
153+
if (referenced) {
154+
tstrings.fields[fieldID][prop] ||= {};
155+
tstrings.fields[fieldID][prop][key] = referenced;
156+
} else if (strict) {
157+
throw new Error(
158+
`Field “${fieldID}” references “${foreignId}” in options.${prop}.${key}, but there is no such ${type}.`,
159+
);
160+
}
161+
}
162+
}
163+
}
164+
}
165+
}

0 commit comments

Comments
 (0)