reladraw 0.15.1 → 0.16.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
@@ -82,7 +82,7 @@ That gets you a copy of the skill at the time you run it, so you'll need to re-r
82
82
 
83
83
  ## Status
84
84
 
85
- Version 0.15.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.
85
+ Version 0.16.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.
86
86
 
87
87
  The language isn't stable yet, so expect the syntax to change.
88
88
 
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.15.1
1
+ # Syntax reference — 0.16.0
2
2
 
3
3
  What the language accepts. The parser, resolver and SVG renderer implement all of it; the sections at the end record what is defective, unchecked or undecided.
4
4
 
@@ -14,7 +14,7 @@ node desktop1 "Desktop 1 / Linux"
14
14
  badge: desktop style: synced
15
15
  ```
16
16
 
17
- A line that starts with whitespace continues the statement above it, and a blank line or the next unindented line ends it. How much whitespace makes no difference — one space and a tab mean the same — so nothing can depend on indentation you cannot see. A bracket may stay open across lines, as in `line: (color: red,` with `pattern: dashed)` on the next. There are no blocks: indentation continues a statement, and never puts one thing inside another.
17
+ A line that starts with whitespace continues the statement above it, and a blank line or the next unindented line ends it. How much whitespace makes no difference — one space and a tab mean the same — so nothing can depend on indentation you cannot see. None after a key's colon is an amount too: `gap:tight` is `gap: tight`. A bracket may stay open across lines, as in `line: (color: red,` with `pattern: dashed)` on the next. There are no blocks: indentation continues a statement, and never puts one thing inside another.
18
18
 
19
19
  An indented line with no statement to continue — at the top of the file, or after a blank line or an unindented comment — is an error rather than a statement of its own, and so is an indented line starting with a statement keyword such as `node`. Both are almost always a stray indent, and both say to remove it.
20
20
 
@@ -43,7 +43,7 @@ Attribute values are a single bare word unless quoted. No commas between attribu
43
43
  node <name> ["<text>" [(<text properties>)]] [<placement> | <attribute>] ...
44
44
  ```
45
45
 
46
- `name` identifies the node and must be unique. `text` is what appears inside it, with ` / ` — a slash with whitespace on both sides — marking a line break.
46
+ `name` identifies the node and must be unique. It may not contain `:`, which would make it a key, or `;`, which is reserved. `text` is what appears inside it, with ` / ` — a slash with whitespace on both sides — marking a line break.
47
47
 
48
48
  The text is optional, and a node without it takes its own name as its text:
49
49
 
@@ -1140,6 +1140,14 @@ That one was found by testing the lexer, not by rendering — and it could not h
1140
1140
 
1141
1141
  Pre-1.0, so the minor number is where a breaking change goes. Every removal below 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.
1142
1142
 
1143
+ **0.16.0**
1144
+
1145
+ A name containing `:` or `;` is now refused; nothing else that worked is. No example diagram draws differently.
1146
+
1147
+ - A key may be written against its value: `gap:tight` is `gap: tight`, and `(gap:wide)` and `line:(color:red)` work too. The amount of whitespace never mattered, and now none counts as an amount.
1148
+ - Breaking: a name may no longer contain `:` or `;`. A colon now makes the word before it a key, and a semicolon is reserved. `node a:b` used to declare a node called `a:b`, and is now refused with an error saying why.
1149
+ - `right of a, gap: tight` names the comma as the mistake. A comma after a target promises another, and the error used to blame `tight`, two words further on.
1150
+
1143
1151
  **0.15.1**
1144
1152
 
1145
1153
  No syntax changes, and no file that worked is refused. No example diagram draws differently.
package/dist/lexer.d.ts CHANGED
@@ -5,6 +5,12 @@ export interface Token {
5
5
  quoted: boolean;
6
6
  /** True when the string was written between `"""` marks. */
7
7
  triple?: boolean;
8
+ /**
9
+ * The whole word as written, on a key split from its value: `gap:tight` lexes
10
+ * as `gap:` and `tight`, and this is `gap:tight` on the first. Kept so an error
11
+ * about the word can quote what the author typed.
12
+ */
13
+ joined?: string;
8
14
  }
9
15
  /**
10
16
  * A `"""` string still open at the end of the text given. `parse` catches this
@@ -37,5 +43,11 @@ export declare class OpenString extends SourceError {
37
43
  * Returns an empty array for a blank or comment-only line.
38
44
  */
39
45
  export declare function tokenizeLine(line: string, lineNumber: number, depth?: number): Token[];
46
+ /**
47
+ * The character in a word that a name may not hold, if any. A colon makes the
48
+ * word a key, written with or without its space, and `;` is kept free so it
49
+ * could one day separate clauses without changing what any file means.
50
+ */
51
+ export declare function nameMark(token: Token): ':' | ';' | undefined;
40
52
  /** A bare token ending in `:` opens the attribute section of a statement. */
41
53
  export declare function isAttrKey(token: Token): boolean;
package/dist/lexer.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import { SourceError } from './errors.js';
2
+ /**
3
+ * A key written against its value. The key part is spelled as the highlighter's
4
+ * attribute pattern spells one, and a value opening with a slash is left whole,
5
+ * so a file name such as `C:/icons/disk.svg` is not torn at the drive letter.
6
+ */
7
+ const JOINED_KEY = /^([A-Za-z][A-Za-z0-9_-]*:)([^/\\].*)$/;
2
8
  /**
3
9
  * A `"""` string still open at the end of the text given. `parse` catches this
4
10
  * one, adds the next line and tries again, which is how a string runs over
@@ -93,6 +99,7 @@ export function tokenizeLine(line, lineNumber, depth = 0) {
93
99
  tokens.push({ text, quoted: true });
94
100
  continue;
95
101
  }
102
+ const start = i;
96
103
  let text = '';
97
104
  while (i < line.length) {
98
105
  const c = line[i];
@@ -105,10 +112,37 @@ export function tokenizeLine(line, lineNumber, depth = 0) {
105
112
  text += c;
106
113
  i += 1;
107
114
  }
115
+ // `gap:tight` is `gap: tight` with no space, since how much whitespace there
116
+ // is never matters. A word straight after a key is its value and is left
117
+ // whole, so `icon: a:b` still names an icon called `a:b`. The rest of the
118
+ // word is read again from just past the colon rather than kept as one
119
+ // token, so `line:(color:red)` opens its bracket as `line: (color: red)` does.
120
+ const previous = tokens[tokens.length - 1];
121
+ const joined = previous && isAttrKey(previous) ? null : JOINED_KEY.exec(text);
122
+ if (joined) {
123
+ tokens.push({ text: joined[1], quoted: false, joined: text });
124
+ i = start + joined[1].length;
125
+ continue;
126
+ }
108
127
  tokens.push({ text, quoted: false });
109
128
  }
110
129
  return tokens;
111
130
  }
131
+ /**
132
+ * The character in a word that a name may not hold, if any. A colon makes the
133
+ * word a key, written with or without its space, and `;` is kept free so it
134
+ * could one day separate clauses without changing what any file means.
135
+ */
136
+ export function nameMark(token) {
137
+ const written = token.joined ?? token.text;
138
+ if (token.quoted)
139
+ return undefined;
140
+ if (written.includes(':'))
141
+ return ':';
142
+ if (written.includes(';'))
143
+ return ';';
144
+ return undefined;
145
+ }
112
146
  /** A bare token ending in `:` opens the attribute section of a statement. */
113
147
  export function isAttrKey(token) {
114
148
  return !token.quoted && token.text.length > 1 && token.text.endsWith(':');
package/dist/parser.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { STATEMENT_KEYWORDS } from './grammar.js';
4
- import { isAttrKey, OpenString, tokenizeLine } from './lexer.js';
4
+ import { isAttrKey, nameMark, OpenString, tokenizeLine } from './lexer.js';
5
5
  import { THEME_COLORS, THEME_NAMES, THEMES } from './themes.js';
6
6
  /**
7
7
  * Parse a whole source file. A statement starts on an unindented line and runs
@@ -645,6 +645,7 @@ function parseEdge(head, line) {
645
645
  if (!rightToken || rightToken.quoted) {
646
646
  throw new SourceError('an edge needs a node on the right of the arrow', line);
647
647
  }
648
+ refuseNameMark(rightToken, `edge ${left} ${arrow.text} ${rightToken.joined ?? rightToken.text}`, line);
648
649
  let at = arrow.next + 1;
649
650
  const stray = head[at];
650
651
  if (stray &&
@@ -857,8 +858,23 @@ function requireName(token, keyword, line) {
857
858
  if (!token || token.quoted) {
858
859
  throw new SourceError(`${keyword} needs a name`, line);
859
860
  }
861
+ refuseNameMark(token, `${keyword} "${token.joined ?? token.text}"`, line);
860
862
  return token.text;
861
863
  }
864
+ /**
865
+ * A word with a colon or a semicolon in it, where a name belongs. Both used to
866
+ * be ordinary characters in a name, so this refuses files that once parsed, and
867
+ * says why rather than reporting whatever the split word turned into next.
868
+ */
869
+ function refuseNameMark(token, where, line) {
870
+ const mark = nameMark(token);
871
+ if (mark === undefined)
872
+ return;
873
+ const reason = mark === ':'
874
+ ? 'a colon makes the word before it a key, with or without a space after it'
875
+ : 'a semicolon is reserved';
876
+ throw new SourceError(`${where}: a name may not contain "${mark}", because ${reason}`, line);
877
+ }
862
878
  /**
863
879
  * Read one placement. A direction and a target (`right of docker`, `below
864
880
  * deploy`), an alignment (`level with docker`), or an overlay (`on hub at
@@ -920,6 +936,11 @@ function readPlacement(tokens, at, line, subject) {
920
936
  `${word.text}\` to straddle it, or a direction like ${DIRECTIONS.join(', ')} to put ` +
921
937
  'it outside', line);
922
938
  }
939
+ // A semicolon is reserved rather than ordinary, and someone writing one
940
+ // between clauses wants to know that spaces already do that job.
941
+ if (word.text.startsWith(';')) {
942
+ throw new SourceError(`${subject}: ";" separates nothing — clauses are separated by spaces alone`, line);
943
+ }
923
944
  throw new SourceError(`${subject}: "${word.text}" is not a direction`, line);
924
945
  }
925
946
  let next = at + 1;
@@ -1130,11 +1151,23 @@ function isSideWord(word) {
1130
1151
  function readTargets(tokens, start, subject, placement, line, partFirst = false) {
1131
1152
  const targets = [];
1132
1153
  let i = start;
1154
+ /** The target just closed with a comma, which promises another. */
1155
+ let comma;
1133
1156
  for (;;) {
1134
1157
  const token = tokens[i];
1135
- if (!token || token.quoted || token.text === '(' || token.text === ')') {
1158
+ // `right of a, gap: tight`: the comma, not the key, is the mistake, and
1159
+ // reading on would take `gap:` as a name and blame the word after it.
1160
+ if (comma !== undefined && (!token || isAttrKey(token) || token.text === '(' || token.text === ')')) {
1161
+ const next = token ? `, but "${token.joined ?? token.text}" ${isAttrKey(token) ? 'is a key' : 'is not a name'}` : '';
1162
+ throw new SourceError(`${subject}: the comma after "${comma}" starts another target${next} — remove the comma`, line);
1163
+ }
1164
+ // A key written with its space, as in `right of gap: tight`, is a target
1165
+ // left out. One written without, `right of a:b`, is more likely a name with
1166
+ // a colon in it, and that gets its own message below.
1167
+ if (!token || token.quoted || token.text === '(' || token.text === ')' || (isAttrKey(token) && !token.joined)) {
1136
1168
  throw new SourceError(`${subject}: "${placement}" names no node`, line);
1137
1169
  }
1170
+ refuseNameMark(token, `${subject}: "${token.joined ?? token.text}"`, line);
1138
1171
  const listed = token.text.endsWith(',') && token.text.length > 1;
1139
1172
  const name = listed ? token.text.slice(0, -1) : token.text;
1140
1173
  i += 1;
@@ -1148,6 +1181,7 @@ function readTargets(tokens, start, subject, placement, line, partFirst = false)
1148
1181
  i += 1;
1149
1182
  continue;
1150
1183
  }
1184
+ comma = listed ? name : undefined;
1151
1185
  if (listed)
1152
1186
  continue;
1153
1187
  return { targets, next: i };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {