@nowline/cli 0.2.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.
Files changed (139) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +372 -0
  3. package/dist/cli/args.d.ts +54 -0
  4. package/dist/cli/args.d.ts.map +1 -0
  5. package/dist/cli/args.js +165 -0
  6. package/dist/cli/args.js.map +1 -0
  7. package/dist/cli/formats.d.ts +61 -0
  8. package/dist/cli/formats.d.ts.map +1 -0
  9. package/dist/cli/formats.js +153 -0
  10. package/dist/cli/formats.js.map +1 -0
  11. package/dist/cli/help.d.ts +3 -0
  12. package/dist/cli/help.d.ts.map +1 -0
  13. package/dist/cli/help.js +90 -0
  14. package/dist/cli/help.js.map +1 -0
  15. package/dist/cli/output-path.d.ts +57 -0
  16. package/dist/cli/output-path.d.ts.map +1 -0
  17. package/dist/cli/output-path.js +70 -0
  18. package/dist/cli/output-path.js.map +1 -0
  19. package/dist/commands/init.d.ts +20 -0
  20. package/dist/commands/init.d.ts.map +1 -0
  21. package/dist/commands/init.js +80 -0
  22. package/dist/commands/init.js.map +1 -0
  23. package/dist/commands/render.d.ts +15 -0
  24. package/dist/commands/render.d.ts.map +1 -0
  25. package/dist/commands/render.js +435 -0
  26. package/dist/commands/render.js.map +1 -0
  27. package/dist/commands/serve.d.ts +16 -0
  28. package/dist/commands/serve.d.ts.map +1 -0
  29. package/dist/commands/serve.js +287 -0
  30. package/dist/commands/serve.js.map +1 -0
  31. package/dist/convert/parse-json.d.ts +7 -0
  32. package/dist/convert/parse-json.d.ts.map +1 -0
  33. package/dist/convert/parse-json.js +34 -0
  34. package/dist/convert/parse-json.js.map +1 -0
  35. package/dist/convert/printer.d.ts +6 -0
  36. package/dist/convert/printer.d.ts.map +1 -0
  37. package/dist/convert/printer.js +334 -0
  38. package/dist/convert/printer.js.map +1 -0
  39. package/dist/convert/schema.d.ts +33 -0
  40. package/dist/convert/schema.d.ts.map +1 -0
  41. package/dist/convert/schema.js +77 -0
  42. package/dist/convert/schema.js.map +1 -0
  43. package/dist/core/parse.d.ts +24 -0
  44. package/dist/core/parse.d.ts.map +1 -0
  45. package/dist/core/parse.js +58 -0
  46. package/dist/core/parse.js.map +1 -0
  47. package/dist/diagnostics/adapt.d.ts +46 -0
  48. package/dist/diagnostics/adapt.d.ts.map +1 -0
  49. package/dist/diagnostics/adapt.js +109 -0
  50. package/dist/diagnostics/adapt.js.map +1 -0
  51. package/dist/diagnostics/format.d.ts +18 -0
  52. package/dist/diagnostics/format.d.ts.map +1 -0
  53. package/dist/diagnostics/format.js +41 -0
  54. package/dist/diagnostics/format.js.map +1 -0
  55. package/dist/diagnostics/index.d.ts +5 -0
  56. package/dist/diagnostics/index.d.ts.map +1 -0
  57. package/dist/diagnostics/index.js +5 -0
  58. package/dist/diagnostics/index.js.map +1 -0
  59. package/dist/diagnostics/json.d.ts +8 -0
  60. package/dist/diagnostics/json.d.ts.map +1 -0
  61. package/dist/diagnostics/json.js +24 -0
  62. package/dist/diagnostics/json.js.map +1 -0
  63. package/dist/diagnostics/model.d.ts +44 -0
  64. package/dist/diagnostics/model.d.ts.map +1 -0
  65. package/dist/diagnostics/model.js +2 -0
  66. package/dist/diagnostics/model.js.map +1 -0
  67. package/dist/diagnostics/text.d.ts +6 -0
  68. package/dist/diagnostics/text.d.ts.map +1 -0
  69. package/dist/diagnostics/text.js +43 -0
  70. package/dist/diagnostics/text.js.map +1 -0
  71. package/dist/generated/templates.d.ts +4 -0
  72. package/dist/generated/templates.d.ts.map +1 -0
  73. package/dist/generated/templates.js +9 -0
  74. package/dist/generated/templates.js.map +1 -0
  75. package/dist/generated/version.d.ts +11 -0
  76. package/dist/generated/version.d.ts.map +1 -0
  77. package/dist/generated/version.js +8 -0
  78. package/dist/generated/version.js.map +1 -0
  79. package/dist/i18n/locale.d.ts +56 -0
  80. package/dist/i18n/locale.d.ts.map +1 -0
  81. package/dist/i18n/locale.js +107 -0
  82. package/dist/i18n/locale.js.map +1 -0
  83. package/dist/index.d.ts +3 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +60 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/io/config.d.ts +2 -0
  88. package/dist/io/config.d.ts.map +1 -0
  89. package/dist/io/config.js +5 -0
  90. package/dist/io/config.js.map +1 -0
  91. package/dist/io/exit-codes.d.ts +12 -0
  92. package/dist/io/exit-codes.d.ts.map +1 -0
  93. package/dist/io/exit-codes.js +15 -0
  94. package/dist/io/exit-codes.js.map +1 -0
  95. package/dist/io/read.d.ts +13 -0
  96. package/dist/io/read.d.ts.map +1 -0
  97. package/dist/io/read.js +53 -0
  98. package/dist/io/read.js.map +1 -0
  99. package/dist/io/write.d.ts +32 -0
  100. package/dist/io/write.d.ts.map +1 -0
  101. package/dist/io/write.js +61 -0
  102. package/dist/io/write.js.map +1 -0
  103. package/dist/version.d.ts +13 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +20 -0
  106. package/dist/version.js.map +1 -0
  107. package/man/fr/nowline.1 +424 -0
  108. package/man/fr/nowline.5 +1864 -0
  109. package/man/nowline.1 +517 -0
  110. package/man/nowline.5 +1784 -0
  111. package/package.json +66 -0
  112. package/scripts/bundle-templates.mjs +105 -0
  113. package/scripts/compile.mjs +131 -0
  114. package/src/cli/args.ts +252 -0
  115. package/src/cli/formats.ts +207 -0
  116. package/src/cli/help.ts +92 -0
  117. package/src/cli/output-path.ts +98 -0
  118. package/src/commands/init.ts +99 -0
  119. package/src/commands/render.ts +566 -0
  120. package/src/commands/serve.ts +322 -0
  121. package/src/convert/parse-json.ts +57 -0
  122. package/src/convert/printer.ts +376 -0
  123. package/src/convert/schema.ts +105 -0
  124. package/src/core/parse.ts +93 -0
  125. package/src/diagnostics/adapt.ts +148 -0
  126. package/src/diagnostics/format.ts +70 -0
  127. package/src/diagnostics/index.ts +4 -0
  128. package/src/diagnostics/json.ts +30 -0
  129. package/src/diagnostics/model.ts +48 -0
  130. package/src/diagnostics/text.ts +62 -0
  131. package/src/generated/templates.ts +12 -0
  132. package/src/generated/version.ts +18 -0
  133. package/src/i18n/locale.ts +133 -0
  134. package/src/index.ts +60 -0
  135. package/src/io/config.ts +11 -0
  136. package/src/io/exit-codes.ts +18 -0
  137. package/src/io/read.ts +70 -0
  138. package/src/io/write.ts +94 -0
  139. package/src/version.ts +21 -0
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@nowline/cli",
3
+ "version": "0.2.0",
4
+ "description": "Nowline command-line interface — validate, convert, init",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "nowline": "./dist/index.js"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "man": [
13
+ "./man/nowline.1",
14
+ "./man/nowline.5"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist/",
24
+ "man/",
25
+ "src/",
26
+ "scripts/"
27
+ ],
28
+ "dependencies": {
29
+ "@babel/code-frame": "^7.29.0",
30
+ "@clack/prompts": "^0.11.0",
31
+ "chalk": "^5.6.0",
32
+ "citty": "^0.2.2",
33
+ "consola": "^3.4.0",
34
+ "js-yaml": "^4.1.0",
35
+ "langium": "~4.2.2",
36
+ "@nowline/config": "0.2.0",
37
+ "@nowline/core": "0.2.0",
38
+ "@nowline/export-core": "0.2.0",
39
+ "@nowline/export-html": "0.2.0",
40
+ "@nowline/export-mermaid": "0.2.0",
41
+ "@nowline/export-msproj": "0.2.0",
42
+ "@nowline/export-png": "0.2.0",
43
+ "@nowline/layout": "0.2.0",
44
+ "@nowline/export-pdf": "0.2.0",
45
+ "@nowline/renderer": "0.2.0",
46
+ "@nowline/export-xlsx": "0.2.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/babel__code-frame": "^7.0.6",
50
+ "@types/js-yaml": "^4.0.9",
51
+ "@types/node": "^22.0.0",
52
+ "typescript": "~5.7.0",
53
+ "vitest": "^3.1.0"
54
+ },
55
+ "scripts": {
56
+ "bundle-templates": "node scripts/bundle-templates.mjs",
57
+ "prebuild": "node scripts/bundle-templates.mjs",
58
+ "build": "tsc -b tsconfig.json",
59
+ "watch": "tsc -b tsconfig.json --watch",
60
+ "pretest": "node scripts/bundle-templates.mjs",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest",
63
+ "compile": "node scripts/compile.mjs",
64
+ "compile:local": "node scripts/compile.mjs --target=local"
65
+ }
66
+ }
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const packageRoot = path.resolve(__dirname, '..');
9
+ const repoRoot = path.resolve(packageRoot, '..', '..');
10
+ const examplesDir = path.join(repoRoot, 'examples');
11
+ const outDir = path.join(packageRoot, 'src', 'generated');
12
+
13
+ const TEMPLATES = [
14
+ { name: 'minimal', file: 'minimal.nowline' },
15
+ { name: 'teams', file: 'teams.nowline' },
16
+ { name: 'product', file: 'product.nowline' },
17
+ ];
18
+
19
+ mkdirSync(outDir, { recursive: true });
20
+
21
+ // 1. Templates
22
+ const templatesOutFile = path.join(outDir, 'templates.ts');
23
+ const entries = TEMPLATES.map(({ name, file }) => {
24
+ const abs = path.join(examplesDir, file);
25
+ const contents = readFileSync(abs, 'utf-8');
26
+ return ` ${JSON.stringify(name)}: ${JSON.stringify(contents)}`;
27
+ });
28
+
29
+ const templatesBanner = [
30
+ '// Auto-generated by scripts/bundle-templates.mjs. Do not edit by hand.',
31
+ '// Run `pnpm run bundle-templates` to regenerate.',
32
+ '',
33
+ "export type TemplateName = 'minimal' | 'teams' | 'product';",
34
+ '',
35
+ "export const TEMPLATE_NAMES: readonly TemplateName[] = ['minimal', 'teams', 'product'];",
36
+ '',
37
+ 'export const TEMPLATES: Record<TemplateName, string> = {',
38
+ ].join('\n');
39
+
40
+ const templatesFooter = '\n};\n';
41
+
42
+ writeFileSync(templatesOutFile, `${templatesBanner}\n${entries.join(',\n')}${templatesFooter}`);
43
+ console.log(`wrote ${path.relative(packageRoot, templatesOutFile)}`);
44
+
45
+ // 2. Version + build metadata.
46
+ //
47
+ // CLI_VERSION is the static SemVer from package.json (the same string
48
+ // `npm publish` uses). CLI_BUILD captures the git state at compile time
49
+ // so `nowline --version` can distinguish a tagged release from a dev
50
+ // build. The two are emitted side-by-side; the renderer composes the
51
+ // final string at runtime per SemVer's build-metadata rules:
52
+ //
53
+ // release -> "0.1.0"
54
+ // dev (clean) -> "0.1.0+abc1234"
55
+ // dev (dirty) -> "0.1.0+abc1234.dirty"
56
+ //
57
+ // `+...` is SemVer build metadata (RFC 2.0 §10) — informational only;
58
+ // publishers (npm, Marketplace) reject it on their own version fields,
59
+ // so it never reaches package.json.
60
+
61
+ const versionOutFile = path.join(outDir, 'version.ts');
62
+ const pkg = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
63
+
64
+ function tryGit(args) {
65
+ try {
66
+ return execFileSync('git', args, {
67
+ cwd: repoRoot,
68
+ stdio: ['ignore', 'pipe', 'ignore'],
69
+ encoding: 'utf-8',
70
+ }).trim();
71
+ } catch {
72
+ return '';
73
+ }
74
+ }
75
+
76
+ const sha = tryGit(['rev-parse', '--short=7', 'HEAD']);
77
+ // `--exact-match` exits non-zero (and tryGit returns '') when HEAD is
78
+ // not a tagged commit, which we treat as "this is a dev build".
79
+ const exactTag = tryGit(['describe', '--exact-match', '--tags', 'HEAD']);
80
+ const expectedTag = `v${pkg.version}`;
81
+ const isRelease = exactTag === expectedTag;
82
+ const isDirty = tryGit(['status', '--porcelain']) !== '';
83
+
84
+ writeFileSync(
85
+ versionOutFile,
86
+ [
87
+ '// Auto-generated by scripts/bundle-templates.mjs. Do not edit by hand.',
88
+ '',
89
+ `export const CLI_VERSION = ${JSON.stringify(pkg.version)};`,
90
+ '',
91
+ 'export interface CliBuild {',
92
+ ' /** Short git SHA at build time, or empty when not in a git checkout. */',
93
+ ' readonly sha: string;',
94
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: emits a TypeScript JSDoc comment that intentionally contains a template-literal placeholder string for readers, not for evaluation.
95
+ ' /** True when HEAD is the tag matching `v${CLI_VERSION}`. */',
96
+ ' readonly isRelease: boolean;',
97
+ ' /** True when the working tree had uncommitted changes at build time. */',
98
+ ' readonly isDirty: boolean;',
99
+ '}',
100
+ '',
101
+ `export const CLI_BUILD: CliBuild = ${JSON.stringify({ sha, isRelease, isDirty }, null, 4)};`,
102
+ '',
103
+ ].join('\n'),
104
+ );
105
+ console.log(`wrote ${path.relative(packageRoot, versionOutFile)}`);
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // Compile the CLI into standalone binaries for the six supported targets.
3
+ // Uses `bun build --compile`. Requires bun to be installed; delegates errors
4
+ // from bun rather than attempting a polyfill.
5
+ //
6
+ // One binary per platform: `nowline-<suffix>`. Bundles every `@nowline/export-*`
7
+ // package — see `specs/cli-distribution.md` for the rationale (the bun runtime
8
+ // dominates compiled binary size; a tiny/full split paid only ~5% size dividend
9
+ // for the cost of doubled CI/release/distribution channels).
10
+
11
+ import { spawnSync } from 'node:child_process';
12
+ import { mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const packageRoot = path.resolve(__dirname, '..');
18
+
19
+ // Per-target size ceilings (MB). Bun's standalone runtime varies by ~50 MB
20
+ // across targets — darwin-arm64 ships ~60 MB, linux-x64 with glibc compat
21
+ // shims is ~95 MB, and windows-x64 is ~110 MB. A single global ceiling would
22
+ // either let darwin regressions slide or fail every Linux/Windows build, so
23
+ // each target carries its own budget = (currently measured size) + ~6–10 MB
24
+ // headroom for future bun-runtime growth and modest exporter additions.
25
+ // Tight by design: a breach should trigger the conversation called out in
26
+ // `specs/cli-distribution.md` "Size budget", not be silently absorbed.
27
+ //
28
+ // Last measured (bun 1.3.13) using --target on macOS-arm64:
29
+ // darwin-arm64=70 darwin-x64=75 linux-arm64=107 linux-x64=107
30
+ // windows-arm64=119 windows-x64=122
31
+ const ALL_TARGETS = [
32
+ { id: 'bun-darwin-arm64', suffix: 'macos-arm64', maxMb: 80 },
33
+ { id: 'bun-darwin-x64', suffix: 'macos-x64', maxMb: 85 },
34
+ { id: 'bun-linux-x64', suffix: 'linux-x64', maxMb: 115 },
35
+ { id: 'bun-linux-arm64', suffix: 'linux-arm64', maxMb: 115 },
36
+ { id: 'bun-windows-x64', suffix: 'windows-x64.exe', maxMb: 130 },
37
+ { id: 'bun-windows-arm64', suffix: 'windows-arm64.exe', maxMb: 125 },
38
+ ];
39
+
40
+ function parseArgs(argv) {
41
+ const out = { target: 'all' };
42
+ for (const arg of argv.slice(2)) {
43
+ if (arg.startsWith('--target=')) out.target = arg.slice('--target='.length);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function pickTargets(selector) {
49
+ if (selector === 'all') return ALL_TARGETS;
50
+ if (selector === 'local') {
51
+ const platformMap = {
52
+ 'darwin/arm64': 'bun-darwin-arm64',
53
+ 'darwin/x64': 'bun-darwin-x64',
54
+ 'linux/x64': 'bun-linux-x64',
55
+ 'linux/arm64': 'bun-linux-arm64',
56
+ 'win32/x64': 'bun-windows-x64',
57
+ 'win32/arm64': 'bun-windows-arm64',
58
+ };
59
+ const key = `${process.platform}/${process.arch}`;
60
+ const id = platformMap[key];
61
+ if (!id) throw new Error(`Unsupported local platform: ${key}`);
62
+ return ALL_TARGETS.filter((t) => t.id === id);
63
+ }
64
+ const match = ALL_TARGETS.filter((t) => t.id === selector || t.suffix === selector);
65
+ if (match.length === 0) throw new Error(`Unknown --target: ${selector}`);
66
+ return match;
67
+ }
68
+
69
+ function main() {
70
+ const { target } = parseArgs(process.argv);
71
+ const targets = pickTargets(target);
72
+ const outDir = path.join(packageRoot, 'dist-bin');
73
+ if (!safeStat(outDir)) {
74
+ mkdirSync(outDir, { recursive: true });
75
+ } else {
76
+ for (const name of readdirSync(outDir)) {
77
+ if (name.startsWith('nowline-')) {
78
+ rmSync(path.join(outDir, name), { force: true });
79
+ }
80
+ }
81
+ }
82
+
83
+ const entry = path.join(packageRoot, 'dist', 'index.js');
84
+ if (!safeStat(entry)) {
85
+ console.error(
86
+ `error: expected ${path.relative(packageRoot, entry)} to exist; run \`pnpm build\` first.`,
87
+ );
88
+ process.exit(1);
89
+ }
90
+
91
+ let failed = 0;
92
+ for (const tgt of targets) {
93
+ const outName = `nowline-${tgt.suffix}`;
94
+ const outPath = path.join(outDir, outName);
95
+ console.log(`compiling ${tgt.id} -> ${path.relative(packageRoot, outPath)}`);
96
+ const args = ['build', entry, '--compile', '--target', tgt.id, '--outfile', outPath];
97
+ const result = spawnSync('bun', args, { stdio: 'inherit', cwd: packageRoot });
98
+ if (result.status !== 0) {
99
+ console.error(` FAILED ${tgt.id}`);
100
+ failed += 1;
101
+ }
102
+ }
103
+
104
+ const targetBySuffix = new Map(ALL_TARGETS.map((t) => [`nowline-${t.suffix}`, t]));
105
+ for (const entryName of readdirSync(outDir)) {
106
+ if (!entryName.startsWith('nowline-')) continue;
107
+ const tgt = targetBySuffix.get(entryName);
108
+ if (!tgt) continue; // unknown artifact; size budget is per known target
109
+ const p = path.join(outDir, entryName);
110
+ const size = statSync(p).size;
111
+ const mb = (size / 1024 / 1024).toFixed(1);
112
+ const maxBytes = tgt.maxMb * 1024 * 1024;
113
+ console.log(` ${entryName}: ${mb} MB (max ${tgt.maxMb} MB)`);
114
+ if (size > maxBytes) {
115
+ console.error(` ERROR: ${entryName} is larger than ${tgt.maxMb} MB (${mb} MB).`);
116
+ failed += 1;
117
+ }
118
+ }
119
+
120
+ process.exit(failed === 0 ? 0 : 1);
121
+ }
122
+
123
+ function safeStat(p) {
124
+ try {
125
+ return statSync(p);
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ main();
@@ -0,0 +1,252 @@
1
+ import { type ParseArgsConfig, parseArgs } from 'node:util';
2
+ import { CliError, ExitCode } from '../io/exit-codes.js';
3
+
4
+ export type ModeKind = 'render' | 'serve' | 'init' | 'help' | 'version';
5
+
6
+ export interface ParsedArgs {
7
+ /** Resolved mode after dispatch (mutual-exclusivity already checked). */
8
+ mode: ModeKind;
9
+ /** Positional argument, or undefined. Render = input path. Init = project name. Serve = input path. */
10
+ positional?: string;
11
+ /** True if `--dry-run` / `-n` was passed (only valid for render mode). */
12
+ dryRun: boolean;
13
+ /** Logging level. Verbose and quiet are mutually exclusive. */
14
+ logLevel: 'verbose' | 'quiet' | 'normal';
15
+
16
+ // I/O
17
+ output?: string;
18
+ format?: string;
19
+ inputFormat?: string;
20
+
21
+ // Render options
22
+ theme?: string;
23
+ /** Now-line date string from `--now`. Undefined means "use the actual
24
+ * current date" (the default). The literal value `"-"` means
25
+ * "suppress the now-line entirely" (mirrors the Unix-`-` convention
26
+ * used elsewhere in the CLI). Otherwise expected as YYYY-MM-DD;
27
+ * parsed downstream by resolveNowArg. */
28
+ now?: string;
29
+ noLinks: boolean;
30
+ scale?: string;
31
+ strict: boolean;
32
+ width?: string;
33
+ assetRoot?: string;
34
+
35
+ // Format-specific options (m2c)
36
+ pageSize?: string;
37
+ orientation?: string;
38
+ margin?: string;
39
+ fontSans?: string;
40
+ fontMono?: string;
41
+ headless: boolean;
42
+ start?: string;
43
+
44
+ /**
45
+ * BCP-47 locale override (`fr-CA`, `fr`, …). When omitted the CLI
46
+ * falls back to `LC_ALL` / `LC_MESSAGES` / `LANG`, then to the
47
+ * file's `nowline v1 locale:` directive, then to `en-US`. See
48
+ * `specs/localization.md`.
49
+ */
50
+ locale?: string;
51
+
52
+ // Serve options
53
+ port?: string;
54
+ host?: string;
55
+ open: boolean;
56
+
57
+ // Validate / dry-run formatting
58
+ diagnosticFormat?: string;
59
+
60
+ // Init
61
+ template?: string;
62
+ }
63
+
64
+ /**
65
+ * Pure argument parser. Walks `argv` once, identifies mode flags, applies
66
+ * mutual-exclusivity rules, and returns a fully-resolved `ParsedArgs`. Throws
67
+ * `CliError(ExitCode.InputError)` for usage errors.
68
+ *
69
+ * Help / version short-circuit any other flag combinations.
70
+ */
71
+ export function parseArgv(argv: readonly string[]): ParsedArgs {
72
+ if (argv.length === 0) {
73
+ return {
74
+ mode: 'help',
75
+ dryRun: false,
76
+ logLevel: 'normal',
77
+ noLinks: false,
78
+ strict: false,
79
+ open: false,
80
+ headless: false,
81
+ };
82
+ }
83
+
84
+ const config: ParseArgsConfig = {
85
+ args: argv as string[],
86
+ allowPositionals: true,
87
+ strict: true,
88
+ options: {
89
+ help: { type: 'boolean', short: 'h' },
90
+ version: { type: 'boolean', short: 'V' },
91
+ verbose: { type: 'boolean', short: 'v' },
92
+ quiet: { type: 'boolean', short: 'q' },
93
+
94
+ output: { type: 'string', short: 'o' },
95
+ format: { type: 'string', short: 'f' },
96
+ 'input-format': { type: 'string' },
97
+
98
+ serve: { type: 'boolean' },
99
+ init: { type: 'boolean' },
100
+ 'dry-run': { type: 'boolean', short: 'n' },
101
+
102
+ theme: { type: 'string', short: 't' },
103
+ // `--now <date>` overrides the now-line position; without it, the
104
+ // CLI defaults to today's calendar date. Use `--now -` to
105
+ // suppress the now-line entirely (Unix-`-` sentinel; same idea
106
+ // as `-o -` for stdout).
107
+ now: { type: 'string' },
108
+ 'no-links': { type: 'boolean' },
109
+ scale: { type: 'string', short: 's' },
110
+ strict: { type: 'boolean' },
111
+ width: { type: 'string', short: 'w' },
112
+ 'asset-root': { type: 'string' },
113
+
114
+ port: { type: 'string', short: 'p' },
115
+ host: { type: 'string' },
116
+ open: { type: 'boolean' },
117
+
118
+ 'diagnostic-format': { type: 'string' },
119
+
120
+ template: { type: 'string' },
121
+
122
+ // Format-specific (m2c)
123
+ 'page-size': { type: 'string' },
124
+ orientation: { type: 'string' },
125
+ margin: { type: 'string' },
126
+ 'font-sans': { type: 'string' },
127
+ 'font-mono': { type: 'string' },
128
+ headless: { type: 'boolean' },
129
+ start: { type: 'string' },
130
+
131
+ // Localization (m-loc-b)
132
+ locale: { type: 'string' },
133
+ },
134
+ };
135
+
136
+ let parsed: ReturnType<typeof parseArgs>;
137
+ try {
138
+ parsed = parseArgs(config);
139
+ } catch (err) {
140
+ const message = err instanceof Error ? err.message : String(err);
141
+ throw new CliError(ExitCode.InputError, formatUsageError(message));
142
+ }
143
+
144
+ const values = parsed.values as Record<string, unknown>;
145
+ const positionals = parsed.positionals;
146
+
147
+ if (values.help === true) {
148
+ return {
149
+ mode: 'help',
150
+ dryRun: false,
151
+ logLevel: 'normal',
152
+ noLinks: false,
153
+ strict: false,
154
+ open: false,
155
+ headless: false,
156
+ };
157
+ }
158
+ if (values.version === true) {
159
+ return {
160
+ mode: 'version',
161
+ dryRun: false,
162
+ logLevel: 'normal',
163
+ noLinks: false,
164
+ strict: false,
165
+ open: false,
166
+ headless: false,
167
+ };
168
+ }
169
+
170
+ if (values.verbose === true && values.quiet === true) {
171
+ throw new CliError(
172
+ ExitCode.InputError,
173
+ 'nowline: --verbose and --quiet are mutually exclusive.',
174
+ );
175
+ }
176
+
177
+ const modes: ModeKind[] = [];
178
+ if (values.serve === true) modes.push('serve');
179
+ if (values.init === true) modes.push('init');
180
+ if (modes.length > 1) {
181
+ throw new CliError(
182
+ ExitCode.InputError,
183
+ `nowline: --${modes[0]} and --${modes[1]} are mutually exclusive.`,
184
+ );
185
+ }
186
+
187
+ const dryRun = values['dry-run'] === true;
188
+ const mode: ModeKind = modes[0] ?? 'render';
189
+
190
+ if (dryRun && (mode === 'serve' || mode === 'init')) {
191
+ throw new CliError(
192
+ ExitCode.InputError,
193
+ `nowline: --dry-run cannot be combined with --${mode}.`,
194
+ );
195
+ }
196
+
197
+ if (positionals.length > 1) {
198
+ const extras = positionals
199
+ .slice(1)
200
+ .map((p) => JSON.stringify(p))
201
+ .join(' ');
202
+ throw new CliError(ExitCode.InputError, `nowline: unexpected extra arguments: ${extras}.`);
203
+ }
204
+
205
+ const logLevel: ParsedArgs['logLevel'] =
206
+ values.verbose === true ? 'verbose' : values.quiet === true ? 'quiet' : 'normal';
207
+
208
+ return {
209
+ mode,
210
+ positional: positionals[0],
211
+ dryRun,
212
+ logLevel,
213
+ output: stringOrUndefined(values.output),
214
+ format: stringOrUndefined(values.format),
215
+ inputFormat: stringOrUndefined(values['input-format']),
216
+ theme: stringOrUndefined(values.theme),
217
+ now: stringOrUndefined(values.now),
218
+ noLinks: values['no-links'] === true,
219
+ scale: stringOrUndefined(values.scale),
220
+ strict: values.strict === true,
221
+ width: stringOrUndefined(values.width),
222
+ assetRoot: stringOrUndefined(values['asset-root']),
223
+ port: stringOrUndefined(values.port),
224
+ host: stringOrUndefined(values.host),
225
+ open: values.open === true,
226
+ diagnosticFormat: stringOrUndefined(values['diagnostic-format']),
227
+ template: stringOrUndefined(values.template),
228
+ pageSize: stringOrUndefined(values['page-size']),
229
+ orientation: stringOrUndefined(values.orientation),
230
+ margin: stringOrUndefined(values.margin),
231
+ fontSans: stringOrUndefined(values['font-sans']),
232
+ fontMono: stringOrUndefined(values['font-mono']),
233
+ headless: values.headless === true,
234
+ start: stringOrUndefined(values.start),
235
+ locale: stringOrUndefined(values.locale),
236
+ };
237
+ }
238
+
239
+ function stringOrUndefined(value: unknown): string | undefined {
240
+ if (typeof value === 'string') return value;
241
+ return undefined;
242
+ }
243
+
244
+ function formatUsageError(message: string): string {
245
+ if (message.startsWith('Unknown option')) {
246
+ return `nowline: ${message}. Try --help for usage.`;
247
+ }
248
+ if (message.includes('expected')) {
249
+ return `nowline: ${message}. Try --help for usage.`;
250
+ }
251
+ return `nowline: ${message}`;
252
+ }