@nowline/cli 0.8.6 → 0.9.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 (89) hide show
  1. package/README.md +32 -9
  2. package/dist/cli/args.d.ts +20 -4
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +20 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/help.d.ts.map +1 -1
  7. package/dist/cli/help.js +30 -2
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/options.d.ts +74 -0
  10. package/dist/cli/options.d.ts.map +1 -0
  11. package/dist/cli/options.js +209 -0
  12. package/dist/cli/options.js.map +1 -0
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +2 -3
  15. package/dist/commands/init.js.map +1 -1
  16. package/dist/commands/mcp-install.d.ts +18 -0
  17. package/dist/commands/mcp-install.d.ts.map +1 -0
  18. package/dist/commands/mcp-install.js +151 -0
  19. package/dist/commands/mcp-install.js.map +1 -0
  20. package/dist/commands/mcp.d.ts.map +1 -1
  21. package/dist/commands/mcp.js +5 -11
  22. package/dist/commands/mcp.js.map +1 -1
  23. package/dist/commands/render.d.ts +7 -0
  24. package/dist/commands/render.d.ts.map +1 -1
  25. package/dist/commands/render.js +161 -155
  26. package/dist/commands/render.js.map +1 -1
  27. package/dist/commands/serve.d.ts.map +1 -1
  28. package/dist/commands/serve.js +87 -60
  29. package/dist/commands/serve.js.map +1 -1
  30. package/dist/diagnostics/adapt.d.ts +12 -1
  31. package/dist/diagnostics/adapt.d.ts.map +1 -1
  32. package/dist/diagnostics/adapt.js +53 -1
  33. package/dist/diagnostics/adapt.js.map +1 -1
  34. package/dist/diagnostics/model.d.ts +1 -1
  35. package/dist/generated/version.d.ts +1 -1
  36. package/dist/generated/version.js +2 -2
  37. package/dist/i18n/locale.d.ts.map +1 -1
  38. package/dist/i18n/locale.js +2 -5
  39. package/dist/i18n/locale.js.map +1 -1
  40. package/dist/index.js +4 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/io/config.d.ts +1 -1
  43. package/dist/io/config.d.ts.map +1 -1
  44. package/dist/io/config.js +1 -1
  45. package/dist/io/config.js.map +1 -1
  46. package/dist/io/log.d.ts +17 -0
  47. package/dist/io/log.d.ts.map +1 -0
  48. package/dist/io/log.js +16 -0
  49. package/dist/io/log.js.map +1 -0
  50. package/dist/mcp-install/clients.d.ts +58 -0
  51. package/dist/mcp-install/clients.d.ts.map +1 -0
  52. package/dist/mcp-install/clients.js +304 -0
  53. package/dist/mcp-install/clients.js.map +1 -0
  54. package/dist/mcp-install/host.d.ts +29 -0
  55. package/dist/mcp-install/host.d.ts.map +1 -0
  56. package/dist/mcp-install/host.js +72 -0
  57. package/dist/mcp-install/host.js.map +1 -0
  58. package/dist/mcp-install/json-config.d.ts +29 -0
  59. package/dist/mcp-install/json-config.d.ts.map +1 -0
  60. package/dist/mcp-install/json-config.js +139 -0
  61. package/dist/mcp-install/json-config.js.map +1 -0
  62. package/dist/mcp-install/server-command.d.ts +26 -0
  63. package/dist/mcp-install/server-command.d.ts.map +1 -0
  64. package/dist/mcp-install/server-command.js +52 -0
  65. package/dist/mcp-install/server-command.js.map +1 -0
  66. package/man/fr/nowline.1 +246 -19
  67. package/man/fr/nowline.5 +819 -45
  68. package/man/nowline.1 +191 -18
  69. package/man/nowline.5 +764 -36
  70. package/package.json +19 -19
  71. package/src/cli/args.ts +55 -5
  72. package/src/cli/help.ts +30 -2
  73. package/src/cli/options.ts +286 -0
  74. package/src/commands/init.ts +2 -3
  75. package/src/commands/mcp-install.ts +211 -0
  76. package/src/commands/mcp.ts +8 -13
  77. package/src/commands/render.ts +198 -195
  78. package/src/commands/serve.ts +102 -74
  79. package/src/diagnostics/adapt.ts +62 -0
  80. package/src/diagnostics/model.ts +1 -1
  81. package/src/generated/version.ts +2 -2
  82. package/src/i18n/locale.ts +2 -6
  83. package/src/index.ts +3 -0
  84. package/src/io/config.ts +0 -1
  85. package/src/io/log.ts +33 -0
  86. package/src/mcp-install/clients.ts +443 -0
  87. package/src/mcp-install/host.ts +106 -0
  88. package/src/mcp-install/json-config.ts +179 -0
  89. package/src/mcp-install/server-command.ts +75 -0
@@ -1,20 +1,17 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import type { ResolveDiagnostic } from '@nowline/core';
3
4
  import {
5
+ acceptStagedRoadmap,
4
6
  type ExportFormat,
5
- exportDocument,
7
+ exportStaged,
6
8
  type HostEnv,
7
9
  type RenderInputs,
10
+ type StageResult,
11
+ stageForExport,
8
12
  } from '@nowline/export';
9
- import { lengthToPoints, parseLength } from '@nowline/export-core';
10
- import {
11
- type NormalizedZone,
12
- normalizeThemeName,
13
- normalizeZone,
14
- resolveToday,
15
- type ThemeName,
16
- TimezoneError,
17
- } from '@nowline/layout';
13
+ import type { PdfPageSize } from '@nowline/export-core';
14
+ import type { NonWorkingDisplay, ThemeName } from '@nowline/layout';
18
15
  import type { AssetResolver } from '@nowline/renderer';
19
16
  import type { ParsedArgs } from '../cli/args.js';
20
17
  import {
@@ -23,12 +20,15 @@ import {
23
20
  type OutputFormat,
24
21
  resolveFormat,
25
22
  } from '../cli/formats.js';
23
+ import { type PdfOrientation, resolveCliOptions } from '../cli/options.js';
26
24
  import { resolveRenderOutputPath } from '../cli/output-path.js';
27
25
  import { parseNowlineJson } from '../convert/parse-json.js';
28
26
  import { printNowlineFile } from '../convert/printer.js';
29
27
  import { serializeToJson } from '../convert/schema.js';
30
- import { parseSource } from '../core/parse.js';
28
+ import { getServices, parseSource } from '../core/parse.js';
31
29
  import {
30
+ adaptResolveDiagnostic,
31
+ type CliDiagnostic,
32
32
  type DiagnosticFormat,
33
33
  type DiagnosticSource,
34
34
  formatDiagnostics,
@@ -39,8 +39,9 @@ import {
39
39
  readDirectiveLocale,
40
40
  resolveLocaleOverride,
41
41
  } from '../i18n/locale.js';
42
- import { loadConfig } from '../io/config.js';
42
+ import { type LoadConfigResult, loadConfig } from '../io/config.js';
43
43
  import { CliError, ExitCode } from '../io/exit-codes.js';
44
+ import { createLog, type Log } from '../io/log.js';
44
45
  import { readInput } from '../io/read.js';
45
46
  import { writeOutput } from '../io/write.js';
46
47
 
@@ -67,18 +68,20 @@ export async function renderHandler(options: RenderHandlerOptions): Promise<void
67
68
  }
68
69
 
69
70
  const isStdoutOutput = args.output === '-';
70
- const config = await loadConfigFor(args.positional, cwd);
71
+ const log = createLog(args.logLevel);
72
+ const { config, path: rcPath } = await loadConfigFor(args.positional, cwd);
73
+ // Every rc-backed setting resolves flag > .nowlinerc > default; a bad
74
+ // value is reported by `assertValid` below, where it always has been.
75
+ const cliOptions = resolveCliOptions(args, config, { rcPath });
71
76
 
72
77
  const format = resolveFormatOrThrow({
73
78
  flag: args.format,
74
79
  outputPath: args.output,
75
- configFormat: typeof config?.defaultFormat === 'string' ? config.defaultFormat : undefined,
80
+ configFormat: cliOptions.defaultFormat,
76
81
  isStdout: isStdoutOutput,
77
82
  });
78
83
 
79
- if (args.logLevel === 'verbose') {
80
- process.stderr.write(`nowline: format=${format} (resolved)\n`);
81
- }
84
+ log.verbose(`nowline: format=${format} (resolved)`);
82
85
 
83
86
  const resolvedOutput = resolveRenderOutputPath({
84
87
  outputArg: args.output,
@@ -96,10 +99,11 @@ export async function renderHandler(options: RenderHandlerOptions): Promise<void
96
99
  const resolved = resolveLocaleOverride({
97
100
  flag: args.locale,
98
101
  env: process.env,
99
- rc: stringFromConfig(config, 'locale'),
102
+ rc: cliOptions.rcLocale,
100
103
  });
101
104
  const locale = resolved.tag;
102
105
  const opLocale = operatorLocale(resolved);
106
+ cliOptions.assertValid();
103
107
 
104
108
  const { rendered, isBinary } = await produce({
105
109
  format,
@@ -108,34 +112,34 @@ export async function renderHandler(options: RenderHandlerOptions): Promise<void
108
112
  displayPath: input.displayPath,
109
113
  absInputPath: input.isStdin ? path.resolve(cwd, 'stdin.nowline') : input.path,
110
114
  isStdin: input.isStdin,
111
- theme: parseTheme(args.theme),
112
- today: resolveNowCli(args),
113
- width: parseWidthArg(args.width),
115
+ theme: cliOptions.surface.theme,
116
+ today: cliOptions.surface.today,
117
+ width: cliOptions.surface.width,
118
+ nonWorking: cliOptions.surface.nonWorking,
114
119
  noLinks: args.noLinks,
115
120
  strict: args.strict,
116
- assetRoot: args.assetRoot,
117
- // m2c format-specific options. Strings get parsed inside the format
118
- // dispatch; the CLI just passes them through.
119
- pageSize: args.pageSize ?? stringFromConfig(config, 'pdfPageSize'),
120
- orientation: args.orientation ?? stringFromConfig(config, 'pdfOrientation'),
121
- margin: args.margin ?? stringFromConfig(config, 'pdfMargin'),
122
- fontSans: args.fontSans ?? stringFromConfig(config, 'fontSans'),
123
- fontMono: args.fontMono ?? stringFromConfig(config, 'fontMono'),
124
- headless: args.headless || boolFromConfig(config, 'headlessFonts'),
125
- useSystemFonts: args.useSystemFonts || boolFromConfig(config, 'useSystemFonts'),
121
+ assetRoot: cliOptions.assetRoot,
122
+ // m2c format-specific options. The PDF settings parse in `produce`,
123
+ // after validation, for every format.
124
+ pageSize: cliOptions.surface.pageSize,
125
+ orientation: cliOptions.orientation,
126
+ marginPt: cliOptions.margin,
127
+ assertPdfSettingsValid: () => cliOptions.assertPdfSettingsValid(),
128
+ fontSans: cliOptions.fontSans,
129
+ fontMono: cliOptions.fontMono,
130
+ headless: cliOptions.headless,
131
+ useSystemFonts: cliOptions.useSystemFonts,
126
132
  scale: args.scale,
127
133
  start: args.start,
128
134
  locale,
129
135
  operatorLocale: opLocale,
130
136
  resolvedLocale: resolved,
131
- verbose: args.logLevel === 'verbose',
137
+ log,
132
138
  diagnosticFormat: resolveDiagnosticFormat(args),
133
139
  });
134
140
 
135
141
  if (args.dryRun) {
136
- if (args.logLevel === 'verbose') {
137
- process.stderr.write('nowline: --dry-run; skipping write\n');
138
- }
142
+ log.verbose('nowline: --dry-run; skipping write');
139
143
  return;
140
144
  }
141
145
 
@@ -146,8 +150,8 @@ export async function renderHandler(options: RenderHandlerOptions): Promise<void
146
150
  { cwd },
147
151
  );
148
152
 
149
- if (args.logLevel === 'verbose' && !resolvedOutput.isStdout) {
150
- process.stderr.write(`nowline: wrote ${resolvedOutput.path}\n`);
153
+ if (!resolvedOutput.isStdout) {
154
+ log.verbose(`nowline: wrote ${resolvedOutput.path}`);
151
155
  }
152
156
  }
153
157
 
@@ -199,13 +203,17 @@ interface ProduceArgs {
199
203
  theme: ThemeName;
200
204
  today?: Date;
201
205
  width?: number;
206
+ nonWorking?: NonWorkingDisplay;
202
207
  noLinks: boolean;
203
208
  strict: boolean;
204
209
  assetRoot?: string;
205
210
  // m2c format-specific
206
- pageSize?: string;
207
- orientation?: string;
208
- margin?: string;
211
+ pageSize?: PdfPageSize;
212
+ orientation?: PdfOrientation;
213
+ /** Page margin in points. */
214
+ marginPt?: number;
215
+ /** Reports a bad PDF setting; run after validation so its error follows any diagnostics. */
216
+ assertPdfSettingsValid: () => void;
209
217
  fontSans?: string;
210
218
  fontMono?: string;
211
219
  headless: boolean;
@@ -219,8 +227,8 @@ interface ProduceArgs {
219
227
  operatorLocale: string;
220
228
  /** Resolved locale override metadata, used for the verbose-mode source line. */
221
229
  resolvedLocale: import('../i18n/locale.js').ResolvedLocale;
222
- /** True for `--verbose`. Gates the `nowline: locale=...` source-line emission. */
223
- verbose: boolean;
230
+ /** `-v` / `-q` gate: verbose lines, warnings, and text-mode warning diagnostics. */
231
+ log: Log;
224
232
  /** How diagnostics are written to stderr. See `resolveDiagnosticFormat`. */
225
233
  diagnosticFormat: DiagnosticFormat;
226
234
  }
@@ -245,17 +253,38 @@ async function produce(args: ProduceArgs): Promise<ProduceResult> {
245
253
  ? jsonToNowlineText(args.contents, args.displayPath)
246
254
  : args.contents;
247
255
 
248
- // Pre-validate with locale-aware diagnostic formatting so validation
249
- // errors reach the operator in their locale. The kernel will re-parse
250
- // the same source below — double work accepted in exchange for faithful
251
- // stderr output.
252
- await parseAndValidate(sourceText, args);
253
-
254
256
  const assetRoot = args.assetRoot
255
257
  ? path.resolve(args.assetRoot)
256
258
  : path.dirname(args.absInputPath);
257
259
 
258
- const host = createNodeHostEnv(assetRoot);
260
+ // Included files' text, kept for the code frames of routed diagnostics.
261
+ const included = new Map<string, string>();
262
+ const nodeHost = createNodeHostEnv(assetRoot);
263
+ const host: HostEnv = {
264
+ ...nodeHost,
265
+ readSource: async (absPath) => {
266
+ const text = await nodeHost.readSource(absPath);
267
+ included.set(absPath, text);
268
+ return text;
269
+ },
270
+ };
271
+
272
+ // What the stage lays out with; the export below must ask for the same.
273
+ const layoutInputs: RenderInputs = {
274
+ sourcePath: args.absInputPath,
275
+ today: args.today,
276
+ locale: args.locale ?? 'und',
277
+ theme: args.theme,
278
+ width: args.width,
279
+ nonWorking: args.nonWorking,
280
+ };
281
+
282
+ // Parse and stage once. Validation diagnostics come out in the operator's
283
+ // locale, and routed include-resolver diagnostics join the validator's
284
+ // here, so they are reported at the same point and in the same report.
285
+ const { stage, stageFailure } = await parseAndValidate(sourceText, args, {
286
+ stage: { inputs: layoutInputs, host, included },
287
+ });
259
288
 
260
289
  // Resolve fonts lazily — only for formats that rasterize or embed them.
261
290
  let fonts: RenderInputs['fonts'];
@@ -263,29 +292,28 @@ async function produce(args: ProduceArgs): Promise<ProduceResult> {
263
292
  fonts = await resolveNodeFonts(args);
264
293
  }
265
294
 
295
+ args.assertPdfSettingsValid();
296
+
266
297
  const kernelInputs: RenderInputs = {
267
- sourcePath: args.absInputPath,
268
- today: args.today,
269
- locale: args.locale ?? 'und',
270
- theme: args.theme,
298
+ ...layoutInputs,
271
299
  fonts,
272
- width: args.width,
273
300
  noLinks: args.noLinks,
274
301
  strict: args.strict,
275
302
  pageSize: args.pageSize,
276
- orientation: parseOrientation(args.orientation),
277
- marginPt: parseMargin(args.margin),
303
+ orientation: args.orientation,
304
+ marginPt: args.marginPt,
278
305
  pngScale: parseScale(args.scale),
279
306
  msprojStart: args.start,
307
+ onNotice: (message) => args.log.notice(message),
308
+ onWarning: (message) => args.log.notice(`warning: ${message}`),
280
309
  };
281
310
 
282
311
  try {
283
- const bytes = await exportDocument(
284
- sourceText,
285
- args.format as ExportFormat,
286
- kernelInputs,
287
- host,
288
- );
312
+ // A stage failure or an uncoded include error was held back until
313
+ // here, so a bad font or PDF setting is still reported first.
314
+ if (!stage) throw stageFailure;
315
+ const staged = acceptStagedRoadmap(stage, kernelInputs);
316
+ const bytes = await exportStaged(staged, args.format as ExportFormat, kernelInputs, host);
289
317
  const isBinary = BINARY_FORMATS.has(args.format);
290
318
  const rendered: string | Uint8Array = isBinary
291
319
  ? bytes
@@ -301,6 +329,18 @@ async function produce(args: ProduceArgs): Promise<ProduceResult> {
301
329
  }
302
330
  }
303
331
 
332
+ /**
333
+ * How an included file is named in diagnostics: relative to the input's
334
+ * display path, so `examples/a.nowline` including `./teams/b.nowline` prints
335
+ * `examples/teams/b.nowline` (absolute when the input was given absolute).
336
+ */
337
+ function includeDisplayPath(absPath: string, args: ProduceArgs): string {
338
+ const rel = path.relative(path.dirname(args.absInputPath), absPath);
339
+ if (path.isAbsolute(rel)) return rel;
340
+ const base = args.isStdin ? '' : path.dirname(args.displayPath);
341
+ return path.join(base, rel);
342
+ }
343
+
304
344
  // ---- Node HostEnv -----------------------------------------------------------
305
345
 
306
346
  function createNodeHostEnv(assetRoot: string): HostEnv {
@@ -371,20 +411,20 @@ async function resolveNodeFonts(
371
411
  if (args.strict) {
372
412
  throw new CliError(ExitCode.InputError, `nowline: ${message}`);
373
413
  }
374
- process.stderr.write(`warning: ${message}\n`);
414
+ args.log.notice(`warning: ${message}`);
375
415
  }
376
416
 
377
417
  // Bundled fallback after an opted-in probe found no usable system font.
378
418
  // Only meaningful when --use-system-fonts is set; warn under --strict.
379
419
  if (args.strict) {
380
420
  if (result.sansFellBackToBundled) {
381
- process.stderr.write(
382
- 'warning: sans font fell back to bundled DejaVu (no platform font found)\n',
421
+ args.log.notice(
422
+ 'warning: sans font fell back to bundled DejaVu (no platform font found)',
383
423
  );
384
424
  }
385
425
  if (result.monoFellBackToBundled) {
386
- process.stderr.write(
387
- 'warning: mono font fell back to bundled DejaVu (no platform font found)\n',
426
+ args.log.notice(
427
+ 'warning: mono font fell back to bundled DejaVu (no platform font found)',
388
428
  );
389
429
  }
390
430
  }
@@ -410,97 +450,99 @@ function jsonToNowlineText(contents: string, displayPath: string): string {
410
450
  return printNowlineFile(ast);
411
451
  }
412
452
 
413
- async function parseAndValidate(contents: string, args: ProduceArgs) {
453
+ /** What `parseAndValidate` stages with, once the source validates. */
454
+ interface StageRequest {
455
+ /** The layout inputs the export that follows will ask for. */
456
+ inputs: RenderInputs;
457
+ host: HostEnv;
458
+ /** Collects the text of each file the stage reads, for code frames. */
459
+ included: Map<string, string>;
460
+ }
461
+
462
+ /**
463
+ * Parse and validate; on errors, report them and throw. With `stage`, the
464
+ * validated document is also staged (include resolution, logo, layout) and a
465
+ * file that has includes reports the routed (wave-rule) diagnostics of that
466
+ * stage with the validator's, in the same report (specs/waves.md §6.1). The
467
+ * stage is returned for the export to reuse. Every other include error, and a
468
+ * stage that throws, is left for the caller to report where the kernel always
469
+ * did (exit 3).
470
+ */
471
+ async function parseAndValidate(
472
+ contents: string,
473
+ args: ProduceArgs,
474
+ options: { stage?: StageRequest } = {},
475
+ ) {
414
476
  const result = await parseSource(contents, args.displayPath, { validate: true });
477
+ const diagnostics = [...result.diagnostics];
478
+ const sources = new Map<string, DiagnosticSource>([[args.displayPath, result.source]]);
479
+ let failed = result.hasErrors;
480
+ let stage: StageResult | undefined;
481
+ let stageFailure: unknown;
482
+ if (!failed && options.stage) {
483
+ const { inputs, host, included } = options.stage;
484
+ try {
485
+ stage = await stageForExport(contents, inputs, host, {
486
+ document: result.document,
487
+ services: getServices(),
488
+ });
489
+ } catch (err) {
490
+ stageFailure = err;
491
+ }
492
+ if (stage && result.ast.includes.length > 0) {
493
+ for (const d of routedDiagnostics(stage)) {
494
+ const isRoot = d.sourcePath === args.absInputPath;
495
+ const file = isRoot ? args.displayPath : includeDisplayPath(d.sourcePath, args);
496
+ const text = isRoot ? result.source.contents : included.get(d.sourcePath);
497
+ if (text !== undefined) sources.set(file, { file, contents: text });
498
+ diagnostics.push(adaptResolveDiagnostic(d, file, text));
499
+ if (d.severity === 'error') failed = true;
500
+ }
501
+ }
502
+ }
415
503
  // Text mode prints only when the run fails. JSON mode prints one document
416
504
  // whenever any diagnostic exists, warnings included, so a warnings-only
417
505
  // run still exits 0 with the document on stderr.
418
- if (result.hasErrors || (args.diagnosticFormat === 'json' && result.diagnostics.length > 0)) {
419
- emitDiagnostics(
420
- result.diagnostics,
421
- result.source,
422
- args.displayPath,
423
- args.operatorLocale,
424
- args.diagnosticFormat,
425
- );
506
+ if (failed || (args.diagnosticFormat === 'json' && diagnostics.length > 0)) {
507
+ emitDiagnostics(diagnostics, sources, args.operatorLocale, args.diagnosticFormat, args.log);
426
508
  }
427
- if (result.hasErrors) {
509
+ if (failed) {
428
510
  throw new CliError(ExitCode.ValidationError, '');
429
511
  }
430
- if (args.verbose) {
431
- const directive = readDirectiveLocale(result.ast);
432
- const { tag, source } = describeContentLocaleSource(directive, args.resolvedLocale);
433
- process.stderr.write(`nowline: locale=${tag} (${source})\n`);
434
- }
435
- return result;
512
+ const directive = readDirectiveLocale(result.ast);
513
+ const { tag, source } = describeContentLocaleSource(directive, args.resolvedLocale);
514
+ args.log.verbose(`nowline: locale=${tag} (${source})`);
515
+ return { ...result, stage, stageFailure };
436
516
  }
437
517
 
438
- async function loadConfigFor(
439
- inputArg: string,
440
- cwd: string,
441
- ): Promise<{ defaultFormat?: string } | null> {
442
- try {
443
- if (inputArg === '-') {
444
- const { config } = await loadConfig(cwd);
445
- return config;
446
- }
447
- const abs = path.resolve(cwd, inputArg);
448
- const dir = path.dirname(abs);
449
- const { config } = await loadConfig(dir);
450
- return config;
451
- } catch {
452
- return null;
453
- }
454
- }
455
-
456
- function parseTheme(raw: string | undefined): ThemeName {
457
- if (!raw) return 'light';
458
- // `greyscale` (UK) is accepted and canonicalized to `grayscale` (US).
459
- const theme = normalizeThemeName(raw);
460
- if (!theme) {
461
- throw new CliError(
462
- ExitCode.InputError,
463
- `nowline: invalid --theme "${raw}". Expected light, dark, or grayscale.`,
464
- );
518
+ /**
519
+ * The routed (wave-rule) diagnostics of a stage's include resolution. Empty
520
+ * when the resolver reports any other include error: that one is more
521
+ * fundamental, and the export reports it as before (exit 3).
522
+ */
523
+ function routedDiagnostics(stage: StageResult): ResolveDiagnostic[] {
524
+ if (stage.kind === 'staged' || (stage.kind === 'include-error' && !stage.legacy)) {
525
+ return stage.routed;
465
526
  }
466
- return theme;
527
+ return [];
467
528
  }
468
529
 
469
- // Resolve the now-line date from the CLI flags.
470
- //
471
- // Precedence:
472
- // 1. `--now -` → undefined (suppress now-line)
473
- // 2. `--now YYYY-MM-DD` → floating date; zone ignored
474
- // 3. `--now YYYY-MM-DDTHH:MM:SS[±HH:MM]` → civil date at embedded offset; --timezone ignored
475
- // 4. `--now YYYY-MM-DDTHH:MM:SSZ` → civil date in UTC; --timezone ignored
476
- // 5. `--now YYYY-MM-DDTHH:MM:SS` → floating; written date part; zone ignored
477
- // 6. flag omitted → civil date of today in --timezone (default: local)
478
- //
479
- // `--timezone` is only consulted for case 6. Authored dates in the roadmap
480
- // (bars, milestones, axis) are floating and are never affected.
481
- function resolveNowCli(args: { now?: string; timezone?: string }): Date | undefined {
482
- let zone: NormalizedZone | undefined;
483
- if (args.timezone) {
484
- try {
485
- zone = normalizeZone(args.timezone);
486
- } catch (err) {
487
- if (err instanceof TimezoneError) {
488
- throw new CliError(ExitCode.InputError, err.message);
489
- }
490
- throw err;
491
- }
492
- }
493
- const result = resolveToday({ now: args.now, zone });
494
- // resolveToday returns undefined for unrecognised strings; surface that as
495
- // an input error so the user gets a clear message instead of a silent no-line.
496
- if (result === undefined && args.now !== undefined && args.now !== '-') {
530
+ // Walk up from the input (or `cwd` for stdin) to the nearest `.nowlinerc`.
531
+ // No rc file is not an error. One that exists but cannot be read or parsed is
532
+ // a usage error (specs/cli.md, `.nowlinerc`): silently ignoring it would
533
+ // render with settings the user believes they changed. The loader's reason
534
+ // already names the file, so it is passed through. `--serve` shares this.
535
+ export async function loadConfigFor(inputArg: string, cwd: string): Promise<LoadConfigResult> {
536
+ try {
537
+ if (inputArg === '-') return await loadConfig(cwd);
538
+ const abs = path.resolve(cwd, inputArg);
539
+ return await loadConfig(path.dirname(abs));
540
+ } catch (err) {
497
541
  throw new CliError(
498
542
  ExitCode.InputError,
499
- `nowline: invalid --now "${args.now}". ` +
500
- `Expected YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS[Z|±HH:MM], or "-".`,
543
+ `nowline: invalid .nowlinerc: ${err instanceof Error ? err.message : String(err)}`,
501
544
  );
502
545
  }
503
- return result;
504
546
  }
505
547
 
506
548
  function parseScale(raw: string | undefined): number | undefined {
@@ -515,54 +557,6 @@ function parseScale(raw: string | undefined): number | undefined {
515
557
  return value;
516
558
  }
517
559
 
518
- function parseOrientation(raw: string | undefined): 'portrait' | 'landscape' | 'auto' | undefined {
519
- if (!raw) return undefined;
520
- const lower = raw.toLowerCase();
521
- if (lower === 'portrait' || lower === 'landscape' || lower === 'auto') return lower;
522
- throw new CliError(
523
- ExitCode.InputError,
524
- `nowline: invalid --orientation "${raw}". Expected portrait, landscape, or auto.`,
525
- );
526
- }
527
-
528
- function parseMargin(raw: string | undefined): number | undefined {
529
- if (!raw) return undefined;
530
- if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw); // bare number → points
531
- try {
532
- return lengthToPoints(parseLength(raw));
533
- } catch (err) {
534
- const message = err instanceof Error ? err.message : String(err);
535
- throw new CliError(ExitCode.InputError, `nowline: invalid --margin "${raw}": ${message}`);
536
- }
537
- }
538
-
539
- function stringFromConfig(
540
- config: { [key: string]: unknown } | null,
541
- key: string,
542
- ): string | undefined {
543
- if (!config) return undefined;
544
- const value = config[key];
545
- return typeof value === 'string' ? value : undefined;
546
- }
547
-
548
- function boolFromConfig(config: { [key: string]: unknown } | null, key: string): boolean {
549
- if (!config) return false;
550
- const value = config[key];
551
- return value === true;
552
- }
553
-
554
- function parseWidthArg(raw: string | undefined): number | undefined {
555
- if (!raw) return undefined;
556
- const value = parseInt(raw, 10);
557
- if (!Number.isFinite(value) || value < 320) {
558
- throw new CliError(
559
- ExitCode.InputError,
560
- `nowline: invalid --width "${raw}". Must be an integer ≥ 320.`,
561
- );
562
- }
563
- return value;
564
- }
565
-
566
560
  /**
567
561
  * `--diagnostic-format` is the explicit switch. Left unset, a `--dry-run` that
568
562
  * asks for `--format=json` gets JSON diagnostics (the machine-consumer shape
@@ -575,20 +569,29 @@ function resolveDiagnosticFormat(args: ParsedArgs): DiagnosticFormat {
575
569
  }
576
570
 
577
571
  function emitDiagnostics(
578
- diagnostics: Parameters<typeof formatDiagnostics>[0],
579
- source: DiagnosticSource,
580
- displayPath: string,
572
+ diagnostics: CliDiagnostic[],
573
+ sources: Map<string, DiagnosticSource>,
581
574
  operatorLocale: string,
582
575
  format: DiagnosticFormat,
576
+ log: Log,
583
577
  ): void {
584
- const sources = new Map<string, DiagnosticSource>([[displayPath, source]]);
585
- const rendered = formatDiagnostics(diagnostics, format, sources, {
578
+ // JSON is machine output and always prints whole; text drops warnings under -q.
579
+ const shown = format === 'text' ? errorsOnlyWhenQuiet(diagnostics, log) : diagnostics;
580
+ const rendered = formatDiagnostics(shown, format, sources, {
586
581
  color: process.stderr.isTTY === true,
587
582
  operatorLocale,
588
583
  });
589
584
  if (rendered) process.stderr.write(`${rendered}\n`);
590
585
  }
591
586
 
587
+ /** Under `-q`, text-mode diagnostics keep only errors. Shared with serve. */
588
+ export function errorsOnlyWhenQuiet<T extends { severity: string }>(
589
+ diagnostics: T[],
590
+ log: Log,
591
+ ): T[] {
592
+ return log.quiet ? diagnostics.filter((d) => d.severity === 'error') : diagnostics;
593
+ }
594
+
592
595
  // ---- Asset resolver (still used by serve.ts) --------------------------------
593
596
 
594
597
  export function createAssetResolver(assetRoot: string): AssetResolver {