reladraw 0.17.1 → 0.19.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/format.js ADDED
@@ -0,0 +1,511 @@
1
+ import { SourceError } from './errors.js';
2
+ import { DEFAULT_FORMAT } from './format-settings.js';
3
+ import { isAttrKey } from './lexer.js';
4
+ import { clausesOf, looksLikeArrow, parse } from './parser.js';
5
+ /**
6
+ * Lay a source file out again: spacing between clauses, columns shared by
7
+ * statements that belong together, comments and blank lines, as `settings`
8
+ * say. Only whitespace changes, and the order of clauses when `clause-order`
9
+ * asks. Every token is copied out of the line it was
10
+ * written on, so no word is respelled, and the result is parsed again and
11
+ * compared with the original, so a mistake here can never change what a file
12
+ * draws: it throws instead.
13
+ *
14
+ * Throws the parser's error for a file that does not parse, so a broken file
15
+ * is never rewritten.
16
+ */
17
+ export function format(source, settings = DEFAULT_FORMAT) {
18
+ const lines = source.split(/\r?\n/);
19
+ const endsWithBreak = lines.length > 1 && lines[lines.length - 1] === '';
20
+ if (endsWithBreak)
21
+ lines.pop();
22
+ const { statements, starts } = clausesOf(source);
23
+ const items = gatherItems(lines, statements, starts, settings);
24
+ const out = layOutItems(items, settings);
25
+ // A file keeps the line breaks it came with, so formatting on Windows
26
+ // doesn't turn every line into a change. The first break decides.
27
+ const lineBreak = /\r\n/.test(source.match(/\r?\n/)?.[0] ?? '') ? '\r\n' : '\n';
28
+ const result = out.join(lineBreak) + (endsWithBreak ? lineBreak : '');
29
+ if (!sameMeaning(source, result)) {
30
+ throw new Error('the formatter would change what this file draws, so it was left alone — please report this as a bug');
31
+ }
32
+ return result;
33
+ }
34
+ /**
35
+ * Does `a` say the same as `b`? Both parse to the same statements, line
36
+ * numbers aside. The keys of a parsed object are compared in any order, since
37
+ * `clause-order` may write `from:` after `via:`; lists keep theirs.
38
+ */
39
+ function sameMeaning(a, b) {
40
+ const strip = (source) => JSON.stringify(parse(source), (key, value) => {
41
+ if (key === 'line')
42
+ return undefined;
43
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
44
+ return value;
45
+ return Object.fromEntries(Object.entries(value).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0)));
46
+ });
47
+ return strip(a) === strip(b);
48
+ }
49
+ const OPENING = new Set(['keyword', 'head']);
50
+ /** The kind of clause a run of tokens is. */
51
+ function kindOf(run) {
52
+ const first = run[0].token;
53
+ if (first.quoted)
54
+ return 'text';
55
+ if (isAttrKey(first))
56
+ return first.text.slice(0, -1);
57
+ return 'placement';
58
+ }
59
+ /** Everything after `//` outside quotes on a line, or nothing. */
60
+ function trailingCommentAfter(line, from) {
61
+ const at = line.indexOf('//', from);
62
+ return at === -1 ? undefined : line.slice(at).trimEnd();
63
+ }
64
+ function gatherItems(lines, statements, starts, settings) {
65
+ const byLine = new Map(statements.map((statement) => [statement.line, statement]));
66
+ const items = [];
67
+ for (let index = 0; index < lines.length; index++) {
68
+ const lineNumber = index + 1;
69
+ const line = lines[index];
70
+ const statement = byLine.get(lineNumber);
71
+ if (statement === undefined) {
72
+ if (line.trim() === '')
73
+ items.push({ kind: 'blank' });
74
+ else
75
+ items.push({ kind: 'comment', text: line.trimEnd() });
76
+ continue;
77
+ }
78
+ // The statement's own lines run to its last line of tokens, and on over any
79
+ // indented comment lines after it: `parse` reads those as inside it too.
80
+ const tokenLines = statement.lines;
81
+ const lastEntry = tokenLines[tokenLines.length - 1];
82
+ let last = lastEntry.line - 1 + newlinesIn(lastEntry.tokens);
83
+ while (last + 1 < lines.length && /^[ \t]+\/\//.test(lines[last + 1]))
84
+ last += 1;
85
+ const span = lines.slice(index, last + 1);
86
+ index = last;
87
+ if (tokenLines.some((entry) => entry.tokens.some((token) => token.triple))) {
88
+ items.push({ kind: 'verbatim', lines: span });
89
+ continue;
90
+ }
91
+ const all = tokenLines.flatMap((entry) => entry.tokens.map((token) => ({ token, line: lines[entry.line - 1] })));
92
+ const keywordCount = all[0].token.text === 'default' || all[0].token.text === 'guide' ? 2 : 1;
93
+ const keyword = all.slice(0, keywordCount).map(({ token, line: text }) => text.slice(token.start, token.end)).join(' ');
94
+ // Comments among the statement's lines: whole comment lines, and comments
95
+ // trailing any line but the last.
96
+ const inside = [];
97
+ let trailing;
98
+ for (let at = 0; at < span.length; at++) {
99
+ const physical = lineNumber + at;
100
+ const entry = tokenLines.find((one) => one.line === physical);
101
+ const text = span[at];
102
+ if (entry === undefined) {
103
+ if (text.trim() !== '')
104
+ inside.push({ line: physical, text: text.trim() });
105
+ continue;
106
+ }
107
+ const comment = trailingCommentAfter(text, entry.tokens[entry.tokens.length - 1].end ?? 0);
108
+ if (comment === undefined)
109
+ continue;
110
+ if (entry === lastEntry)
111
+ trailing = comment;
112
+ else
113
+ inside.push({ line: physical, text: comment });
114
+ }
115
+ // A trailing comment on the last line is inside too when whole comment lines follow it.
116
+ if (trailing !== undefined && inside.some((one) => one.line > lastEntry.line)) {
117
+ inside.push({ line: lastEntry.line, text: trailing });
118
+ inside.sort((a, b) => a.line - b.line);
119
+ trailing = undefined;
120
+ }
121
+ const cellsOf = (entries) => statementCells(entries, keywordCount, starts, settings);
122
+ if (inside.length > 0 && settings['comments-inside'] === 'refuse') {
123
+ throw new SourceError('a comment among the lines of a statement has nowhere to go when the statement is joined onto one line — ' +
124
+ 'move it above the statement, or set comments-inside: keep-lines', inside[0].line);
125
+ }
126
+ if (inside.length > 0 && settings['comments-inside'] === 'keep-lines') {
127
+ const kept = [];
128
+ for (let at = 0; at < span.length; at++) {
129
+ const physical = lineNumber + at;
130
+ const entry = tokenLines.find((one) => one.line === physical);
131
+ const text = span[at];
132
+ if (entry === undefined) {
133
+ if (text.trim() !== '')
134
+ kept.push({ cells: [], comment: text.trim() });
135
+ continue;
136
+ }
137
+ const own = all.filter(({ token }) => entry.tokens.includes(token));
138
+ const comment = trailingCommentAfter(text, entry.tokens[entry.tokens.length - 1].end ?? 0);
139
+ // The first line keeps its keyword as a cell of its own.
140
+ const lineCells = at === 0 ? cellsOf(own) : continuedCells(own, starts);
141
+ kept.push({ cells: lineCells, ...(comment !== undefined ? { comment } : {}) });
142
+ }
143
+ items.push({ kind: 'kept', keyword, lines: kept, first: cellsOf(all) });
144
+ continue;
145
+ }
146
+ for (const comment of inside)
147
+ items.push({ kind: 'comment', text: comment.text });
148
+ const cells = orderCells(cellsOf(all), settings);
149
+ items.push({ kind: 'statement', keyword, cells, ...(trailing !== undefined ? { comment: trailing } : {}) });
150
+ }
151
+ return items;
152
+ }
153
+ /** How many physical lines a `"""` string on this line runs on past it. */
154
+ function newlinesIn(tokens) {
155
+ return tokens.reduce((count, token) => count + (token.triple ? token.text.split('\n').length - 1 : 0), 0);
156
+ }
157
+ /**
158
+ * Split a statement's tokens into its cells: the keyword, what follows it up
159
+ * to the first clause (a name, or an edge's name and ends), then one cell per
160
+ * clause. A clause starts where the parser began reading one, outside
161
+ * brackets; a quoted text starts one too, unless it is a key's value.
162
+ */
163
+ function statementCells(all, keywordCount, starts, settings) {
164
+ const result = [];
165
+ const keyword = all.slice(0, keywordCount);
166
+ result.push({ text: joinTokens(keyword), kind: 'keyword' });
167
+ const runs = clauseRuns(all.slice(keywordCount), starts);
168
+ const head = runs[0] && !isAttrKey(runs[0][0].token) && !starts.has(runs[0][0].token) ? runs.shift() : undefined;
169
+ if (head) {
170
+ if (all[0].token.text === 'edge' && settings['edge-name-and-ends'] === 'own-columns')
171
+ result.push(...edgeHeadCells(head));
172
+ else
173
+ result.push({ text: joinTokens(head), kind: 'head' });
174
+ }
175
+ for (const run of runs)
176
+ result.push({ text: joinTokens(run), kind: kindOf(run), ...(run[0].token.quoted ? { quoted: true } : {}) });
177
+ return result;
178
+ }
179
+ /** The cells of a continued line in a kept statement: no keyword, no head. */
180
+ function continuedCells(own, starts) {
181
+ return clauseRuns(own, starts).map((run) => ({ text: joinTokens(run), kind: kindOf(run) }));
182
+ }
183
+ /**
184
+ * A statement's clauses in the order `clause-order` lists their kinds, the
185
+ * kinds it leaves out after them as written; clauses of one kind keep their
186
+ * order. Quoted texts never move: the language takes them only straight after
187
+ * the name. A clause only moves where moving it can't change what the
188
+ * statement says, so one that would read differently reordered keeps its
189
+ * written order.
190
+ */
191
+ function orderCells(cells, settings) {
192
+ const order = settings['clause-order'];
193
+ if (order === 'as-written')
194
+ return cells;
195
+ const stays = (cell) => OPENING.has(cell.kind) || cell.quoted === true;
196
+ const opening = cells.filter(stays);
197
+ const rank = (cell) => (order.includes(cell.kind) ? order.indexOf(cell.kind) : order.length);
198
+ const rest = cells
199
+ .filter((cell) => !stays(cell))
200
+ .map((cell, at) => ({ cell, at }))
201
+ .sort((a, b) => rank(a.cell) - rank(b.cell) || a.at - b.at)
202
+ .map(({ cell }) => cell);
203
+ const ordered = [...opening, ...rest];
204
+ const line = (some) => some.map((cell) => cell.text).filter((text) => text !== '').join(' ');
205
+ try {
206
+ return sameMeaning(line(cells), line(ordered)) ? ordered : cells;
207
+ }
208
+ catch {
209
+ return cells;
210
+ }
211
+ }
212
+ /** Tokens grouped into clauses, by where clauses start outside brackets. */
213
+ function clauseRuns(tokens, starts) {
214
+ const runs = [];
215
+ let depth = 0;
216
+ let inKey = false;
217
+ for (const entry of tokens) {
218
+ const { token } = entry;
219
+ const opens = depth === 0 &&
220
+ token.text !== '(' &&
221
+ (starts.has(token) || (token.quoted && !inKey) || runs.length === 0);
222
+ if (opens) {
223
+ runs.push([entry]);
224
+ inKey = isAttrKey(token);
225
+ }
226
+ else {
227
+ runs[runs.length - 1].push(entry);
228
+ }
229
+ if (!token.quoted && token.text === '(')
230
+ depth += 1;
231
+ else if (!token.quoted && token.text === ')')
232
+ depth -= 1;
233
+ }
234
+ return runs;
235
+ }
236
+ /**
237
+ * An edge's name and ends as a cell each: the name if it has one, the first
238
+ * end, the arrow, the second end. An edge with no name gets an empty name cell,
239
+ * so its ends still line up with a named one's.
240
+ */
241
+ function edgeHeadCells(head) {
242
+ const arrowAt = head.findIndex(({ token }) => token.text !== '(' && looksLikeArrow(token));
243
+ if (arrowAt === -1)
244
+ return [{ text: joinTokens(head), kind: 'head' }];
245
+ const before = head.slice(0, arrowAt);
246
+ const named = before.length > 1 && !before[0].token.text.endsWith(',') && before[0].token.text !== 'each';
247
+ return [
248
+ { text: named ? joinTokens(before.slice(0, 1)) : '', kind: 'head' },
249
+ { text: joinTokens(named ? before.slice(1) : before), kind: 'head' },
250
+ { text: joinTokens(head.slice(arrowAt, arrowAt + 1)), kind: 'head' },
251
+ { text: joinTokens(head.slice(arrowAt + 1)), kind: 'head' },
252
+ ];
253
+ }
254
+ /**
255
+ * Tokens written back as one stretch of text, spaced the way the docs spell a
256
+ * clause however it was written: one space between words and after a key's
257
+ * colon, none just inside a bracket, and a lone comma against the word before
258
+ * it. `line:( corners:rounded , path:square )` comes out as
259
+ * `line: (corners: rounded, path: square)`.
260
+ */
261
+ function joinTokens(tokens) {
262
+ let text = '';
263
+ tokens.forEach(({ token, line }, index) => {
264
+ const previous = tokens[index - 1];
265
+ if (previous) {
266
+ const opens = !previous.token.quoted && previous.token.text === '(';
267
+ const closes = !token.quoted && (token.text === ')' || token.text === ',');
268
+ text += opens || closes ? '' : ' ';
269
+ }
270
+ const written = line.slice(token.start, token.end);
271
+ // A comma the lexer joined on from further along the line.
272
+ text += !token.quoted && token.text.endsWith(',') && !written.endsWith(',') ? `${written},` : written;
273
+ });
274
+ return text;
275
+ }
276
+ /** The width of a stretch of text in characters, counting each code point once. */
277
+ function textWidth(text) {
278
+ return [...text].length;
279
+ }
280
+ /** How wide a tab counts as, where a width has to be known. */
281
+ const TAB_WIDTH = 4;
282
+ /**
283
+ * Whether a statement goes past `wrap-at` or `clause-per-line-at`, and if so
284
+ * where its first line ends. Its length is measured on its own, unaligned, so
285
+ * whether one statement breaks never depends on its neighbors. The first line
286
+ * holds the keyword and name, and the texts after them if `first-line` says
287
+ * so, and shares columns with its neighbors like a one-line statement; the
288
+ * clauses after it are laid out by `continuedLines`.
289
+ */
290
+ function breakStatement(item, settings, gap) {
291
+ const texts = item.cells.map((cell) => cell.text);
292
+ const length = textWidth(texts.filter((text, at) => text !== '' || at === 0).join(gap));
293
+ const perLine = settings['clause-per-line-at'];
294
+ const wrap = settings['wrap-at'];
295
+ const oneEach = perLine === 'always' || (typeof perLine === 'number' && length > perLine);
296
+ if (!oneEach && !(typeof wrap === 'number' && length > wrap))
297
+ return item;
298
+ let firstEnd = item.cells.findIndex((cell) => !OPENING.has(cell.kind));
299
+ if (firstEnd === -1)
300
+ return item;
301
+ if (settings['first-line'] === 'text')
302
+ while (item.cells[firstEnd]?.quoted)
303
+ firstEnd += 1;
304
+ const rest = item.cells.slice(firstEnd).map((cell) => cell.text);
305
+ if (rest.length === 0)
306
+ return item;
307
+ return {
308
+ kind: 'broken',
309
+ keyword: item.keyword,
310
+ first: item.cells.slice(0, firstEnd),
311
+ rest,
312
+ oneEach,
313
+ ...(item.comment !== undefined ? { comment: item.comment } : {}),
314
+ };
315
+ }
316
+ /**
317
+ * A broken statement's lines after its first: one clause to a line, or as
318
+ * many as fit under `wrap-at`. A clause is never split, so one longer than
319
+ * the width stands alone on its line.
320
+ */
321
+ function continuedLines(item, indent, settings, gap, gapWidth) {
322
+ if (item.oneEach)
323
+ return item.rest.map((text) => indent + text);
324
+ const wrap = settings['wrap-at'];
325
+ const indentWidth = indent === '\t' ? TAB_WIDTH : indent.length;
326
+ const lines = [];
327
+ let line = '';
328
+ for (const text of item.rest) {
329
+ if (line !== '' && indentWidth + textWidth(line) + gapWidth + textWidth(text) > wrap) {
330
+ lines.push(indent + line);
331
+ line = '';
332
+ }
333
+ line = line === '' ? text : line + gap + text;
334
+ }
335
+ lines.push(indent + line);
336
+ return lines;
337
+ }
338
+ function layOutItems(written, settings) {
339
+ const gap = settings['clause-gap'] === 'tab' ? '\t' : ' '.repeat(settings['clause-gap']);
340
+ const gapWidth = settings['clause-gap'] === 'tab' ? 1 : settings['clause-gap'];
341
+ const items = written.map((item) => (item.kind === 'statement' ? breakStatement(item, settings, gap) : item));
342
+ // What lines up in columns: a one-line statement whole, a broken one's first line.
343
+ const aligned = (item) => item.kind === 'statement' ? item.cells : item.kind === 'broken' ? item.first : undefined;
344
+ // Which statements share columns: each is given a group number. A comment
345
+ // line is passed over, as if it weren't there: it labels what follows
346
+ // rather than separating it.
347
+ const group = new Map();
348
+ let current = -1;
349
+ let previous;
350
+ let lastStatement;
351
+ items.forEach((item, index) => {
352
+ if (item.kind === 'comment')
353
+ return;
354
+ if (item.kind !== 'statement' && item.kind !== 'broken') {
355
+ if (item.kind !== 'blank' || settings['columns-shared-by'] !== 'same-keyword') {
356
+ if (settings['columns-shared-by'] !== 'file')
357
+ lastStatement = undefined;
358
+ }
359
+ previous = item;
360
+ return;
361
+ }
362
+ const by = settings['columns-shared-by'];
363
+ const joins = lastStatement !== undefined &&
364
+ (by === 'file' ||
365
+ (by === 'neighbors' && previous === lastStatement) ||
366
+ (by === 'same-keyword-neighbors' && previous === lastStatement && lastStatement.keyword === item.keyword) ||
367
+ (by === 'same-keyword' && lastStatement.keyword === item.keyword));
368
+ if (!joins)
369
+ current += 1;
370
+ group.set(index, current);
371
+ lastStatement = item;
372
+ previous = item;
373
+ });
374
+ // Each group's column widths, and the column its trailing comments start at.
375
+ const widths = new Map();
376
+ const commentAt = new Map();
377
+ items.forEach((item, index) => {
378
+ const cells = aligned(item);
379
+ if (cells === undefined)
380
+ return;
381
+ const id = group.get(index);
382
+ const own = widths.get(id) ?? [];
383
+ cells.forEach((cell, at) => (own[at] = Math.max(own[at] ?? 0, textWidth(cell.text))));
384
+ widths.set(id, own);
385
+ });
386
+ // Each cell's text, and where on the line it starts.
387
+ const placed = (cells, id) => {
388
+ const own = widths.get(id);
389
+ const result = [];
390
+ let at = 0;
391
+ cells.forEach((cell, index) => {
392
+ // A column empty in every statement of the set, such as the name column
393
+ // of unnamed edges, takes no room; with columns off, neither does an
394
+ // empty cell.
395
+ if (settings.columns === 'off' ? cell.text === '' && index > 0 : own[index] === 0)
396
+ return;
397
+ result.push({ text: cell.text, at });
398
+ at += (settings.columns === 'off' ? textWidth(cell.text) : own[index]) + gapWidth;
399
+ });
400
+ return result;
401
+ };
402
+ const lineOf = (cells, id) => {
403
+ if (settings.columns === 'off')
404
+ return placed(cells, id).map((one) => one.text).join(gap);
405
+ let line = '';
406
+ for (const one of placed(cells, id))
407
+ line += ' '.repeat(one.at - textWidth(line)) + one.text;
408
+ return line;
409
+ };
410
+ items.forEach((item, index) => {
411
+ if (item.kind !== 'statement')
412
+ return;
413
+ const id = group.get(index);
414
+ commentAt.set(id, Math.max(commentAt.get(id) ?? 0, textWidth(lineOf(item.cells, id))));
415
+ });
416
+ // Where a broken statement's later lines start, measured on its first line
417
+ // as aligned, so `under-text` sits under the text where it was drawn.
418
+ const brokenIndent = (item, id) => {
419
+ const indent = settings['continued-lines-indent'];
420
+ if (indent === 'tab' || typeof indent === 'number')
421
+ return continuedIndent(item.first, settings, gapWidth);
422
+ const cells = placed(item.first, id);
423
+ const name = cells[1]?.at ?? textWidth(item.first[0].text) + gapWidth;
424
+ if (indent === 'under-name')
425
+ return ' '.repeat(name);
426
+ if (indent === 'under-text') {
427
+ const at = item.first.findIndex((cell) => cell.quoted);
428
+ const text = cells.find((one) => one.text === item.first[at]?.text);
429
+ return ' '.repeat(text?.at ?? name);
430
+ }
431
+ return ' '.repeat(name + indent.underName);
432
+ };
433
+ const out = [];
434
+ let blankRun = 0;
435
+ const flushBlanks = () => {
436
+ const blanks = settings['blank-lines'];
437
+ const count = blanks === 'as-written' ? blankRun : 'exactly' in blanks ? (blankRun > 0 ? blanks.exactly : 0) : Math.min(blankRun, blanks.atMost);
438
+ for (let i = 0; i < count; i++)
439
+ out.push('');
440
+ blankRun = 0;
441
+ };
442
+ items.forEach((item, index) => {
443
+ if (item.kind === 'blank') {
444
+ blankRun += 1;
445
+ return;
446
+ }
447
+ flushBlanks();
448
+ switch (item.kind) {
449
+ case 'comment':
450
+ out.push(item.text);
451
+ return;
452
+ case 'verbatim':
453
+ out.push(...item.lines);
454
+ return;
455
+ case 'broken': {
456
+ const id = group.get(index);
457
+ const lines = [lineOf(item.first, id), ...continuedLines(item, brokenIndent(item, id), settings, gap, gapWidth)];
458
+ lines.forEach((line, at) => {
459
+ const last = at === lines.length - 1;
460
+ out.push(last && item.comment !== undefined ? `${line}${gap}${item.comment}` : line);
461
+ });
462
+ return;
463
+ }
464
+ case 'kept': {
465
+ const indent = continuedIndent(item.first, settings, gapWidth);
466
+ item.lines.forEach((line, at) => {
467
+ const text = line.cells.map((cell) => cell.text).join(gap);
468
+ const body = line.comment === undefined ? text : text === '' ? line.comment : `${text}${gap}${line.comment}`;
469
+ out.push(at === 0 ? body : indent + body);
470
+ });
471
+ return;
472
+ }
473
+ case 'statement': {
474
+ const id = group.get(index);
475
+ const text = lineOf(item.cells, id);
476
+ if (item.comment === undefined)
477
+ out.push(text);
478
+ else if (settings['trailing-comments'] === 'shared-column') {
479
+ out.push(text + ' '.repeat(commentAt.get(id) - textWidth(text) + gapWidth) + item.comment);
480
+ }
481
+ else
482
+ out.push(text + gap + item.comment);
483
+ }
484
+ }
485
+ });
486
+ flushBlanks();
487
+ return out;
488
+ }
489
+ /**
490
+ * Where a statement's continued lines start, as `continued-lines-indent` says,
491
+ * measured on its first line's cells. `under-text` falls back to under the
492
+ * name for a statement with no text.
493
+ */
494
+ function continuedIndent(first, settings, gapWidth) {
495
+ const indent = settings['continued-lines-indent'];
496
+ if (indent === 'tab')
497
+ return '\t';
498
+ if (typeof indent === 'number')
499
+ return ' '.repeat(indent);
500
+ const name = textWidth(first[0].text) + gapWidth;
501
+ if (indent === 'under-name')
502
+ return ' '.repeat(name);
503
+ if (indent === 'under-text') {
504
+ const at = first.findIndex((cell) => cell.quoted);
505
+ if (at === -1)
506
+ return ' '.repeat(name);
507
+ const before = first.slice(0, at).filter((cell, index) => cell.text !== '' || index === 0);
508
+ return ' '.repeat(before.reduce((width, cell) => width + textWidth(cell.text) + gapWidth, 0));
509
+ }
510
+ return ' '.repeat(name + indent.underName);
511
+ }
package/dist/icons.d.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * which caps the useful vocabulary at something that fits in a head. Add a name
19
19
  * when a diagram asks for a distinction it cannot otherwise make.
20
20
  */
21
+ import { type ShapeLook } from './outline.js';
21
22
  /**
22
23
  * Icons carry three tones rather than colors. `ink` is the drawn line, `shade`
23
24
  * the body it encloses, and `void` is the page showing through — the same
@@ -57,23 +58,21 @@ export declare const ICON_NAMES: string[];
57
58
  * The outlines a node's body can take. `rectangle` is the plain one and needs no
58
59
  * word, since it is what a node is when it says nothing.
59
60
  *
60
- * Named for what a node *is*, never for the geometry, which is the same rule the
61
- * icon names follow: `document` and not `folded-corner`. A shape carrying a
62
- * conventional meaning is a second channel alongside color, and a stronger one
63
- * — a fill is whatever the author assigned and has to be learnt from the
64
- * diagram, while a folded corner has meant "an artifact, not a process" in
65
- * flowcharts for decades and reads with no legend at all.
61
+ * A shape geometry has a word for is named by that word, how it looks:
62
+ * `diamond`, never `decision`, since someone may just want a diamond, and
63
+ * reading `decision` as a diamond is a translation every reader has to make.
64
+ * Those take settings in a bracket for what the box doesn't decide. A
65
+ * flowchart shape geometry has no word for is named `flow-` and what it means,
66
+ * drawn the way the standard draws it, with no settings. No picture has two
67
+ * names. `document`, the folded corner, is older than the rule and keeps its
68
+ * name; the standard's wavy one is `flow-document`.
66
69
  *
67
- * `circle` is the one geometric name, because a circle carries no single
68
- * meaning to name it after — it is a state, a step, a person, whatever the
69
- * diagram's own convention makes it. `diamond` joins these when a diagram
70
- * asks. The set being short is a fact about what has been drawn, not about the
71
- * key.
70
+ * The geometry of each is in `outline.ts`.
72
71
  */
73
- export declare const OUTLINES: readonly ["rectangle", "document", "circle"];
72
+ export declare const OUTLINES: readonly ["rectangle", "document", "circle", "diamond", "pill", "parallelogram", "hexagon", "trapezoid", "triangle", "cylinder", "flow-subprocess", "flow-document", "flow-off-page", "flow-delay", "flow-manual-input", "flow-stored-data", "flow-loop-start", "flow-loop-end", "flow-card", "flow-display", "flow-tape", "flow-punched-tape", "flow-collate"];
74
73
  export type Outline = (typeof OUTLINES)[number];
75
74
  /** Every value `shape:` accepts, `none` included. */
76
- export declare const SHAPE_WORDS: readonly ["rectangle", "document", "circle", "none"];
75
+ export declare const SHAPE_WORDS: readonly ["rectangle", "document", "circle", "diamond", "pill", "parallelogram", "hexagon", "trapezoid", "triangle", "cylinder", "flow-subprocess", "flow-document", "flow-off-page", "flow-delay", "flow-manual-input", "flow-stored-data", "flow-loop-start", "flow-loop-end", "flow-card", "flow-display", "flow-tape", "flow-punched-tape", "flow-collate", "none"];
77
76
  /**
78
77
  * What a node is drawn as: an outline, a picture, or nothing at all.
79
78
  *
@@ -88,6 +87,7 @@ export declare const SHAPE_WORDS: readonly ["rectangle", "document", "circle", "
88
87
  export type Body = {
89
88
  kind: 'shape';
90
89
  outline: Outline;
90
+ look: ShapeLook;
91
91
  } | {
92
92
  kind: 'icon';
93
93
  icon: Icon;
package/dist/icons.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * when a diagram asks for a distinction it cannot otherwise make.
20
20
  */
21
21
  import { SourceError } from './errors.js';
22
+ import { readShapeLook } from './outline.js';
22
23
  const GRID = 24;
23
24
  /** Line width on the 24-unit grid, scaled with everything else. */
24
25
  export const ICON_STROKE = 1.1;
@@ -119,23 +120,45 @@ export const ICON_NAMES = Object.keys(ICONS);
119
120
  * The outlines a node's body can take. `rectangle` is the plain one and needs no
120
121
  * word, since it is what a node is when it says nothing.
121
122
  *
122
- * Named for what a node *is*, never for the geometry, which is the same rule the
123
- * icon names follow: `document` and not `folded-corner`. A shape carrying a
124
- * conventional meaning is a second channel alongside color, and a stronger one
125
- * — a fill is whatever the author assigned and has to be learnt from the
126
- * diagram, while a folded corner has meant "an artifact, not a process" in
127
- * flowcharts for decades and reads with no legend at all.
123
+ * A shape geometry has a word for is named by that word, how it looks:
124
+ * `diamond`, never `decision`, since someone may just want a diamond, and
125
+ * reading `decision` as a diamond is a translation every reader has to make.
126
+ * Those take settings in a bracket for what the box doesn't decide. A
127
+ * flowchart shape geometry has no word for is named `flow-` and what it means,
128
+ * drawn the way the standard draws it, with no settings. No picture has two
129
+ * names. `document`, the folded corner, is older than the rule and keeps its
130
+ * name; the standard's wavy one is `flow-document`.
128
131
  *
129
- * `circle` is the one geometric name, because a circle carries no single
130
- * meaning to name it after — it is a state, a step, a person, whatever the
131
- * diagram's own convention makes it. `diamond` joins these when a diagram
132
- * asks. The set being short is a fact about what has been drawn, not about the
133
- * key.
132
+ * The geometry of each is in `outline.ts`.
134
133
  */
135
- export const OUTLINES = ['rectangle', 'document', 'circle'];
134
+ export const OUTLINES = [
135
+ 'rectangle',
136
+ 'document',
137
+ 'circle',
138
+ 'diamond',
139
+ 'pill',
140
+ 'parallelogram',
141
+ 'hexagon',
142
+ 'trapezoid',
143
+ 'triangle',
144
+ 'cylinder',
145
+ 'flow-subprocess',
146
+ 'flow-document',
147
+ 'flow-off-page',
148
+ 'flow-delay',
149
+ 'flow-manual-input',
150
+ 'flow-stored-data',
151
+ 'flow-loop-start',
152
+ 'flow-loop-end',
153
+ 'flow-card',
154
+ 'flow-display',
155
+ 'flow-tape',
156
+ 'flow-punched-tape',
157
+ 'flow-collate',
158
+ ];
136
159
  /** Every value `shape:` accepts, `none` included. */
137
160
  export const SHAPE_WORDS = [...OUTLINES, 'none'];
138
- const PLAIN = { kind: 'shape', outline: 'rectangle' };
161
+ const PLAIN = { kind: 'shape', outline: 'rectangle', look: {} };
139
162
  /**
140
163
  * Which body a node has, from what it wrote and what its style carried.
141
164
  *
@@ -158,14 +181,37 @@ export function bodyFor(attrs, appearance, line, declared = new Map()) {
158
181
  throw new SourceError(`shape: ${shape} and icon: ${icon} both say what this node is drawn as, and it has one ` +
159
182
  'body — `shape:` is the outline it is drawn with, `icon:` is the picture it is drawn as', line);
160
183
  }
184
+ // The settings come from wherever the shape did: a node's own `shape:` takes
185
+ // its own bracket, never a style's.
186
+ const from = wroteShape ? attrs : appearance;
187
+ const settings = {};
188
+ for (const [key, value] of Object.entries(from))
189
+ if (key.startsWith('shape.'))
190
+ settings[key.slice('shape.'.length)] = value;
161
191
  if (icon !== undefined)
162
192
  return { kind: 'icon', icon: iconNamed(icon, line, declared) };
163
193
  if (shape === undefined)
164
194
  return PLAIN;
165
- if (shape === 'none')
195
+ if (shape === 'none') {
196
+ if (Object.keys(settings).length > 0)
197
+ refuse(`\`shape: none\` draws no outline, so it takes no settings`, line);
166
198
  return { kind: 'none' };
199
+ }
167
200
  if (OUTLINES.includes(shape)) {
168
- return { kind: 'shape', outline: shape };
201
+ return { kind: 'shape', outline: shape, look: readShapeLook(shape, settings, `shape: ${shape}`, line) };
202
+ }
203
+ if (shape === 'hourglass') {
204
+ return refuse('`shape: hourglass` is the flowchart\'s collate symbol, and it is `flow-collate`', line);
205
+ }
206
+ if (shape === 'pentagon' || shape === 'pointed' || shape === 'home-plate') {
207
+ return refuse(`there is no shape "${shape}" — the flowchart's off-page connector, a box with one end pointed, is \`flow-off-page\``, line);
208
+ }
209
+ if (shape === 'ellipse' || shape === 'oval') {
210
+ return refuse(`there is no shape "${shape}" — an ellipse is a circle given a width and a height: \`shape: circle width: 20 height: 8\``, line);
211
+ }
212
+ if (shape === 'decision' || shape === 'terminal' || shape === 'process') {
213
+ const look = { decision: 'diamond', terminal: 'pill', process: 'rectangle' }[shape];
214
+ return refuse(`a shape is named for how it looks, so a ${shape} is \`shape: ${look}\``, line);
169
215
  }
170
216
  // Until 0.3.0 `shape:` also took an icon name and drew the node as that
171
217
  // picture. The two jobs are two parts and now have two keys, so an older file