@moood/check 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ // What check finds wrong, as data: a problem is {at — where (a file, a file:line, a folder), code, values — names,
2
+ // paths, texts and numbers as written} — and say(problem, tr) says it in words, in the language of the translator it
3
+ // is given (MooodI18n.translator: English for the command line; a page its own language — the translations beside
4
+ // this file, lang/<lang>.json: a browser's page loads them as any part of moood's pages, i18n/words.js). A code
5
+ // say(…) knows nothing of is said as it is.
6
+ export const problem = (at, code, values = {}) => ({ at, code, values });
7
+
8
+ export function say({ code, values: v }, tr) {
9
+ switch (code) {
10
+ // where worlds, games and articles lie; what they say of themselves
11
+ case 'two-kinds': return tr('{files} in one folder — a folder is one world, one game or one article', v);
12
+ case 'json': return tr('not JSON: {error}', v);
13
+ case 'not-object': return tr('not an object', v);
14
+ case 'languages': return tr('"languages" — the languages it is in: a list of moood\'s ({known})', v);
15
+ case 'languages-unknown': return tr('"languages" — {languages}: not moood\'s ({known})', v);
16
+ case 'languages-twice': return tr('"languages" — {languages} twice', v);
17
+ // how they name each other ({field}: "world", "about"; {ref}: as written)
18
+ case 'ref-not-path': return tr('"{field}" {ref} — not a path', v);
19
+ case 'ref-repository': return tr('"{field}" {ref} — {name}: not a repository (owner/name)', v);
20
+ case 'ref-version': return tr('"{field}" {ref} — {version}: not a branch, a tag or a commit', v);
21
+ case 'ref-path': return tr('"{field}" {ref} — {path}: not a path in the repository (. — its root)', v);
22
+ case 'ref-neither': return tr('"{field}" {ref} — neither a path from here nor repo@ref:path', v);
23
+ case 'ref-out': return tr('"{field}" {ref} — out of the repository', v);
24
+ case 'world-none': return tr('"world" — the world it is played on: a path from here, or repo@ref:path', v);
25
+ case 'world-not-there': return tr('"{field}" {ref} — no world there ({file})', v);
26
+ case 'world-languages': return tr('"{field}" {ref} — its world is not in {languages}: a game is in a language only if its world is', v);
27
+ case 'about-not-list': return tr('"about" — a list of the worlds, games and scenes it is about (paths from here, or repo@ref:path)', v);
28
+ case 'about-nothing': return tr('"about" {ref} — no world, game or scene there', v);
29
+ // the engine a scene is for
30
+ case 'engine': return tr('"engine" {engine} — there is engine 3 (every new scene), and engine 1 (a scene that says none)', v);
31
+ case 'engine-gone': return tr('"engine" 2 — engine 2 is gone: the scene is written anew for engine 3 ("engine": 3)', v);
32
+ // a scene's music, as a host makes it (engines/1/music.js: what Lyria takes)
33
+ case 'music-prompts': return tr('"music" — {count} descriptions that sound: {most} at most', v);
34
+ case 'music-text': return tr('"music" — the description {n} that sounds: {length} characters, {most} at most', v);
35
+ case 'music-range': return tr('"music".{field} {value} — {least} to {most}', v);
36
+ case 'music-scale': return tr('"music".scale "{scale}" — one of {scales}', v);
37
+ // the code's texts
38
+ case 'not-javascript': return tr('does not read as JavaScript: {error}', v);
39
+ case 'tr-not-literal': return tr('tr(…) — its text is not a literal', v);
40
+ case 'tr-n-not-literal': return tr('tr.n(count, one, other) — its forms are not literals', v);
41
+ case 'tr-c-not-literal': return tr('tr.c(context, text) — not literals', v);
42
+ case 'tr-line-not-literal': return tr('tr.{name}(who, text) — not literals', v);
43
+ case 'tr-not-own': return tr('tr.{name} — not for a world\'s or a game\'s code (tr, tr.n, tr.c, tr.line, tr.thought)', v);
44
+ case 'tr-tag': return tr('tr`…` — call it: tr(\'…\')', v);
45
+ case 'tr-value': return tr('tr — not a value (T = tr, f(tr)): call it where its text is written', v);
46
+ case 'own-variable': return tr('{name} is the language\'s own name — not a variable', v);
47
+ case 'own-function': return tr('{name} is the language\'s own name — not a function', v);
48
+ case 'own-set': return tr('{name} is the language\'s own name — not to be set', v);
49
+ case 'own-parameter': return tr('{name} is the language\'s own name — not a parameter', v);
50
+ // a line someone says
51
+ case 'person-line-not-literal': return tr('person(who).{name}(text) — not literals', v);
52
+ case 'act-say-game': return tr('act(who, \'say\', text) — a game\'s line is b.say(who, text)', v);
53
+ case 'act-say-not-literal': return tr('act(who, \'say\', text) — not literals', v);
54
+ case 'line-speaker': return tr('{name}(who, text) — the text written here, its speaker not: a line is said by someone written beside it ({name}(\'who\', \'…\'))', v);
55
+ case 'line-whose': return tr('.{name}(\'…\') — whose line it is is not written beside it: s.person(\'who\').{name}(\'…\'), or s.{name}(\'who\', \'…\')', v);
56
+ case 'line-bare': return tr('{name}(who, text) — a text in it is not a line: write it whole here, or mark each where it is written (tr.line, tr.thought)', v);
57
+ case 'line-kept-how': return tr('{name}(who, line, { how }) — a kept line\'s own manner is kept with it: tr.line(who, text, { how })', v);
58
+ case 'voice-not-literal': return tr('{name} — its voice, how are not literals', v);
59
+ case 'how-not-literal': return tr('{name} — its how is not a literal', v);
60
+ case 'voice-who': return tr('{name} — "who" is not heard: a voice is {voice, how}', v);
61
+ case 'voice-spread': return tr('{name} — its voice is spread (…): say it whole, in literals', v);
62
+ case 'voice-game': return tr('{name} — a game\'s line is heard in its person\'s voice (people.json): its own is { how }', v);
63
+ // a text shown as it is (a read field, markup)
64
+ case 'braced': return tr('"{text}" — a { name } in a text shown as it is (nothing goes into it)', v);
65
+ case 'not-text': return tr('{field} — not a text', v);
66
+ // translations
67
+ case 'not-strings': return tr('not {"strings": [entries]}', v);
68
+ case 'translated-twice': return tr('"{text}" — translated twice', v);
69
+ case 'no-translations': return tr.n(v.count, 'none — {n} text to translate into {lang}', 'none — {n} texts to translate into {lang}', v);
70
+ case 'untranslated': return tr('"{text}" — not translated', v);
71
+ case 'untranslated-forms': return tr('"{text}" — not translated (its forms: {forms})', v);
72
+ case 'no-form': return tr('"{text}" — no "{form}" form', v);
73
+ case 'form-names': return tr('"{text}" — its "{form}" form says {{names}}, the text {{wanted}} (and { n }, or not)', v);
74
+ case 'names': return tr('"{text}" — the translation says {{names}}, the text {{wanted}}', v);
75
+ case 'tags': return tr('"{text}" — the translation\'s tags are {tags}, the line\'s {wanted} (heard as written, in English)', v);
76
+ case 'unsaid': return tr('"{text}" — the code no longer says it (check --write lays the file out anew)', v);
77
+ case 'data': return tr('its data ({files}) is not the source\'s ({wanted})', v);
78
+ // a game's people
79
+ case 'person-not-object': return tr('"{who}" — not {voice, how, short}', v);
80
+ case 'person-field': return tr('"{who}".{field} — a person is {voice, how, short}', v);
81
+ case 'person-name': return tr('"{who}".name — a person is {voice, how, short} (who they are is their voice and their manner)', v);
82
+ case 'person-not-text': return tr('"{who}".{field} — not a text', v);
83
+ case 'line-no-person': return tr('"{text}" — said by "{who}", no one of {people}', v);
84
+ case 'thought-no-short': return tr('"{text}" — thought by "{who}", who has no short name ({people})', v);
85
+ // articles
86
+ case 'article-title': return tr('no "title" and "date"', v);
87
+ case 'article-story': return tr('none — the article\'s text', v);
88
+ case 'article-none': return tr('none — the article in {lang}', v);
89
+ case 'article-no-title': return tr('no "title" — the article in {lang}', v);
90
+ case 'article-no-summary': return tr('no "summary"', v);
91
+ case 'article-story-lang': return tr('none — the article\'s text in {lang}', v);
92
+ default: return code;
93
+ }
94
+ }
package/repository.js ADDED
@@ -0,0 +1,126 @@
1
+ // A repository of content as it lies: a folder in which every folder (at any depth, the root too) with world.json is
2
+ // a world, with game.json a game, with article.json an article — as many as it has, mixed; a folder that is one is not
3
+ // searched further (its scenes/, modules/, bespoke/ are its own), and one with two of these files is wrong — and how
4
+ // they name each other: a path from the one that names, or `repo@ref:path` for another repository. What check reads
5
+ // (scopes.js) and the hosts serve (the local player: player/; moood's studio: a git tree at a commit).
6
+ // A repository is read through a source of its files — a folder on disk (folder), or whatever a host reads one from:
7
+ // list(dir) → the entries of a folder ('' the root, else ending in /): [{name, dir}], or null — there is none;
8
+ // read(path) → a file's text (UTF-8), or null — there is none;
9
+ // and one that is written to (a folder: check --write lays out the translations' files) —
10
+ // write(path, text) — the file, its folders made as needed;
11
+ // and a host's (docs/host.md: the files the pages are given by their content) —
12
+ // id(path) → the id of a file's content: git's blob sha (blobId), or null — there is none.
13
+ // A folder on disk is read as git reads a repository: a symbolic link is no file and no folder of it — never followed,
14
+ // never written through —, and nothing outside it is read.
15
+ import { createHash } from 'node:crypto';
16
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
17
+ import { dirname, resolve, sep, posix as P } from 'node:path';
18
+ import { problem } from './problems/problems.js';
19
+
20
+ export const natural = (a, b) => a.localeCompare(b, 'en', { numeric: true });
21
+ const KINDS = ['world', 'game', 'article'];
22
+
23
+ // A path of a folder (real: the folder's own path, links resolved) as it lies there → the path, or null: it leads out
24
+ // of the folder, or through a symbolic link (or there is nothing there)
25
+ export function within(real, path) {
26
+ const p = resolve(real, `.${sep}${path}`);
27
+ if (p !== real && !p.startsWith(`${real}${sep}`)) return null;
28
+ try { return realpathSync(p) === p ? p : null; } catch { return null; }
29
+ }
30
+ const kind = p => { try { return lstatSync(p); } catch { return null; } };
31
+
32
+ // the id of a file's content (its bytes): git's blob sha — the same whether a host reads it from disk or from git
33
+ export const blobId = bytes => createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
34
+
35
+ // a folder on disk as a source
36
+ export function folder(root) {
37
+ const real = realpathSync(root);
38
+ return {
39
+ async list(dir) {
40
+ const p = within(real, dir);
41
+ if (!p || !kind(p).isDirectory()) return null;
42
+ return readdirSync(p, { withFileTypes: true }).filter(d => d.isDirectory() || d.isFile()).map(d => ({ name: d.name, dir: d.isDirectory() }));
43
+ },
44
+ async read(path) {
45
+ const p = within(real, path);
46
+ return p && kind(p).isFile() ? readFileSync(p, 'utf8') : null;
47
+ },
48
+ // (a file that is there is written over only if it is one, not a link; folders made only under one that is the
49
+ // folder's own)
50
+ async write(path, text) {
51
+ const p = resolve(real, `.${sep}${path}`);
52
+ let up = dirname(p);
53
+ while (!kind(up)) up = dirname(up);
54
+ const there = kind(p);
55
+ if (!p.startsWith(`${real}${sep}`) || !within(real, up.slice(real.length + 1)) || (there && !there.isFile())) throw new Error(`${path}: not a file of the folder (a symbolic link on the way, or out of it) — not written`);
56
+ mkdirSync(dirname(p), { recursive: true });
57
+ writeFileSync(p, text);
58
+ },
59
+ };
60
+ }
61
+
62
+ // The worlds, games and articles of a repository (its source): [{kind, base — its folder from root ('' the root, else
63
+ // ending in /), json — its world.json, game.json or article.json read (null: not an object — the problem said)}], and
64
+ // what is wrong with where they lie (problems/problems.js)
65
+ export async function units(source) {
66
+ const problems = [];
67
+ const walk = async folder => {
68
+ const entries = await source.list(folder) ?? [];
69
+ const kinds = KINDS.filter(k => entries.some(e => !e.dir && e.name === `${k}.json`));
70
+ if (kinds.length > 1) { problems.push(problem(folder || './', 'two-kinds', { files: kinds.map(k => `${k}.json`).join(', ') })); return []; }
71
+ if (kinds.length) {
72
+ const name = `${folder}${kinds[0]}.json`;
73
+ let json = null;
74
+ try { json = JSON.parse(await source.read(name)); } catch (e) { problems.push(problem(name, 'json', { error: e.message })); }
75
+ if (json !== null && (typeof json !== 'object' || Array.isArray(json))) { problems.push(problem(name, 'not-object')); json = null; }
76
+ return [{ kind: kinds[0], base: folder, json }];
77
+ }
78
+ const inside = entries.filter(e => e.dir && !e.name.startsWith('.') && e.name !== 'node_modules').sort((a, b) => natural(a.name, b.name));
79
+ return (await Promise.all(inside.map(e => walk(`${folder}${e.name}/`)))).flat();
80
+ };
81
+ const found = await walk('');
82
+ return { units: found, problems };
83
+ }
84
+
85
+ // The languages a world, a game or an article says it is in (its "languages": a list of moood's — `known` —, each
86
+ // once) → {languages} | {problem: {code, values}} — not said rightly: in none (the one rule of check and the hosts)
87
+ export function languagesOf(json, known) {
88
+ const l = json?.languages, all = known.join(', ');
89
+ if (!Array.isArray(l) || !l.length || !l.every(x => typeof x === 'string')) return { problem: { code: 'languages', values: { known: all } } };
90
+ const bad = l.filter(x => !known.includes(x)), twice = l.filter((x, i) => l.indexOf(x) !== i);
91
+ if (bad.length) return { problem: { code: 'languages-unknown', values: { languages: bad.join(', '), known: all } } };
92
+ if (twice.length) return { problem: { code: 'languages-twice', values: { languages: twice.join(', ') } } };
93
+ return { languages: l };
94
+ }
95
+
96
+ // A reference to a world, a game or a scene (a game's "world"; an article's "about"): another
97
+ // repository's — repo@ref:path (owner/name; a branch, a tag or a commit; a path in it, `.` its root) —, or a path from
98
+ // the folder `from` (from root, '' or ending in /), within the repository:
99
+ // → {foreign: {repo, ref, path — from its root, posix, '' the root}} | {path: from root, posix, '' the root} | {wrong:
100
+ // {code, values} — what is wrong with it: a problem's (problems/problems.js), with the field and the reference}
101
+ const REPO = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;
102
+ const REF = /^(?![-/.])(?!.*(?:\.\.|\/\/|@\{|\.lock$|[/.]$))[A-Za-z0-9._/-]+$/;
103
+ const relativePath = p => p !== '' && !p.startsWith('/') && !p.includes('\\');
104
+ const wrong = (code, values = {}) => ({ wrong: { code, values } });
105
+ export function reference(ref, from) {
106
+ if (typeof ref !== 'string' || !ref) return wrong('ref-not-path');
107
+ const m = /^([^@:]+)@([^:]+):(.*)$/.exec(ref);
108
+ if (m) {
109
+ if (!REPO.test(m[1])) return wrong('ref-repository', { name: m[1] });
110
+ if (!REF.test(m[2])) return wrong('ref-version', { version: m[2] });
111
+ const p = P.normalize(m[3] || '/');
112
+ if (!relativePath(m[3]) || p === '..' || p.startsWith('../')) return wrong('ref-path', { path: m[3] });
113
+ return { foreign: { repo: m[1], ref: m[2], path: p === '.' ? '' : p.replace(/\/$/, '') } };
114
+ }
115
+ if (ref.includes(':') || !relativePath(ref)) return wrong('ref-neither');
116
+ const p = P.normalize(`${from}${ref}`);
117
+ if (p === '..' || p.startsWith('../')) return wrong('ref-out');
118
+ return { path: p === '.' ? '' : p.replace(/\/$/, '') };
119
+ }
120
+
121
+ // a path in a repository as a world's scene (<world>/scenes/<folder>): {world — the world's folder ('' the root),
122
+ // folder} | null — it is not one
123
+ export function scenePath(path) {
124
+ const m = /^(?:(.*)\/)?scenes\/([^/]+)$/.exec(path);
125
+ return m ? { world: m[1] ?? '', folder: m[2] } : null;
126
+ }
package/scopes.js ADDED
@@ -0,0 +1,119 @@
1
+ // What a repository holds, where its texts come from and where their translations lie. A repository is a folder: every
2
+ // folder in it (at any depth, the root too) with world.json is a world, with game.json a game, with article.json an
3
+ // article — as many as it has, mixed; a folder that is one is not searched further (its scenes/, modules/, bespoke/ are
4
+ // its own), and one with two of these files is wrong. Each says its languages (`"languages": [...]`, moood's:
5
+ // i18n/languages.json) and is checked in those, and only those; a game names its world by a path from its own folder,
6
+ // or `repo@ref:path` for another repository (only its form is checked here: nothing is fetched), and is in a language
7
+ // only if its world is; an article names the worlds, games and scenes it is about (`"about": [...]`) the same way
8
+ // (repository.js).
9
+ // The scopes: a world's (world.json → lang/<lang>.json), each of its scenes' (scenes/<folder>: scene.json, its code,
10
+ // its widgets' markup → scenes/<folder>/lang/<lang>.json), each of its modules' (modules/<name>.js →
11
+ // modules/<name>.<lang>.json); a game's (game.json, bespoke/people.json, bespoke/'s code → bespoke/lang/<lang>/strings.json;
12
+ // its data by language: bespoke/lang/<lang>/<name>.json, the source language's too). An article is translated whole
13
+ // (index.js). A folder with no world, game or article in it is a folder of moood's own pages (its code and pages →
14
+ // lang/<lang>.json), in every language moood has.
15
+ import { readFileSync } from 'node:fs';
16
+ import { FIELDS, fromCode, fromJson, fromMarkup } from './extract.js';
17
+ import { problem } from './problems/problems.js';
18
+ import { languagesOf, natural, reference, scenePath, units } from './repository.js';
19
+
20
+ export const LANGUAGES = Object.keys(JSON.parse(readFileSync(new URL('./i18n/languages.json', import.meta.url), 'utf8')));
21
+
22
+ // the files under a folder of the source ('.' the root), by their path from its root, in natural order; skip — a path
23
+ // left out
24
+ async function filesUnder(source, folder, skip = () => false) {
25
+ const walk = async dir => {
26
+ const entries = await source.list(dir) ?? [];
27
+ return (await Promise.all(entries.map(e => (e.dir ? walk(`${dir}${e.name}/`) : [`${dir}${e.name}`])))).flat();
28
+ };
29
+ return (await walk(folder === '.' ? '' : `${folder.replace(/\/$/, '')}/`)).filter(p => !skip(p)).sort(natural);
30
+ }
31
+ // whether a file is there
32
+ const there = async (source, path) => await source.read(path) !== null;
33
+
34
+ // what the files of a scope say: {entries, problems} — own: a world's or a game's (extract.js fromCode); game: a game's
35
+ async function read(source, files, own, game) {
36
+ const entries = [], problems = [];
37
+ for (const f of files) {
38
+ const text = await source.read(f), name = f.split('/').pop();
39
+ const r = name in FIELDS ? fromJson(text, f, FIELDS[name])
40
+ : /\.(m?js)$/.test(f) ? fromCode(text, f, own, game)
41
+ : /\.html$/.test(f) ? fromMarkup(text, f, own, game) : null;
42
+ if (r) { entries.push(...r.entries); problems.push(...r.problems); }
43
+ }
44
+ return { entries, problems };
45
+ }
46
+
47
+ // a world's, game's or article's languages as it says them (null: not said rightly — the problem said: repository.js)
48
+ function languages(u, problems) {
49
+ if (!u.json) return null;
50
+ const l = languagesOf(u.json, LANGUAGES);
51
+ if (l.problem) problems.push(problem(`${u.base}${u.kind}.json`, l.problem.code, l.problem.values));
52
+ return l.languages ?? null;
53
+ }
54
+ // what is wrong with a reference (repository.js: its wrong), as a problem of the file that makes it
55
+ const misnamed = (name, field, ref, w) => problem(name, w.code, { field, ref: JSON.stringify(ref), ...w.values });
56
+
57
+ // A repository's (or a pages' folder's), read through its source (repository.js): its scopes — [{name, kind, languages
58
+ // — the ones it is checked in, files, file(lang) — where its translation lies, data(lang) — the folder of its data by
59
+ // language and people — its people.json (a game's)}] —, its articles ([{base, json, languages}]) and what is wrong with
60
+ // the repository itself: where its worlds, games and articles lie, what they say of themselves and of each other
61
+ // (problems/problems.js)
62
+ export async function scopes(source) {
63
+ const out = [], articles = [];
64
+ const { units: found, problems } = await units(source);
65
+ if (!found.length && !problems.length) {
66
+ // (a folder of pages: its code, its pages; not a library's bundle built into it, *.min.js)
67
+ out.push({ name: '.', kind: 'front', languages: LANGUAGES, files: (await filesUnder(source, '.', p => p.startsWith('lang/') || p.includes('node_modules/') || p.endsWith('.min.js')))
68
+ .filter(p => /\.(m?js|html)$/.test(p) || p.split('/').pop() in FIELDS),
69
+ file: lang => `lang/${lang}.json` });
70
+ }
71
+ const worlds = new Map(); // a world's folder ('' the root; no ending /) → its languages (null: not said rightly)
72
+ for (const u of found) if (u.kind === 'world') worlds.set(u.base.replace(/\/$/, ''), languages(u, problems));
73
+ for (const u of found) {
74
+ const b = u.base, name = `${b}${u.kind}.json`;
75
+ if (u.kind === 'article') {
76
+ articles.push({ ...u, languages: languages(u, problems) });
77
+ // what it is about: worlds, games, scenes — paths from its folder, or repo@ref:path
78
+ const about = u.json?.about ?? [];
79
+ if (!Array.isArray(about)) problems.push(problem(name, 'about-not-list'));
80
+ else for (const a of about) {
81
+ const r = reference(a, b);
82
+ if (r.wrong) { problems.push(misnamed(name, 'about', a, r.wrong)); continue; }
83
+ if (r.foreign) continue;
84
+ const of = scenePath(r.path), scene = of && worlds.has(of.world) && await there(source, `${r.path}/scene.json`);
85
+ if (!scene && !found.some(f => f.kind !== 'article' && f.base === (r.path ? `${r.path}/` : ''))) problems.push(problem(name, 'about-nothing', { ref: JSON.stringify(a) }));
86
+ }
87
+ } else if (u.kind === 'world') {
88
+ const said = worlds.get(b.replace(/\/$/, '')) ?? [];
89
+ out.push({ name, kind: 'world', languages: said, files: [name], file: lang => `${b}lang/${lang}.json` });
90
+ for (const folder of ((await source.list(`${b}scenes/`)) ?? []).filter(e => e.dir).map(e => e.name).sort(natural)) {
91
+ const base = `${b}scenes/${folder}`;
92
+ out.push({ name: base, kind: 'scene', languages: said, files: (await filesUnder(source, base, p => p.startsWith(`${base}/lang/`))).filter(p => /\.(js|html)$/.test(p) || p.endsWith('/scene.json')),
93
+ file: lang => `${base}/lang/${lang}.json` });
94
+ }
95
+ for (const m of (await filesUnder(source, `${b}modules`)).filter(p => /^modules\/[^/]+\.js$/.test(p.slice(b.length)))) {
96
+ const base = m.slice(0, -3);
97
+ out.push({ name: base, kind: 'module', languages: said, files: [m], file: lang => `${base}.${lang}.json` });
98
+ }
99
+ } else {
100
+ const said = languages(u, problems);
101
+ // its world: there, and in every language the game is in
102
+ if (u.json) {
103
+ const w = reference(u.json.world, b), at = { field: 'world', ref: JSON.stringify(u.json.world) };
104
+ if (u.json.world === undefined) problems.push(problem(name, 'world-none'));
105
+ else if (w.wrong) problems.push(misnamed(name, 'world', u.json.world, w.wrong));
106
+ else if (!w.foreign && !worlds.has(w.path)) problems.push(problem(name, 'world-not-there', { ...at, file: `${w.path ? `${w.path}/` : ''}world.json` }));
107
+ else if (!w.foreign) {
108
+ const l = worlds.get(w.path), missing = l && said ? said.filter(x => !l.includes(x)) : [];
109
+ if (missing.length) problems.push(problem(name, 'world-languages', { ...at, languages: missing.join(', ') }));
110
+ }
111
+ }
112
+ const skip = p => p.startsWith(`${b}bespoke/lang/`);
113
+ out.push({ name, kind: 'game', languages: said ?? [],
114
+ files: [name, ...(await filesUnder(source, `${b}bespoke`, skip)).filter(p => /\.(m?js|html)$/.test(p) || p === `${b}bespoke/people.json`)],
115
+ file: lang => `${b}bespoke/lang/${lang}/strings.json`, data: lang => `${b}bespoke/lang/${lang}`, people: `${b}bespoke/people.json` });
116
+ }
117
+ }
118
+ return { problems, articles, scopes: await Promise.all(out.map(async s => ({ ...s, ...await read(source, s.files, s.kind !== 'front', s.kind === 'game') }))) };
119
+ }