@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.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @moood/check
2
+
3
+ A moood repository checked. A repository is a folder: every folder in it — at any depth, the root too — with
4
+ `world.json` is a world, with `game.json` a game, with `article.json` an article; a folder that is one is not searched
5
+ further, and one with two of these files is wrong. Each says the languages it is in (`"languages": ["en", "ru"]`, of
6
+ moood's: `i18n/languages.json`) and is checked in those, and only those. A game names its world (`"world"`) by a
7
+ path from its own folder (`"../usher"`), or as `owner/repo@ref:path` in another repository (only the form is checked:
8
+ nothing is fetched); it is in a language only if its world is. An article names the worlds, games and scenes it is about (`"about"`) the same way.
9
+
10
+ Every text a person reads or hears is written in the code as a literal (`tr`, `tr.n`, `tr.c`, `tr.line`, `tr.thought`;
11
+ a line someone says — `say`, `think`) or in a read JSON field (scene.json `title`, `description`, `caption`,
12
+ `captions[].text`; world.json `title`, `description`; game.json `title`, `description`, `line`,
13
+ `source`; `bespoke/people.json` `*.short`), and translated into each language it is in (English is the source). A
14
+ line's manner (`how`: a person's in people.json, a line's own in `say`, `think`, `tr.line`, `tr.thought`) is a
15
+ direction to its voice, in English, a literal — never shown, never translated; `--json`'s lines carry it as the page
16
+ asks for it (the person's, then the line's: `play/voices.js`).
17
+
18
+ npm i -g @moood/check
19
+ moood-check <repo> --write # each language's file laid out anew from what the code says (translations kept)
20
+ moood-check <repo> # checked: "all right", or what is wrong — exits 1
21
+ moood-check <repo> --json # the whole report: every text; every line heard, in each language, as its voice is asked for
22
+
23
+ Where the translations lie (from the world's or the game's folder): a scene's — `scenes/<folder>/lang/<lang>.json`; a
24
+ module's — `modules/<name>.<lang>.json`; the world's — `lang/<lang>.json`; a game's — `bespoke/lang/<lang>/strings.json`
25
+ (its data by language beside it: `bespoke/lang/<lang>/<name>.json`, the same files in every language). One entry a
26
+ line: `{text, translation, where}` (`plural` — its forms in the language; `context`, `who` — what tells it apart).
27
+ Translate `translation`; nothing else changes by hand. An entry twice (a merge leaves it so) is wrong: `--write` keeps
28
+ one. An article is translated whole, by hand: `article.json` (`title`, `date`, `summary`) and `story.md`; beside them
29
+ `article.<lang>.json` (`title`, `summary`) and `story.<lang>.md`.
30
+
31
+ What it finds wrong in the code: a text that is not a literal (`tr(x)`; a bare text in a line, `say('who', a ? 'x' : 'y')`
32
+ — each written whole, or marked where it is written: `tr.line`, `tr.thought`), `tr` made a value or a tag (`T = tr`,
33
+ `f(tr)`, ``tr`…` ``), `tr`, `say`, `think` declared or set by the code, a line's voice not in literals (a scene's
34
+ `{voice, how}`; a game's line its own manner only, `{how}` — of `say`, `think`, `tr.line`, `tr.thought`; a kept line said
35
+ with one; `who` is not heard). In a game's people (`bespoke/people.json`: `{who: {voice, how, short}}`) anything else; in a
36
+ line's translation, other inline tags than the line's (`<sigh>`, `<short pause>` — heard as written, in English). A
37
+ scene's `"engine"` other than 3 or none (engine 1) — engine 2 is gone. In a scene's music, what a host making it with
38
+ Lyria does not take (`engines/1/music.js`: `MooodMusic.LIMITS` — `bpm` 60–200, `density` and `brightness` 0–1, one of
39
+ Lyria's scales, at most 20 descriptions that sound, 500 characters each).
40
+
41
+ It reads a repository through a source of its files (`repository.js`: `folder(root)` — a folder on disk, read as git
42
+ reads it: a symbolic link is not followed nor written through, nothing outside it is read; a host's own — the studio
43
+ reads a git tree so): `check(source, {write})`, `lines(source, report)`. What it finds wrong is data —
44
+ `{at, code, values}` —, said in words by `problems/problems.js`: `say(problem, tr)` in the language of the translator
45
+ it is given (English on the command line; a page in its own — the translations beside it, `problems/lang/`; it
46
+ imports nothing of Node's: a browser's page says them too). The hosts read a repository by the same rules
47
+ (`repository.js`): `units` (what it holds), `languagesOf` (the languages one says — a value that is not moood's makes
48
+ it in none), `reference` (how they name each other), `blobId` (a file's content's id: git's blob sha).
49
+
50
+ A folder with no world, game or article in it is a folder of moood's own pages (`engines/1`, `play`, …): its code and
51
+ pages, translated beside them (`lang/<lang>.json`) into every language moood has.
52
+
53
+ `npm run build` copies what it shares with the runtime into the package: `i18n/`, the play's `voices.js` and words,
54
+ and `engines/1/music.js`.
55
+ moood's CI publishes it to npm from a tag `check-v<version>` — the version `package.json` says (bump it there first).
package/cli.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ // moood-check <folder> [--write] [--json] — a repository of worlds, games and articles (or a folder of moood's own
3
+ // pages): it checked, its texts translated (index.js); --write lays out each language's file anew from what the code says; --json — the whole
4
+ // report (every text; every line heard, in every language, as its voice is asked for: lines.js; each problem with its
5
+ // text). What is wrong is said in English (problems/problems.js). Exits 1 when something is wrong.
6
+ import { check } from './index.js';
7
+ import { lines } from './lines.js';
8
+ import { say } from './problems/problems.js';
9
+ import { folder } from './repository.js';
10
+ import './i18n/i18n.js';
11
+
12
+ const args = process.argv.slice(2);
13
+ const source = folder(args.find(a => !a.startsWith('--')) || '.');
14
+ // (laid out, then checked: what is left to translate is said)
15
+ if (args.includes('--write')) await check(source, { write: true });
16
+ const report = await check(source), en = globalThis.MooodI18n.translator(globalThis.MooodI18n.source);
17
+ const said = report.problems.map(p => ({ ...p, text: say(p, en) }));
18
+ if (args.includes('--json')) console.log(JSON.stringify({ ...report, problems: said, lines: await lines(source, report) }, null, 1));
19
+ else {
20
+ for (const p of said) console.log(`${p.at}: ${p.text}`);
21
+ const n = report.scopes.reduce((k, s) => k + s.entries.length, 0);
22
+ console.log(`${report.problems.length ? `${report.problems.length} wrong` : 'all right'} — ${n} texts in ${report.scopes.length} scopes`);
23
+ }
24
+ process.exitCode = report.problems.length ? 1 : 0; // (not exit(): a long report to a pipe is written out first)
@@ -0,0 +1,273 @@
1
+ // A scene's music (spec.music: weighted text descriptions and settings — instrumental, made by the host's music, as
2
+ // Lyria RealTime makes it), heard from its recording: made once, kept — everyone hears the same, whoever came first
3
+ // had it made. It starts at once where the browser lets sound begin, else at the first gesture on the page, and
4
+ // follows the open scene.
5
+ // Its recording, the host's (docs/host.md): POST <host>music {music} → {key} — its recording begun there, unless it is
6
+ // recorded or being made; GET <host>music?key=… only listens — frames [type:1][length:4 LE][payload], 3 = a second as
7
+ // it is kept (a byte — 1: a new encoder begins there —, then its Opus packets, each [length:2 LE][packet]), 2 = a JSON
8
+ // event; sent up to a minute ahead (a slow or a broken connection is not heard), held here as they came and decoded
9
+ // (WebCodecs, one decoder throughout) only AHEAD seconds before they sound. A whole recording is heard round and round:
10
+ // a stream that ends has heard a making that failed or was lost — the music is asked for again (its POST begins it
11
+ // anew), TRIES times at most while nothing more is heard. Weight 0 means "not now" and is never sent.
12
+ // One music gives way to another (another scene's) crossfading over FADE seconds; the same music — the same
13
+ // descriptions, weights, settings — goes on as it was. What goes wrong lands in window.__mooodErrors, and is said
14
+ // (silent): a host's refusal in the page's words — one that makes no music is asked no more, the scenes go silent; the
15
+ // quota spent, that music is asked no more.
16
+ // MooodMusic.init({ track, listen, silent }) — once (a page with a stage, or without: with i18n.js and volume.js
17
+ // before it, its words said — words.js speak): track(music, signal) → a promise of its key (POST music),
18
+ // listen(key, signal) → of its stream (GET music: a Response) — a refusal an Error in the page's language, its code
19
+ // with it (scene/host.js); silent(why) — the music could not be heard (why: in the page's language); show(music) — the
20
+ // open scene's (or none); pause(on) — the play paused; asked(music) — a scene's music as the host is asked for it (the
21
+ // body of POST music: {prompts, config}), or null — none sounds: what a host that makes the music ahead makes; LIMITS —
22
+ // what a host making it with Lyria takes (beyond it, a request refused: check says it of a scene). A classic script:
23
+ // it sets globalThis.MooodMusic (a page's script; a module's import — check and the host's side read asked() and
24
+ // LIMITS so).
25
+ globalThis.MooodMusic = (() => {
26
+ const RATE = 48000, LEAD = 0.25, FADE = 4, AHEAD = 8; // seconds of audio queued ahead; of a crossfade; of a recording decoded ahead
27
+ const TRIES = 3, AGAIN = 5; // a music asked for again while nothing more is heard, and seconds between
28
+ const OPUS = { codec: 'opus', sampleRate: RATE, numberOfChannels: 2 };
29
+ // (Lyria RealTime's: its settings' ranges — bpm a whole number —, its scales — a major and its relative minor —, the
30
+ // descriptions that sound at most, and their characters)
31
+ const LIMITS = { prompts: 20, text: 500, ranges: { bpm: [60, 200], density: [0, 1], brightness: [0, 1] },
32
+ scales: ['C_MAJOR_A_MINOR', 'D_FLAT_MAJOR_B_FLAT_MINOR', 'D_MAJOR_B_MINOR', 'E_FLAT_MAJOR_C_MINOR', 'E_MAJOR_D_FLAT_MINOR', 'F_MAJOR_D_MINOR',
33
+ 'G_FLAT_MAJOR_E_FLAT_MINOR', 'G_MAJOR_E_MINOR', 'A_FLAT_MAJOR_F_MINOR', 'A_MAJOR_G_FLAT_MINOR', 'B_FLAT_MAJOR_G_MINOR', 'B_MAJOR_A_FLAT_MINOR'] };
34
+ let track = null, listen = null, silent = null, music = null; // (music: asked(the open scene's))
35
+ let ctx = null, out = null;
36
+ let playing = false, pushTimer = null;
37
+ let deck = null; // what sounds now: { what, gain, nextT, stop() }
38
+ let standing = false; // an engine 3 scene stands now (Moood.onPause): what starts meanwhile waits with it
39
+ let mute = false; // the host makes no music
40
+ const refused = new Set(); // the musics refused (the quota spent): not asked for again
41
+
42
+ const tr = MooodI18n.part('engine1'); // (the engine's words, as the page said its language: words.js speak)
43
+ // what went wrong: noted, and said — a host's refusal in the page's words, anything else as music not heard
44
+ const failed = e => {
45
+ const detail = e?.detail || e?.message || String(e);
46
+ (window.__mooodErrors ||= []).push(`music: ${detail}`);
47
+ globalThis.MooodSeen?.say('music', { failed: String(detail).slice(0, 200) });
48
+ silent?.(e?.code ? e.message : tr('the music could not be heard'));
49
+ };
50
+ // What music sounds: its descriptions that have weight (trimmed) and its settings — the same, the same music
51
+ function asked(m) {
52
+ const prompts = (Array.isArray(m?.prompts) ? m.prompts : []).map(p => ({ text: String(p?.text || '').trim(), weight: Number(p?.weight) || 0 }))
53
+ .filter(p => p.text && p.weight > 0);
54
+ if (!prompts.length) return null;
55
+ const config = {};
56
+ for (const k of ['density', 'brightness']) if (typeof m[k] === 'number') config[k] = m[k];
57
+ if (typeof m.bpm === 'number') config.bpm = Math.round(m.bpm);
58
+ if (typeof m.scale === 'string') config.scale = m.scale; // the world's key (Lyria's name: C_MAJOR_A_MINOR…)
59
+ return { prompts, config };
60
+ }
61
+
62
+ // ── a deck: one music, into its own gain ─────────────────────────────────
63
+ function deckOf(whatNow) {
64
+ const gain = ctx.createGain();
65
+ gain.gain.value = 0;
66
+ gain.connect(out);
67
+ const d = { what: whatNow, gain, nextT: 0, aborted: false, abort: new AbortController(), held: [], heard: 0, dec: null, feeding: false, timer: 0,
68
+ pcm: [], frames: 0, used: 0 };
69
+ d.stop = () => {
70
+ d.aborted = true; d.abort.abort(); clearInterval(d.timer);
71
+ if (d.dec?.state === 'configured') d.dec.close();
72
+ d.pcm.forEach(a => a.close()); d.pcm = [];
73
+ setTimeout(() => gain.disconnect(), 200);
74
+ };
75
+ return d;
76
+ }
77
+ // a recording's second, as it came: held till it is near its time
78
+ function hold(d, bytes) {
79
+ window.__mooodMusicChunks = (window.__mooodMusicChunks || 0) + 1; // for checking the stream
80
+ if (d.aborted) return;
81
+ d.heard++;
82
+ d.held.push(bytes);
83
+ d.timer ||= setInterval(() => feed(d), 1000);
84
+ feed(d);
85
+ }
86
+ // what is held decoded and queued, while what is queued (and in the decoder) sounds for less than AHEAD seconds more —
87
+ // one decoder for the whole recording, never flushed between its seconds (Opus carries its state from packet to packet:
88
+ // a decoder started anew each second clicks at every second's edge); anew only where a new encoder began
89
+ async function feed(d) {
90
+ if (d.feeding) return;
91
+ d.feeding = true;
92
+ try {
93
+ while (!d.aborted && d.held.length && ahead(d) < AHEAD) {
94
+ const bytes = d.held.shift();
95
+ if (bytes[0] === 1 || !d.dec) await anew(d);
96
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
97
+ for (let i = 1; i < bytes.length;) {
98
+ const n = view.getUint16(i, true);
99
+ d.dec.decode(new EncodedAudioChunk({ type: 'key', timestamp: (d.ts += 20000), data: bytes.subarray(i + 2, i + 2 + n) }));
100
+ i += 2 + n;
101
+ }
102
+ }
103
+ } catch (e) { if (!d.aborted && deck === d) failed(e); } finally { d.feeding = false; }
104
+ }
105
+ // seconds the deck has ahead of now: queued to sound, decoded and not yet queued, still in the decoder (20 ms a packet)
106
+ const ahead = d => Math.max(0, d.nextT - ctx.currentTime) + d.frames / RATE + (d.dec ? d.dec.decodeQueueSize * 0.02 : 0);
107
+ // a decoder for what a new encoder made: the one before let out to its end first (its last part sounded as it is)
108
+ async function anew(d) {
109
+ if (d.dec?.state === 'configured') {
110
+ await d.dec.flush();
111
+ if (d.frames) sound(d, take(d, d.frames));
112
+ d.dec.close();
113
+ }
114
+ d.dec = new AudioDecoder({ output: a => gather(d, a), error: () => {} });
115
+ d.dec.configure(OPUS);
116
+ d.ts = 0;
117
+ }
118
+ // what the decoder lets out, gathered into seconds: each whole one queued to sound
119
+ function gather(d, a) {
120
+ if (d.aborted) { a.close(); return; }
121
+ d.pcm.push(a);
122
+ d.frames += a.numberOfFrames;
123
+ while (d.frames >= RATE) sound(d, take(d, RATE));
124
+ }
125
+ // n frames of what is gathered, as a buffer (from the oldest, a piece of one at its edge)
126
+ function take(d, n) {
127
+ const buf = ctx.createBuffer(2, n, RATE);
128
+ for (let at = 0; at < n;) {
129
+ const a = d.pcm[0], k = Math.min(n - at, a.numberOfFrames - d.used);
130
+ for (let c = 0; c < 2; c++) a.copyTo(buf.getChannelData(c).subarray(at, at + k), { planeIndex: c, frameOffset: d.used, frameCount: k, format: 'f32-planar' });
131
+ at += k; d.used += k;
132
+ if (d.used === a.numberOfFrames) { d.pcm.shift().close(); d.used = 0; }
133
+ }
134
+ d.frames -= n;
135
+ return buf;
136
+ }
137
+ // a buffer after what the deck has queued
138
+ function sound(d, buf) {
139
+ if (d.aborted) return;
140
+ const src = ctx.createBufferSource();
141
+ src.buffer = buf;
142
+ src.connect(d.gain);
143
+ const at = Math.max(d.nextT, ctx.currentTime + LEAD);
144
+ src.start(at);
145
+ d.nextT = at + buf.duration;
146
+ }
147
+ // a stream of frames (a Response) into the deck: ev(event) for its events; → once it has ended — whether it ended
148
+ // there (event ended: nothing is being made of it)
149
+ async function read(d, r, ev) {
150
+ if (!r.body) throw new Error('the music stream did not open');
151
+ const reader = r.body.getReader();
152
+ let buf = new Uint8Array(0);
153
+ for (;;) {
154
+ const { value, done } = await reader.read();
155
+ if (done) return;
156
+ const joined = new Uint8Array(buf.length + value.length);
157
+ joined.set(buf); joined.set(value, buf.length);
158
+ buf = joined;
159
+ while (buf.length >= 5) {
160
+ const len = buf[1] | (buf[2] << 8) | (buf[3] << 16) | (buf[4] << 24);
161
+ if (buf.length < 5 + len) break;
162
+ const type = buf[0], payload = buf.slice(5, 5 + len);
163
+ buf = buf.slice(5 + len);
164
+ if (type === 3) hold(d, payload);
165
+ else if (type === 2) ev(JSON.parse(new TextDecoder().decode(payload)));
166
+ }
167
+ }
168
+ }
169
+ // the deck that sounds now gives way to d over `fade` seconds (nothing sounded: d comes in quickly)
170
+ function swap(d, fade = FADE) {
171
+ const was = deck, t = ctx.currentTime;
172
+ deck = d;
173
+ if (was) {
174
+ was.gain.gain.cancelScheduledValues(t);
175
+ was.gain.gain.setValueAtTime(was.gain.gain.value, t);
176
+ was.gain.gain.linearRampToValueAtTime(0, t + fade);
177
+ setTimeout(() => was.stop(), fade * 1000 + 300);
178
+ }
179
+ if (d) {
180
+ d.gain.gain.setValueAtTime(0, t);
181
+ d.gain.gain.linearRampToValueAtTime(1, t + (was ? fade : 0.3));
182
+ }
183
+ }
184
+
185
+ // the music's recording: asked for (its making begun, if it is neither recorded nor being made) and heard. A stream
186
+ // that ends, or breaks (the connection's TypeError), before the recording is whole — its making failed or was lost, the
187
+ // host went — is asked for again, TRIES times while nothing more is heard. A refusal ends it: no music made here — none
188
+ // asked for again; the quota spent — not this music
189
+ function recorded(whatNow, m) {
190
+ const d = deckOf(whatNow);
191
+ (async () => {
192
+ for (let tries = 0, heard = 0; ; tries = d.heard > heard ? 0 : tries + 1, heard = d.heard) {
193
+ if (tries === TRIES) throw new Error(`the music's stream ended ${TRIES} times with nothing more heard`);
194
+ if (tries) await new Promise(ok => setTimeout(ok, AGAIN * 1000));
195
+ if (d.aborted) return;
196
+ try {
197
+ const key = await track(m, d.abort.signal);
198
+ await read(d, await listen(key, d.abort.signal), e => {
199
+ if (e.ready && deck === d) globalThis.MooodSeen?.say('music', { playing: true });
200
+ });
201
+ } catch (e) {
202
+ if (d.aborted) return;
203
+ if (e?.code || !(e instanceof TypeError)) throw e;
204
+ }
205
+ }
206
+ })().catch(e => {
207
+ if (e?.code === 'no-music') mute = true;
208
+ if (e?.code === 'quota') refused.add(whatNow);
209
+ if (!d.aborted && deck === d) failed(e);
210
+ });
211
+ return d;
212
+ }
213
+
214
+ // what should sound now, sounding: nothing (no weights, no music), or its recording
215
+ function push() {
216
+ if (!playing) return;
217
+ const now = music && JSON.stringify(music);
218
+ if (!music || mute || refused.has(now)) { swap(null); return; }
219
+ if (deck && deck.what === now) return;
220
+ swap(recorded(now, music));
221
+ }
222
+ function pushSoon() { clearTimeout(pushTimer); pushTimer = setTimeout(push, 200); }
223
+
224
+ // It may be asked again while it is starting: a browser that does not let sound begin yet leaves the context's
225
+ // resume() unanswered — for ever, if nothing asks it again —, and the first gesture's asking is what it answers. Of
226
+ // the askings then answered the first one plays; the others find it playing
227
+ async function start() {
228
+ if (playing) return;
229
+ try {
230
+ ctx = ctx || new AudioContext({ sampleRate: RATE });
231
+ // (an engine 3 scene standing: the music begins as the scene goes on — stands)
232
+ await (standing ? ctx.suspend() : ctx.resume());
233
+ if (playing) return; // begun by another asking meanwhile
234
+ if (!out) { out = ctx.createGain(); out.connect(ctx.destination); }
235
+ out.gain.setTargetAtTime(MooodVolume.get('music'), ctx.currentTime, 0.3);
236
+ playing = true;
237
+ push();
238
+ } catch (e) { playing = false; failed(e); }
239
+ }
240
+
241
+ // ── the page's side ──────────────────────────────────────────────────────
242
+ function init(opts) {
243
+ ({ track, listen, silent } = opts);
244
+ MooodVolume.on((kind, v) => { if (kind === 'music' && out) out.gain.setTargetAtTime(v, ctx.currentTime, 0.1); });
245
+ // it starts at once, as far as the browser lets sound begin; where it does not, the first gesture of any kind
246
+ // starts it
247
+ const first = () => { removeEventListener('pointerdown', first, true); removeEventListener('keydown', first, true); start(); };
248
+ addEventListener('pointerdown', first, true);
249
+ addEventListener('keydown', first, true);
250
+ start();
251
+ // (an engine 3 scene standing — the play paused, nobody watching it: the music stands with it; of any stage of the
252
+ // page, mounted before this or after — a page without a stage, none: a host's page where music is tuned)
253
+ globalThis.Moood?.onPause(stands);
254
+ }
255
+
256
+ // the open scene's music (from its spec), or none
257
+ function show(m) {
258
+ music = asked(m);
259
+ if (playing) pushSoon();
260
+ }
261
+
262
+ // the play paused — or an engine 3 scene standing: the music stands where it is, and goes on when it isn't (it is the
263
+ // sound's clock that stands, not what plays; going on, it ends an engine 3 scene's standing too — of one the stage
264
+ // left for another engine's while the play was paused)
265
+ function pause(on) {
266
+ if (!on) standing = false;
267
+ if (ctx) (on ? ctx.suspend() : ctx.resume()).catch(() => {});
268
+ }
269
+ // an engine 3 scene stands (the play paused, nobody watching it, a layer of the page over it), and goes on
270
+ function stands(on) { standing = on; pause(on); }
271
+
272
+ return { init, show, pause, asked, LIMITS };
273
+ })();
package/extract.js ADDED
@@ -0,0 +1,192 @@
1
+ // What a file says to a person: the texts of its code (tr, tr.n, tr.c, say, think — their literals), of a widget's
2
+ // markup (its text), of a JSON file's fields that are read or heard (FIELDS). Each found is an entry —
3
+ // {text, plural?, context?, who?, voice?, how?, where}
4
+ // (who: a line said — say/think; voice: how it is heard, when the code says it: a scene's say(who, text, {voice,
5
+ // how}); how: a game's line's own manner — its say(who, text, {how}), think's, tr.line's, tr.thought's: its voice is
6
+ // its person's) — and what is wrong is a problem: a text that is not a literal (nothing could find it), a name of the
7
+ // language's own (tr, say, think) made the code's own (problems/problems.js).
8
+ import * as acorn from 'acorn';
9
+ import * as walk from 'acorn-walk';
10
+ import { problem } from './problems/problems.js';
11
+ import './i18n/i18n.js';
12
+
13
+ // a JSON file's fields that are read: by its name — [path] (the language's own list: what a page shows translated)
14
+ export const { FIELDS } = globalThis.MooodI18n;
15
+ const OWN = new Set(['tr', 'say', 'think']);
16
+ // (a text a page shows as it is — a field, markup — is given nothing to put in: a {name} in it is an error)
17
+ const BRACED = /\{\w+\}/;
18
+
19
+ const literal = node => {
20
+ if (node?.type === 'Literal' && typeof node.value === 'string') return node.value;
21
+ if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked;
22
+ return null;
23
+ };
24
+
25
+ // a line's text that is not a literal: whether a text is in it bare (cond ? 'a' : 'b', 'a' + b) — not a line marked
26
+ // where it is written (tr.line, tr.thought) — nothing could find it
27
+ function bareText(arg) {
28
+ let bare = false;
29
+ walk.recursive(arg, null, {
30
+ Literal(n) { if (typeof n.value === 'string') bare = true; },
31
+ TemplateLiteral() { bare = true; },
32
+ CallExpression(n, st, c) {
33
+ if (n.callee.type === 'MemberExpression' && n.callee.object.type === 'Identifier' && n.callee.object.name === 'tr') return;
34
+ c(n.callee, st);
35
+ for (const a of n.arguments) c(a, st);
36
+ },
37
+ // (what chooses the text is not it: a condition, a comparison, a key)
38
+ ConditionalExpression(n, st, c) { c(n.consequent, st); c(n.alternate, st); },
39
+ BinaryExpression(n, st, c) { if (n.operator === '+') { c(n.left, st); c(n.right, st); } },
40
+ MemberExpression(n, st, c) { c(n.object, st); },
41
+ });
42
+ return bare;
43
+ }
44
+
45
+ function parse(code) {
46
+ const opts = { ecmaVersion: 'latest', locations: true, allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true,
47
+ allowImportExportEverywhere: true, allowHashBang: true };
48
+ try { return acorn.parse(code, { ...opts, sourceType: 'module' }); } catch {
49
+ return acorn.parse(code, { ...opts, sourceType: 'script' });
50
+ }
51
+ }
52
+
53
+ // The code's texts. file — its path (what an entry and a problem say where); own — the language's own names are the
54
+ // language's (a world's, a game's code: tr, say, think none of its own); moood's own pages make their translator
55
+ // themselves; game — a game's code (its lines are b.say's, never a scene's act(who, 'say', …)).
56
+ export function fromCode(code, file, own = true, game = false) {
57
+ const entries = [], problems = [];
58
+ let ast;
59
+ try { ast = parse(code); } catch (e) { return { entries, problems: [problem(file, 'not-javascript', { error: e.message })] }; }
60
+ const wrong = (node, code, values) => problems.push(problem(`${file}:${node.loc.start.line}`, code, values));
61
+ const add = (node, e) => entries.push({ ...e, where: file, line: node.loc.start.line });
62
+ // a line's voice, as it is asked for (the options it is said with): a scene's — {voice, how}; a game's — {how}, the
63
+ // line's own manner (its voice is its person's: people.json); literals, written whole; who no longer (the voice says
64
+ // it is someone; the manner, how)
65
+ const voiceOf = (arg, node, name) => {
66
+ const o = arg?.type === 'ObjectExpression' ? arg : null;
67
+ const voice = o && Object.fromEntries(o.properties.filter(p => p.type === 'Property' && ['voice', 'how'].includes(p.key?.name))
68
+ .map(p => [p.key.name, literal(p.value)]));
69
+ if (voice && Object.values(voice).some(v => v == null)) wrong(node, game ? 'how-not-literal' : 'voice-not-literal', { name });
70
+ if (o?.properties.some(p => p.type === 'Property' && p.key?.name === 'who')) wrong(node, 'voice-who', { name });
71
+ if (o?.properties.some(p => p.type === 'SpreadElement')) wrong(node, 'voice-spread', { name });
72
+ if (game && voice && 'voice' in voice) wrong(node, 'voice-game', { name });
73
+ if (game) return voice?.how ? { how: voice.how } : {};
74
+ return voice?.voice ? { voice } : {};
75
+ };
76
+ walk.ancestor(ast, {
77
+ CallExpression(node) {
78
+ const c = node.callee, args = node.arguments;
79
+ const name = c.type === 'Identifier' ? c.name : c.type === 'MemberExpression' && !c.computed ? c.property.name : null;
80
+ const obj = c.type === 'MemberExpression' && c.object.type === 'Identifier' ? c.object.name : null;
81
+ if (c.type === 'Identifier' && name === 'tr') {
82
+ const text = literal(args[0]);
83
+ if (text == null) wrong(node, 'tr-not-literal');
84
+ else add(node, { text });
85
+ } else if (obj === 'tr' && name === 'n') {
86
+ const one = literal(args[1]), other = literal(args[2]);
87
+ if (one == null || other == null) wrong(node, 'tr-n-not-literal');
88
+ else add(node, { text: one, plural: other });
89
+ } else if (obj === 'tr' && name === 'c') {
90
+ const context = literal(args[0]), text = literal(args[1]);
91
+ if (context == null || text == null) wrong(node, 'tr-c-not-literal');
92
+ else add(node, { context, text });
93
+ } else if (obj === 'tr' && (name === 'line' || name === 'thought')) {
94
+ const who = literal(args[0]), text = literal(args[1]);
95
+ if (who == null || text == null) wrong(node, 'tr-line-not-literal', { name });
96
+ else add(node, { who, text, said: name === 'line' ? 'say' : 'think', ...voiceOf(args[2], node, `tr.${name}`) });
97
+ } else if (obj === 'tr' && own) {
98
+ wrong(node, 'tr-not-own', { name });
99
+ } else if (own && (name === 'say' || name === 'think') && c.type === 'MemberExpression' && c.object.type === 'CallExpression'
100
+ && c.object.callee.type === 'MemberExpression' && c.object.callee.property.name === 'person') {
101
+ // (a person's own verb: s.person('who').say('…', {voice, how, who}) — their line)
102
+ const who = literal(c.object.arguments[0]), text = literal(args[0]);
103
+ if (who == null || text == null) wrong(node, 'person-line-not-literal', { name });
104
+ else add(node, { who, text, said: name, ...voiceOf(args[1], node, name) });
105
+ } else if (own && name === 'act' && literal(args[1]) === 'say') {
106
+ // (an action: act('who', 'say', '…') — a scene's: the scene translates it; a game's line is b.say)
107
+ const who = literal(args[0]), text = literal(args[2]);
108
+ if (game) wrong(node, 'act-say-game');
109
+ else if (who == null || text == null) wrong(node, 'act-say-not-literal');
110
+ else add(node, { who, text, said: 'say' });
111
+ } else if (own && (name === 'say' || name === 'think')) {
112
+ // (a line written where it is said; else it was marked where it is kept: tr.line, tr.thought)
113
+ const who = literal(args[0]), text = literal(args[1]);
114
+ if (who != null && text != null) {
115
+ add(node, { who, text, said: name, ...voiceOf(args[2], node, name) });
116
+ } else if (literal(args[1]) != null || args[1]?.type === 'TemplateLiteral') {
117
+ wrong(node, 'line-speaker', { name });
118
+ } else if (c.type === 'MemberExpression' && (literal(args[0]) != null || args[0]?.type === 'TemplateLiteral') && (!args[1] || args[1].type === 'ObjectExpression')) {
119
+ // (a person's verb on a name of the code's own: who says it is not written beside it)
120
+ wrong(node, 'line-whose', { name });
121
+ } else if (text == null && args[1] && bareText(args[1])) {
122
+ wrong(node, 'line-bare', { name });
123
+ } else if (game && text == null && args[2]?.type === 'ObjectExpression' && args[2].properties.some(p => p.type === 'Property' && p.key?.name === 'how')) {
124
+ // (a line kept is said as it was kept: its own manner with it)
125
+ wrong(node, 'line-kept-how', { name });
126
+ }
127
+ }
128
+ },
129
+ // the language's own names are the language's: tr, say, think made a variable, a function, a parameter of the code's
130
+ // (a name inside a pattern too: [tr] = …, { say } = …, (a = tr) => …)
131
+ VariableDeclarator(node) { if (own) for (const n of bound(node.id)) if (OWN.has(n)) wrong(node, 'own-variable', { name: n }); },
132
+ FunctionDeclaration(node) { if (own && OWN.has(node.id?.name)) wrong(node, 'own-function', { name: node.id.name }); },
133
+ AssignmentExpression(node) { if (own) for (const n of bound(node.left)) if (OWN.has(n)) wrong(node, 'own-set', { name: n }); },
134
+ // tr is called where its text is written: never made another name (T = tr), handed on (f(tr)), a tag (tr`…`)
135
+ TaggedTemplateExpression(node) { if (own && node.tag.type === 'Identifier' && node.tag.name === 'tr') wrong(node, 'tr-tag'); },
136
+ Identifier(node, ancestors) {
137
+ const up = ancestors[ancestors.length - 2];
138
+ if (!own || node.name !== 'tr' || (up?.type === 'CallExpression' && up.callee === node) || (up?.type === 'MemberExpression' && up.object === node)
139
+ || up?.type === 'TaggedTemplateExpression') return;
140
+ wrong(node, 'tr-value');
141
+ },
142
+ Function(node) { if (own) for (const p of node.params) for (const n of bound(p)) if (OWN.has(n)) wrong(node, 'own-parameter', { name: n }); },
143
+ });
144
+ return { entries, problems };
145
+ }
146
+
147
+ // the names a pattern binds (a declarator's, a parameter's)
148
+ function bound(p) {
149
+ if (!p) return [];
150
+ if (p.type === 'Identifier') return [p.name];
151
+ if (p.type === 'ArrayPattern') return p.elements.flatMap(bound);
152
+ if (p.type === 'ObjectPattern') return p.properties.flatMap(q => bound(q.type === 'RestElement' ? q.argument : q.value));
153
+ if (p.type === 'AssignmentPattern') return bound(p.left);
154
+ if (p.type === 'RestElement') return bound(p.argument);
155
+ return [];
156
+ }
157
+
158
+ // A widget's markup: its texts (the language's own reading of markup: what a page translates), and its scripts'.
159
+ export function fromMarkup(html, file, own = true, game = false) {
160
+ const entries = [], problems = [];
161
+ for (const m of html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)) {
162
+ const r = fromCode(m[1], file, own, game);
163
+ entries.push(...r.entries); problems.push(...r.problems);
164
+ }
165
+ for (const text of globalThis.MooodI18n.markupTexts(html)) {
166
+ if (BRACED.test(text)) problems.push(problem(file, 'braced', { text }));
167
+ else entries.push({ text, where: file });
168
+ }
169
+ return { entries, problems };
170
+ }
171
+
172
+ // A JSON file's texts: its FIELDS.
173
+ export function fromJson(json, file, fields) {
174
+ const entries = [], problems = [];
175
+ let data;
176
+ try { data = JSON.parse(json); } catch (e) { return { entries, problems: [problem(file, 'json', { error: e.message })] }; }
177
+ const take = (value, path, where) => {
178
+ if (!path.length) {
179
+ if (typeof value === 'string' && BRACED.test(value)) problems.push(problem(file, 'braced', { text: value }));
180
+ else if (typeof value === 'string' && value.trim()) entries.push({ text: value, where });
181
+ else if (value != null && typeof value !== 'string') problems.push(problem(file, 'not-text', { field: where.slice(file.length).trim() }));
182
+ return;
183
+ }
184
+ const [head, ...rest] = path;
185
+ if (value == null || typeof value !== 'object') return;
186
+ if (head === '*') for (const [k, v] of Object.entries(value)) take(v, rest, `${where}.${k}`);
187
+ else if (head.endsWith('[]')) (value[head.slice(0, -2)] || []).forEach((v, i) => take(v, rest, `${where} ${head.slice(0, -2)}[${i}]`));
188
+ else take(value[head], rest, path.length === 1 ? `${where} ${head}` : where);
189
+ };
190
+ for (const f of fields) take(data, f.split('.'), file);
191
+ return { entries, problems };
192
+ }