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/README.md CHANGED
@@ -43,7 +43,7 @@ npm install -g reladraw
43
43
  reladraw render diagram.reladraw -o diagram.svg
44
44
  ```
45
45
 
46
- `reladraw check diagram.reladraw` prints the warnings without writing a picture.
46
+ `reladraw check diagram.reladraw` prints the warnings without writing a picture, and `reladraw format diagram.reladraw` lines its statements up in columns.
47
47
 
48
48
  Or from a clone, which also gets you the examples:
49
49
 
@@ -86,7 +86,7 @@ That gets you a copy of the skill at the time you run it, so you'll need to re-r
86
86
 
87
87
  ## Status
88
88
 
89
- Version 0.17.1. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
89
+ Version 0.19.0. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
90
90
 
91
91
  The language isn't stable yet, so expect the syntax to change.
92
92
 
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.17.1
1
+ # Syntax reference — 0.19.0
2
2
 
3
3
  What the language accepts. The changelog at the end lists what changed in each published version; 0.17.0's changes are large, and its entry has a table of every old spelling and what to write instead.
4
4
 
@@ -55,7 +55,7 @@ What every statement of a kind has is positional; what is optional is a key. A w
55
55
 
56
56
  **A value ends when its own shape is complete.** `height: 0.5 group height right of a` reads a size, then a placement. `to: top, right of z` keeps `right of z` inside `to:`, because it is part of that value. So word order stays free and nothing is ambiguous.
57
57
 
58
- **A bracket modifies the one thing it follows.** A setting that belongs to one part of a statement is written in brackets right after that part: a placement's `(gap: wide)`, a text's `(size: small, wrap: 30)`, a line's `line: (path: square)`, a layout's `layout: column (gap: none)`, a waypoint's `b top (gap: wide)`. The scope is visible instead of inferred. Inside a bracket the comma is optional.
58
+ **A bracket modifies the one thing it follows.** A setting that belongs to one part of a statement is written in brackets right after that part: a placement's `(gap: wide)`, a text's `(size: small, wrap: 30)`, a line's `line: (path: square)`, a layout's `layout: column (gap: none)`, a waypoint's `b top (gap: wide)`. The scope is visible instead of inferred. Inside a bracket the comma is optional, and spaces around it don't matter: `(corners: rounded,path: square)` and `(corners: rounded , path: square)` are both `(corners: rounded, path: square)`.
59
59
 
60
60
  ### Names and words
61
61
 
@@ -104,14 +104,44 @@ node <name> ["<text>" [(<text settings>)]] [<placement> | <attribute>] ...
104
104
 
105
105
  Every node has one body, the thing drawn where it is, named by one of two keys.
106
106
 
107
- `shape:` is the outline:
107
+ `shape:` is the outline. A shape geometry has a word for is named by it, and takes settings in a bracket after its name for what its box doesn't decide: an amount, in the file's unit, and a direction, whose first word below is the default.
108
+
109
+ | shape | draws | its bracket |
110
+ |---|---|---|
111
+ | `rectangle` | the plain box, its corners rounded unless `corners: sharp`; the default | |
112
+ | `document` | the box with its top-right corner folded, for an artifact rather than a process; its other corners follow `corners:` | `fold:` |
113
+ | `circle` | a circle sized to hold its text; given a `width:` and a `height:` that differ, an ellipse | |
114
+ | `diamond` | a diamond, a flowchart's decision | |
115
+ | `pill` | a box with both ends round, a flowchart's start or end | |
116
+ | `parallelogram` | a slanted box, a flowchart's input or output | `slant:`, `lean: right \| left` |
117
+ | `hexagon` | a box with pointed ends, a flowchart's preparation | `point:` |
118
+ | `trapezoid` | a box narrower at one end, a flowchart's manual operation | `taper:`, `narrow: bottom \| top` |
119
+ | `triangle` | a triangle, a flowchart's merge (down) or extract (up) | `aim: down \| up \| left \| right` |
120
+ | `cylinder` | a cylinder standing, or `lying` on its side, for stored data | `cap:`, `lying` |
121
+ | `none` | no outline, fill or padding: bare text, or an invisible container | |
122
+
123
+ ```
124
+ node in "read input" shape: parallelogram (slant: 2, lean: left)
125
+ node queue "queue" shape: cylinder (cap: 1.5, lying) right of in
126
+ node flat "valid?" shape: diamond width: 20 height: 5 below in
127
+ ```
128
+
129
+ A flowchart shape geometry has no word for is named `flow-` and what it means, and drawn the way the flowchart standard draws it, with no settings:
108
130
 
109
131
  | shape | draws |
110
132
  |---|---|
111
- | `rectangle` | the plain box, its corners rounded unless `corners: sharp`; the default |
112
- | `document` | the box with its top-right corner folded, for an artifact rather than a process; its other corners follow `corners:` |
113
- | `circle` | a circle sized to hold its text, always round |
114
- | `none` | no outline, fill or padding: bare text, or an invisible container |
133
+ | `flow-subprocess` | a step drawn as its own chart: a box with a bar inside each side |
134
+ | `flow-document` | a document as the standard draws it, its bottom wavy |
135
+ | `flow-off-page` | continued on another page: a box pointed at the bottom |
136
+ | `flow-delay` | a delay or a wait: a box with its right end round |
137
+ | `flow-manual-input` | typed in by hand: a box whose top slopes |
138
+ | `flow-stored-data` | the standard's other storage: curved ends, the right one hollow |
139
+ | `flow-loop-start`, `flow-loop-end` | the start and end of a loop: a box with two corners cut, at the top or the bottom |
140
+ | `flow-card` | a punched card: a box with its top-left corner cut |
141
+ | `flow-display` | a screen: pointed on the left, round on the right |
142
+ | `flow-tape` | magnetic tape: a circle with a tail |
143
+ | `flow-punched-tape` | paper tape: wavy top and bottom |
144
+ | `flow-collate` | collate: two triangles point to point, its text on a patch where they meet |
115
145
 
116
146
  `icon:` is a picture the node is drawn *as*, with no box:
117
147
 
@@ -131,11 +161,15 @@ node aside "Written by a nightly cron job." (wrap: 30) shape: none below dump
131
161
  node svc icon: cube right of dump
132
162
  ```
133
163
 
134
- Writing both keys is an error naming both. An unknown name is refused with the whole set. A name says what a thing is, never what the picture looks like: `document`, not `folded-corner`. The exception is a picture with no single meaning, such as `circle` or `cube`.
164
+ Writing both keys is an error naming both. An unknown name is refused with the whole set, and so is a setting the shape doesn't take. A shape is named for how it looks, `diamond` and never `decision`, and no picture has two names. An icon is named for what it is: `package`, not `open-box`.
165
+
166
+ **Size and text.** A shape grows until its text, with its padding, fits at the shape's middle, the center of its area: a diamond is twice the padded text each way, and a triangle's text sits a third of the way from its base. A stated `width:` and `height:` stretch the shape to fill them, which is how a diamond is made flatter; a text that no longer fits is drawn as it is and reported as `text-overflow`. The text-position words of a [node's text](#where-it-sits), such as `(inside bottom)`, move it off the middle. `flow-collate`'s text sits on a patch of its own fill where its two triangles meet; `(fill: none)` in the text's bracket draws it without one.
167
+
168
+ **Sides.** A side of a shape is the stretch of its outline between the lines from its middle to two corners of its box: a rectangle's sides, a circle's quarters, and on a diamond the stretch round each point. So `from: right` leaves a diamond from its right point, as a flowchart's "no" does, and means the same on every shape. A corner such as `top-right` is where the line from the middle to that corner of the box meets the outline: on a circle halfway between its sides, on a diamond the middle of an edge. Several line ends on one side spread along it.
135
169
 
136
- A picture can't contain anything. A `shape: none` node with children is an invisible container ([Containers](#containers)). A circle can hold other nodes, centered in it. A side of a circle is the quarter of it about that compass point, so `from: right` means the same on every shape; several line ends on one side spread round that arc, each meeting the circle square on.
170
+ A picture can't contain anything. A `shape: none` node with children is an invisible container ([Containers](#containers)). A circle can hold other nodes, centered in it; the other shapes don't hold nodes yet, and one with children is refused.
137
171
 
138
- **Corners.** `corners: sharp` draws a box's corners square, and `corners: rounded`, the default, draws them as they always were. It is the same word and values as a line's `corners:`. It reaches every outline with corners: a rectangle, a container, and a document's three round corners, whose fold stays as it is. A circle has none, so `corners:` written on a circle is refused; from a style or a default it lands only on the nodes with corners. A picture and bare text have no outline, so they refuse it too. Like a color, it never moves anything.
172
+ **Corners.** `corners: sharp` draws a box's corners square, and `corners: rounded`, the default, draws them as they always were. It is the same word and values as a line's `corners:`. It reaches every outline with corners that round: a rectangle, a container, a `flow-subprocess`, and a document's three round corners, whose fold stays as it is. Every other shape is drawn with its points sharp, so `corners:` written on one is refused; from a style or a default it lands only on the nodes it can. A picture and bare text have no outline, so they refuse it too. Like a color, it never moves anything.
139
173
 
140
174
  ```
141
175
  style plain corners: sharp
@@ -324,7 +358,7 @@ left-center center right-center
324
358
  bottom-left bottom-center bottom-right
325
359
  ```
326
360
 
327
- **A side is a segment and a point is a point.** Bare `top` is the whole side; `top-center` is the one point halfway along it, which is why every midpoint carries `-center`. Naming a side leaves the other axis free. On a circle, a corner is the point on the circle halfway between its two sides.
361
+ **A side is a segment and a point is a point.** Bare `top` is the whole side; `top-center` is the one point halfway along it, which is why every midpoint carries `-center`. Naming a side leaves the other axis free. On any shape but a box, a point is where the line from the shape's middle toward that point of its box meets the outline ([Sides](#the-body)): on a circle a corner is halfway between its two sides.
328
362
 
329
363
  A part is written as the name, then the part: `b top`, `e bottom-left`, `hub text`.
330
364
 
@@ -588,6 +622,7 @@ node aside "Written nightly." (size: small, wrap: 30) shape: none
588
622
  | `size` | `small`, `normal` or `large`; a multiple of normal (`1.2`); or pixels (`16px`) |
589
623
  | `wrap` | a count of characters to fold at, on top of ` / ` |
590
624
  | `align` | how the text's own lines range: `left`, `center`, `right`, or where along a side for a title (below) |
625
+ | `fill` | on a `flow-collate` only, the patch behind its text: a color, or `none` |
591
626
 
592
627
  `small` is 21/25 of normal and `large` the same step the other way. The names are preferred: bundled into a style, a size says *why* one text is smaller. `wrap:` is a character count, not a distance: it is how a text is made narrow and tall to sit beside something.
593
628
 
@@ -1127,6 +1162,59 @@ reladraw check diagram.reladraw
1127
1162
 
1128
1163
  The output is a standalone SVG: every picture is drawn into it as path data, never from a font or a linked file. A web page can draw a diagram in place with `<reladraw-diagram>`; see "In a web page" in the README.
1129
1164
 
1165
+ ### Formatting
1166
+
1167
+ ```
1168
+ reladraw format diagram.reladraw other.reladraw
1169
+ reladraw format --check diagram.reladraw
1170
+ reladraw format - < diagram.reladraw
1171
+ reladraw format --init
1172
+ ```
1173
+
1174
+ `format` lays each file out again in place. By default every statement is on one line, and neighboring statements with the same keyword line their clauses up in columns, each as wide as its longest clause plus two spaces; a blank line or a new keyword starts a new set of columns, and a comment line between them doesn't. A comment at the end of a line lines up with the others in its set. A statement written over several lines with a comment among them keeps its lines, with the indent tidied. Blank lines stay as written, and so do Windows line endings.
1175
+
1176
+ Inside a clause, spacing comes out as these pages write it, whatever was typed: one space after a key's colon and between words, none just inside a bracket, and a comma against the word before it, so `line:( corners:rounded , path:square )` becomes `line: (corners: rounded, path: square)`.
1177
+
1178
+ Only whitespace changes, and the order of clauses when `clause-order` asks: every word is copied as it was written, and the result is checked to draw exactly what the original did. A file that doesn't parse is reported and left alone, and the other files still run. `--check` changes nothing, prints each file that would change, and fails if there is one, for a pre-commit hook or CI. A shell expands `*.reladraw` into the files; folders aren't searched.
1179
+
1180
+ `-` in place of the files reads the diagram from standard input and prints it formatted, touching no file. That is how an editor formats a buffer, saved or not: Vim's `:%!reladraw format -`, or the command a format-on-save package runs. Its settings come from the nearest `.reladraw-format` to the current folder.
1181
+
1182
+ The settings are in the nearest `.reladraw-format`, in the file's folder or a folder above it; with none, the defaults. A project that puts one at its top keeps everyone's formatting the same. `reladraw format --init` writes one in the current folder with every setting at its default and a comment on what each value does, and refuses if one is there already. One setting a line, as in a diagram:
1183
+
1184
+ ```
1185
+ // keep each clause straight after the last
1186
+ columns: off
1187
+ clause-gap: 2
1188
+ ```
1189
+
1190
+ | setting | values, default first |
1191
+ |---|---|
1192
+ | `wrap-at` | `off`, or a number of characters: past it, a statement wraps onto as few lines as fit |
1193
+ | `clause-per-line-at` | `off`, a number of characters, or `always`: past it, every clause gets its own line |
1194
+ | `first-line` | `text` (keyword, name and texts), `name`: where a broken statement's first line ends |
1195
+ | `clause-order` | `as-written`, or a list of kinds: `placement`, and keys by name, such as `style, placement, fill` |
1196
+ | `columns` | `aligned`, `off` |
1197
+ | `columns-shared-by` | `same-keyword-neighbors`, `neighbors`, `same-keyword` (across blank lines), `file` |
1198
+ | `clause-gap` | a number of spaces, `2`; or `tab`, with `columns: off` only |
1199
+ | `trailing-comments` | `shared-column`, `after-statement` |
1200
+ | `comments-inside` | `keep-lines`, `move-above` (joined, comments above it), `refuse` (left alone, comment named) |
1201
+ | `continued-lines-indent` | where a broken or kept statement's later lines start: `4` or another number, `tab`, `under-name`, `under-name + 2`, `under-text` (under the name when there's no text) |
1202
+ | `blank-lines` | `as-written`, `exactly 2`, `at-most 3` |
1203
+ | `edge-name-and-ends` | `one-column`, `own-columns` (name, each end and the arrow a column each) |
1204
+
1205
+ An unknown setting, one written twice, or a value it doesn't take is an error naming it.
1206
+
1207
+ **Breaking a long statement.** With both at a number, a statement up to `wrap-at` stays on one line, one up to `clause-per-line-at` wraps, and a longer one goes one clause to a line. Its length is measured on its own, before columns, so whether it breaks never depends on its neighbors; its first line still lines up with theirs. A break falls only between clauses, never inside one or inside a bracket, so a clause longer than the width stands alone on its line, past it. A statement broken by hand is broken again by these rules, or joined when they leave it short enough; one kept on its lines for a comment among them is left as it is. A comment at the end of a broken statement follows its last line.
1208
+
1209
+ ```
1210
+ // wrap-at: 60
1211
+ node confirm "Confirm"
1212
+ below accept (gap: tight) left of accept (gap: wide)
1213
+ style: plain
1214
+ ```
1215
+
1216
+ **Ordering clauses.** `clause-order` lists the kinds of clause in the order they are written: `placement` for every relation, and a key by its name (`style`, `fill`, `via`). Kinds it leaves out follow, in the order written, and clauses of one kind keep theirs. A node's or edge's texts never move, since the language takes them only straight after the name; `text` in the list means a style's `text:` key. A clause never moves where moving it would change what the statement says.
1217
+
1130
1218
  ## Attributes
1131
1219
 
1132
1220
  Every attribute, and what takes one. A node's kind is what its body is: a box (`shape:`), a picture (`icon:`) or bare text (`shape: none`).
@@ -1139,7 +1227,7 @@ Every attribute, and what takes one. A node's kind is what its body is: a box (`
1139
1227
  | `badge` | ✓ | | | | a picture joined to the text |
1140
1228
  | `cards` | ✓ | | | | a deck's cards, when they are only texts, front first |
1141
1229
  | `fill`, `border` | ✓ | | | | colors |
1142
- | `corners` | ✓ | | | | `sharp` or `rounded`; not on a circle. A line's go in `line: (…)` |
1230
+ | `corners` | ✓ | | | | `sharp` or `rounded`; only on a box's outline. A line's go in `line: (…)` |
1143
1231
  | `text` | ✓ | ✓ | ✓ | ✓ | in a style only; on a statement, the bracket after the text |
1144
1232
  | `layout` | ✓ | | ✓ | | how the children are arranged |
1145
1233
  | `gather-children` | ✓ | | ✓ | | where the children sit in spare room |
@@ -1193,6 +1281,21 @@ Designed and absent from the code.
1193
1281
 
1194
1282
  Pre-1.0, so the minor number is where a breaking change goes. Every removal is refused by name with the replacement quoted, rather than dropped in silence: an older file stops with an error saying what to write instead.
1195
1283
 
1284
+ **0.19.0**
1285
+
1286
+ - **`reladraw format`** lays a file out again: each statement on one line, neighboring statements with the same keyword lined up in columns, comments kept beside what they are about. No word is respelled, and the result is checked to draw what the original did. `--check` fails on a file that would change, and `.reladraw-format` holds a project's settings; `reladraw format --init` writes one with every setting explained. `reladraw format -` reads standard input and prints the result, for editors. A long statement can wrap past a width (`wrap-at`) or go one clause to a line (`clause-per-line-at`), and `clause-order` puts placements and keys in a chosen order. The playground has a Format button (Shift+Alt+F, undone with Ctrl+Z) and a panel for its settings, which can copy them as a `.reladraw-format`. Spacing inside a clause is evened out too: `gap:tight` becomes `gap: tight`, `( gap: tight )` becomes `(gap: tight)`. Prompted by #14, where a long statement was hard to read in a plain editor.
1287
+ - **A comma may touch the next word or stand apart from both.** `(corners: rounded,path: square)` used to be refused with "`corners` has no value", and `(at: start , above edge)` was refused in a text's bracket; both now read as `rounded, path:` and `start, above` do.
1288
+ - **The playground opens and saves `.reladraw` files**, with Open… and Save… above the source. Save suggests the name of the file last opened or saved; Chrome and Edge ask where to put it, other browsers save it with the downloads. Opening a file over edits that haven't been saved to one asks first.
1289
+ - **The playground's error message can be copied**, with a Copy button beside it or by selecting it. Only the underlined line number jumps to the line now; a click anywhere on the message used to, which took a selection away.
1290
+ - **The playground no longer fails on some diagrams with 0.18.0's shapes**, which stopped with "insidePolygon is not defined". The command line was unaffected.
1291
+ - **The agent skill checks it matches the installed tool.** It compares its own version with `reladraw --version` and tells the user which of the two to update.
1292
+
1293
+ **0.18.0**
1294
+
1295
+ - **More shapes, for flowcharts and the rest** (#18): `diamond`, `pill`, `parallelogram`, `hexagon`, `trapezoid`, `triangle` and `cylinder`, named for how they look, and the flowchart standard's shapes that have no geometric name as `flow-` and what they mean, from `flow-subprocess` and `flow-delay` to `flow-collate`. A shape's settings go in a bracket after its name: `shape: parallelogram (slant: 2, lean: left)`, `shape: cylinder (lying)`, `shape: document (fold: 1.5)`.
1296
+ - **A shape grows to hold its text at its middle**, and a stated `width:` and `height:` stretch it. Its sides are the stretches of outline round its points, so `from: right` leaves a diamond from its right point.
1297
+ - **A circle given a `width:` and a `height:` that differ is an ellipse.** It used to stay round and say nothing about the height.
1298
+
1196
1299
  **0.17.1**
1197
1300
 
1198
1301
  - **`reladraw --version`**, or `-v`, prints the version.
package/dist/checks.js CHANGED
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import { isWithin, joinedToText } from './model.js';
11
11
  import { ARROW_MARKER_WIDTH, LINE_WIDTH, UNIT, arrowLength } from './constants.js';
12
- import { crossingPoints, describeEdge, planSides, traceRoute, traceVisible } from './lines.js';
12
+ import { centerOf, crossingPoints, describeEdge, faceOfNode, planSides, traceRoute, traceVisible } from './lines.js';
13
+ import { clearOf, insideOutline, sizeFor } from './outline.js';
13
14
  import { placeLineTexts } from './linetexts.js';
14
15
  /**
15
16
  * The kinds of finding, each with whether it is on when nothing says. Named in
@@ -135,8 +136,8 @@ function edgeNodeOverlaps(layout, routes) {
135
136
  if (holdsEnd(node, edge))
136
137
  continue;
137
138
  const inner = { x: node.x + 1, y: node.y + 1, width: node.width - 2, height: node.height - 2 };
138
- const round = node.body.kind === 'shape' && node.body.outline === 'circle';
139
- const through = round ? crossingCircle(trace, node) : crossing(trace, inner);
139
+ const face = faceOfNode(node);
140
+ const through = face.round || face.outline ? crossingCircle(trace, node, face) : crossing(trace, inner);
140
141
  if (through === undefined)
141
142
  continue;
142
143
  const own = node === edge.from || node === edge.to;
@@ -249,14 +250,18 @@ function crossing(trace, box) {
249
250
  return { enters, leaves };
250
251
  }
251
252
  /**
252
- * The same for a circle, which is drawn in its node's square: walked a couple
253
- * of pixels at a time, a point counting as inside only past a pixel within the
254
- * outline, so a line ending on the circle doesn't cross it.
253
+ * The same for a circle, or any outline but a rectangle's, which is drawn in
254
+ * its node's box: walked a couple of pixels at a time, a point counting as
255
+ * inside only past a pixel within the outline, so a line ending on it doesn't
256
+ * cross it.
255
257
  */
256
- function crossingCircle(trace, node) {
258
+ function crossingCircle(trace, node, face) {
257
259
  const radius = node.width / 2 - 1;
258
- const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
259
- const inside = (p) => Math.hypot(p.x - center.x, p.y - center.y) < radius;
260
+ const center = centerOf(face);
261
+ const polygon = face.outline;
262
+ const inside = polygon
263
+ ? (p) => insideOutline(polygon, p) && clearOf(polygon, p, 1)
264
+ : (p) => Math.hypot(p.x - center.x, p.y - center.y) < radius;
260
265
  const sideAt = (p) => {
261
266
  const dx = p.x - center.x;
262
267
  const dy = p.y - center.y;
@@ -564,6 +569,22 @@ function textOverflows(layout) {
564
569
  const place = node.textAttrs['place'];
565
570
  if (place !== undefined && place !== 'center' && !place.startsWith('inside'))
566
571
  continue;
572
+ const face = faceOfNode(node);
573
+ if (face.outline && node.body.outline !== 'flow-collate') {
574
+ // Any other outline than a box's: the text spills where it passes the outline.
575
+ const { outline, look } = node.body;
576
+ const corners = [0, 0.5, 1].flatMap((fx) => [0, 0.5, 1].map((fy) => ({ x: node.x + text.x + text.width * fx, y: node.y + text.y + text.height * fy })));
577
+ if (corners.every((p) => insideOutline(face.outline, p)))
578
+ continue;
579
+ const needs = sizeFor(outline, look, text.width, text.height);
580
+ const wider = Math.max(0, needs.width - node.width);
581
+ const taller = Math.max(0, needs.height - node.height);
582
+ const more = [wider > EPSILON ? `${units(wider)} more width` : '', taller > EPSILON ? `${units(taller)} more height` : ''].filter((part) => part.length > 0);
583
+ found.push(new Finding('text-overflow', `the text of node ${node.name} runs past the outline of its ${outline}` + (more.length > 0 ? `; it needs ${more.join(' and ')}` : ''), node.line));
584
+ continue;
585
+ }
586
+ if (node.body.outline === 'flow-collate')
587
+ continue;
567
588
  const spill = {
568
589
  left: Math.max(0, -text.x),
569
590
  right: Math.max(0, text.x + text.width - node.width),
package/dist/cli.js CHANGED
@@ -4,17 +4,32 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { dirname, resolve as resolvePath } from 'node:path';
5
5
  import { nearMatch, readChecks } from './checks.js';
6
6
  import { SourceError } from './errors.js';
7
+ import { format } from './format.js';
8
+ import { DEFAULT_FORMAT, readFormatSettings, SETTINGS_FILE, settingsFileText } from './format-settings.js';
7
9
  import { draw } from './index.js';
8
10
  import { THEME_NAMES, THEMES } from './themes.js';
9
11
  const USAGE = `reladraw — draw a diagram from stated placement
10
12
 
11
13
  reladraw render <input.reladraw> [-o <output.svg>] [options]
12
14
  reladraw check <input.reladraw> [options]
15
+ reladraw format <input.reladraw>... [--check]
16
+ reladraw format - [--check]
17
+ reladraw format --init
13
18
 
14
19
  render draw the diagram to an SVG, and print any findings: places
15
20
  where the picture, drawn as written, may not read as meant.
16
21
  check lay the diagram out and print only the findings. No SVG is
17
22
  written, so a good one is left alone while you iterate.
23
+ format lay each file out again in place: spacing, columns, comments
24
+ and blank lines. No word is respelled, and a file that
25
+ doesn't parse is left alone. The nearest .reladraw-format, in
26
+ the file's folder or one above it, holds the settings.
27
+ --check changes nothing, and fails if a file would change.
28
+ - reads the diagram from standard input and prints it
29
+ formatted, for an editor; settings come from the current
30
+ folder up.
31
+ --init writes a .reladraw-format here, every setting at its
32
+ default with what it does.
18
33
 
19
34
  -o, --out where render writes the SVG. Defaults to the input path with
20
35
  its extension replaced by .svg. Use - for standard output.
@@ -29,7 +44,7 @@ const USAGE = `reladraw — draw a diagram from stated placement
29
44
  print the version.
30
45
  -h, --help print this.
31
46
  `;
32
- const COMMANDS = ['render', 'check'];
47
+ const COMMANDS = ['render', 'check', 'format'];
33
48
  async function main(argv) {
34
49
  if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
35
50
  process.stdout.write(USAGE);
@@ -56,6 +71,8 @@ async function main(argv) {
56
71
  return 1;
57
72
  }
58
73
  const command = verb;
74
+ if (command === 'format')
75
+ return formatFiles(argv.slice(1));
59
76
  let input;
60
77
  let out;
61
78
  let theme;
@@ -172,6 +189,176 @@ async function main(argv) {
172
189
  process.stderr.write(`${target}\n`);
173
190
  return 0;
174
191
  }
192
+ /**
193
+ * `reladraw format`: each file laid out again in place, by the settings in
194
+ * the nearest `.reladraw-format`. A file that fails is reported and the rest
195
+ * still run, so one broken file doesn't hide the state of the others.
196
+ */
197
+ async function formatFiles(args) {
198
+ const files = [];
199
+ let check = false;
200
+ let init = false;
201
+ for (const arg of args) {
202
+ if (arg === '--check')
203
+ check = true;
204
+ else if (arg === '--init')
205
+ init = true;
206
+ else if (arg.startsWith('-') && arg !== '-') {
207
+ process.stderr.write(`reladraw: format takes --check and --init, not ${arg}\n`);
208
+ return 1;
209
+ }
210
+ else
211
+ files.push(arg);
212
+ }
213
+ if (init) {
214
+ if (check || files.length > 0) {
215
+ process.stderr.write('reladraw: --init writes a settings file and formats nothing — run reladraw format --init on its own\n');
216
+ return 1;
217
+ }
218
+ if (existsSync(SETTINGS_FILE)) {
219
+ process.stderr.write(`reladraw: there is already a ${SETTINGS_FILE} here — delete it first to write a fresh one\n`);
220
+ return 1;
221
+ }
222
+ await writeFile(SETTINGS_FILE, settingsFileText(), 'utf8');
223
+ process.stderr.write(`${resolvePath(SETTINGS_FILE)}\n`);
224
+ return 0;
225
+ }
226
+ if (files.length === 0) {
227
+ process.stderr.write('reladraw: no input file — reladraw format <input.reladraw>..., or - to read standard input\n');
228
+ return 1;
229
+ }
230
+ if (files.includes('-'))
231
+ return formatStandardInput(files, check);
232
+ let status = 0;
233
+ const found = new Map();
234
+ for (const file of files) {
235
+ let settings = DEFAULT_FORMAT;
236
+ try {
237
+ const nearest = nearestSettings(dirname(resolvePath(file)), found);
238
+ if (nearest)
239
+ settings = nearest.settings;
240
+ }
241
+ catch (error) {
242
+ if (!(error instanceof SettingsError))
243
+ throw error;
244
+ process.stderr.write(`${error.cause.format(error.path)}\n`);
245
+ status = 1;
246
+ continue;
247
+ }
248
+ let source;
249
+ try {
250
+ source = await readFile(file, 'utf8');
251
+ }
252
+ catch {
253
+ process.stderr.write(`reladraw: can't read ${file}\n`);
254
+ status = 1;
255
+ continue;
256
+ }
257
+ let laid;
258
+ try {
259
+ laid = format(source, settings);
260
+ }
261
+ catch (error) {
262
+ process.stderr.write(error instanceof SourceError ? `${error.format(file)}\n` : `reladraw: ${file}: ${error.message}\n`);
263
+ status = 1;
264
+ continue;
265
+ }
266
+ if (laid === source)
267
+ continue;
268
+ if (check) {
269
+ process.stderr.write(`${file}: not formatted — reladraw format ${file} lays it out\n`);
270
+ status = 1;
271
+ continue;
272
+ }
273
+ await writeFile(file, laid, 'utf8');
274
+ process.stderr.write(`formatted ${file}\n`);
275
+ }
276
+ return status;
277
+ }
278
+ /**
279
+ * `reladraw format -`: the diagram read from standard input and printed
280
+ * formatted, which is how an editor formats a buffer it hasn't saved. The
281
+ * settings are the nearest `.reladraw-format` from the current folder. With
282
+ * `--check` nothing is printed, and it fails if the text would change.
283
+ */
284
+ async function formatStandardInput(files, check) {
285
+ if (files.length > 1) {
286
+ process.stderr.write('reladraw: - reads standard input, so it goes on its own — reladraw format -\n');
287
+ return 1;
288
+ }
289
+ if (process.stdin.isTTY) {
290
+ process.stderr.write('reladraw: format - reads a diagram from standard input — pipe one in: reladraw format - < diagram.reladraw\n');
291
+ return 1;
292
+ }
293
+ let settings = DEFAULT_FORMAT;
294
+ try {
295
+ settings = nearestSettings(process.cwd(), new Map())?.settings ?? DEFAULT_FORMAT;
296
+ }
297
+ catch (error) {
298
+ if (!(error instanceof SettingsError))
299
+ throw error;
300
+ process.stderr.write(`${error.cause.format(error.path)}\n`);
301
+ return 1;
302
+ }
303
+ const chunks = [];
304
+ for await (const chunk of process.stdin)
305
+ chunks.push(chunk);
306
+ const source = Buffer.concat(chunks).toString('utf8');
307
+ let laid;
308
+ try {
309
+ laid = format(source, settings);
310
+ }
311
+ catch (error) {
312
+ process.stderr.write(error instanceof SourceError ? `${error.format('<stdin>')}\n` : `reladraw: <stdin>: ${error.message}\n`);
313
+ return 1;
314
+ }
315
+ if (check) {
316
+ if (laid === source)
317
+ return 0;
318
+ process.stderr.write('<stdin>: not formatted\n');
319
+ return 1;
320
+ }
321
+ process.stdout.write(laid);
322
+ return 0;
323
+ }
324
+ /** A `.reladraw-format` that doesn't read, and where it is. */
325
+ class SettingsError extends Error {
326
+ path;
327
+ cause;
328
+ constructor(path, cause) {
329
+ super(cause.message);
330
+ this.path = path;
331
+ this.cause = cause;
332
+ }
333
+ }
334
+ /**
335
+ * The settings in the nearest `.reladraw-format`, looking in `folder` and
336
+ * then each folder above it, or nothing when there is none. Each folder is
337
+ * looked in once however many files share it.
338
+ */
339
+ function nearestSettings(folder, found) {
340
+ const known = found.get(folder);
341
+ if (known !== undefined)
342
+ return known;
343
+ const path = resolvePath(folder, SETTINGS_FILE);
344
+ let result;
345
+ if (existsSync(path)) {
346
+ try {
347
+ result = { path, settings: readFormatSettings(readFileSync(path, 'utf8')) };
348
+ }
349
+ catch (error) {
350
+ if (error instanceof SourceError)
351
+ throw new SettingsError(path, error);
352
+ throw error;
353
+ }
354
+ }
355
+ else {
356
+ const parent = dirname(folder);
357
+ result = parent === folder ? null : nearestSettings(parent, found);
358
+ }
359
+ found.set(folder, result);
360
+ return result;
361
+ }
175
362
  main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
176
363
  process.stderr.write(`reladraw: ${error instanceof Error ? error.message : String(error)}\n`);
177
364
  process.exit(1);
@@ -0,0 +1,60 @@
1
+ /**
2
+ * How `reladraw format` lays a file out. Each key is a setting as it is
3
+ * written in a `.reladraw-format` file, so an error, the generated file and
4
+ * the code all use one name for each.
5
+ */
6
+ export interface FormatSettings {
7
+ 'wrap-at': 'off' | number;
8
+ 'clause-per-line-at': 'off' | 'always' | number;
9
+ 'first-line': 'text' | 'name';
10
+ 'continued-lines-indent': number | 'tab' | 'under-name' | 'under-text' | {
11
+ underName: number;
12
+ };
13
+ 'clause-order': 'as-written' | string[];
14
+ 'clause-gap': number | 'tab';
15
+ columns: 'aligned' | 'off';
16
+ 'columns-shared-by': 'same-keyword-neighbors' | 'neighbors' | 'same-keyword' | 'file';
17
+ 'comments-inside': 'keep-lines' | 'move-above' | 'refuse';
18
+ 'trailing-comments': 'shared-column' | 'after-statement';
19
+ 'blank-lines': 'as-written' | {
20
+ exactly: number;
21
+ } | {
22
+ atMost: number;
23
+ };
24
+ 'edge-name-and-ends': 'one-column' | 'own-columns';
25
+ }
26
+ /** One setting: what it controls, its values in words, and how to read one. */
27
+ interface Setting {
28
+ name: keyof FormatSettings;
29
+ /** One sentence for the generated file: what the setting controls. */
30
+ about: string;
31
+ /** Each value as written, and what it means. The first is the default. */
32
+ values: [string, string][];
33
+ /** The default, as written. */
34
+ written: string;
35
+ /** The value as written, read; a string thrown is the reason it is refused. */
36
+ read: (value: string) => FormatSettings[keyof FormatSettings];
37
+ }
38
+ /**
39
+ * The kinds of clause `clause-order` can list: a statement's texts, its
40
+ * placements, and each key by its own name. `text` is also a style's `text:`
41
+ * key, which is a style's way of saying what a text says.
42
+ */
43
+ export declare const CLAUSE_KINDS: readonly string[];
44
+ export declare const SETTINGS: Setting[];
45
+ export declare const DEFAULT_FORMAT: FormatSettings;
46
+ /** The file a project's settings are written in. */
47
+ export declare const SETTINGS_FILE = ".reladraw-format";
48
+ /**
49
+ * Read a `.reladraw-format` file: one `key: value` setting a line, `//`
50
+ * comments, blank lines. An unknown setting, one written twice, or a value it
51
+ * doesn't take is refused by name, with the line.
52
+ */
53
+ export declare function readFormatSettings(text: string): FormatSettings;
54
+ /**
55
+ * The `.reladraw-format` that `reladraw format --init` writes: every setting
56
+ * at its default, each under a comment saying what it controls and what each
57
+ * of its values means.
58
+ */
59
+ export declare function settingsFileText(): string;
60
+ export {};