calliope-ts 0.0.1
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/.claude/settings.local.json +8 -0
- package/.opencode/skills/calliope-ts/SKILL.md +74 -0
- package/LICENSE +201 -0
- package/README.md +485 -0
- package/dist/depfix.d.ts +13 -0
- package/dist/depfix.d.ts.map +1 -0
- package/dist/depfix.js +84 -0
- package/dist/display.d.ts +38 -0
- package/dist/display.d.ts.map +1 -0
- package/dist/display.js +890 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +504 -0
- package/dist/parser.d.ts +10 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +688 -0
- package/dist/phonological.d.ts +41 -0
- package/dist/phonological.d.ts.map +1 -0
- package/dist/phonological.js +788 -0
- package/dist/rhyme.d.ts +26 -0
- package/dist/rhyme.d.ts.map +1 -0
- package/dist/rhyme.js +340 -0
- package/dist/scandroid.d.ts +17 -0
- package/dist/scandroid.d.ts.map +1 -0
- package/dist/scandroid.js +435 -0
- package/dist/scansion.d.ts +37 -0
- package/dist/scansion.d.ts.map +1 -0
- package/dist/scansion.js +1007 -0
- package/dist/scansion_debug.js +586 -0
- package/dist/stress.d.ts +32 -0
- package/dist/stress.d.ts.map +1 -0
- package/dist/stress.js +1372 -0
- package/dist/tagfix.d.ts +6 -0
- package/dist/tagfix.d.ts.map +1 -0
- package/dist/tagfix.js +101 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +62 -0
- package/src/depfix.ts +88 -0
- package/src/display.ts +954 -0
- package/src/index.ts +541 -0
- package/src/parser.ts +837 -0
- package/src/phonological.ts +849 -0
- package/src/rhyme.ts +328 -0
- package/src/scandroid.ts +434 -0
- package/src/scansion.ts +1053 -0
- package/src/stress.ts +1381 -0
- package/src/tagfix.ts +104 -0
- package/src/types.ts +230 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as readline from 'readline';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { parseDocument } from './parser.js';
|
|
7
|
+
import {
|
|
8
|
+
assignLexicalStress,
|
|
9
|
+
applyCompoundStress,
|
|
10
|
+
applyNuclearStress,
|
|
11
|
+
assignRelativeStresses,
|
|
12
|
+
} from './stress.js';
|
|
13
|
+
import {
|
|
14
|
+
buildPhonologicalHierarchy,
|
|
15
|
+
renderHierarchy,
|
|
16
|
+
renderKeyStresses,
|
|
17
|
+
flattenDisplayEntries,
|
|
18
|
+
} from './phonological.js';
|
|
19
|
+
import { extractKeyStresses, scoreMeters, applyStanzaConsensus, applyRhythmLayer } from './scansion.js';
|
|
20
|
+
import { applyRhymeAndForm } from './rhyme.js';
|
|
21
|
+
import {
|
|
22
|
+
scandroidCorralWeird,
|
|
23
|
+
scandroidMaximizeNormal,
|
|
24
|
+
stressToMarks,
|
|
25
|
+
} from './scandroid.js';
|
|
26
|
+
import {
|
|
27
|
+
renderUnifiedDisplay,
|
|
28
|
+
renderFullLegend,
|
|
29
|
+
renderReadingView,
|
|
30
|
+
type ReadingStanza,
|
|
31
|
+
} from './display.js';
|
|
32
|
+
import type {
|
|
33
|
+
ClsSentence,
|
|
34
|
+
MetreName,
|
|
35
|
+
IntonationalUnit,
|
|
36
|
+
StressLevel,
|
|
37
|
+
ScansionResult,
|
|
38
|
+
LineResult,
|
|
39
|
+
PhonologicalScansionDetail,
|
|
40
|
+
SyllableDisplayEntry,
|
|
41
|
+
FootDisplayEntry,
|
|
42
|
+
FormattedDisplay,
|
|
43
|
+
DisplayOptions,
|
|
44
|
+
} from './types.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Scan one VERSE LINE (which may parse into several grammatical sentences).
|
|
48
|
+
*
|
|
49
|
+
* The line — not the sentence — is the metrical domain (McAleese; Kiparsky's
|
|
50
|
+
* "phonological phrasing determines the location of caesurae in verse"). A
|
|
51
|
+
* line like "You'll slurp potato soup. No straws! Suck gauze." is ONE iambic
|
|
52
|
+
* pentameter with internal intonational breaks, not three fragments each
|
|
53
|
+
* carrying its own meter. So the linguistic passes (lexical stress, phrasal
|
|
54
|
+
* hierarchy, compound/nuclear/relative stress) run per sentence — those rules
|
|
55
|
+
* are intra-sentence by nature — but the metrical fit runs once over the
|
|
56
|
+
* line's full concatenated syllable stream, with each sentence's IUs preserved
|
|
57
|
+
* as IU boundaries (→ hard caesurae) inside the line.
|
|
58
|
+
*/
|
|
59
|
+
function processLine(sents: ClsSentence[]): LineResult | null {
|
|
60
|
+
if (sents.length === 0) return null;
|
|
61
|
+
|
|
62
|
+
const iusPerSent: IntonationalUnit[][] = [];
|
|
63
|
+
for (const sent of sents) {
|
|
64
|
+
assignLexicalStress(sent.words);
|
|
65
|
+
const sentIus = buildPhonologicalHierarchy(sent);
|
|
66
|
+
applyCompoundStress(sentIus);
|
|
67
|
+
applyNuclearStress(sentIus);
|
|
68
|
+
assignRelativeStresses(sent.words, sentIus);
|
|
69
|
+
iusPerSent.push(sentIus);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Merge the sentences' streams into the line-level scansion domain.
|
|
73
|
+
const words = sents.flatMap(s => s.words);
|
|
74
|
+
const ius = iusPerSent.flat();
|
|
75
|
+
let merged: ClsSentence;
|
|
76
|
+
if (sents.length === 1) {
|
|
77
|
+
merged = sents[0];
|
|
78
|
+
} else {
|
|
79
|
+
// Re-index sequentially so per-sentence 1-based indices don't collide in
|
|
80
|
+
// any downstream order-by-index logic. (All hierarchy/dependency passes
|
|
81
|
+
// above are already complete and reference words by object identity.)
|
|
82
|
+
words.forEach((w, i) => { w.index = i + 1; });
|
|
83
|
+
merged = {
|
|
84
|
+
index: sents[0].index,
|
|
85
|
+
nodes: null,
|
|
86
|
+
dependencies: sents.flatMap(s => s.dependencies),
|
|
87
|
+
words,
|
|
88
|
+
xml: '',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const keyStresses = extractKeyStresses(ius, words);
|
|
93
|
+
|
|
94
|
+
// Full phonological scansion over the whole line.
|
|
95
|
+
const phonoDetail = scoreMeters(keyStresses, words, ius);
|
|
96
|
+
phonoDetail.all = renderHierarchy(ius, words);
|
|
97
|
+
phonoDetail.keyStresses = renderKeyStresses(ius, words, keyStresses);
|
|
98
|
+
|
|
99
|
+
// Scandroid – use the actual foot count from the phonological detection
|
|
100
|
+
const stressPattern: StressLevel[] = words.flatMap((w) =>
|
|
101
|
+
w.syllables.map((s) => s.relativeStress ?? 'w')
|
|
102
|
+
);
|
|
103
|
+
const marks = stressToMarks(stressPattern);
|
|
104
|
+
const actualFeet = phonoDetail.footCount > 0 ? phonoDetail.footCount : 5; // fallback only if unknown
|
|
105
|
+
const corral = scandroidCorralWeird(marks, actualFeet);
|
|
106
|
+
const max = scandroidMaximizeNormal(marks, actualFeet);
|
|
107
|
+
|
|
108
|
+
const corralResult: ScansionResult | undefined = corral.footlist.length
|
|
109
|
+
? {
|
|
110
|
+
meter: 'iambic',
|
|
111
|
+
scansion: corral.footlist.map(f => f.replace(/[()]/g, '')).join(' | '),
|
|
112
|
+
certainty: 0,
|
|
113
|
+
weightScore: 0,
|
|
114
|
+
maxPossibleWeight: 0,
|
|
115
|
+
algorithm: 'Scandroid Corral the Weird',
|
|
116
|
+
}
|
|
117
|
+
: undefined;
|
|
118
|
+
const maxResult: ScansionResult | undefined = max.footlist.length
|
|
119
|
+
? {
|
|
120
|
+
meter: 'iambic',
|
|
121
|
+
scansion: max.footlist.map(f => f.replace(/[()]/g, '')).join(' | '),
|
|
122
|
+
certainty: 0,
|
|
123
|
+
weightScore: 0,
|
|
124
|
+
maxPossibleWeight: 0,
|
|
125
|
+
algorithm: 'Scandroid Maximise the Normal',
|
|
126
|
+
}
|
|
127
|
+
: undefined;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
sentence: merged,
|
|
131
|
+
phonologicalHierarchy: ius,
|
|
132
|
+
keyStresses,
|
|
133
|
+
phonologicalScansion: phonoDetail,
|
|
134
|
+
scandroidCorral: corralResult,
|
|
135
|
+
scandroidMaximise: maxResult,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Analyse a multi‑line text with stanza awareness.
|
|
141
|
+
* Returns a list of stanza arrays, each containing the per‑line results.
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Continuity rename (maintainer directive 2026-06-14): a line whose standalone
|
|
146
|
+
* meter merely edges out the stanza/poem-dominant meter (consensusMeter set by
|
|
147
|
+
* applyStanzaConsensus) ADOPTS the dominant meter as its base reading — the
|
|
148
|
+
* scansion, foot count, and certainty are re-fitted under that meter — and the
|
|
149
|
+
* numerically-best standalone meter is kept as a concise note
|
|
150
|
+
* (`standaloneMeter`). Metrical continuity outranks a hair of fit score.
|
|
151
|
+
*/
|
|
152
|
+
function applyContinuityRename(results: LineResult[]): void {
|
|
153
|
+
// A stanza-level rhythm verdict (set on at least half the lines) means the
|
|
154
|
+
// group reads as accentual/dolnik/taktovik — classical continuity renaming
|
|
155
|
+
// does not apply there.
|
|
156
|
+
const noted = results.filter(r => r.phonologicalScansion.rhythmNote).length;
|
|
157
|
+
if (results.length > 0 && noted >= results.length / 2) return;
|
|
158
|
+
for (const res of results) {
|
|
159
|
+
const d = res.phonologicalScansion;
|
|
160
|
+
if (!d.consensusMeter) continue;
|
|
161
|
+
const family = d.consensusMeter.split(' ')[0] as MetreName;
|
|
162
|
+
const forced = scoreMeters(res.keyStresses, res.sentence.words, res.phonologicalHierarchy, family);
|
|
163
|
+
if (!forced || forced.meterName === 'free verse' || forced.footCount <= 0) continue;
|
|
164
|
+
d.standaloneMeter = d.meter;
|
|
165
|
+
d.meter = forced.meter;
|
|
166
|
+
d.meterName = forced.meterName;
|
|
167
|
+
d.footCount = forced.footCount;
|
|
168
|
+
d.scansion = forced.scansion;
|
|
169
|
+
d.certainty = forced.certainty;
|
|
170
|
+
d.summary = forced.summary;
|
|
171
|
+
d.consensusMeter = undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function analyzeStanzas(text: string, useScandroid = true): LineResult[][] {
|
|
176
|
+
const stanzas = text.split(/\n\s*\n/);
|
|
177
|
+
const results: LineResult[][] = [];
|
|
178
|
+
for (const stanza of stanzas) {
|
|
179
|
+
const lines = stanza.split('\n').filter(l => l.trim() !== '');
|
|
180
|
+
const stanzaResults: LineResult[] = [];
|
|
181
|
+
for (const line of lines) {
|
|
182
|
+
const doc = parseDocument(line);
|
|
183
|
+
const res = processLine(doc.sentences);
|
|
184
|
+
if (res) stanzaResults.push(res);
|
|
185
|
+
}
|
|
186
|
+
// Resolve near-tie lines toward the stanza's dominant meter (explicit, non-
|
|
187
|
+
// destructive: annotates phonologicalScansion.consensusMeter), then classify
|
|
188
|
+
// non-classical rhythm (dolnik/taktovik/accentual → rhythmNote; ballad is a
|
|
189
|
+
// FORM verdict and belongs to the rhyme-aware form layer).
|
|
190
|
+
applyStanzaConsensus(stanzaResults.map(r => r.phonologicalScansion));
|
|
191
|
+
// Classical-vs-accentual is decided FIRST (rhythm layer); continuity
|
|
192
|
+
// renaming applies only where no stanza-level accentual/dolnik verdict
|
|
193
|
+
// fired — otherwise the rename would snowball weak scattered classical
|
|
194
|
+
// readings into false dominance (Wyatt lost its "4-beat accentual").
|
|
195
|
+
applyRhythmLayer(stanzaResults.map(r => r.phonologicalScansion));
|
|
196
|
+
applyContinuityRename(stanzaResults);
|
|
197
|
+
results.push(stanzaResults);
|
|
198
|
+
}
|
|
199
|
+
// Poem-scale continuity: lines left un-renamed (their own stanza had no
|
|
200
|
+
// unique dominant) get a second chance against the poem-wide dominant.
|
|
201
|
+
if (results.length > 1) {
|
|
202
|
+
const all = results.flat();
|
|
203
|
+
applyStanzaConsensus(all.map(r => r.phonologicalScansion));
|
|
204
|
+
applyContinuityRename(all);
|
|
205
|
+
for (const st of results) applyRhythmLayer(st.map(r => r.phonologicalScansion));
|
|
206
|
+
}
|
|
207
|
+
// Rhyme scheme + poetic-form identification spans stanzas (sonnets, terza
|
|
208
|
+
// rima); annotation-only (rhyme/formNote on each line's detail).
|
|
209
|
+
applyRhymeAndForm(results);
|
|
210
|
+
return results;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Convenience wrapper: analyse a multi‑line text, ignore stanza breaks,
|
|
215
|
+
* return a flat list of LineResult for each line.
|
|
216
|
+
*/
|
|
217
|
+
export function analyzeText(text: string, useScandroid = true): LineResult[] {
|
|
218
|
+
const stanzaResults = analyzeStanzas(text, useScandroid);
|
|
219
|
+
return stanzaResults.flat();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Analyse a document for the reading view: like analyzeStanzas, but retains
|
|
224
|
+
* each original input line alongside its (1+) parsed sentence results so the
|
|
225
|
+
* stress gradient can be projected back over the verbatim text.
|
|
226
|
+
*/
|
|
227
|
+
export function analyzeReadingDocument(text: string): ReadingStanza[] {
|
|
228
|
+
const stanzas = text.split(/\n\s*\n/);
|
|
229
|
+
const out: ReadingStanza[] = [];
|
|
230
|
+
for (const stanza of stanzas) {
|
|
231
|
+
const rawLines = stanza.split('\n').filter(l => l.trim() !== '');
|
|
232
|
+
if (rawLines.length === 0) continue;
|
|
233
|
+
const lines = rawLines.map(raw => {
|
|
234
|
+
const doc = parseDocument(raw);
|
|
235
|
+
const res = processLine(doc.sentences);
|
|
236
|
+
return { raw, results: res ? [res] : [] };
|
|
237
|
+
});
|
|
238
|
+
applyStanzaConsensus(lines.flatMap(l => l.results.map(r => r.phonologicalScansion)));
|
|
239
|
+
applyRhythmLayer(lines.flatMap(l => l.results.map(r => r.phonologicalScansion)));
|
|
240
|
+
applyContinuityRename(lines.flatMap(l => l.results));
|
|
241
|
+
out.push({ lines });
|
|
242
|
+
}
|
|
243
|
+
if (out.length > 1) {
|
|
244
|
+
const all = out.flatMap(st => st.lines.flatMap(l => l.results));
|
|
245
|
+
applyStanzaConsensus(all.map(r => r.phonologicalScansion));
|
|
246
|
+
applyContinuityRename(all);
|
|
247
|
+
for (const st of out) applyRhythmLayer(st.lines.flatMap(l => l.results.map(r => r.phonologicalScansion)));
|
|
248
|
+
}
|
|
249
|
+
applyRhymeAndForm(out.map(st => st.lines.flatMap(l => l.results)));
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── CLI HELPERS ────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function showResults(text: string): void {
|
|
256
|
+
// Use the reading-document analysis so each LineResult keeps its verbatim input
|
|
257
|
+
// line (for the tail Reading Projection) and the stanza-consensus annotation.
|
|
258
|
+
const stanzas = analyzeReadingDocument(text);
|
|
259
|
+
|
|
260
|
+
for (let s = 0; s < stanzas.length; s++) {
|
|
261
|
+
if (stanzas.length > 1) {
|
|
262
|
+
console.log('\n' + chalk.bold('═══ Stanza ' + (s + 1) + ' ═══'));
|
|
263
|
+
}
|
|
264
|
+
for (const ln of stanzas[s].lines) {
|
|
265
|
+
for (const res of ln.results) {
|
|
266
|
+
console.log(renderUnifiedDisplay(res, ln.raw));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function showReadingView(text: string): void {
|
|
273
|
+
const stanzas = analyzeReadingDocument(text);
|
|
274
|
+
console.log(renderReadingView(stanzas));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── MULTI-LINE INPUT (paste-friendly) ──────────────────────────
|
|
278
|
+
// Goal: the user pastes a whole poem (stanza breaks and all) and presses Enter
|
|
279
|
+
// ONCE to scan it; Esc returns to the menu. The trick is distinguishing a blank
|
|
280
|
+
// line that is a *stanza break* (part of the pasted burst) from a blank line that
|
|
281
|
+
// means *"I'm done"* (a deliberate, later keystroke). A paste streams in as one
|
|
282
|
+
// rapid burst (sub-ms between lines); a human Enter comes after a real pause. So
|
|
283
|
+
// once a burst has been seen, the next Enter that arrives after an idle gap
|
|
284
|
+
// submits — flushing the current line whether or not the paste ended in a newline.
|
|
285
|
+
// The pure decision below is unit-tested (a TTY can't be driven from CI).
|
|
286
|
+
|
|
287
|
+
export const ML_IDLE_MS = 120;
|
|
288
|
+
|
|
289
|
+
export type MLEvent =
|
|
290
|
+
| { kind: 'char'; str: string; gap: number }
|
|
291
|
+
| { kind: 'return'; gap: number }
|
|
292
|
+
| { kind: 'backspace'; gap: number }
|
|
293
|
+
| { kind: 'escape' }
|
|
294
|
+
| { kind: 'eof' };
|
|
295
|
+
|
|
296
|
+
export interface MLState { lines: string[]; cur: string; sawBurst: boolean; }
|
|
297
|
+
export type MLResult = 'continue' | 'submit' | 'cancel';
|
|
298
|
+
|
|
299
|
+
export function newMLState(): MLState { return { lines: [], cur: '', sawBurst: false }; }
|
|
300
|
+
|
|
301
|
+
function mlHasContent(st: MLState): boolean {
|
|
302
|
+
return st.cur.trim() !== '' || st.lines.some(l => l.trim() !== '');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Fold one input event into the multi-line buffer, returning whether to keep
|
|
307
|
+
* reading ('continue'), scan the buffer ('submit'), or abandon it ('cancel').
|
|
308
|
+
* • A burst-speed Enter (gap < ML_IDLE_MS) is always a line break — so pasted
|
|
309
|
+
* stanza-break blank lines are preserved.
|
|
310
|
+
* • After a burst, the first idle Enter submits (flushing any pending line).
|
|
311
|
+
* • With no burst (slow hand-typing), a non-empty line + Enter is a line break and
|
|
312
|
+
* a blank line submits — the conventional "blank line to finish".
|
|
313
|
+
* • Esc cancels (→ menu); Ctrl-D submits whatever is there.
|
|
314
|
+
*/
|
|
315
|
+
export function feedMultilineEvent(st: MLState, ev: MLEvent): MLResult {
|
|
316
|
+
switch (ev.kind) {
|
|
317
|
+
case 'escape':
|
|
318
|
+
return 'cancel';
|
|
319
|
+
case 'eof':
|
|
320
|
+
if (st.cur.length) { st.lines.push(st.cur); st.cur = ''; }
|
|
321
|
+
return mlHasContent(st) ? 'submit' : 'cancel';
|
|
322
|
+
case 'backspace':
|
|
323
|
+
if (st.cur.length) st.cur = st.cur.slice(0, -1);
|
|
324
|
+
return 'continue';
|
|
325
|
+
case 'char': {
|
|
326
|
+
if (ev.gap < ML_IDLE_MS) st.sawBurst = true;
|
|
327
|
+
// A pasted chunk may arrive with embedded newlines in one event.
|
|
328
|
+
const parts = ev.str.split(/\r\n|\r|\n/);
|
|
329
|
+
for (let i = 0; i < parts.length; i++) {
|
|
330
|
+
if (i > 0) { st.lines.push(st.cur); st.cur = ''; }
|
|
331
|
+
st.cur += parts[i];
|
|
332
|
+
}
|
|
333
|
+
return 'continue';
|
|
334
|
+
}
|
|
335
|
+
case 'return': {
|
|
336
|
+
if (ev.gap < ML_IDLE_MS) { // burst-speed → a line break (keep blanks)
|
|
337
|
+
st.sawBurst = true;
|
|
338
|
+
st.lines.push(st.cur); st.cur = '';
|
|
339
|
+
return 'continue';
|
|
340
|
+
}
|
|
341
|
+
// Deliberate (idle) Enter.
|
|
342
|
+
if (st.sawBurst || st.cur.trim() === '') {
|
|
343
|
+
if (st.cur.length) { st.lines.push(st.cur); st.cur = ''; }
|
|
344
|
+
return mlHasContent(st) ? 'submit' : 'continue';
|
|
345
|
+
}
|
|
346
|
+
// Slow hand-typing of a fresh non-empty line → just a line break.
|
|
347
|
+
st.lines.push(st.cur); st.cur = '';
|
|
348
|
+
return 'continue';
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Strip trailing blank lines (e.g. a paste's trailing newline) from a buffer. */
|
|
354
|
+
function trimTrailingBlanks(lines: string[]): string[] {
|
|
355
|
+
const out = lines.slice();
|
|
356
|
+
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Read a pasteable multi-line block from the TTY in raw mode (so Esc and the
|
|
362
|
+
* paste-burst timing are observable). Resolves to the lines, or null if the user
|
|
363
|
+
* pressed Esc (→ return to menu).
|
|
364
|
+
*/
|
|
365
|
+
async function readPastableBlock(): Promise<string[] | null> {
|
|
366
|
+
const stdin = process.stdin;
|
|
367
|
+
return new Promise<string[] | null>((resolve) => {
|
|
368
|
+
const st = newMLState();
|
|
369
|
+
let lastTime = Date.now();
|
|
370
|
+
readline.emitKeypressEvents(stdin);
|
|
371
|
+
const wasRaw = !!(stdin as any).isRaw;
|
|
372
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
373
|
+
stdin.resume();
|
|
374
|
+
|
|
375
|
+
const onKey = (str: string | undefined, key: any) => {
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
const gap = now - lastTime;
|
|
378
|
+
lastTime = now;
|
|
379
|
+
key = key || {};
|
|
380
|
+
if (key.ctrl && key.name === 'c') { // Ctrl-C → quit
|
|
381
|
+
cleanup(); process.stdout.write('\n'); process.exit(0);
|
|
382
|
+
}
|
|
383
|
+
let ev: MLEvent | null = null;
|
|
384
|
+
if (key.name === 'escape') ev = { kind: 'escape' };
|
|
385
|
+
else if (key.ctrl && key.name === 'd') ev = { kind: 'eof' };
|
|
386
|
+
else if (key.name === 'return' || key.name === 'enter') ev = { kind: 'return', gap };
|
|
387
|
+
else if (key.name === 'backspace') ev = { kind: 'backspace', gap };
|
|
388
|
+
else if (str && !key.ctrl && !key.meta) ev = { kind: 'char', str, gap };
|
|
389
|
+
if (!ev) return; // ignore arrows / fn keys
|
|
390
|
+
// Echo (raw mode does not echo for us).
|
|
391
|
+
if (ev.kind === 'char') process.stdout.write(str!);
|
|
392
|
+
else if (ev.kind === 'return') process.stdout.write('\n');
|
|
393
|
+
else if (ev.kind === 'backspace' && st.cur.length) process.stdout.write('\b \b');
|
|
394
|
+
|
|
395
|
+
const result = feedMultilineEvent(st, ev);
|
|
396
|
+
if (result === 'submit') { cleanup(); process.stdout.write('\n'); resolve(st.lines); }
|
|
397
|
+
else if (result === 'cancel') { cleanup(); process.stdout.write('\n'); resolve(null); }
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
function cleanup() {
|
|
401
|
+
stdin.removeListener('keypress', onKey);
|
|
402
|
+
if (stdin.isTTY) stdin.setRawMode(wasRaw);
|
|
403
|
+
stdin.pause();
|
|
404
|
+
}
|
|
405
|
+
stdin.on('keypress', onKey);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function replMode(): Promise<void> {
|
|
410
|
+
const prompts = (await import('prompts')).default;
|
|
411
|
+
|
|
412
|
+
console.log('');
|
|
413
|
+
console.log(chalk.bold(' CALLIOPE_TS — Phonological Poetry Scansion (CLI) '));
|
|
414
|
+
console.log(chalk.dim('• Multi-Step Syntactic, Phonological, & Prosodic Analysis •'));
|
|
415
|
+
console.log('');
|
|
416
|
+
|
|
417
|
+
let running = true;
|
|
418
|
+
while (running) {
|
|
419
|
+
const response = await prompts({
|
|
420
|
+
type: 'select',
|
|
421
|
+
name: 'action',
|
|
422
|
+
message: 'Choose an action:',
|
|
423
|
+
choices: [
|
|
424
|
+
{ title: 'Parse & Scan (multi-line reading view)', value: 'reading-multi' },
|
|
425
|
+
{ title: 'Single Line Analysis (detailed view)', value: 'scan' },
|
|
426
|
+
{ title: 'Line-by-Line Analysis (detailed view)', value: 'multiline' },
|
|
427
|
+
{ title: 'Parse & Scan from File (reading view)', value: 'reading-file' },
|
|
428
|
+
{ title: 'Analyze from File (detailed view)', value: 'file' },
|
|
429
|
+
{ title: 'Display Legend', value: 'legend' },
|
|
430
|
+
{ title: 'Exit', value: 'exit' },
|
|
431
|
+
],
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
if (!response.action || response.action === 'exit') {
|
|
435
|
+
running = false;
|
|
436
|
+
console.log(chalk.dim('\nGoodbye.\n'));
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (response.action === 'legend') {
|
|
441
|
+
console.log('\n' + renderFullLegend() + '\n');
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (response.action === 'scan') {
|
|
446
|
+
const lineResponse = await prompts({
|
|
447
|
+
type: 'text',
|
|
448
|
+
name: 'line',
|
|
449
|
+
message: 'Enter a line of verse:',
|
|
450
|
+
});
|
|
451
|
+
if (lineResponse.line && lineResponse.line.trim()) {
|
|
452
|
+
try {
|
|
453
|
+
showResults(lineResponse.line.trim());
|
|
454
|
+
} catch (err) {
|
|
455
|
+
console.error(chalk.red('Error during scansion:'), err);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (response.action === 'file' || response.action === 'reading-file') {
|
|
462
|
+
const render = response.action === 'reading-file' ? showReadingView : showResults;
|
|
463
|
+
const fileResponse = await prompts({
|
|
464
|
+
type: 'text',
|
|
465
|
+
name: 'path',
|
|
466
|
+
message: 'Enter file path:',
|
|
467
|
+
});
|
|
468
|
+
if (fileResponse.path && fileResponse.path.trim()) {
|
|
469
|
+
try {
|
|
470
|
+
const text = fs.readFileSync(fileResponse.path.trim(), 'utf-8');
|
|
471
|
+
render(text);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
console.error(chalk.red('Error reading file:'), err);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (response.action === 'multiline' || response.action === 'reading-multi') {
|
|
480
|
+
const render = response.action === 'reading-multi' ? showReadingView : showResults;
|
|
481
|
+
console.log(chalk.dim('Paste your poem and press Enter to scan it. (Esc to cancel)'));
|
|
482
|
+
const block = await readPastableBlock();
|
|
483
|
+
if (block === null) continue; // Esc → back to the menu
|
|
484
|
+
const lines = trimTrailingBlanks(block);
|
|
485
|
+
if (lines.length > 0) {
|
|
486
|
+
try {
|
|
487
|
+
render(lines.join('\n'));
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error(chalk.red('Error during scansion:'), err);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ─── CLI ENTRY POINT ─────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
async function main(): Promise<void> {
|
|
500
|
+
let rawArgs = process.argv.slice(2);
|
|
501
|
+
// --reading / -r : emit the compact reading view (poem in original formatting,
|
|
502
|
+
// syllables stress-coloured, + per-line stress maps) instead of the full dump.
|
|
503
|
+
const reading = rawArgs.includes('--reading') || rawArgs.includes('-r');
|
|
504
|
+
rawArgs = rawArgs.filter(a => a !== '--reading' && a !== '-r');
|
|
505
|
+
const show = reading ? showReadingView : showResults;
|
|
506
|
+
|
|
507
|
+
// Explicit arguments take precedence over piped stdin — otherwise running
|
|
508
|
+
// `calliope_ts "some line"` from a script/CI (where stdin is a non-TTY but
|
|
509
|
+
// empty) silently analysed the empty pipe and ignored the argument.
|
|
510
|
+
if (rawArgs.length > 0) {
|
|
511
|
+
// Check if first arg is a file
|
|
512
|
+
const firstArg = rawArgs[0];
|
|
513
|
+
if (fs.existsSync(firstArg) && fs.statSync(firstArg).isFile()) {
|
|
514
|
+
const text = fs.readFileSync(firstArg, 'utf-8');
|
|
515
|
+
show(text);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
// Otherwise treat as text input
|
|
519
|
+
const text = rawArgs.join(' ');
|
|
520
|
+
show(text);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// No arguments: piped input (file redirect / heredoc) is the document.
|
|
525
|
+
if (!process.stdin.isTTY) {
|
|
526
|
+
const text = fs.readFileSync(0, 'utf-8');
|
|
527
|
+
show(text);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// No arguments: launch interactive REPL
|
|
532
|
+
await replMode();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
536
|
+
if (isMain) {
|
|
537
|
+
main().catch(err => {
|
|
538
|
+
console.error(chalk.red('Fatal error:'), err);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
});
|
|
541
|
+
}
|