@nowline/core 0.6.0 → 0.8.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/dist/convert/parse-json.d.ts +7 -0
- package/dist/convert/parse-json.d.ts.map +1 -0
- package/dist/convert/parse-json.js +33 -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 +331 -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/i18n/codes.d.ts +1 -1
- package/dist/i18n/codes.d.ts.map +1 -1
- package/dist/i18n/codes.js +9 -0
- package/dist/i18n/codes.js.map +1 -1
- package/dist/i18n/messages.en.d.ts +25 -0
- package/dist/i18n/messages.en.d.ts.map +1 -1
- package/dist/i18n/messages.en.js +11 -0
- package/dist/i18n/messages.en.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/language/nowline-validator.d.ts.map +1 -1
- package/dist/language/nowline-validator.js +25 -1
- package/dist/language/nowline-validator.js.map +1 -1
- package/dist/templates.d.ts +3 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +2 -0
- package/dist/templates.js.map +1 -0
- package/package.json +2 -2
- package/src/convert/parse-json.ts +44 -0
- package/src/convert/printer.ts +358 -0
- package/src/convert/schema.ts +105 -0
- package/src/i18n/codes.ts +21 -1
- package/src/i18n/messages.en.ts +20 -0
- package/src/index.ts +11 -0
- package/src/language/nowline-validator.ts +30 -1
- package/src/templates.ts +3 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { JsonAstNode } from './schema.js';
|
|
2
|
+
|
|
3
|
+
// Keyed-property canonical order. Keys not in this list sort alphabetically after.
|
|
4
|
+
const KEY_ORDER = [
|
|
5
|
+
'date',
|
|
6
|
+
'effort',
|
|
7
|
+
'on',
|
|
8
|
+
'size',
|
|
9
|
+
'duration',
|
|
10
|
+
'status',
|
|
11
|
+
'owner',
|
|
12
|
+
'after',
|
|
13
|
+
'before',
|
|
14
|
+
'remaining',
|
|
15
|
+
'labels',
|
|
16
|
+
'style',
|
|
17
|
+
'link',
|
|
18
|
+
'author',
|
|
19
|
+
'start',
|
|
20
|
+
'scale',
|
|
21
|
+
'calendar',
|
|
22
|
+
'header-position',
|
|
23
|
+
'timeline-position',
|
|
24
|
+
'minor-grid',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const INDENT = ' ';
|
|
28
|
+
|
|
29
|
+
export interface PrintOptions {
|
|
30
|
+
indent?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function printNowlineFile(ast: JsonAstNode, options: PrintOptions = {}): string {
|
|
34
|
+
const printer = new Printer(options.indent ?? INDENT);
|
|
35
|
+
printer.file(ast);
|
|
36
|
+
return printer.toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class Printer {
|
|
40
|
+
private readonly lines: string[] = [];
|
|
41
|
+
|
|
42
|
+
constructor(private readonly indent: string) {}
|
|
43
|
+
|
|
44
|
+
toString(): string {
|
|
45
|
+
const text = this.lines.join('\n');
|
|
46
|
+
return text.endsWith('\n') ? text : `${text}\n`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
file(file: JsonAstNode): void {
|
|
50
|
+
assertType(file, 'NowlineFile');
|
|
51
|
+
const directive = file.directive as JsonAstNode | undefined;
|
|
52
|
+
if (directive) {
|
|
53
|
+
const props = asArray(directive.properties);
|
|
54
|
+
const tail = props.length > 0 ? ` ${props.map(renderProperty).join(' ')}` : '';
|
|
55
|
+
this.line(0, `nowline ${getString(directive, 'version')}${tail}`);
|
|
56
|
+
this.blank();
|
|
57
|
+
}
|
|
58
|
+
for (const inc of asArray(file.includes)) {
|
|
59
|
+
this.include(inc);
|
|
60
|
+
}
|
|
61
|
+
if (asArray(file.includes).length > 0) this.blank();
|
|
62
|
+
if (file.hasConfig) {
|
|
63
|
+
this.line(0, 'config');
|
|
64
|
+
this.blank();
|
|
65
|
+
for (const entry of asArray(file.configEntries)) {
|
|
66
|
+
this.configEntry(entry);
|
|
67
|
+
this.blank();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (file.roadmapDecl) {
|
|
71
|
+
this.roadmap(file.roadmapDecl as JsonAstNode);
|
|
72
|
+
this.blank();
|
|
73
|
+
}
|
|
74
|
+
for (const entry of asArray(file.roadmapEntries)) {
|
|
75
|
+
this.roadmapEntry(entry, 0);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
include(inc: JsonAstNode): void {
|
|
80
|
+
assertType(inc, 'IncludeDeclaration');
|
|
81
|
+
const p = getString(inc, 'path');
|
|
82
|
+
const options = asArray(inc.options)
|
|
83
|
+
.map((o) => `${getString(o, 'key')}:${getString(o, 'value')}`)
|
|
84
|
+
.join(' ');
|
|
85
|
+
const tail = options ? ` ${options}` : '';
|
|
86
|
+
this.line(0, `include ${JSON.stringify(p)}${tail}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
configEntry(entry: JsonAstNode): void {
|
|
90
|
+
switch (entry.$type) {
|
|
91
|
+
case 'ScaleBlock':
|
|
92
|
+
return this.blockDecl('scale', asArray(entry.properties));
|
|
93
|
+
case 'CalendarBlock':
|
|
94
|
+
return this.blockDecl('calendar', asArray(entry.properties));
|
|
95
|
+
case 'StyleDeclaration':
|
|
96
|
+
return this.styleDecl(entry);
|
|
97
|
+
case 'DefaultDeclaration':
|
|
98
|
+
return this.defaultDecl(entry);
|
|
99
|
+
default:
|
|
100
|
+
throw new Error(`Unknown config entry type: ${String(entry.$type)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
blockDecl(keyword: string, properties: JsonAstNode[]): void {
|
|
105
|
+
this.line(0, keyword);
|
|
106
|
+
for (const p of properties) {
|
|
107
|
+
this.line(1, renderBlockProperty(p));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
styleDecl(entry: JsonAstNode): void {
|
|
112
|
+
assertType(entry, 'StyleDeclaration');
|
|
113
|
+
const header = declarationHeader('style', entry, []);
|
|
114
|
+
this.line(0, header);
|
|
115
|
+
for (const p of asArray(entry.properties)) {
|
|
116
|
+
this.line(1, renderBlockProperty(p));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
defaultDecl(entry: JsonAstNode): void {
|
|
121
|
+
assertType(entry, 'DefaultDeclaration');
|
|
122
|
+
const entityType = getString(entry, 'entityType');
|
|
123
|
+
const props = renderProperties(asArray(entry.properties));
|
|
124
|
+
const tail = props ? ` ${props}` : '';
|
|
125
|
+
this.line(0, `default ${entityType}${tail}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
roadmap(decl: JsonAstNode): void {
|
|
129
|
+
assertType(decl, 'RoadmapDeclaration');
|
|
130
|
+
this.line(0, declarationHeader('roadmap', decl, asArray(decl.properties)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
roadmapEntry(entry: JsonAstNode, depth: number): void {
|
|
134
|
+
switch (entry.$type) {
|
|
135
|
+
case 'PersonDeclaration':
|
|
136
|
+
return this.simpleEntity('person', entry, depth);
|
|
137
|
+
case 'TeamDeclaration':
|
|
138
|
+
return this.team(entry, depth);
|
|
139
|
+
case 'AnchorDeclaration':
|
|
140
|
+
return this.simpleEntity('anchor', entry, depth);
|
|
141
|
+
case 'SizeDeclaration':
|
|
142
|
+
return this.simpleEntity('size', entry, depth);
|
|
143
|
+
case 'StatusDeclaration':
|
|
144
|
+
return this.simpleEntity('status', entry, depth);
|
|
145
|
+
case 'LabelDeclaration':
|
|
146
|
+
return this.simpleEntity('label', entry, depth);
|
|
147
|
+
case 'MilestoneDeclaration':
|
|
148
|
+
return this.simpleEntity('milestone', entry, depth);
|
|
149
|
+
case 'FootnoteDeclaration':
|
|
150
|
+
return this.simpleEntity('footnote', entry, depth);
|
|
151
|
+
case 'SwimlaneDeclaration':
|
|
152
|
+
return this.swimlane(entry, depth);
|
|
153
|
+
default:
|
|
154
|
+
throw new Error(`Unknown roadmap entry type: ${String(entry.$type)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
simpleEntity(keyword: string, entry: JsonAstNode, depth: number): void {
|
|
159
|
+
this.line(depth, declarationHeader(keyword, entry, asArray(entry.properties)));
|
|
160
|
+
this.maybeDescription(entry, depth + 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
team(entry: JsonAstNode, depth: number): void {
|
|
164
|
+
this.line(depth, declarationHeader('team', entry, asArray(entry.properties)));
|
|
165
|
+
for (const child of asArray(entry.content)) {
|
|
166
|
+
this.teamContent(child, depth + 1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
teamContent(node: JsonAstNode, depth: number): void {
|
|
171
|
+
if (node.$type === 'PersonMemberRef') {
|
|
172
|
+
this.line(depth, `person ${getString(node, 'ref')}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (node.$type === 'TeamDeclaration') {
|
|
176
|
+
return this.team(node, depth);
|
|
177
|
+
}
|
|
178
|
+
if (node.$type === 'PersonDeclaration') {
|
|
179
|
+
return this.simpleEntity('person', node, depth);
|
|
180
|
+
}
|
|
181
|
+
if (node.$type === 'DescriptionDirective') {
|
|
182
|
+
return this.descriptionDirective(node, depth);
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`Unknown team content type: ${String(node.$type)}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
swimlane(entry: JsonAstNode, depth: number): void {
|
|
188
|
+
this.line(depth, declarationHeader('swimlane', entry, asArray(entry.properties)));
|
|
189
|
+
for (const child of asArray(entry.content)) {
|
|
190
|
+
this.swimlaneContent(child, depth + 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
swimlaneContent(node: JsonAstNode, depth: number): void {
|
|
195
|
+
switch (node.$type) {
|
|
196
|
+
case 'ItemDeclaration':
|
|
197
|
+
this.simpleEntity('item', node, depth);
|
|
198
|
+
return;
|
|
199
|
+
case 'ParallelBlock':
|
|
200
|
+
this.parallelBlock(node, depth);
|
|
201
|
+
return;
|
|
202
|
+
case 'GroupBlock':
|
|
203
|
+
this.groupBlock(node, depth);
|
|
204
|
+
return;
|
|
205
|
+
case 'DescriptionDirective':
|
|
206
|
+
this.descriptionDirective(node, depth);
|
|
207
|
+
return;
|
|
208
|
+
default:
|
|
209
|
+
throw new Error(`Unknown swimlane content type: ${String(node.$type)}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
parallelBlock(entry: JsonAstNode, depth: number): void {
|
|
214
|
+
this.line(depth, declarationHeader('parallel', entry, asArray(entry.properties)));
|
|
215
|
+
for (const child of asArray(entry.content)) {
|
|
216
|
+
this.swimlaneContent(child, depth + 1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
groupBlock(entry: JsonAstNode, depth: number): void {
|
|
221
|
+
this.line(depth, declarationHeader('group', entry, asArray(entry.properties)));
|
|
222
|
+
for (const child of asArray(entry.content)) {
|
|
223
|
+
this.swimlaneContent(child, depth + 1);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
descriptionDirective(node: JsonAstNode, depth: number): void {
|
|
228
|
+
assertType(node, 'DescriptionDirective');
|
|
229
|
+
this.line(depth, `description ${JSON.stringify(getString(node, 'text'))}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
maybeDescription(entry: JsonAstNode, depth: number): void {
|
|
233
|
+
const desc = entry.description as JsonAstNode | undefined;
|
|
234
|
+
if (desc) this.descriptionDirective(desc, depth);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private line(depth: number, text: string): void {
|
|
238
|
+
this.lines.push(this.indent.repeat(depth) + text);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private blank(): void {
|
|
242
|
+
if (this.lines.length === 0) return;
|
|
243
|
+
if (this.lines[this.lines.length - 1] === '') return;
|
|
244
|
+
this.lines.push('');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function declarationHeader(keyword: string, entry: JsonAstNode, properties: JsonAstNode[]): string {
|
|
249
|
+
const id = entry.name as string | undefined;
|
|
250
|
+
const title = entry.title as string | undefined;
|
|
251
|
+
const parts = [keyword];
|
|
252
|
+
if (id) parts.push(id);
|
|
253
|
+
if (title) parts.push(JSON.stringify(title));
|
|
254
|
+
const props = renderProperties(properties);
|
|
255
|
+
if (props) parts.push(props);
|
|
256
|
+
return parts.join(' ');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderProperties(properties: JsonAstNode[]): string {
|
|
260
|
+
return orderProperties(properties).map(renderProperty).join(' ');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function orderProperties(properties: JsonAstNode[]): JsonAstNode[] {
|
|
264
|
+
const indexOf = new Map(KEY_ORDER.map((k, i) => [k, i] as const));
|
|
265
|
+
return [...properties].sort((a, b) => {
|
|
266
|
+
const ak = normalizeKey(getString(a, 'key'));
|
|
267
|
+
const bk = normalizeKey(getString(b, 'key'));
|
|
268
|
+
const ai = indexOf.get(ak);
|
|
269
|
+
const bi = indexOf.get(bk);
|
|
270
|
+
if (ai !== undefined && bi !== undefined) return ai - bi;
|
|
271
|
+
if (ai !== undefined) return -1;
|
|
272
|
+
if (bi !== undefined) return 1;
|
|
273
|
+
return ak.localeCompare(bk);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function renderProperty(prop: JsonAstNode): string {
|
|
278
|
+
const key = normalizeKey(getString(prop, 'key'));
|
|
279
|
+
const values = asStringArray(prop.values);
|
|
280
|
+
const value = prop.value as string | undefined;
|
|
281
|
+
if (values.length >= 2) {
|
|
282
|
+
return `${key}:[${values.map(formatAtom).join(', ')}]`;
|
|
283
|
+
}
|
|
284
|
+
if (values.length === 1) {
|
|
285
|
+
return `${key}:${formatAtom(values[0])}`;
|
|
286
|
+
}
|
|
287
|
+
if (value !== undefined && value !== '') {
|
|
288
|
+
return `${key}:${formatAtom(value)}`;
|
|
289
|
+
}
|
|
290
|
+
return `${key}:`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Block-style property (one per line, rendered as `key: value`). Used inside
|
|
294
|
+
// indented blocks like `scale`, `calendar`, and `style`.
|
|
295
|
+
function renderBlockProperty(prop: JsonAstNode): string {
|
|
296
|
+
const key = normalizeKey(getString(prop, 'key'));
|
|
297
|
+
const values = asStringArray(prop.values);
|
|
298
|
+
const value = prop.value as string | undefined;
|
|
299
|
+
if (values.length >= 2) {
|
|
300
|
+
return `${key}: [${values.map(formatAtom).join(', ')}]`;
|
|
301
|
+
}
|
|
302
|
+
if (values.length === 1) {
|
|
303
|
+
return `${key}: ${formatAtom(values[0])}`;
|
|
304
|
+
}
|
|
305
|
+
if (value !== undefined && value !== '') {
|
|
306
|
+
return `${key}: ${formatAtom(value)}`;
|
|
307
|
+
}
|
|
308
|
+
return `${key}:`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeKey(key: string): string {
|
|
312
|
+
return key.endsWith(':') ? key.slice(0, -1) : key;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const URL_RE = /^https?:\/\//;
|
|
316
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
317
|
+
const DURATION_RE = /^\d+(?:\.\d+)?[dwmqy]$/;
|
|
318
|
+
const PERCENTAGE_RE = /^\d+%$/;
|
|
319
|
+
const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
|
|
320
|
+
const INTEGER_RE = /^\d+$/;
|
|
321
|
+
const ID_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
322
|
+
|
|
323
|
+
function formatAtom(atom: string): string {
|
|
324
|
+
if (
|
|
325
|
+
URL_RE.test(atom) ||
|
|
326
|
+
DATE_RE.test(atom) ||
|
|
327
|
+
DURATION_RE.test(atom) ||
|
|
328
|
+
PERCENTAGE_RE.test(atom) ||
|
|
329
|
+
HEX_COLOR_RE.test(atom) ||
|
|
330
|
+
INTEGER_RE.test(atom) ||
|
|
331
|
+
ID_RE.test(atom)
|
|
332
|
+
) {
|
|
333
|
+
return atom;
|
|
334
|
+
}
|
|
335
|
+
if (atom.startsWith('"') && atom.endsWith('"')) return atom;
|
|
336
|
+
return JSON.stringify(atom);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function asArray(value: unknown): JsonAstNode[] {
|
|
340
|
+
if (Array.isArray(value)) return value as JsonAstNode[];
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function asStringArray(value: unknown): string[] {
|
|
345
|
+
if (!Array.isArray(value)) return [];
|
|
346
|
+
return value.filter((v): v is string => typeof v === 'string');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function getString(node: JsonAstNode | Record<string, unknown>, key: string): string {
|
|
350
|
+
const value = (node as Record<string, unknown>)[key];
|
|
351
|
+
return typeof value === 'string' ? value : '';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function assertType(node: JsonAstNode, expected: string): void {
|
|
355
|
+
if (node.$type !== expected) {
|
|
356
|
+
throw new Error(`Expected $type "${expected}", got "${String(node.$type)}"`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { AstNode, CstNode, LangiumDocument } from 'langium';
|
|
2
|
+
import type { NowlineFile } from '../generated/ast.js';
|
|
3
|
+
|
|
4
|
+
export const NOWLINE_SCHEMA_VERSION = '1';
|
|
5
|
+
|
|
6
|
+
export interface NowlineDocument {
|
|
7
|
+
$nowlineSchema: string;
|
|
8
|
+
file: { uri: string; source: string };
|
|
9
|
+
ast: JsonAstNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Position {
|
|
13
|
+
start: { line: number; column: number; offset: number };
|
|
14
|
+
end: { line: number; column: number; offset: number };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface JsonAstNode {
|
|
18
|
+
$type: string;
|
|
19
|
+
$position?: Position;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SerializeOptions {
|
|
24
|
+
includePositions?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Keys that Langium adds to AST nodes that we don't want in the JSON form.
|
|
28
|
+
const CONTAINER_KEYS = new Set(['$container', '$containerProperty', '$containerIndex']);
|
|
29
|
+
// $cstNode / $document are runtime-only. $type and $position are emitted.
|
|
30
|
+
const RUNTIME_KEYS = new Set(['$cstNode', '$document']);
|
|
31
|
+
|
|
32
|
+
export function serializeToJson(
|
|
33
|
+
document: LangiumDocument<NowlineFile>,
|
|
34
|
+
source: string,
|
|
35
|
+
options: SerializeOptions = {},
|
|
36
|
+
): NowlineDocument {
|
|
37
|
+
const includePositions = options.includePositions ?? true;
|
|
38
|
+
return {
|
|
39
|
+
$nowlineSchema: NOWLINE_SCHEMA_VERSION,
|
|
40
|
+
file: {
|
|
41
|
+
uri: document.uri.toString(),
|
|
42
|
+
source,
|
|
43
|
+
},
|
|
44
|
+
ast: serializeNode(document.parseResult.value, includePositions),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function serializeNode(node: AstNode, includePositions: boolean): JsonAstNode {
|
|
49
|
+
const out: JsonAstNode = { $type: node.$type };
|
|
50
|
+
if (includePositions) {
|
|
51
|
+
const pos = cstPosition(node.$cstNode);
|
|
52
|
+
if (pos) out.$position = pos;
|
|
53
|
+
}
|
|
54
|
+
for (const [key, value] of Object.entries(node)) {
|
|
55
|
+
if (key.startsWith('$')) continue;
|
|
56
|
+
if (CONTAINER_KEYS.has(key) || RUNTIME_KEYS.has(key)) continue;
|
|
57
|
+
out[key] = serializeValue(value, includePositions);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function serializeValue(value: unknown, includePositions: boolean): unknown {
|
|
63
|
+
if (value === null || value === undefined) return value;
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
return value.map((v) => serializeValue(v, includePositions));
|
|
66
|
+
}
|
|
67
|
+
if (isAstNode(value)) {
|
|
68
|
+
return serializeNode(value, includePositions);
|
|
69
|
+
}
|
|
70
|
+
if (typeof value === 'object') {
|
|
71
|
+
const record = value as Record<string, unknown>;
|
|
72
|
+
const out: Record<string, unknown> = {};
|
|
73
|
+
for (const [k, v] of Object.entries(record)) {
|
|
74
|
+
if (k.startsWith('$')) continue;
|
|
75
|
+
if (CONTAINER_KEYS.has(k) || RUNTIME_KEYS.has(k)) continue;
|
|
76
|
+
out[k] = serializeValue(v, includePositions);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isAstNode(value: unknown): value is AstNode {
|
|
84
|
+
return (
|
|
85
|
+
value !== null &&
|
|
86
|
+
typeof value === 'object' &&
|
|
87
|
+
typeof (value as { $type?: unknown }).$type === 'string'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cstPosition(cst: CstNode | undefined): Position | undefined {
|
|
92
|
+
if (!cst) return undefined;
|
|
93
|
+
return {
|
|
94
|
+
start: {
|
|
95
|
+
line: cst.range.start.line + 1,
|
|
96
|
+
column: cst.range.start.character + 1,
|
|
97
|
+
offset: cst.offset,
|
|
98
|
+
},
|
|
99
|
+
end: {
|
|
100
|
+
line: cst.range.end.line + 1,
|
|
101
|
+
column: cst.range.end.character + 1,
|
|
102
|
+
offset: cst.end,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/i18n/codes.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// NL.E0800–E0899 style / color / symbol
|
|
15
15
|
// NL.E0900–E0999 config blocks (calendar, scale, default)
|
|
16
16
|
// NL.W0700–W0799 warnings (silently-ignored input)
|
|
17
|
+
// NL.W1000–W1099 layout warnings (author intent lost)
|
|
18
|
+
// NL.I1000–I1099 layout insights (informational reflow consequences)
|
|
17
19
|
//
|
|
18
20
|
// The full list of allocated codes lives in `messages.en.ts` (one entry
|
|
19
21
|
// per code). The runtime walks the locale fallback chain and falls
|
|
@@ -69,7 +71,18 @@ export type MessageCode =
|
|
|
69
71
|
| 'NL.E0600' // item-requires-size-or-duration
|
|
70
72
|
|
|
71
73
|
// Warnings (NL.W0700–W0799)
|
|
72
|
-
| 'NL.W0700'
|
|
74
|
+
| 'NL.W0700' // unknown-entity-property
|
|
75
|
+
|
|
76
|
+
// Layout warnings (NL.W1000–W1099)
|
|
77
|
+
| 'NL.W1000' // now-line-outside-window
|
|
78
|
+
|
|
79
|
+
// Layout insights (NL.I1000–I1099)
|
|
80
|
+
| 'NL.I1000' // caption-spilled-right
|
|
81
|
+
| 'NL.I1001' // label-chips-outside
|
|
82
|
+
| 'NL.I1002' // bar-too-narrow
|
|
83
|
+
| 'NL.I1003' // before-overflow
|
|
84
|
+
| 'NL.I1004' // lane-multi-row
|
|
85
|
+
| 'NL.I1005'; // lane-over-capacity
|
|
73
86
|
|
|
74
87
|
export const ALL_CODES: ReadonlyArray<MessageCode> = [
|
|
75
88
|
'NL.E0001',
|
|
@@ -107,4 +120,11 @@ export const ALL_CODES: ReadonlyArray<MessageCode> = [
|
|
|
107
120
|
'NL.E0505',
|
|
108
121
|
'NL.E0600',
|
|
109
122
|
'NL.W0700',
|
|
123
|
+
'NL.W1000',
|
|
124
|
+
'NL.I1000',
|
|
125
|
+
'NL.I1001',
|
|
126
|
+
'NL.I1002',
|
|
127
|
+
'NL.I1003',
|
|
128
|
+
'NL.I1004',
|
|
129
|
+
'NL.I1005',
|
|
110
130
|
] as const;
|
package/src/i18n/messages.en.ts
CHANGED
|
@@ -91,4 +91,24 @@ export const messages = {
|
|
|
91
91
|
`Unknown property "${a.key}" on ${a.entity}. The renderer ignores it.${
|
|
92
92
|
a.suggested ? ` Did you mean "${a.suggested}"?` : ''
|
|
93
93
|
}`,
|
|
94
|
+
|
|
95
|
+
// Layout warnings
|
|
96
|
+
'NL.W1000': (a: { date: string; start: string; end: string }) =>
|
|
97
|
+
`Now-line date ${a.date} is outside the roadmap window (${a.start} – ${a.end}); it will not be drawn.`,
|
|
98
|
+
|
|
99
|
+
// Layout insights (informational)
|
|
100
|
+
'NL.I1000': (a: { name: string }) =>
|
|
101
|
+
`Title on item "${a.name}" is wider than its bar at this scale; it rendered to the right of the bar.`,
|
|
102
|
+
'NL.I1001': (a: { name: string }) =>
|
|
103
|
+
`Label chips on item "${a.name}" did not fit inside the bar; they rendered in a spill column to the right.`,
|
|
104
|
+
'NL.I1002': (a: { name: string }) =>
|
|
105
|
+
`Item "${a.name}" is too narrow to show its marker at this scale; decorations spilled beside the bar.`,
|
|
106
|
+
'NL.I1003': (a: { name: string; anchor?: string }) =>
|
|
107
|
+
a.anchor
|
|
108
|
+
? `Item "${a.name}" extends past its "before:" anchor "${a.anchor}".`
|
|
109
|
+
: `Item "${a.name}" extends past its "before:" anchor.`,
|
|
110
|
+
'NL.I1004': (a: { lane: string; rows: number }) =>
|
|
111
|
+
`Swimlane "${a.lane}" packed into ${a.rows} rows to avoid overlap.`,
|
|
112
|
+
'NL.I1005': (a: { lane: string }) =>
|
|
113
|
+
`Swimlane "${a.lane}" is over capacity in one or more periods (red utilization band).`,
|
|
94
114
|
};
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
export { type ParseJsonResult, parseNowlineJson } from './convert/parse-json.js';
|
|
2
|
+
export { type PrintOptions, printNowlineFile } from './convert/printer.js';
|
|
3
|
+
export {
|
|
4
|
+
type JsonAstNode,
|
|
5
|
+
NOWLINE_SCHEMA_VERSION,
|
|
6
|
+
type NowlineDocument,
|
|
7
|
+
type Position,
|
|
8
|
+
type SerializeOptions,
|
|
9
|
+
serializeToJson,
|
|
10
|
+
} from './convert/schema.js';
|
|
1
11
|
export {
|
|
2
12
|
collectDocumentDiagnostics,
|
|
3
13
|
extractSuggestion,
|
|
@@ -35,3 +45,4 @@ export {
|
|
|
35
45
|
export type { NowlineAddedServices, NowlineServices } from './language/nowline-module.js';
|
|
36
46
|
export { createNowlineServices, NowlineModule } from './language/nowline-module.js';
|
|
37
47
|
export { NowlineValidator, registerValidationChecks } from './language/nowline-validator.js';
|
|
48
|
+
export { TEMPLATE_NAMES, type TemplateName } from './templates.js';
|
|
@@ -329,6 +329,35 @@ function suggestKey(key: string, candidates: Iterable<string>, cap = 2): string
|
|
|
329
329
|
return best;
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
// Conceptual mistakes that aren't typos of a real key — e.g. `progress:60` is a
|
|
333
|
+
// reasonable guess for "60% done", but Nowline models completion as
|
|
334
|
+
// `status:` + `remaining:` (work *left*). Levenshtein can't bridge that gap, so
|
|
335
|
+
// we map the concept directly. `requires` names a key the entity must support,
|
|
336
|
+
// so the hint only fires where it's actionable (e.g. on an `item`, which has
|
|
337
|
+
// `remaining`, not on a `roadmap` line).
|
|
338
|
+
const CONCEPT_ALIASES: ReadonlyArray<{
|
|
339
|
+
keys: ReadonlySet<string>;
|
|
340
|
+
requires: string;
|
|
341
|
+
suggest: string;
|
|
342
|
+
}> = [
|
|
343
|
+
{
|
|
344
|
+
keys: new Set(['progress', 'percent', 'percentage', 'pct', 'complete', 'completion']),
|
|
345
|
+
requires: 'remaining',
|
|
346
|
+
suggest: 'status: + remaining:',
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
// Suggest the canonical property (or combination) for a known conceptual
|
|
351
|
+
// mistake, gated to entities that actually support the target key. Returns
|
|
352
|
+
// undefined when no concept matches so callers fall back to `suggestKey`.
|
|
353
|
+
function suggestConcept(key: string, known: ReadonlySet<string>): string | undefined {
|
|
354
|
+
const lower = key.toLowerCase();
|
|
355
|
+
for (const alias of CONCEPT_ALIASES) {
|
|
356
|
+
if (alias.keys.has(lower) && known.has(alias.requires)) return alias.suggest;
|
|
357
|
+
}
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
332
361
|
function propKey(prop: { key: string }): string {
|
|
333
362
|
return prop.key.endsWith(':') ? prop.key.slice(0, -1) : prop.key;
|
|
334
363
|
}
|
|
@@ -1195,7 +1224,7 @@ export class NowlineValidator {
|
|
|
1195
1224
|
if (key === 'footnote') continue;
|
|
1196
1225
|
if (computedBanned?.has(key)) continue;
|
|
1197
1226
|
const candidates: string[] = [...known, ...UNIVERSAL_ENTITY_PROPS];
|
|
1198
|
-
const suggested = suggestKey(key, candidates) ?? '';
|
|
1227
|
+
const suggested = suggestConcept(key, known) ?? suggestKey(key, candidates) ?? '';
|
|
1199
1228
|
acceptTr(accept, 'warning', { node: prop, property: 'key' }, 'NL.W0700', {
|
|
1200
1229
|
key,
|
|
1201
1230
|
entity: describeNode(node),
|
package/src/templates.ts
ADDED