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 +1 -1
- package/SYNTAX.md +11 -3
- package/dist/lexer.d.ts +12 -0
- package/dist/lexer.js +34 -0
- package/dist/parser.js +36 -2
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
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 };
|