@nowline/cli 0.8.5 → 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 (102) hide show
  1. package/README.md +42 -12
  2. package/dist/cli/args.d.ts +21 -5
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +26 -1
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/formats.d.ts.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +35 -2
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/options.d.ts +74 -0
  11. package/dist/cli/options.d.ts.map +1 -0
  12. package/dist/cli/options.js +209 -0
  13. package/dist/cli/options.js.map +1 -0
  14. package/dist/commands/init.d.ts.map +1 -1
  15. package/dist/commands/init.js +2 -3
  16. package/dist/commands/init.js.map +1 -1
  17. package/dist/commands/mcp-install.d.ts +18 -0
  18. package/dist/commands/mcp-install.d.ts.map +1 -0
  19. package/dist/commands/mcp-install.js +151 -0
  20. package/dist/commands/mcp-install.js.map +1 -0
  21. package/dist/commands/mcp.d.ts.map +1 -1
  22. package/dist/commands/mcp.js +6 -12
  23. package/dist/commands/mcp.js.map +1 -1
  24. package/dist/commands/render.d.ts +7 -0
  25. package/dist/commands/render.d.ts.map +1 -1
  26. package/dist/commands/render.js +176 -152
  27. package/dist/commands/render.js.map +1 -1
  28. package/dist/commands/serve.d.ts.map +1 -1
  29. package/dist/commands/serve.js +87 -60
  30. package/dist/commands/serve.js.map +1 -1
  31. package/dist/core/parse.js.map +1 -1
  32. package/dist/diagnostics/adapt.d.ts +12 -1
  33. package/dist/diagnostics/adapt.d.ts.map +1 -1
  34. package/dist/diagnostics/adapt.js +53 -1
  35. package/dist/diagnostics/adapt.js.map +1 -1
  36. package/dist/diagnostics/format.js.map +1 -1
  37. package/dist/diagnostics/model.d.ts +1 -1
  38. package/dist/diagnostics/text.js.map +1 -1
  39. package/dist/generated/version.d.ts +1 -1
  40. package/dist/generated/version.js +2 -2
  41. package/dist/i18n/locale.d.ts +1 -1
  42. package/dist/i18n/locale.d.ts.map +1 -1
  43. package/dist/i18n/locale.js +2 -5
  44. package/dist/i18n/locale.js.map +1 -1
  45. package/dist/index.js +4 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/io/config.d.ts +1 -1
  48. package/dist/io/config.d.ts.map +1 -1
  49. package/dist/io/config.js +1 -1
  50. package/dist/io/config.js.map +1 -1
  51. package/dist/io/exit-codes.d.ts.map +1 -1
  52. package/dist/io/exit-codes.js.map +1 -1
  53. package/dist/io/log.d.ts +17 -0
  54. package/dist/io/log.d.ts.map +1 -0
  55. package/dist/io/log.js +16 -0
  56. package/dist/io/log.js.map +1 -0
  57. package/dist/io/read.d.ts +3 -0
  58. package/dist/io/read.d.ts.map +1 -1
  59. package/dist/io/read.js +32 -5
  60. package/dist/io/read.js.map +1 -1
  61. package/dist/io/write.js.map +1 -1
  62. package/dist/mcp-install/clients.d.ts +58 -0
  63. package/dist/mcp-install/clients.d.ts.map +1 -0
  64. package/dist/mcp-install/clients.js +304 -0
  65. package/dist/mcp-install/clients.js.map +1 -0
  66. package/dist/mcp-install/host.d.ts +29 -0
  67. package/dist/mcp-install/host.d.ts.map +1 -0
  68. package/dist/mcp-install/host.js +72 -0
  69. package/dist/mcp-install/host.js.map +1 -0
  70. package/dist/mcp-install/json-config.d.ts +29 -0
  71. package/dist/mcp-install/json-config.d.ts.map +1 -0
  72. package/dist/mcp-install/json-config.js +139 -0
  73. package/dist/mcp-install/json-config.js.map +1 -0
  74. package/dist/mcp-install/server-command.d.ts +26 -0
  75. package/dist/mcp-install/server-command.d.ts.map +1 -0
  76. package/dist/mcp-install/server-command.js +52 -0
  77. package/dist/mcp-install/server-command.js.map +1 -0
  78. package/man/fr/nowline.1 +246 -19
  79. package/man/fr/nowline.5 +819 -45
  80. package/man/nowline.1 +191 -18
  81. package/man/nowline.5 +764 -36
  82. package/package.json +24 -24
  83. package/src/cli/args.ts +69 -8
  84. package/src/cli/help.ts +35 -2
  85. package/src/cli/options.ts +286 -0
  86. package/src/commands/init.ts +2 -3
  87. package/src/commands/mcp-install.ts +211 -0
  88. package/src/commands/mcp.ts +9 -14
  89. package/src/commands/render.ts +219 -186
  90. package/src/commands/serve.ts +102 -74
  91. package/src/diagnostics/adapt.ts +62 -0
  92. package/src/diagnostics/model.ts +1 -1
  93. package/src/generated/version.ts +2 -2
  94. package/src/i18n/locale.ts +2 -6
  95. package/src/index.ts +3 -0
  96. package/src/io/config.ts +0 -1
  97. package/src/io/log.ts +33 -0
  98. package/src/io/read.ts +39 -5
  99. package/src/mcp-install/clients.ts +443 -0
  100. package/src/mcp-install/host.ts +106 -0
  101. package/src/mcp-install/json-config.ts +179 -0
  102. 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,20 +20,28 @@ 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';
31
- import { type DiagnosticSource, formatDiagnostics } from '../diagnostics/index.js';
28
+ import { getServices, parseSource } from '../core/parse.js';
29
+ import {
30
+ adaptResolveDiagnostic,
31
+ type CliDiagnostic,
32
+ type DiagnosticFormat,
33
+ type DiagnosticSource,
34
+ formatDiagnostics,
35
+ } from '../diagnostics/index.js';
32
36
  import {
33
37
  describeContentLocaleSource,
34
38
  operatorLocale,
35
39
  readDirectiveLocale,
36
40
  resolveLocaleOverride,
37
41
  } from '../i18n/locale.js';
38
- import { loadConfig } from '../io/config.js';
42
+ import { type LoadConfigResult, loadConfig } from '../io/config.js';
39
43
  import { CliError, ExitCode } from '../io/exit-codes.js';
44
+ import { createLog, type Log } from '../io/log.js';
40
45
  import { readInput } from '../io/read.js';
41
46
  import { writeOutput } from '../io/write.js';
42
47
 
@@ -63,18 +68,20 @@ export async function renderHandler(options: RenderHandlerOptions): Promise<void
63
68
  }
64
69
 
65
70
  const isStdoutOutput = args.output === '-';
66
- 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 });
67
76
 
68
77
  const format = resolveFormatOrThrow({
69
78
  flag: args.format,
70
79
  outputPath: args.output,
71
- configFormat: typeof config?.defaultFormat === 'string' ? config.defaultFormat : undefined,
80
+ configFormat: cliOptions.defaultFormat,
72
81
  isStdout: isStdoutOutput,
73
82
  });
74
83
 
75
- if (args.logLevel === 'verbose') {
76
- process.stderr.write(`nowline: format=${format} (resolved)\n`);
77
- }
84
+ log.verbose(`nowline: format=${format} (resolved)`);
78
85
 
79
86
  const resolvedOutput = resolveRenderOutputPath({
80
87
  outputArg: args.output,
@@ -92,10 +99,11 @@ export async function renderHandler(options: RenderHandlerOptions): Promise<void
92
99
  const resolved = resolveLocaleOverride({
93
100
  flag: args.locale,
94
101
  env: process.env,
95
- rc: stringFromConfig(config, 'locale'),
102
+ rc: cliOptions.rcLocale,
96
103
  });
97
104
  const locale = resolved.tag;
98
105
  const opLocale = operatorLocale(resolved);
106
+ cliOptions.assertValid();
99
107
 
100
108
  const { rendered, isBinary } = await produce({
101
109
  format,
@@ -104,33 +112,34 @@ export async function renderHandler(options: RenderHandlerOptions): Promise<void
104
112
  displayPath: input.displayPath,
105
113
  absInputPath: input.isStdin ? path.resolve(cwd, 'stdin.nowline') : input.path,
106
114
  isStdin: input.isStdin,
107
- theme: parseTheme(args.theme),
108
- today: resolveNowCli(args),
109
- width: parseWidthArg(args.width),
115
+ theme: cliOptions.surface.theme,
116
+ today: cliOptions.surface.today,
117
+ width: cliOptions.surface.width,
118
+ nonWorking: cliOptions.surface.nonWorking,
110
119
  noLinks: args.noLinks,
111
120
  strict: args.strict,
112
- assetRoot: args.assetRoot,
113
- // m2c format-specific options. Strings get parsed inside the format
114
- // dispatch; the CLI just passes them through.
115
- pageSize: args.pageSize ?? stringFromConfig(config, 'pdfPageSize'),
116
- orientation: args.orientation ?? stringFromConfig(config, 'pdfOrientation'),
117
- margin: args.margin ?? stringFromConfig(config, 'pdfMargin'),
118
- fontSans: args.fontSans ?? stringFromConfig(config, 'fontSans'),
119
- fontMono: args.fontMono ?? stringFromConfig(config, 'fontMono'),
120
- headless: args.headless || boolFromConfig(config, 'headlessFonts'),
121
- 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,
122
132
  scale: args.scale,
123
133
  start: args.start,
124
134
  locale,
125
135
  operatorLocale: opLocale,
126
136
  resolvedLocale: resolved,
127
- verbose: args.logLevel === 'verbose',
137
+ log,
138
+ diagnosticFormat: resolveDiagnosticFormat(args),
128
139
  });
129
140
 
130
141
  if (args.dryRun) {
131
- if (args.logLevel === 'verbose') {
132
- process.stderr.write('nowline: --dry-run; skipping write\n');
133
- }
142
+ log.verbose('nowline: --dry-run; skipping write');
134
143
  return;
135
144
  }
136
145
 
@@ -141,8 +150,8 @@ export async function renderHandler(options: RenderHandlerOptions): Promise<void
141
150
  { cwd },
142
151
  );
143
152
 
144
- if (args.logLevel === 'verbose' && !resolvedOutput.isStdout) {
145
- process.stderr.write(`nowline: wrote ${resolvedOutput.path}\n`);
153
+ if (!resolvedOutput.isStdout) {
154
+ log.verbose(`nowline: wrote ${resolvedOutput.path}`);
146
155
  }
147
156
  }
148
157
 
@@ -194,13 +203,17 @@ interface ProduceArgs {
194
203
  theme: ThemeName;
195
204
  today?: Date;
196
205
  width?: number;
206
+ nonWorking?: NonWorkingDisplay;
197
207
  noLinks: boolean;
198
208
  strict: boolean;
199
209
  assetRoot?: string;
200
210
  // m2c format-specific
201
- pageSize?: string;
202
- orientation?: string;
203
- 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;
204
217
  fontSans?: string;
205
218
  fontMono?: string;
206
219
  headless: boolean;
@@ -214,8 +227,10 @@ interface ProduceArgs {
214
227
  operatorLocale: string;
215
228
  /** Resolved locale override metadata, used for the verbose-mode source line. */
216
229
  resolvedLocale: import('../i18n/locale.js').ResolvedLocale;
217
- /** True for `--verbose`. Gates the `nowline: locale=...` source-line emission. */
218
- verbose: boolean;
230
+ /** `-v` / `-q` gate: verbose lines, warnings, and text-mode warning diagnostics. */
231
+ log: Log;
232
+ /** How diagnostics are written to stderr. See `resolveDiagnosticFormat`. */
233
+ diagnosticFormat: DiagnosticFormat;
219
234
  }
220
235
 
221
236
  interface ProduceResult {
@@ -238,17 +253,38 @@ async function produce(args: ProduceArgs): Promise<ProduceResult> {
238
253
  ? jsonToNowlineText(args.contents, args.displayPath)
239
254
  : args.contents;
240
255
 
241
- // Pre-validate with locale-aware diagnostic formatting so validation
242
- // errors reach the operator in their locale. The kernel will re-parse
243
- // the same source below — double work accepted in exchange for faithful
244
- // stderr output.
245
- await parseAndValidate(sourceText, args);
246
-
247
256
  const assetRoot = args.assetRoot
248
257
  ? path.resolve(args.assetRoot)
249
258
  : path.dirname(args.absInputPath);
250
259
 
251
- 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
+ });
252
288
 
253
289
  // Resolve fonts lazily — only for formats that rasterize or embed them.
254
290
  let fonts: RenderInputs['fonts'];
@@ -256,29 +292,28 @@ async function produce(args: ProduceArgs): Promise<ProduceResult> {
256
292
  fonts = await resolveNodeFonts(args);
257
293
  }
258
294
 
295
+ args.assertPdfSettingsValid();
296
+
259
297
  const kernelInputs: RenderInputs = {
260
- sourcePath: args.absInputPath,
261
- today: args.today,
262
- locale: args.locale ?? 'und',
263
- theme: args.theme,
298
+ ...layoutInputs,
264
299
  fonts,
265
- width: args.width,
266
300
  noLinks: args.noLinks,
267
301
  strict: args.strict,
268
302
  pageSize: args.pageSize,
269
- orientation: parseOrientation(args.orientation),
270
- marginPt: parseMargin(args.margin),
303
+ orientation: args.orientation,
304
+ marginPt: args.marginPt,
271
305
  pngScale: parseScale(args.scale),
272
306
  msprojStart: args.start,
307
+ onNotice: (message) => args.log.notice(message),
308
+ onWarning: (message) => args.log.notice(`warning: ${message}`),
273
309
  };
274
310
 
275
311
  try {
276
- const bytes = await exportDocument(
277
- sourceText,
278
- args.format as ExportFormat,
279
- kernelInputs,
280
- host,
281
- );
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);
282
317
  const isBinary = BINARY_FORMATS.has(args.format);
283
318
  const rendered: string | Uint8Array = isBinary
284
319
  ? bytes
@@ -294,6 +329,18 @@ async function produce(args: ProduceArgs): Promise<ProduceResult> {
294
329
  }
295
330
  }
296
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
+
297
344
  // ---- Node HostEnv -----------------------------------------------------------
298
345
 
299
346
  function createNodeHostEnv(assetRoot: string): HostEnv {
@@ -364,20 +411,20 @@ async function resolveNodeFonts(
364
411
  if (args.strict) {
365
412
  throw new CliError(ExitCode.InputError, `nowline: ${message}`);
366
413
  }
367
- process.stderr.write(`warning: ${message}\n`);
414
+ args.log.notice(`warning: ${message}`);
368
415
  }
369
416
 
370
417
  // Bundled fallback after an opted-in probe found no usable system font.
371
418
  // Only meaningful when --use-system-fonts is set; warn under --strict.
372
419
  if (args.strict) {
373
420
  if (result.sansFellBackToBundled) {
374
- process.stderr.write(
375
- '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)',
376
423
  );
377
424
  }
378
425
  if (result.monoFellBackToBundled) {
379
- process.stderr.write(
380
- '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)',
381
428
  );
382
429
  }
383
430
  }
@@ -403,86 +450,99 @@ function jsonToNowlineText(contents: string, displayPath: string): string {
403
450
  return printNowlineFile(ast);
404
451
  }
405
452
 
406
- async function parseAndValidate(contents: string, args: ProduceArgs) {
407
- const result = await parseSource(contents, args.displayPath, { validate: true });
408
- if (result.hasErrors) {
409
- emitDiagnostics(result.diagnostics, result.source, args.displayPath, args.operatorLocale);
410
- throw new CliError(ExitCode.ValidationError, '');
411
- }
412
- if (args.verbose) {
413
- const directive = readDirectiveLocale(result.ast);
414
- const { tag, source } = describeContentLocaleSource(directive, args.resolvedLocale);
415
- process.stderr.write(`nowline: locale=${tag} (${source})\n`);
416
- }
417
- return result;
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>;
418
460
  }
419
461
 
420
- async function loadConfigFor(
421
- inputArg: string,
422
- cwd: string,
423
- ): Promise<{ defaultFormat?: string } | null> {
424
- try {
425
- if (inputArg === '-') {
426
- const { config } = await loadConfig(cwd);
427
- return config;
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
+ ) {
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
+ }
428
501
  }
429
- const abs = path.resolve(cwd, inputArg);
430
- const dir = path.dirname(abs);
431
- const { config } = await loadConfig(dir);
432
- return config;
433
- } catch {
434
- return null;
435
502
  }
503
+ // Text mode prints only when the run fails. JSON mode prints one document
504
+ // whenever any diagnostic exists, warnings included, so a warnings-only
505
+ // run still exits 0 with the document on stderr.
506
+ if (failed || (args.diagnosticFormat === 'json' && diagnostics.length > 0)) {
507
+ emitDiagnostics(diagnostics, sources, args.operatorLocale, args.diagnosticFormat, args.log);
508
+ }
509
+ if (failed) {
510
+ throw new CliError(ExitCode.ValidationError, '');
511
+ }
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
- function parseTheme(raw: string | undefined): ThemeName {
439
- if (!raw) return 'light';
440
- // `greyscale` (UK) is accepted and canonicalized to `grayscale` (US).
441
- const theme = normalizeThemeName(raw);
442
- if (!theme) {
443
- throw new CliError(
444
- ExitCode.InputError,
445
- `nowline: invalid --theme "${raw}". Expected light, dark, or grayscale.`,
446
- );
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;
447
526
  }
448
- return theme;
527
+ return [];
449
528
  }
450
529
 
451
- // Resolve the now-line date from the CLI flags.
452
- //
453
- // Precedence:
454
- // 1. `--now -` → undefined (suppress now-line)
455
- // 2. `--now YYYY-MM-DD` → floating date; zone ignored
456
- // 3. `--now YYYY-MM-DDTHH:MM:SS[±HH:MM]` → civil date at embedded offset; --timezone ignored
457
- // 4. `--now YYYY-MM-DDTHH:MM:SSZ` → civil date in UTC; --timezone ignored
458
- // 5. `--now YYYY-MM-DDTHH:MM:SS` → floating; written date part; zone ignored
459
- // 6. flag omitted → civil date of today in --timezone (default: local)
460
- //
461
- // `--timezone` is only consulted for case 6. Authored dates in the roadmap
462
- // (bars, milestones, axis) are floating and are never affected.
463
- function resolveNowCli(args: { now?: string; timezone?: string }): Date | undefined {
464
- let zone: NormalizedZone | undefined;
465
- if (args.timezone) {
466
- try {
467
- zone = normalizeZone(args.timezone);
468
- } catch (err) {
469
- if (err instanceof TimezoneError) {
470
- throw new CliError(ExitCode.InputError, err.message);
471
- }
472
- throw err;
473
- }
474
- }
475
- const result = resolveToday({ now: args.now, zone });
476
- // resolveToday returns undefined for unrecognised strings; surface that as
477
- // an input error so the user gets a clear message instead of a silent no-line.
478
- 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) {
479
541
  throw new CliError(
480
542
  ExitCode.InputError,
481
- `nowline: invalid --now "${args.now}". ` +
482
- `Expected YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS[Z|±HH:MM], or "-".`,
543
+ `nowline: invalid .nowlinerc: ${err instanceof Error ? err.message : String(err)}`,
483
544
  );
484
545
  }
485
- return result;
486
546
  }
487
547
 
488
548
  function parseScale(raw: string | undefined): number | undefined {
@@ -497,68 +557,41 @@ function parseScale(raw: string | undefined): number | undefined {
497
557
  return value;
498
558
  }
499
559
 
500
- function parseOrientation(raw: string | undefined): 'portrait' | 'landscape' | 'auto' | undefined {
501
- if (!raw) return undefined;
502
- const lower = raw.toLowerCase();
503
- if (lower === 'portrait' || lower === 'landscape' || lower === 'auto') return lower;
504
- throw new CliError(
505
- ExitCode.InputError,
506
- `nowline: invalid --orientation "${raw}". Expected portrait, landscape, or auto.`,
507
- );
508
- }
509
-
510
- function parseMargin(raw: string | undefined): number | undefined {
511
- if (!raw) return undefined;
512
- if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw); // bare number → points
513
- try {
514
- return lengthToPoints(parseLength(raw));
515
- } catch (err) {
516
- const message = err instanceof Error ? err.message : String(err);
517
- throw new CliError(ExitCode.InputError, `nowline: invalid --margin "${raw}": ${message}`);
518
- }
519
- }
520
-
521
- function stringFromConfig(
522
- config: { [key: string]: unknown } | null,
523
- key: string,
524
- ): string | undefined {
525
- if (!config) return undefined;
526
- const value = config[key];
527
- return typeof value === 'string' ? value : undefined;
528
- }
529
-
530
- function boolFromConfig(config: { [key: string]: unknown } | null, key: string): boolean {
531
- if (!config) return false;
532
- const value = config[key];
533
- return value === true;
534
- }
535
-
536
- function parseWidthArg(raw: string | undefined): number | undefined {
537
- if (!raw) return undefined;
538
- const value = parseInt(raw, 10);
539
- if (!Number.isFinite(value) || value < 320) {
540
- throw new CliError(
541
- ExitCode.InputError,
542
- `nowline: invalid --width "${raw}". Must be an integer ≥ 320.`,
543
- );
544
- }
545
- return value;
560
+ /**
561
+ * `--diagnostic-format` is the explicit switch. Left unset, a `--dry-run` that
562
+ * asks for `--format=json` gets JSON diagnostics (the machine-consumer shape
563
+ * in `specs/cli.md`); every other run keeps the text frames.
564
+ */
565
+ function resolveDiagnosticFormat(args: ParsedArgs): DiagnosticFormat {
566
+ if (args.diagnosticFormat) return args.diagnosticFormat;
567
+ if (args.dryRun && args.format?.toLowerCase() === 'json') return 'json';
568
+ return 'text';
546
569
  }
547
570
 
548
571
  function emitDiagnostics(
549
- diagnostics: Parameters<typeof formatDiagnostics>[0],
550
- source: DiagnosticSource,
551
- displayPath: string,
572
+ diagnostics: CliDiagnostic[],
573
+ sources: Map<string, DiagnosticSource>,
552
574
  operatorLocale: string,
575
+ format: DiagnosticFormat,
576
+ log: Log,
553
577
  ): void {
554
- const sources = new Map<string, DiagnosticSource>([[displayPath, source]]);
555
- const rendered = formatDiagnostics(diagnostics, 'text', 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, {
556
581
  color: process.stderr.isTTY === true,
557
582
  operatorLocale,
558
583
  });
559
584
  if (rendered) process.stderr.write(`${rendered}\n`);
560
585
  }
561
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
+
562
595
  // ---- Asset resolver (still used by serve.ts) --------------------------------
563
596
 
564
597
  export function createAssetResolver(assetRoot: string): AssetResolver {