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