Skip to content

Commit 27dc43a

Browse files
committed
Merge 0.10.4
2 parents 75957cc + 1356ed0 commit 27dc43a

File tree

9 files changed

+289
-38
lines changed

9 files changed

+289
-38
lines changed

CHANGES.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,34 @@ To be released.
3737
[#120]: https://github.com/dahlia/optique/issues/120
3838

3939

40+
Version 0.10.4
41+
--------------
42+
43+
Released on February 19, 2026.
44+
45+
### @optique/core
46+
47+
- Fixed `formatMessage()` to correctly handle a `lineBreak()` term that is
48+
immediately followed by a newline character in the source template literal.
49+
Previously, the newline after `${lineBreak()}` was normalized to a space
50+
(as single `\n` characters in text terms are), producing a spurious leading
51+
space at the start of the next line. The newline immediately following a
52+
`lineBreak()` term is now dropped instead of being converted to a space.
53+
54+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
55+
disappearing from the subcommand list in help output when the parser uses
56+
a `withDefault(or(...))` construct. The root cause was that
57+
`getDocPage()` used a `do...while` loop, which ran the parser at least
58+
once even with an empty argument buffer. Because `withDefault(or(...))`
59+
allows the inner parser to succeed without consuming any tokens, the
60+
`longestMatch` combinator would record the user's parser as “selected”
61+
and subsequently return only that parser's doc fragments—silently
62+
dropping the meta command entries. The loop is now a `while` loop that
63+
skips parsing entirely when the buffer is empty. [[#121]]
64+
65+
[#121]: https://github.com/dahlia/optique/issues/121
66+
67+
4068
Version 0.10.3
4169
--------------
4270

@@ -948,6 +976,25 @@ to generate Unix man pages that stay synchronized with parser definitions.
948976
[#77]: https://github.com/dahlia/optique/issues/77
949977

950978

979+
Version 0.9.10
980+
--------------
981+
982+
Released on February 19, 2026.
983+
984+
### @optique/core
985+
986+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
987+
disappearing from the subcommand list in help output when the parser uses
988+
a `withDefault(or(...))` construct. The root cause was that
989+
`getDocPage()` used a `do...while` loop, which ran the parser at least
990+
once even with an empty argument buffer. Because `withDefault(or(...))`
991+
allows the inner parser to succeed without consuming any tokens, the
992+
`longestMatch` combinator would record the user's parser as “selected
993+
and subsequently return only that parser's doc fragments—silently
994+
dropping the meta command entries. The loop is now a `while` loop that
995+
skips parsing entirely when the buffer is empty. [[#121]]
996+
997+
951998
Version 0.9.9
952999
-------------
9531000

@@ -1471,6 +1518,25 @@ remotes) using [isomorphic-git]. [[#71], [#72]]
14711518
[#72]: https://github.com/dahlia/optique/pull/72
14721519
14731520
1521+
Version 0.8.16
1522+
--------------
1523+
1524+
Released on February 19, 2026.
1525+
1526+
### @optique/core
1527+
1528+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
1529+
disappearing from the subcommand list in help output when the parser uses
1530+
a `withDefault(or(...))` construct. The root cause was that
1531+
`getDocPage()` used a `do...while` loop, which ran the parser at least
1532+
once even with an empty argument buffer. Because `withDefault(or(...))`
1533+
allows the inner parser to succeed without consuming any tokens, the
1534+
`longestMatch` combinator would record the user's parser as “selected”
1535+
and subsequently return only that parser's doc fragments—silently
1536+
dropping the meta command entries. The loop is now a `while` loop that
1537+
skips parsing entirely when the buffer is empty. [[#121]]
1538+
1539+
14741540
Version 0.8.15
14751541
--------------
14761542
@@ -1903,6 +1969,25 @@ parsing strategies.
19031969
[LogTape]: https://logtape.org/
19041970
19051971
1972+
Version 0.7.18
1973+
--------------
1974+
1975+
Released on February 19, 2026.
1976+
1977+
### @optique/core
1978+
1979+
- Fixed meta commands (`help`, `version`, `completion`, `completions`)
1980+
disappearing from the subcommand list in help output when the parser uses
1981+
a `withDefault(or(...))` construct. The root cause was that
1982+
`getDocPage()` used a `do...while` loop, which ran the parser at least
1983+
once even with an empty argument buffer. Because `withDefault(or(...))`
1984+
allows the inner parser to succeed without consuming any tokens, the
1985+
`longestMatch` combinator would record the user's parser as “selected”
1986+
and subsequently return only that parser's doc fragments—silently
1987+
dropping the meta command entries. The loop is now a `while` loop that
1988+
skips parsing entirely when the buffer is empty. [[#121]]
1989+
1990+
19061991
Version 0.7.17
19071992
--------------
19081993

docs/concepts/messages.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,13 @@ Examples:
560560
zsh: eval "$(mycli completion zsh)"
561561
~~~~
562562

563+
Note that `lineBreak()` absorbs the newline that immediately follows it in a
564+
template literal. In the example above, each `${lineBreak()}` is followed by
565+
a real newline character in the source, but that newline is dropped rather than
566+
being normalized to a space. This means you can write multi-line template
567+
literals naturally—breaking the source line right after `${lineBreak()}`—and
568+
the output will not gain a spurious leading space on the next line.
569+
563570
### Single newlines (`\n`)
564571

565572
Single newlines in `text()` terms are treated as soft breaks and converted to

docs/concepts/runners.md

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,40 +1172,30 @@ Choose your execution strategy based on your application's needs.
11721172
For guidance on whether to use `Program` objects or pass metadata directly,
11731173
see [*Bundling parsers with metadata*](#bundling-parsers-with-metadata).
11741174

1175-
### Use *@optique/run* when:
1176-
1177-
- Building CLI applications for Node.js, Bun, or Deno
1178-
- You want automatic `process.argv` parsing and `process.exit()` handling
1179-
- You need automatic terminal capability detection (colors, width)
1180-
- You prefer a simple, batteries-included approach
1181-
1182-
### Use *@optique/core* instead when:
1183-
1184-
- Building web applications or libraries
1185-
- You need full control over argument sources and error handling
1186-
- Working in environments without `process` (browsers, web workers)
1187-
- Building reusable parser components
1188-
11891175
### Use `parse()` when:
11901176

11911177
- *Testing parsers*: You need to inspect parsing results in tests
11921178
- *Complex integration*: Parsing is part of a larger application flow
11931179
- *Custom error handling*: You need application-specific error recovery
11941180
- *Multiple attempts*: You want to try different parsers or arguments
1181+
- *Reusable components*: Building parser components for use in libraries
1182+
- *Environment constraints*: Running without `process` (browsers, web workers)
11951183

11961184
### Use `runParser()` from `@optique/core/facade` when:
11971185

11981186
- *Framework integration*: Working with web frameworks or custom runtimes
11991187
- *Library development*: Building CLI libraries for other applications
12001188
- *Custom I/O*: You need non-standard input/output handling
12011189
- *Controlled exit*: The application manages its own lifecycle
1190+
- *Non-CLI contexts*: Building tools that embed a CLI interface in a larger app
12021191

12031192
### Use `run()` from `@optique/run` when:
12041193

1205-
- *Standalone CLIs*: Building command-line applications
1194+
- *Standalone CLIs*: Building command-line applications for Node.js, Bun, or Deno
12061195
- *Rapid prototyping*: You want to get a CLI running quickly
12071196
- *Standard behavior*: Your application follows typical CLI conventions
1208-
- *Node.js/Bun/Deno*: You're running in a standard JavaScript runtime
1197+
- *Batteries-included*: You want automatic argument extraction, terminal
1198+
detection, and process exit handling
12091199

12101200
The progression from `parse()` to *@optique/run*'s `run()` trades control for
12111201
convenience. Start with the highest-level approach that meets your needs, then

examples/gitique/src/index.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22
import { or } from "@optique/core/constructs";
33
import { defineProgram } from "@optique/core/program";
4-
import { commandLine, message, url } from "@optique/core/message";
4+
import { commandLine, lineBreak, message, url } from "@optique/core/message";
55
import { printError, run } from "@optique/run";
66
import { addCommand, executeAdd } from "./commands/add.ts";
77
import { commitCommand, executeCommit } from "./commands/commit.ts";
@@ -48,21 +48,28 @@ const program = defineProgram({
4848
author: message`Hong Minhee ${url("https://hongminhee.org/")}`,
4949
examples: message`Common commands:
5050
51-
${commandLine("gitique add .")} Stage all changes
52-
53-
${commandLine('gitique commit -m "message"')} Create a commit
54-
55-
${commandLine("gitique status")} Show working tree status
56-
57-
${commandLine("gitique log --oneline")} View commit history
58-
59-
${commandLine("gitique diff --cached")} Show staged changes
51+
${
52+
commandLine("gitique add .")
53+
} Stage all changes${lineBreak()}
54+
${
55+
commandLine('gitique commit -m "message"')
56+
} Create a commit${lineBreak()}
57+
${
58+
commandLine("gitique status")
59+
} Show working tree status${lineBreak()}
60+
${
61+
commandLine("gitique log --oneline")
62+
} View commit history${lineBreak()}
63+
${
64+
commandLine("gitique diff --cached")
65+
} Show staged changes${lineBreak()}
6066
6167
Shell completion:
6268
63-
${commandLine("gitique completion bash > ~/.bashrc.d/gitique.bash")}
64-
65-
${commandLine("gitique completion zsh > ~/.zsh/completions/_gitique")}`,
69+
${
70+
commandLine("gitique completion bash > ~/.bashrc.d/gitique.bash")
71+
}${lineBreak()}
72+
${commandLine("gitique completion zsh > ~/.zsh/completions/_gitique")}`,
6673
footer: message`For more information, visit ${
6774
url("https://github.com/dahlia/optique")
6875
}.`,

packages/core/src/message.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,74 @@ describe("formatMessage - explicit line breaks", () => {
921921
// Multiple double newlines still create single paragraph break
922922
assert.equal(formatted, "Line 1\n\nLine 2");
923923
});
924+
925+
it("should strip leading newline from text immediately after lineBreak()", () => {
926+
// When lineBreak() is followed by a text term starting with \n (as happens in
927+
// template literals like `${lineBreak()}\nContent`), the leading \n must be
928+
// dropped to avoid yielding an extra space.
929+
const msg: Message = [
930+
{ type: "text", text: "Before" },
931+
lineBreak(),
932+
{ type: "text", text: "\nAfter" },
933+
];
934+
const formatted = formatMessage(msg, { quotes: false });
935+
936+
assert.equal(formatted, "Before\nAfter");
937+
});
938+
939+
it("should strip leading newline but preserve remaining indentation after lineBreak()", () => {
940+
// The leading \n must be stripped, but subsequent whitespace (indentation)
941+
// must be preserved.
942+
const msg: Message = [
943+
{ type: "text", text: "Before" },
944+
lineBreak(),
945+
{ type: "text", text: "\n indented" },
946+
];
947+
const formatted = formatMessage(msg, { quotes: false });
948+
949+
assert.equal(formatted, "Before\n indented");
950+
});
951+
952+
it("should not add extra space between lineBreak() and commandLine() in template literal", () => {
953+
// Regression test: template literals produce a text("\n") between lineBreak()
954+
// and the next interpolated value. That \n was being normalized to a space,
955+
// creating a spurious leading space on the next line.
956+
const msg = message`Common:${lineBreak()}
957+
${commandLine("myapp add .")} Stage changes${lineBreak()}
958+
${commandLine("myapp status")} Show status`;
959+
const formatted = formatMessage(msg, { quotes: false });
960+
961+
const lines = formatted.split("\n");
962+
assert.equal(lines.length, 3);
963+
assert.equal(lines[0], "Common:");
964+
assert.ok(
965+
lines[1].startsWith("myapp add ."),
966+
`Expected line to start with "myapp add .", got: ${
967+
JSON.stringify(lines[1])
968+
}`,
969+
);
970+
assert.ok(
971+
lines[2].startsWith("myapp status"),
972+
`Expected line to start with "myapp status", got: ${
973+
JSON.stringify(lines[2])
974+
}`,
975+
);
976+
});
977+
978+
it("should preserve paragraph break (\\n\\n) following lineBreak()", () => {
979+
// A double newline after lineBreak() is an intentional paragraph separator
980+
// and must NOT be stripped. Only a lone \n (soft-break artifact) is absorbed.
981+
const msg: Message = [
982+
{ type: "text", text: "Line 1" },
983+
lineBreak(),
984+
{ type: "text", text: "\n\nSection header" },
985+
];
986+
const formatted = formatMessage(msg, { quotes: false });
987+
988+
// lineBreak() yields one \n; the \n\n yields another \n (paragraph break);
989+
// total: two \n before "Section header".
990+
assert.equal(formatted, "Line 1\n\nSection header");
991+
});
924992
});
925993

926994
describe("valueSet", () => {

packages/core/src/message.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -509,14 +509,30 @@ export function formatMessage(
509509

510510
function* stream(): Generator<{ text: string; width: number }> {
511511
const wordPattern = /\s*\S+\s*/g;
512+
let prevWasLineBreak = false;
512513
for (const term of msg) {
514+
// Capture and reset before processing each term so non-text terms
515+
// (e.g. optionName, commandLine) also clear the flag.
516+
const isAfterLineBreak = prevWasLineBreak;
517+
prevWasLineBreak = false;
518+
513519
if (term.type === "text") {
520+
// Strip a lone leading \n immediately following a lineBreak() term.
521+
// In template literals such as `${lineBreak()}\nContent`, the parser
522+
// produces a text("\n…") term; that \n is a source-formatting artifact
523+
// that should be dropped rather than normalized to a space.
524+
// Only a lone \n is stripped; \n\n (paragraph break) is intentional
525+
// and must be preserved so the double-newline → hard-break logic works.
526+
const rawText = isAfterLineBreak
527+
? term.text.replace(/^\n(?!\n)/, "")
528+
: term.text;
529+
514530
// Handle explicit line breaks:
515531
// - Single \n: treated as space (soft break, word wrap friendly)
516532
// - Double \n\n or more: treated as hard line break (paragraph break)
517-
if (term.text.includes("\n\n")) {
533+
if (rawText.includes("\n\n")) {
518534
// Split on double newlines to find paragraph breaks
519-
const paragraphs = term.text.split(/\n\n+/);
535+
const paragraphs = rawText.split(/\n\n+/);
520536
for (
521537
let paragraphIndex = 0;
522538
paragraphIndex < paragraphs.length;
@@ -538,7 +554,7 @@ export function formatMessage(
538554
}
539555
} else {
540556
// Text without double newlines: replace single \n with space
541-
const normalizedText = term.text.replace(/\n/g, " ");
557+
const normalizedText = rawText.replace(/\n/g, " ");
542558

543559
// Handle whitespace-only text specially to preserve spaces
544560
if (normalizedText.trim() === "" && normalizedText.length > 0) {
@@ -627,6 +643,7 @@ export function formatMessage(
627643
} else if (term.type === "lineBreak") {
628644
// Explicit hard line break
629645
yield { text: "\n", width: -1 };
646+
prevWasLineBreak = true;
630647
} else if (term.type === "url") {
631648
const urlString = term.url.href;
632649
const displayText = useQuotes ? `<${urlString}>` : urlString;

0 commit comments

Comments
 (0)