@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.
- package/LICENSE +190 -0
- package/README.md +372 -0
- package/dist/cli/args.d.ts +54 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +165 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/formats.d.ts +61 -0
- package/dist/cli/formats.d.ts.map +1 -0
- package/dist/cli/formats.js +153 -0
- package/dist/cli/formats.js.map +1 -0
- package/dist/cli/help.d.ts +3 -0
- package/dist/cli/help.d.ts.map +1 -0
- package/dist/cli/help.js +90 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/output-path.d.ts +57 -0
- package/dist/cli/output-path.d.ts.map +1 -0
- package/dist/cli/output-path.js +70 -0
- package/dist/cli/output-path.js.map +1 -0
- package/dist/commands/init.d.ts +20 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +80 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/render.d.ts +15 -0
- package/dist/commands/render.d.ts.map +1 -0
- package/dist/commands/render.js +435 -0
- package/dist/commands/render.js.map +1 -0
- package/dist/commands/serve.d.ts +16 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +287 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/convert/parse-json.d.ts +7 -0
- package/dist/convert/parse-json.d.ts.map +1 -0
- package/dist/convert/parse-json.js +34 -0
- package/dist/convert/parse-json.js.map +1 -0
- package/dist/convert/printer.d.ts +6 -0
- package/dist/convert/printer.d.ts.map +1 -0
- package/dist/convert/printer.js +334 -0
- package/dist/convert/printer.js.map +1 -0
- package/dist/convert/schema.d.ts +33 -0
- package/dist/convert/schema.d.ts.map +1 -0
- package/dist/convert/schema.js +77 -0
- package/dist/convert/schema.js.map +1 -0
- package/dist/core/parse.d.ts +24 -0
- package/dist/core/parse.d.ts.map +1 -0
- package/dist/core/parse.js +58 -0
- package/dist/core/parse.js.map +1 -0
- package/dist/diagnostics/adapt.d.ts +46 -0
- package/dist/diagnostics/adapt.d.ts.map +1 -0
- package/dist/diagnostics/adapt.js +109 -0
- package/dist/diagnostics/adapt.js.map +1 -0
- package/dist/diagnostics/format.d.ts +18 -0
- package/dist/diagnostics/format.d.ts.map +1 -0
- package/dist/diagnostics/format.js +41 -0
- package/dist/diagnostics/format.js.map +1 -0
- package/dist/diagnostics/index.d.ts +5 -0
- package/dist/diagnostics/index.d.ts.map +1 -0
- package/dist/diagnostics/index.js +5 -0
- package/dist/diagnostics/index.js.map +1 -0
- package/dist/diagnostics/json.d.ts +8 -0
- package/dist/diagnostics/json.d.ts.map +1 -0
- package/dist/diagnostics/json.js +24 -0
- package/dist/diagnostics/json.js.map +1 -0
- package/dist/diagnostics/model.d.ts +44 -0
- package/dist/diagnostics/model.d.ts.map +1 -0
- package/dist/diagnostics/model.js +2 -0
- package/dist/diagnostics/model.js.map +1 -0
- package/dist/diagnostics/text.d.ts +6 -0
- package/dist/diagnostics/text.d.ts.map +1 -0
- package/dist/diagnostics/text.js +43 -0
- package/dist/diagnostics/text.js.map +1 -0
- package/dist/generated/templates.d.ts +4 -0
- package/dist/generated/templates.d.ts.map +1 -0
- package/dist/generated/templates.js +9 -0
- package/dist/generated/templates.js.map +1 -0
- package/dist/generated/version.d.ts +11 -0
- package/dist/generated/version.d.ts.map +1 -0
- package/dist/generated/version.js +8 -0
- package/dist/generated/version.js.map +1 -0
- package/dist/i18n/locale.d.ts +56 -0
- package/dist/i18n/locale.d.ts.map +1 -0
- package/dist/i18n/locale.js +107 -0
- package/dist/i18n/locale.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/io/config.d.ts +2 -0
- package/dist/io/config.d.ts.map +1 -0
- package/dist/io/config.js +5 -0
- package/dist/io/config.js.map +1 -0
- package/dist/io/exit-codes.d.ts +12 -0
- package/dist/io/exit-codes.d.ts.map +1 -0
- package/dist/io/exit-codes.js +15 -0
- package/dist/io/exit-codes.js.map +1 -0
- package/dist/io/read.d.ts +13 -0
- package/dist/io/read.d.ts.map +1 -0
- package/dist/io/read.js +53 -0
- package/dist/io/read.js.map +1 -0
- package/dist/io/write.d.ts +32 -0
- package/dist/io/write.d.ts.map +1 -0
- package/dist/io/write.js +61 -0
- package/dist/io/write.js.map +1 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +20 -0
- package/dist/version.js.map +1 -0
- package/man/fr/nowline.1 +424 -0
- package/man/fr/nowline.5 +1864 -0
- package/man/nowline.1 +517 -0
- package/man/nowline.5 +1784 -0
- package/package.json +66 -0
- package/scripts/bundle-templates.mjs +105 -0
- package/scripts/compile.mjs +131 -0
- package/src/cli/args.ts +252 -0
- package/src/cli/formats.ts +207 -0
- package/src/cli/help.ts +92 -0
- package/src/cli/output-path.ts +98 -0
- package/src/commands/init.ts +99 -0
- package/src/commands/render.ts +566 -0
- package/src/commands/serve.ts +322 -0
- package/src/convert/parse-json.ts +57 -0
- package/src/convert/printer.ts +376 -0
- package/src/convert/schema.ts +105 -0
- package/src/core/parse.ts +93 -0
- package/src/diagnostics/adapt.ts +148 -0
- package/src/diagnostics/format.ts +70 -0
- package/src/diagnostics/index.ts +4 -0
- package/src/diagnostics/json.ts +30 -0
- package/src/diagnostics/model.ts +48 -0
- package/src/diagnostics/text.ts +62 -0
- package/src/generated/templates.ts +12 -0
- package/src/generated/version.ts +18 -0
- package/src/i18n/locale.ts +133 -0
- package/src/index.ts +60 -0
- package/src/io/config.ts +11 -0
- package/src/io/exit-codes.ts +18 -0
- package/src/io/read.ts +70 -0
- package/src/io/write.ts +94 -0
- package/src/version.ts +21 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { resolveIncludes } from '@nowline/core';
|
|
4
|
+
import { lengthToPoints, parseLength } from '@nowline/export-core';
|
|
5
|
+
import { layoutRoadmap, type ThemeName } from '@nowline/layout';
|
|
6
|
+
import { type AssetResolver, renderSvg } from '@nowline/renderer';
|
|
7
|
+
import type { ParsedArgs } from '../cli/args.js';
|
|
8
|
+
import {
|
|
9
|
+
FormatResolutionError,
|
|
10
|
+
isInputFormat,
|
|
11
|
+
type OutputFormat,
|
|
12
|
+
resolveFormat,
|
|
13
|
+
} from '../cli/formats.js';
|
|
14
|
+
import { resolveRenderOutputPath } from '../cli/output-path.js';
|
|
15
|
+
import { parseNowlineJson } from '../convert/parse-json.js';
|
|
16
|
+
import { printNowlineFile } from '../convert/printer.js';
|
|
17
|
+
import { serializeToJson } from '../convert/schema.js';
|
|
18
|
+
import { getServices, parseSource } from '../core/parse.js';
|
|
19
|
+
import { type DiagnosticSource, formatDiagnostics } from '../diagnostics/index.js';
|
|
20
|
+
import {
|
|
21
|
+
describeContentLocaleSource,
|
|
22
|
+
operatorLocale,
|
|
23
|
+
readDirectiveLocale,
|
|
24
|
+
resolveLocaleOverride,
|
|
25
|
+
} from '../i18n/locale.js';
|
|
26
|
+
import { loadConfig } from '../io/config.js';
|
|
27
|
+
import { CliError, ExitCode } from '../io/exit-codes.js';
|
|
28
|
+
import { readInput } from '../io/read.js';
|
|
29
|
+
import { writeOutput } from '../io/write.js';
|
|
30
|
+
|
|
31
|
+
export interface RenderHandlerOptions {
|
|
32
|
+
args: ParsedArgs;
|
|
33
|
+
/** Test seam: cwd override. Defaults to `process.cwd()`. */
|
|
34
|
+
cwd?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default render handler. Produces output in the resolved format and writes
|
|
39
|
+
* it to the resolved path (file or stdout). Honors `--dry-run` (skip write
|
|
40
|
+
* step), `--input-format`, and `.json` AST input.
|
|
41
|
+
*/
|
|
42
|
+
export async function renderHandler(options: RenderHandlerOptions): Promise<void> {
|
|
43
|
+
const { args } = options;
|
|
44
|
+
const cwd = options.cwd ?? process.cwd();
|
|
45
|
+
|
|
46
|
+
if (!args.positional) {
|
|
47
|
+
throw new CliError(
|
|
48
|
+
ExitCode.InputError,
|
|
49
|
+
'nowline: missing input file. Pass a path, "-" for stdin, or run `nowline --help`.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isStdoutOutput = args.output === '-';
|
|
54
|
+
const config = await loadConfigFor(args.positional, cwd);
|
|
55
|
+
|
|
56
|
+
const format = resolveFormatOrThrow({
|
|
57
|
+
flag: args.format,
|
|
58
|
+
outputPath: args.output,
|
|
59
|
+
configFormat: typeof config?.defaultFormat === 'string' ? config.defaultFormat : undefined,
|
|
60
|
+
isStdout: isStdoutOutput,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (args.logLevel === 'verbose') {
|
|
64
|
+
process.stderr.write(`nowline: format=${format} (resolved)\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const resolvedOutput = resolveRenderOutputPath({
|
|
68
|
+
outputArg: args.output,
|
|
69
|
+
isStdout: isStdoutOutput,
|
|
70
|
+
inputArg: args.positional,
|
|
71
|
+
isStdin: args.positional === '-',
|
|
72
|
+
format,
|
|
73
|
+
cwd,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const inputFormat = resolveInputFormat(args.positional, args.inputFormat);
|
|
77
|
+
|
|
78
|
+
const input = await readInput(args.positional, { cwd });
|
|
79
|
+
|
|
80
|
+
const resolved = resolveLocaleOverride({
|
|
81
|
+
flag: args.locale,
|
|
82
|
+
env: process.env,
|
|
83
|
+
rc: stringFromConfig(config, 'locale'),
|
|
84
|
+
});
|
|
85
|
+
const locale = resolved.tag;
|
|
86
|
+
const opLocale = operatorLocale(resolved);
|
|
87
|
+
|
|
88
|
+
const { rendered, isBinary } = await produce({
|
|
89
|
+
format,
|
|
90
|
+
inputFormat,
|
|
91
|
+
contents: input.contents,
|
|
92
|
+
displayPath: input.displayPath,
|
|
93
|
+
absInputPath: input.isStdin ? path.resolve(cwd, 'stdin.nowline') : input.path,
|
|
94
|
+
isStdin: input.isStdin,
|
|
95
|
+
theme: parseTheme(args.theme),
|
|
96
|
+
today: resolveNowArg(args),
|
|
97
|
+
width: parseWidthArg(args.width),
|
|
98
|
+
noLinks: args.noLinks,
|
|
99
|
+
strict: args.strict,
|
|
100
|
+
assetRoot: args.assetRoot,
|
|
101
|
+
// m2c format-specific options. Strings get parsed inside the format
|
|
102
|
+
// dispatch; the CLI just passes them through.
|
|
103
|
+
pageSize: args.pageSize ?? stringFromConfig(config, 'pdfPageSize'),
|
|
104
|
+
orientation: args.orientation ?? stringFromConfig(config, 'pdfOrientation'),
|
|
105
|
+
margin: args.margin ?? stringFromConfig(config, 'pdfMargin'),
|
|
106
|
+
fontSans: args.fontSans ?? stringFromConfig(config, 'fontSans'),
|
|
107
|
+
fontMono: args.fontMono ?? stringFromConfig(config, 'fontMono'),
|
|
108
|
+
headless: args.headless || boolFromConfig(config, 'headlessFonts'),
|
|
109
|
+
scale: args.scale,
|
|
110
|
+
start: args.start,
|
|
111
|
+
locale,
|
|
112
|
+
operatorLocale: opLocale,
|
|
113
|
+
resolvedLocale: resolved,
|
|
114
|
+
verbose: args.logLevel === 'verbose',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (args.dryRun) {
|
|
118
|
+
if (args.logLevel === 'verbose') {
|
|
119
|
+
process.stderr.write('nowline: --dry-run; skipping write\n');
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await writeOutput(
|
|
125
|
+
resolvedOutput.isStdout ? '-' : resolvedOutput.path,
|
|
126
|
+
rendered,
|
|
127
|
+
isBinary ? 'binary' : 'text',
|
|
128
|
+
{ cwd },
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (args.logLevel === 'verbose' && !resolvedOutput.isStdout) {
|
|
132
|
+
process.stderr.write(`nowline: wrote ${resolvedOutput.path}\n`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveFormatOrThrow(inputs: {
|
|
137
|
+
flag?: string;
|
|
138
|
+
outputPath?: string;
|
|
139
|
+
configFormat?: string;
|
|
140
|
+
isStdout: boolean;
|
|
141
|
+
}): OutputFormat {
|
|
142
|
+
try {
|
|
143
|
+
return resolveFormat({
|
|
144
|
+
flagFormat: inputs.flag,
|
|
145
|
+
outputPath: inputs.outputPath,
|
|
146
|
+
configFormat: inputs.configFormat,
|
|
147
|
+
isStdout: inputs.isStdout,
|
|
148
|
+
}).format;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err instanceof FormatResolutionError) {
|
|
151
|
+
throw new CliError(ExitCode.InputError, `nowline: ${err.message}`);
|
|
152
|
+
}
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveInputFormat(inputArg: string, override: string | undefined): 'nowline' | 'json' {
|
|
158
|
+
if (override) {
|
|
159
|
+
const lower = override.toLowerCase();
|
|
160
|
+
if (!isInputFormat(lower)) {
|
|
161
|
+
throw new CliError(
|
|
162
|
+
ExitCode.InputError,
|
|
163
|
+
`nowline: invalid --input-format "${override}". Expected nowline or json.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return lower;
|
|
167
|
+
}
|
|
168
|
+
if (inputArg === '-') return 'nowline';
|
|
169
|
+
const ext = path.extname(inputArg).toLowerCase();
|
|
170
|
+
if (ext === '.json') return 'json';
|
|
171
|
+
return 'nowline';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface ProduceArgs {
|
|
175
|
+
format: OutputFormat;
|
|
176
|
+
inputFormat: 'nowline' | 'json';
|
|
177
|
+
contents: string;
|
|
178
|
+
displayPath: string;
|
|
179
|
+
absInputPath: string;
|
|
180
|
+
isStdin: boolean;
|
|
181
|
+
theme: ThemeName;
|
|
182
|
+
today?: Date;
|
|
183
|
+
width?: number;
|
|
184
|
+
noLinks: boolean;
|
|
185
|
+
strict: boolean;
|
|
186
|
+
assetRoot?: string;
|
|
187
|
+
// m2c format-specific
|
|
188
|
+
pageSize?: string;
|
|
189
|
+
orientation?: string;
|
|
190
|
+
margin?: string;
|
|
191
|
+
fontSans?: string;
|
|
192
|
+
fontMono?: string;
|
|
193
|
+
headless: boolean;
|
|
194
|
+
scale?: string;
|
|
195
|
+
start?: string;
|
|
196
|
+
/** Resolved locale override (CLI flag or env-var fallback); undefined falls through to the directive. */
|
|
197
|
+
locale?: string;
|
|
198
|
+
/** Operator locale used to format CLI message output (validator diagnostics on stderr). */
|
|
199
|
+
operatorLocale: string;
|
|
200
|
+
/** Resolved locale override metadata, used for the verbose-mode source line. */
|
|
201
|
+
resolvedLocale: import('../i18n/locale.js').ResolvedLocale;
|
|
202
|
+
/** True for `--verbose`. Gates the `nowline: locale=...` source-line emission. */
|
|
203
|
+
verbose: boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface ProduceResult {
|
|
207
|
+
rendered: string | Uint8Array;
|
|
208
|
+
isBinary: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function produce(args: ProduceArgs): Promise<ProduceResult> {
|
|
212
|
+
if (args.format === 'json') {
|
|
213
|
+
return { rendered: await produceJson(args), isBinary: false };
|
|
214
|
+
}
|
|
215
|
+
if (args.format === 'nowline') {
|
|
216
|
+
return { rendered: await produceCanonicalNowline(args), isBinary: false };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The remaining formats all start from a positioned model + (sometimes) an
|
|
220
|
+
// SVG. Build them once and dispatch to the format-specific exporter via
|
|
221
|
+
// dynamic import — keeps each exporter's heavy deps off cold paths and
|
|
222
|
+
// leaves room to re-extract a package later if a future build profile
|
|
223
|
+
// wants to slim down.
|
|
224
|
+
const stage = await stageRoadmap(args);
|
|
225
|
+
|
|
226
|
+
if (args.format === 'svg') {
|
|
227
|
+
return { rendered: stage.svg, isBinary: false };
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
if (args.format === 'html') {
|
|
231
|
+
const mod = await import('@nowline/export-html');
|
|
232
|
+
const html = await mod.exportHtml(stage.exportInputs, stage.svg);
|
|
233
|
+
return { rendered: html, isBinary: false };
|
|
234
|
+
}
|
|
235
|
+
if (args.format === 'mermaid') {
|
|
236
|
+
const mod = await import('@nowline/export-mermaid');
|
|
237
|
+
const md = mod.exportMermaid(stage.exportInputs);
|
|
238
|
+
return { rendered: md, isBinary: false };
|
|
239
|
+
}
|
|
240
|
+
if (args.format === 'msproj') {
|
|
241
|
+
const mod = await import('@nowline/export-msproj');
|
|
242
|
+
const xml = mod.exportMsProjXml(stage.exportInputs, {
|
|
243
|
+
startDate: args.start,
|
|
244
|
+
});
|
|
245
|
+
return { rendered: xml, isBinary: false };
|
|
246
|
+
}
|
|
247
|
+
if (args.format === 'png') {
|
|
248
|
+
const fonts = await stage.fontPair();
|
|
249
|
+
const mod = await import('@nowline/export-png');
|
|
250
|
+
const png = await mod.exportPng(stage.exportInputs, stage.svg, {
|
|
251
|
+
scale: parseScale(args.scale),
|
|
252
|
+
fonts,
|
|
253
|
+
});
|
|
254
|
+
return { rendered: png, isBinary: true };
|
|
255
|
+
}
|
|
256
|
+
if (args.format === 'pdf') {
|
|
257
|
+
const fonts = await stage.fontPair();
|
|
258
|
+
const mod = await import('@nowline/export-pdf');
|
|
259
|
+
const pdf = await mod.exportPdf(stage.exportInputs, stage.svg, {
|
|
260
|
+
pageSize: args.pageSize,
|
|
261
|
+
orientation: parseOrientation(args.orientation),
|
|
262
|
+
marginPt: parseMargin(args.margin),
|
|
263
|
+
fonts,
|
|
264
|
+
});
|
|
265
|
+
return { rendered: pdf, isBinary: true };
|
|
266
|
+
}
|
|
267
|
+
if (args.format === 'xlsx') {
|
|
268
|
+
const mod = await import('@nowline/export-xlsx');
|
|
269
|
+
const xlsx = await mod.exportXlsx(stage.exportInputs, {
|
|
270
|
+
generated: args.today,
|
|
271
|
+
});
|
|
272
|
+
return { rendered: xlsx, isBinary: true };
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (err instanceof CliError) throw err;
|
|
276
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
277
|
+
throw new CliError(
|
|
278
|
+
ExitCode.OutputError,
|
|
279
|
+
`nowline: ${args.format} export failed: ${message}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
throw new CliError(ExitCode.InputError, `nowline: unsupported format "${args.format}".`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function produceJson(args: ProduceArgs): Promise<string> {
|
|
287
|
+
if (args.inputFormat === 'json') {
|
|
288
|
+
// Re-parse JSON → DSL → JSON to canonicalize through @nowline/core.
|
|
289
|
+
const { ast } = parseNowlineJson(args.contents, args.displayPath);
|
|
290
|
+
const text = printNowlineFile(ast);
|
|
291
|
+
const parsed = await parseAndValidate(text, args);
|
|
292
|
+
return JSON.stringify(serializeToJson(parsed.document, text), null, 2);
|
|
293
|
+
}
|
|
294
|
+
const parsed = await parseAndValidate(args.contents, args);
|
|
295
|
+
return JSON.stringify(serializeToJson(parsed.document, args.contents), null, 2);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function produceCanonicalNowline(args: ProduceArgs): Promise<string> {
|
|
299
|
+
if (args.inputFormat === 'json') {
|
|
300
|
+
const { ast } = parseNowlineJson(args.contents, args.displayPath);
|
|
301
|
+
return printNowlineFile(ast);
|
|
302
|
+
}
|
|
303
|
+
const parsed = await parseAndValidate(args.contents, args);
|
|
304
|
+
const doc = serializeToJson(parsed.document, args.contents);
|
|
305
|
+
return printNowlineFile(doc.ast);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
interface StagedRoadmap {
|
|
309
|
+
svg: string;
|
|
310
|
+
exportInputs: import('@nowline/export-core').ExportInputs;
|
|
311
|
+
/** Lazy: only loads the resolved font pair when a format actually needs it. */
|
|
312
|
+
fontPair: () => Promise<import('@nowline/export-core').ResolvedFontPair>;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function stageRoadmap(args: ProduceArgs): Promise<StagedRoadmap> {
|
|
316
|
+
const parsed = await parseAndValidate(
|
|
317
|
+
args.inputFormat === 'json'
|
|
318
|
+
? jsonToNowlineText(args.contents, args.displayPath)
|
|
319
|
+
: args.contents,
|
|
320
|
+
args,
|
|
321
|
+
);
|
|
322
|
+
const resolved = await resolveIncludes(parsed.ast, args.absInputPath, {
|
|
323
|
+
services: getServices().Nowline,
|
|
324
|
+
});
|
|
325
|
+
for (const diag of resolved.diagnostics) {
|
|
326
|
+
if (diag.severity === 'error') {
|
|
327
|
+
process.stderr.write(`${diag.sourcePath}: ${diag.message}\n`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (resolved.diagnostics.some((d) => d.severity === 'error')) {
|
|
331
|
+
throw new CliError(ExitCode.ValidationError, '');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const model = layoutRoadmap(parsed.ast, resolved, {
|
|
335
|
+
theme: args.theme,
|
|
336
|
+
today: args.today,
|
|
337
|
+
width: args.width,
|
|
338
|
+
locale: args.locale,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const assetRoot = args.assetRoot
|
|
342
|
+
? path.resolve(args.assetRoot)
|
|
343
|
+
: path.dirname(args.absInputPath);
|
|
344
|
+
const resolver: AssetResolver = createAssetResolver(assetRoot);
|
|
345
|
+
|
|
346
|
+
const warnings: string[] = [];
|
|
347
|
+
const svg = await renderSvg(model, {
|
|
348
|
+
assetResolver: resolver,
|
|
349
|
+
noLinks: args.noLinks,
|
|
350
|
+
strict: args.strict,
|
|
351
|
+
warn: (msg) => warnings.push(msg),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
for (const w of warnings) {
|
|
355
|
+
process.stderr.write(`warning: ${w}\n`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let cachedFonts: import('@nowline/export-core').ResolvedFontPair | undefined;
|
|
359
|
+
const fontPair = async () => {
|
|
360
|
+
if (cachedFonts) return cachedFonts;
|
|
361
|
+
const mod = await import('@nowline/export-core');
|
|
362
|
+
const result = await mod.resolveFonts({
|
|
363
|
+
fontSans: args.fontSans,
|
|
364
|
+
fontMono: args.fontMono,
|
|
365
|
+
headless: args.headless,
|
|
366
|
+
});
|
|
367
|
+
if (args.strict) {
|
|
368
|
+
if (result.sansFellBackToBundled) {
|
|
369
|
+
process.stderr.write(
|
|
370
|
+
'warning: sans font fell back to bundled DejaVu (no platform font found)\n',
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (result.monoFellBackToBundled) {
|
|
374
|
+
process.stderr.write(
|
|
375
|
+
'warning: mono font fell back to bundled DejaVu (no platform font found)\n',
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
cachedFonts = { sans: result.sans, mono: result.mono };
|
|
380
|
+
return cachedFonts;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
svg,
|
|
385
|
+
exportInputs: {
|
|
386
|
+
ast: parsed.ast,
|
|
387
|
+
resolved,
|
|
388
|
+
model,
|
|
389
|
+
sourcePath: args.displayPath,
|
|
390
|
+
today: args.today,
|
|
391
|
+
},
|
|
392
|
+
fontPair,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function jsonToNowlineText(contents: string, displayPath: string): string {
|
|
397
|
+
const { ast } = parseNowlineJson(contents, displayPath);
|
|
398
|
+
return printNowlineFile(ast);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function parseAndValidate(contents: string, args: ProduceArgs) {
|
|
402
|
+
const result = await parseSource(contents, args.displayPath, { validate: true });
|
|
403
|
+
if (result.hasErrors) {
|
|
404
|
+
emitDiagnostics(result.diagnostics, result.source, args.displayPath, args.operatorLocale);
|
|
405
|
+
throw new CliError(ExitCode.ValidationError, '');
|
|
406
|
+
}
|
|
407
|
+
if (args.verbose) {
|
|
408
|
+
const directive = readDirectiveLocale(result.ast);
|
|
409
|
+
const { tag, source } = describeContentLocaleSource(directive, args.resolvedLocale);
|
|
410
|
+
process.stderr.write(`nowline: locale=${tag} (${source})\n`);
|
|
411
|
+
}
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function loadConfigFor(
|
|
416
|
+
inputArg: string,
|
|
417
|
+
cwd: string,
|
|
418
|
+
): Promise<{ defaultFormat?: string } | null> {
|
|
419
|
+
try {
|
|
420
|
+
if (inputArg === '-') {
|
|
421
|
+
const { config } = await loadConfig(cwd);
|
|
422
|
+
return config;
|
|
423
|
+
}
|
|
424
|
+
const abs = path.resolve(cwd, inputArg);
|
|
425
|
+
const dir = path.dirname(abs);
|
|
426
|
+
const { config } = await loadConfig(dir);
|
|
427
|
+
return config;
|
|
428
|
+
} catch {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function parseTheme(raw: string | undefined): ThemeName {
|
|
434
|
+
if (!raw) return 'light';
|
|
435
|
+
const lower = raw.toLowerCase();
|
|
436
|
+
if (lower !== 'light' && lower !== 'dark') {
|
|
437
|
+
throw new CliError(
|
|
438
|
+
ExitCode.InputError,
|
|
439
|
+
`nowline: invalid --theme "${raw}". Expected light or dark.`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return lower;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Resolve the now-line date from the CLI flag.
|
|
446
|
+
//
|
|
447
|
+
// Precedence:
|
|
448
|
+
// 1. `--now -` → undefined (suppresses the now-line)
|
|
449
|
+
// 2. `--now <YYYY-MM-DD>` → that date
|
|
450
|
+
// 3. flag omitted → today (UTC calendar date)
|
|
451
|
+
//
|
|
452
|
+
// The "default to today" behavior matches what the tool's name promises —
|
|
453
|
+
// you should see a "now" line by default. Use `--now -` to opt out (Unix
|
|
454
|
+
// `-` sentinel, mirroring `-o -` for stdout), or `--now <date>` for
|
|
455
|
+
// deterministic snapshots / planning a hypothetical date.
|
|
456
|
+
function resolveNowArg(args: { now?: string }): Date | undefined {
|
|
457
|
+
if (args.now === '-') return undefined;
|
|
458
|
+
if (args.now) {
|
|
459
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(args.now);
|
|
460
|
+
if (!m) {
|
|
461
|
+
throw new CliError(
|
|
462
|
+
ExitCode.InputError,
|
|
463
|
+
`nowline: invalid --now "${args.now}". Expected YYYY-MM-DD or "-".`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return new Date(Date.UTC(parseInt(m[1], 10), parseInt(m[2], 10) - 1, parseInt(m[3], 10)));
|
|
467
|
+
}
|
|
468
|
+
const today = new Date();
|
|
469
|
+
return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function parseScale(raw: string | undefined): number | undefined {
|
|
473
|
+
if (!raw) return undefined;
|
|
474
|
+
const value = Number(raw);
|
|
475
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
476
|
+
throw new CliError(
|
|
477
|
+
ExitCode.InputError,
|
|
478
|
+
`nowline: invalid --scale "${raw}". Must be a positive number.`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function parseOrientation(raw: string | undefined): 'portrait' | 'landscape' | 'auto' | undefined {
|
|
485
|
+
if (!raw) return undefined;
|
|
486
|
+
const lower = raw.toLowerCase();
|
|
487
|
+
if (lower === 'portrait' || lower === 'landscape' || lower === 'auto') return lower;
|
|
488
|
+
throw new CliError(
|
|
489
|
+
ExitCode.InputError,
|
|
490
|
+
`nowline: invalid --orientation "${raw}". Expected portrait, landscape, or auto.`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function parseMargin(raw: string | undefined): number | undefined {
|
|
495
|
+
if (!raw) return undefined;
|
|
496
|
+
if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw); // bare number → points
|
|
497
|
+
try {
|
|
498
|
+
return lengthToPoints(parseLength(raw));
|
|
499
|
+
} catch (err) {
|
|
500
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
501
|
+
throw new CliError(ExitCode.InputError, `nowline: invalid --margin "${raw}": ${message}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function stringFromConfig(
|
|
506
|
+
config: { [key: string]: unknown } | null,
|
|
507
|
+
key: string,
|
|
508
|
+
): string | undefined {
|
|
509
|
+
if (!config) return undefined;
|
|
510
|
+
const value = config[key];
|
|
511
|
+
return typeof value === 'string' ? value : undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function boolFromConfig(config: { [key: string]: unknown } | null, key: string): boolean {
|
|
515
|
+
if (!config) return false;
|
|
516
|
+
const value = config[key];
|
|
517
|
+
return value === true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function parseWidthArg(raw: string | undefined): number | undefined {
|
|
521
|
+
if (!raw) return undefined;
|
|
522
|
+
const value = parseInt(raw, 10);
|
|
523
|
+
if (!Number.isFinite(value) || value < 320) {
|
|
524
|
+
throw new CliError(
|
|
525
|
+
ExitCode.InputError,
|
|
526
|
+
`nowline: invalid --width "${raw}". Must be an integer ≥ 320.`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
return value;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function emitDiagnostics(
|
|
533
|
+
diagnostics: Parameters<typeof formatDiagnostics>[0],
|
|
534
|
+
source: DiagnosticSource,
|
|
535
|
+
displayPath: string,
|
|
536
|
+
operatorLocale: string,
|
|
537
|
+
): void {
|
|
538
|
+
const sources = new Map<string, DiagnosticSource>([[displayPath, source]]);
|
|
539
|
+
const rendered = formatDiagnostics(diagnostics, 'text', sources, {
|
|
540
|
+
color: process.stderr.isTTY === true,
|
|
541
|
+
operatorLocale,
|
|
542
|
+
});
|
|
543
|
+
if (rendered) process.stderr.write(`${rendered}\n`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function createAssetResolver(assetRoot: string): AssetResolver {
|
|
547
|
+
const root = path.resolve(assetRoot);
|
|
548
|
+
return async (ref: string) => {
|
|
549
|
+
const absPath = path.resolve(root, ref);
|
|
550
|
+
if (!absPath.startsWith(root + path.sep) && absPath !== root) {
|
|
551
|
+
throw new Error(`Asset ${ref} escapes asset-root ${assetRoot}`);
|
|
552
|
+
}
|
|
553
|
+
const bytes = await fs.readFile(absPath);
|
|
554
|
+
const mime = guessMime(absPath);
|
|
555
|
+
return { bytes: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), mime };
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function guessMime(p: string): string {
|
|
560
|
+
const ext = path.extname(p).toLowerCase();
|
|
561
|
+
if (ext === '.svg') return 'image/svg+xml';
|
|
562
|
+
if (ext === '.png') return 'image/png';
|
|
563
|
+
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
|
564
|
+
if (ext === '.webp') return 'image/webp';
|
|
565
|
+
return 'application/octet-stream';
|
|
566
|
+
}
|