reladraw 0.0.1 → 0.1.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/LICENSE +202 -0
- package/NOTICE +14 -0
- package/README.md +133 -2
- package/SYNTAX.md +534 -0
- package/dist/ast.d.ts +160 -0
- package/dist/ast.js +78 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +72 -0
- package/dist/constants.d.ts +142 -0
- package/dist/constants.js +187 -0
- package/dist/constrain.d.ts +56 -0
- package/dist/constrain.js +95 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +14 -0
- package/dist/grammar.d.ts +103 -0
- package/dist/grammar.js +215 -0
- package/dist/icons.d.ts +86 -0
- package/dist/icons.js +166 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +16 -0
- package/dist/lexer.d.ts +25 -0
- package/dist/lexer.js +91 -0
- package/dist/measure.d.ts +38 -0
- package/dist/measure.js +66 -0
- package/dist/model.d.ts +78 -0
- package/dist/model.js +1 -0
- package/dist/parser.d.ts +3 -0
- package/dist/parser.js +418 -0
- package/dist/render.d.ts +31 -0
- package/dist/render.js +1164 -0
- package/dist/resolve.d.ts +22 -0
- package/dist/resolve.js +1012 -0
- package/package.json +42 -4
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Placement } from './ast.js';
|
|
2
|
+
/**
|
|
3
|
+
* The tightest arrangement satisfying a set of relative distances.
|
|
4
|
+
*
|
|
5
|
+
* Every placement in the language turns into the same shape of fact: one thing sits
|
|
6
|
+
* at least so far along an axis from another. Written as `pos[to] >= pos[from]
|
|
7
|
+
* + weight`, a whole diagram is a system of those, and the arrangement the
|
|
8
|
+
* author meant is the one where nothing is further apart than it has to be.
|
|
9
|
+
*
|
|
10
|
+
* That arrangement is unique and is found by longest paths, so no search
|
|
11
|
+
* happens and no alternative is ever weighed. Distances come out of the
|
|
12
|
+
* arithmetic; which side of what a thing sits on came from the author.
|
|
13
|
+
*/
|
|
14
|
+
export interface Constraint {
|
|
15
|
+
from: number;
|
|
16
|
+
to: number;
|
|
17
|
+
weight: number;
|
|
18
|
+
/** The placement this came from, so a failure can be reported in the author's words. */
|
|
19
|
+
placement?: Placement;
|
|
20
|
+
}
|
|
21
|
+
/** An exact distance, which is two inequalities pointing opposite ways. */
|
|
22
|
+
export declare function fix(from: number, to: number, distance: number, placement?: Placement): Constraint[];
|
|
23
|
+
export interface Contradiction {
|
|
24
|
+
/** The placements that cannot all hold. Empty if the loop is entirely implicit. */
|
|
25
|
+
placements: Placement[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Solve, or report the placements that fight.
|
|
29
|
+
*
|
|
30
|
+
* Longest path from a virtual source joined to everything at zero, which is
|
|
31
|
+
* Bellman-Ford with the comparison flipped. A distance that keeps growing after
|
|
32
|
+
* one pass per node means the constraints run in a circle that demands ever
|
|
33
|
+
* more room, and that circle is exactly the set of placements to quote back.
|
|
34
|
+
*/
|
|
35
|
+
export declare function tightest(count: number, constraints: Constraint[]): {
|
|
36
|
+
positions: number[];
|
|
37
|
+
} | {
|
|
38
|
+
contradiction: Contradiction;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Which members can be driven apart from which, along one axis.
|
|
42
|
+
*
|
|
43
|
+
* A constraint says `pos[to] >= pos[from] + weight`, so it bounds `to` from
|
|
44
|
+
* below and leaves it free to move further away. Follow those edges and the
|
|
45
|
+
* question "may this one end up beyond that one?" becomes plain reachability:
|
|
46
|
+
* if `j` is reachable from `i` and `i` is not reachable from `j`, then the file
|
|
47
|
+
* lets the distance from `i` to `j` grow without limit and never lets it be
|
|
48
|
+
* closed from the other side. Pushing `j` past `i` is then the one separation
|
|
49
|
+
* the file allows, and no choice was made.
|
|
50
|
+
*
|
|
51
|
+
* Reachable both ways means the two are pinned at a fixed distance, so they
|
|
52
|
+
* cannot be separated along this axis at all. Reachable neither way means the
|
|
53
|
+
* file said nothing that orders them, which is the case the caller must refuse
|
|
54
|
+
* rather than guess at.
|
|
55
|
+
*/
|
|
56
|
+
export declare function reachability(count: number, constraints: Constraint[]): boolean[][];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** An exact distance, which is two inequalities pointing opposite ways. */
|
|
2
|
+
export function fix(from, to, distance, placement) {
|
|
3
|
+
return [
|
|
4
|
+
{ from, to, weight: distance, ...(placement ? { placement } : {}) },
|
|
5
|
+
{ from: to, to: from, weight: -distance, ...(placement ? { placement } : {}) },
|
|
6
|
+
];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Solve, or report the placements that fight.
|
|
10
|
+
*
|
|
11
|
+
* Longest path from a virtual source joined to everything at zero, which is
|
|
12
|
+
* Bellman-Ford with the comparison flipped. A distance that keeps growing after
|
|
13
|
+
* one pass per node means the constraints run in a circle that demands ever
|
|
14
|
+
* more room, and that circle is exactly the set of placements to quote back.
|
|
15
|
+
*/
|
|
16
|
+
export function tightest(count, constraints) {
|
|
17
|
+
const positions = new Array(count).fill(0);
|
|
18
|
+
const cameFrom = new Array(count).fill(undefined);
|
|
19
|
+
for (let pass = 0; pass < count; pass += 1) {
|
|
20
|
+
let moved = false;
|
|
21
|
+
for (const constraint of constraints) {
|
|
22
|
+
const candidate = positions[constraint.from] + constraint.weight;
|
|
23
|
+
if (candidate > positions[constraint.to] + 1e-9) {
|
|
24
|
+
positions[constraint.to] = candidate;
|
|
25
|
+
cameFrom[constraint.to] = constraint;
|
|
26
|
+
moved = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!moved)
|
|
30
|
+
return { positions };
|
|
31
|
+
}
|
|
32
|
+
// Still moving after a full pass per node, so some loop demands more room
|
|
33
|
+
// every time round it. Walk backwards to find it.
|
|
34
|
+
for (const constraint of constraints) {
|
|
35
|
+
if (positions[constraint.from] + constraint.weight > positions[constraint.to] + 1e-9) {
|
|
36
|
+
return { contradiction: { placements: loopFrom(constraint.to, cameFrom, count) } };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { contradiction: { placements: [] } };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Which members can be driven apart from which, along one axis.
|
|
43
|
+
*
|
|
44
|
+
* A constraint says `pos[to] >= pos[from] + weight`, so it bounds `to` from
|
|
45
|
+
* below and leaves it free to move further away. Follow those edges and the
|
|
46
|
+
* question "may this one end up beyond that one?" becomes plain reachability:
|
|
47
|
+
* if `j` is reachable from `i` and `i` is not reachable from `j`, then the file
|
|
48
|
+
* lets the distance from `i` to `j` grow without limit and never lets it be
|
|
49
|
+
* closed from the other side. Pushing `j` past `i` is then the one separation
|
|
50
|
+
* the file allows, and no choice was made.
|
|
51
|
+
*
|
|
52
|
+
* Reachable both ways means the two are pinned at a fixed distance, so they
|
|
53
|
+
* cannot be separated along this axis at all. Reachable neither way means the
|
|
54
|
+
* file said nothing that orders them, which is the case the caller must refuse
|
|
55
|
+
* rather than guess at.
|
|
56
|
+
*/
|
|
57
|
+
export function reachability(count, constraints) {
|
|
58
|
+
const reach = Array.from({ length: count }, () => new Array(count).fill(false));
|
|
59
|
+
for (const constraint of constraints)
|
|
60
|
+
reach[constraint.from][constraint.to] = true;
|
|
61
|
+
for (let via = 0; via < count; via += 1) {
|
|
62
|
+
for (let from = 0; from < count; from += 1) {
|
|
63
|
+
if (!reach[from][via])
|
|
64
|
+
continue;
|
|
65
|
+
for (let to = 0; to < count; to += 1) {
|
|
66
|
+
if (reach[via][to])
|
|
67
|
+
reach[from][to] = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return reach;
|
|
72
|
+
}
|
|
73
|
+
/** The placements on the loop reached by following each position back to what set it. */
|
|
74
|
+
function loopFrom(start, cameFrom, count) {
|
|
75
|
+
let at = start;
|
|
76
|
+
for (let step = 0; step < count; step += 1) {
|
|
77
|
+
const previous = cameFrom[at];
|
|
78
|
+
if (previous === undefined)
|
|
79
|
+
break;
|
|
80
|
+
at = previous.from;
|
|
81
|
+
}
|
|
82
|
+
const placements = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
let cursor = at;
|
|
85
|
+
while (!seen.has(cursor)) {
|
|
86
|
+
seen.add(cursor);
|
|
87
|
+
const previous = cameFrom[cursor];
|
|
88
|
+
if (previous === undefined)
|
|
89
|
+
break;
|
|
90
|
+
if (previous.placement)
|
|
91
|
+
placements.push(previous.placement);
|
|
92
|
+
cursor = previous.from;
|
|
93
|
+
}
|
|
94
|
+
return placements.reverse();
|
|
95
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A problem in the source, reported in the vocabulary of the source. */
|
|
2
|
+
export declare class SourceError extends Error {
|
|
3
|
+
readonly line: number;
|
|
4
|
+
constructor(message: string, line: number);
|
|
5
|
+
/** `12: two placements for "server"` — the form the command-line tool prints. */
|
|
6
|
+
format(file?: string): string;
|
|
7
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A problem in the source, reported in the vocabulary of the source. */
|
|
2
|
+
export class SourceError extends Error {
|
|
3
|
+
line;
|
|
4
|
+
constructor(message, line) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.line = line;
|
|
7
|
+
this.name = 'SourceError';
|
|
8
|
+
}
|
|
9
|
+
/** `12: two placements for "server"` — the form the command-line tool prints. */
|
|
10
|
+
format(file) {
|
|
11
|
+
const where = file ? `${file}:${this.line}` : `line ${this.line}`;
|
|
12
|
+
return `${where}: ${this.message}`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language's *lexical* vocabulary, and a scanner that classifies one line of
|
|
3
|
+
* source into coloured spans.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately separate from `parser.ts`, and it is not a second parser.
|
|
6
|
+
* The parser answers "what does this file mean" and refuses anything it cannot
|
|
7
|
+
* answer for; a highlighter has to colour a half-typed line without complaint,
|
|
8
|
+
* so it answers only "what kind of word is this" and never fails. Every rule
|
|
9
|
+
* below is a regex applied to a single line, in priority order, with one bit of
|
|
10
|
+
* carried state (whether anything has been seen on the line yet, and whether the
|
|
11
|
+
* previous token was an attribute key).
|
|
12
|
+
*
|
|
13
|
+
* **That restriction is the point, and it is what makes this reusable.** An
|
|
14
|
+
* Emacs `font-lock-keywords` list, a Vim `syntax match` file and a TextMate
|
|
15
|
+
* grammar are all exactly this: an ordered list of single-line regexes with a
|
|
16
|
+
* face attached. So the vocabulary here — which is the half that goes stale, and
|
|
17
|
+
* which is imported from `ast.ts`, `constants.ts` and `icons.ts` rather than
|
|
18
|
+
* retyped — can be emitted into any of those without this file being ported.
|
|
19
|
+
* The scanner is the JavaScript consumer of the same spec, and the playground is
|
|
20
|
+
* its only caller.
|
|
21
|
+
*
|
|
22
|
+
* Nothing in the language spans lines: `//` runs to the end of one, a string
|
|
23
|
+
* closes on one, and there are no blocks. That is the property those editor
|
|
24
|
+
* formats need and the reason a `.reladraw` grammar is small in all of them.
|
|
25
|
+
*/
|
|
26
|
+
/** What a span of source is, for colouring. */
|
|
27
|
+
export type TokenKind =
|
|
28
|
+
/** `// to the end of the line` */
|
|
29
|
+
'comment'
|
|
30
|
+
/** A quoted string, quotes included. Unterminated ones count, so typing is quiet. */
|
|
31
|
+
| 'string'
|
|
32
|
+
/** The word a statement opens with: `box`, `link`, … */
|
|
33
|
+
| 'keyword'
|
|
34
|
+
/** The name a statement declares, right after its keyword. */
|
|
35
|
+
| 'name'
|
|
36
|
+
/** `->` and `<->`. */
|
|
37
|
+
| 'arrow'
|
|
38
|
+
/** Placement and link vocabulary: `right`, `of`, `level`, `with`, `and`, `between`, … */
|
|
39
|
+
| 'relation'
|
|
40
|
+
/** A `key:` opening an attribute or a bracketed modifier. */
|
|
41
|
+
| 'attribute'
|
|
42
|
+
/** The single word an attribute takes. */
|
|
43
|
+
| 'value'
|
|
44
|
+
/** `#14532d`, wherever it appears. */
|
|
45
|
+
| 'colour'
|
|
46
|
+
/** `(` and `)` around a placement's or a label's modifiers. */
|
|
47
|
+
| 'bracket'
|
|
48
|
+
/** Everything else: node names being referred to, and whitespace. */
|
|
49
|
+
| 'plain';
|
|
50
|
+
export interface Span {
|
|
51
|
+
kind: TokenKind;
|
|
52
|
+
/** Half-open, in characters, into the line it came from. */
|
|
53
|
+
start: number;
|
|
54
|
+
end: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The words a statement may open with. `parseStatement` in `parser.ts` is the
|
|
58
|
+
* authority — its switch is what actually accepts them — and this list mirrors
|
|
59
|
+
* it. A word missing here is a word that draws in the plain colour, which is a
|
|
60
|
+
* dull page rather than a wrong one.
|
|
61
|
+
*/
|
|
62
|
+
export declare const STATEMENT_KEYWORDS: readonly ["box", "note", "link", "deck", "style", "diagram"];
|
|
63
|
+
/**
|
|
64
|
+
* Every word that says something about where a thing goes. Assembled from the
|
|
65
|
+
* lists the parser itself reads, so a direction or a passage axis added there
|
|
66
|
+
* colours here without anybody remembering to come back.
|
|
67
|
+
*/
|
|
68
|
+
export declare const RELATION_WORDS: string[];
|
|
69
|
+
/**
|
|
70
|
+
* The patterns, as source strings, so a generator can emit them into another
|
|
71
|
+
* editor's grammar without reaching into a compiled RegExp. Each is written to
|
|
72
|
+
* match at the point the scanner has reached; the scanner adds the sticky flag.
|
|
73
|
+
*
|
|
74
|
+
* A name may contain dots (containment) and hyphens, which is why the word
|
|
75
|
+
* pattern is what it is rather than `\w+`.
|
|
76
|
+
*/
|
|
77
|
+
export declare const PATTERNS: {
|
|
78
|
+
readonly comment: "\\/\\/.*";
|
|
79
|
+
readonly string: "\"(?:\\\\.|[^\"\\\\])*\"?";
|
|
80
|
+
readonly keyword: `(?:${string})\\b`;
|
|
81
|
+
readonly arrow: "<->|->|<-";
|
|
82
|
+
readonly attribute: "[A-Za-z][A-Za-z0-9_-]*:";
|
|
83
|
+
readonly colour: "#[0-9A-Fa-f]{3,8}\\b";
|
|
84
|
+
readonly relation: `(?:${string})\\b`;
|
|
85
|
+
readonly bracket: "[()]";
|
|
86
|
+
readonly word: "(?:[^\\s()\"\\/]|\\/(?!\\/))(?:[^\\s\"\\/]|\\/(?!\\/))*";
|
|
87
|
+
/**
|
|
88
|
+
* The same, inside an open bracket, where `)` closes the group instead of
|
|
89
|
+
* being an ordinary character. An editor grammar that cannot count brackets
|
|
90
|
+
* should use this one throughout: mistaking `rgb(20,20,20)` for three tokens
|
|
91
|
+
* is a smaller wrong than swallowing the `)` that ends `(gap: tight)`.
|
|
92
|
+
*/
|
|
93
|
+
readonly wordInGroup: "(?:[^\\s()\"\\/]|\\/(?!\\/))(?:[^\\s)\"\\/]|\\/(?!\\/))*";
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Classify one line. Always returns spans covering it end to end, in order, so a
|
|
97
|
+
* caller can rebuild the text by concatenation and can trust that nothing was
|
|
98
|
+
* dropped — which is what a highlighter drawn *behind* a textarea needs, since
|
|
99
|
+
* a lost character would slide every following one out of register.
|
|
100
|
+
*/
|
|
101
|
+
export declare function highlightLine(line: string): Span[];
|
|
102
|
+
/** Every line of a source file, classified. Line endings are not included. */
|
|
103
|
+
export declare function highlight(source: string): Span[][];
|
package/dist/grammar.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language's *lexical* vocabulary, and a scanner that classifies one line of
|
|
3
|
+
* source into coloured spans.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately separate from `parser.ts`, and it is not a second parser.
|
|
6
|
+
* The parser answers "what does this file mean" and refuses anything it cannot
|
|
7
|
+
* answer for; a highlighter has to colour a half-typed line without complaint,
|
|
8
|
+
* so it answers only "what kind of word is this" and never fails. Every rule
|
|
9
|
+
* below is a regex applied to a single line, in priority order, with one bit of
|
|
10
|
+
* carried state (whether anything has been seen on the line yet, and whether the
|
|
11
|
+
* previous token was an attribute key).
|
|
12
|
+
*
|
|
13
|
+
* **That restriction is the point, and it is what makes this reusable.** An
|
|
14
|
+
* Emacs `font-lock-keywords` list, a Vim `syntax match` file and a TextMate
|
|
15
|
+
* grammar are all exactly this: an ordered list of single-line regexes with a
|
|
16
|
+
* face attached. So the vocabulary here — which is the half that goes stale, and
|
|
17
|
+
* which is imported from `ast.ts`, `constants.ts` and `icons.ts` rather than
|
|
18
|
+
* retyped — can be emitted into any of those without this file being ported.
|
|
19
|
+
* The scanner is the JavaScript consumer of the same spec, and the playground is
|
|
20
|
+
* its only caller.
|
|
21
|
+
*
|
|
22
|
+
* Nothing in the language spans lines: `//` runs to the end of one, a string
|
|
23
|
+
* closes on one, and there are no blocks. That is the property those editor
|
|
24
|
+
* formats need and the reason a `.reladraw` grammar is small in all of them.
|
|
25
|
+
*/
|
|
26
|
+
import { DIRECTIONS, EDGES, PASSAGE_AXES } from './ast.js';
|
|
27
|
+
/**
|
|
28
|
+
* The words a statement may open with. `parseStatement` in `parser.ts` is the
|
|
29
|
+
* authority — its switch is what actually accepts them — and this list mirrors
|
|
30
|
+
* it. A word missing here is a word that draws in the plain colour, which is a
|
|
31
|
+
* dull page rather than a wrong one.
|
|
32
|
+
*/
|
|
33
|
+
export const STATEMENT_KEYWORDS = ['box', 'note', 'link', 'deck', 'style', 'diagram'];
|
|
34
|
+
/** Statements whose second word declares a name. `diagram` has none. */
|
|
35
|
+
const DECLARES_NAME = ['box', 'note', 'deck', 'style'];
|
|
36
|
+
/**
|
|
37
|
+
* Every word that says something about where a thing goes. Assembled from the
|
|
38
|
+
* lists the parser itself reads, so a direction or a passage axis added there
|
|
39
|
+
* colours here without anybody remembering to come back.
|
|
40
|
+
*/
|
|
41
|
+
export const RELATION_WORDS = [
|
|
42
|
+
...DIRECTIONS,
|
|
43
|
+
...EDGES,
|
|
44
|
+
...Object.keys(PASSAGE_AXES),
|
|
45
|
+
// The connecting words. `of` is optional after a direction, `and` joins
|
|
46
|
+
// targets, `between` opens a passage, `level with` is the alignment.
|
|
47
|
+
'of',
|
|
48
|
+
'and',
|
|
49
|
+
'level',
|
|
50
|
+
'with',
|
|
51
|
+
'between',
|
|
52
|
+
];
|
|
53
|
+
/** Longest first, so `above-left` is not read as `above` followed by `-left`. */
|
|
54
|
+
function alternation(words) {
|
|
55
|
+
return [...new Set(words)].sort((a, b) => b.length - a.length).join('|');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The patterns, as source strings, so a generator can emit them into another
|
|
59
|
+
* editor's grammar without reaching into a compiled RegExp. Each is written to
|
|
60
|
+
* match at the point the scanner has reached; the scanner adds the sticky flag.
|
|
61
|
+
*
|
|
62
|
+
* A name may contain dots (containment) and hyphens, which is why the word
|
|
63
|
+
* pattern is what it is rather than `\w+`.
|
|
64
|
+
*/
|
|
65
|
+
export const PATTERNS = {
|
|
66
|
+
comment: '\\/\\/.*',
|
|
67
|
+
// The closing quote is optional: every string is unterminated for as long as
|
|
68
|
+
// it is being typed, and a highlighter that waits for the quote repaints the
|
|
69
|
+
// rest of the file on every keystroke.
|
|
70
|
+
string: '"(?:\\\\.|[^"\\\\])*"?',
|
|
71
|
+
keyword: `(?:${alternation(STATEMENT_KEYWORDS)})\\b`,
|
|
72
|
+
// `<->` first, or `<-` would match its opening half and leave a stray `>`.
|
|
73
|
+
arrow: '<->|->|<-',
|
|
74
|
+
attribute: '[A-Za-z][A-Za-z0-9_-]*:',
|
|
75
|
+
colour: '#[0-9A-Fa-f]{3,8}\\b',
|
|
76
|
+
relation: `(?:${alternation(RELATION_WORDS)})\\b`,
|
|
77
|
+
bracket: '[()]',
|
|
78
|
+
// Matches what `tokenizeLine` treats as one bare token, and the awkwardness is
|
|
79
|
+
// load-bearing rather than accidental. A `(` counts as punctuation only where
|
|
80
|
+
// a token starts, so `rgb(20,20,20)` is one word — hence the first character
|
|
81
|
+
// being spelled differently from the rest. A lone `/` is ordinary and only a
|
|
82
|
+
// doubled one opens a comment, which is what the lookahead is for.
|
|
83
|
+
word: '(?:[^\\s()"\\/]|\\/(?!\\/))(?:[^\\s"\\/]|\\/(?!\\/))*',
|
|
84
|
+
/**
|
|
85
|
+
* The same, inside an open bracket, where `)` closes the group instead of
|
|
86
|
+
* being an ordinary character. An editor grammar that cannot count brackets
|
|
87
|
+
* should use this one throughout: mistaking `rgb(20,20,20)` for three tokens
|
|
88
|
+
* is a smaller wrong than swallowing the `)` that ends `(gap: tight)`.
|
|
89
|
+
*/
|
|
90
|
+
wordInGroup: '(?:[^\\s()"\\/]|\\/(?!\\/))(?:[^\\s)"\\/]|\\/(?!\\/))*',
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* The rules that are pure regex, in priority order. Three things are missing and
|
|
94
|
+
* each for a reason: a comment wins over all of them and is tried first, a
|
|
95
|
+
* bracket is punctuation only at certain depths so the scanner counts them
|
|
96
|
+
* itself, and the catch-all word rule varies with that same depth and is
|
|
97
|
+
* appended per call.
|
|
98
|
+
*/
|
|
99
|
+
const RULES = [
|
|
100
|
+
{ kind: 'string', re: sticky(PATTERNS.string) },
|
|
101
|
+
{ kind: 'arrow', re: sticky(PATTERNS.arrow) },
|
|
102
|
+
// Before `relation`, because the trailing colon is what tells `left: …` from
|
|
103
|
+
// the `left` of a placement, and after `arrow` so `->` is never a word.
|
|
104
|
+
{ kind: 'attribute', re: sticky(PATTERNS.attribute) },
|
|
105
|
+
{ kind: 'colour', re: sticky(PATTERNS.colour) },
|
|
106
|
+
{ kind: 'relation', re: sticky(PATTERNS.relation) },
|
|
107
|
+
];
|
|
108
|
+
const SPACE = sticky('[ \\t]+');
|
|
109
|
+
const COMMENT = sticky(PATTERNS.comment);
|
|
110
|
+
const KEYWORD = sticky(PATTERNS.keyword);
|
|
111
|
+
const WORD = sticky(PATTERNS.word);
|
|
112
|
+
const WORD_IN_GROUP = sticky(PATTERNS.wordInGroup);
|
|
113
|
+
function sticky(source) {
|
|
114
|
+
return new RegExp(source, 'y');
|
|
115
|
+
}
|
|
116
|
+
function match(re, line, at) {
|
|
117
|
+
re.lastIndex = at;
|
|
118
|
+
const found = re.exec(line);
|
|
119
|
+
return found ? found[0] : null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Classify one line. Always returns spans covering it end to end, in order, so a
|
|
123
|
+
* caller can rebuild the text by concatenation and can trust that nothing was
|
|
124
|
+
* dropped — which is what a highlighter drawn *behind* a textarea needs, since
|
|
125
|
+
* a lost character would slide every following one out of register.
|
|
126
|
+
*/
|
|
127
|
+
export function highlightLine(line) {
|
|
128
|
+
const spans = [];
|
|
129
|
+
let at = 0;
|
|
130
|
+
/** Nothing but whitespace seen yet, so the next word is the statement keyword. */
|
|
131
|
+
let opening = true;
|
|
132
|
+
/** The last thing emitted was a `key:`, so the next word is its value. */
|
|
133
|
+
let expectingValue = false;
|
|
134
|
+
/** How many brackets are open, which is what makes a `)` punctuation. */
|
|
135
|
+
let depth = 0;
|
|
136
|
+
const push = (kind, end) => {
|
|
137
|
+
spans.push({ kind, start: at, end });
|
|
138
|
+
at = end;
|
|
139
|
+
};
|
|
140
|
+
while (at < line.length) {
|
|
141
|
+
const gap = match(SPACE, line, at);
|
|
142
|
+
if (gap !== null) {
|
|
143
|
+
push('plain', at + gap.length);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// A comment wins everywhere, including in the middle of a statement.
|
|
147
|
+
const comment = match(COMMENT, line, at);
|
|
148
|
+
if (comment !== null) {
|
|
149
|
+
push('comment', at + comment.length);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (opening) {
|
|
153
|
+
opening = false;
|
|
154
|
+
const keyword = match(KEYWORD, line, at);
|
|
155
|
+
if (keyword !== null) {
|
|
156
|
+
push('keyword', at + keyword.length);
|
|
157
|
+
if (DECLARES_NAME.includes(keyword)) {
|
|
158
|
+
const space = match(SPACE, line, at);
|
|
159
|
+
if (space !== null) {
|
|
160
|
+
push('plain', at + space.length);
|
|
161
|
+
const name = match(WORD, line, at);
|
|
162
|
+
// `style backup stroke: …` declares a name; `box fill: red` is a
|
|
163
|
+
// half-typed line whose second word is already an attribute, and
|
|
164
|
+
// colouring that as a name would be a lie about what it is.
|
|
165
|
+
if (name !== null && !name.endsWith(':'))
|
|
166
|
+
push('name', at + name.length);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Brackets before the rest, and only where they are really punctuation: an
|
|
173
|
+
// opening one wherever a token starts, a closing one only while a group is
|
|
174
|
+
// open. That is `tokenizeLine`'s rule, and it is what keeps an unquoted
|
|
175
|
+
// `rgb(20,20,20)` a single word rather than three.
|
|
176
|
+
const ch = line[at];
|
|
177
|
+
if (ch === '(') {
|
|
178
|
+
depth += 1;
|
|
179
|
+
expectingValue = false;
|
|
180
|
+
push('bracket', at + 1);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (ch === ')' && depth > 0) {
|
|
184
|
+
depth -= 1;
|
|
185
|
+
expectingValue = false;
|
|
186
|
+
push('bracket', at + 1);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
let matched = false;
|
|
190
|
+
for (const rule of [...RULES, { kind: 'plain', re: depth > 0 ? WORD_IN_GROUP : WORD }]) {
|
|
191
|
+
const text = match(rule.re, line, at);
|
|
192
|
+
if (text === null)
|
|
193
|
+
continue;
|
|
194
|
+
// An attribute's value is whatever single token follows it, whatever it
|
|
195
|
+
// would otherwise have been called: `to: right` is a value, not a
|
|
196
|
+
// direction, and `style: wide` is a style name, not a gap.
|
|
197
|
+
const kind = expectingValue && rule.kind !== 'string' && rule.kind !== 'colour' && rule.kind !== 'bracket'
|
|
198
|
+
? 'value'
|
|
199
|
+
: rule.kind;
|
|
200
|
+
expectingValue = rule.kind === 'attribute';
|
|
201
|
+
push(kind, at + text.length);
|
|
202
|
+
matched = true;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
// Nothing in `RULES` can fail on a non-space character, but a scanner that
|
|
206
|
+
// could loop forever is not worth the saved line.
|
|
207
|
+
if (!matched)
|
|
208
|
+
push('plain', at + 1);
|
|
209
|
+
}
|
|
210
|
+
return spans;
|
|
211
|
+
}
|
|
212
|
+
/** Every line of a source file, classified. Line endings are not included. */
|
|
213
|
+
export function highlight(source) {
|
|
214
|
+
return source.split(/\r?\n/).map(highlightLine);
|
|
215
|
+
}
|
package/dist/icons.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in icon set.
|
|
3
|
+
*
|
|
4
|
+
* Every glyph is path data written into the SVG, and that is the whole reason
|
|
5
|
+
* this file exists rather than a dependency. The output contract is a standalone
|
|
6
|
+
* document: an icon font would render as blank boxes on any machine that does
|
|
7
|
+
* not have the font, and an `<image href>` would need the file to travel beside
|
|
8
|
+
* the SVG. Inline paths cost a few hundred bytes each and always arrive.
|
|
9
|
+
*
|
|
10
|
+
* A name here says what the thing *is*, never what the picture looks like. The
|
|
11
|
+
* same discipline as `gap: wide` over `gap: 110` and `muted` over a hex value:
|
|
12
|
+
* the word is the whole interface, so it has to carry meaning rather than
|
|
13
|
+
* geometry, and naming the meaning is what lets the drawing be improved later
|
|
14
|
+
* without every diagram that uses it changing sense.
|
|
15
|
+
*
|
|
16
|
+
* The set is deliberately small. In a drawing tool you pick a shape out of a
|
|
17
|
+
* visual palette and hundreds are browsable; here you type the word from memory,
|
|
18
|
+
* which caps the useful vocabulary at something that fits in a head. Add a name
|
|
19
|
+
* when a diagram asks for a distinction it cannot otherwise make.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Icons carry three tones rather than colours. `ink` is the drawn line, `shade`
|
|
23
|
+
* the body it encloses, and `void` is the page showing through — the same
|
|
24
|
+
* knockout a link label already uses. Naming tones instead of colours is what
|
|
25
|
+
* lets one glyph sit correctly on a dark theme and a light one.
|
|
26
|
+
*/
|
|
27
|
+
export type IconTone = 'ink' | 'shade' | 'void';
|
|
28
|
+
export interface IconPath {
|
|
29
|
+
d: string;
|
|
30
|
+
fill?: IconTone;
|
|
31
|
+
stroke?: IconTone;
|
|
32
|
+
}
|
|
33
|
+
export interface Icon {
|
|
34
|
+
/** Side of the square the paths are drawn on. Scaled to the drawn size. */
|
|
35
|
+
readonly grid: number;
|
|
36
|
+
readonly paths: readonly IconPath[];
|
|
37
|
+
}
|
|
38
|
+
/** Line width on the 24-unit grid, scaled with everything else. */
|
|
39
|
+
export declare const ICON_STROKE = 1.1;
|
|
40
|
+
export declare const ICONS: Record<string, Icon>;
|
|
41
|
+
export declare const ICON_NAMES: string[];
|
|
42
|
+
/**
|
|
43
|
+
* The icon a node asks for, or nothing. Called by the resolver, which reserves
|
|
44
|
+
* the room, and by the renderer, which fills it, so the two cannot disagree
|
|
45
|
+
* about whether there is an icon at all.
|
|
46
|
+
*
|
|
47
|
+
* An unknown name is refused rather than dropped. That is the same rule
|
|
48
|
+
* `DIAGRAM_KEYS` follows and it is here for the same reason: a misspelt
|
|
49
|
+
* `icon: laptp` that quietly draws nothing is indistinguishable from the tool
|
|
50
|
+
* being broken, and an author will stare at the file looking for the mistake in
|
|
51
|
+
* the wrong place. Since the vocabulary is closed and short, the error can list
|
|
52
|
+
* the whole of it.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* The outlines a box can take. `box` is the plain rectangle and needs no word.
|
|
56
|
+
*
|
|
57
|
+
* Named for what a node *is*, never for the geometry, which is the same rule the
|
|
58
|
+
* icon names follow: `document` and not `folded-corner`. A shape carrying a
|
|
59
|
+
* conventional meaning is a second channel alongside colour, and a stronger one
|
|
60
|
+
* — a fill is whatever the author assigned and has to be learnt from the
|
|
61
|
+
* diagram, while a folded corner has meant "an artifact, not a process" in
|
|
62
|
+
* flowcharts for decades and reads with no legend at all.
|
|
63
|
+
*/
|
|
64
|
+
export declare const BOX_SHAPES: readonly ["document"];
|
|
65
|
+
export type BoxShape = 'box' | (typeof BOX_SHAPES)[number];
|
|
66
|
+
export interface NodeShape {
|
|
67
|
+
outline: BoxShape;
|
|
68
|
+
/**
|
|
69
|
+
* Set when the node is drawn as a glyph rather than as a box. There is then
|
|
70
|
+
* no outline, no fill and no padding: the node *is* the picture, and its size
|
|
71
|
+
* is the picture's. `icon:` decorates a box, this replaces it.
|
|
72
|
+
*/
|
|
73
|
+
body?: Icon;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* What a node is drawn as. Shared by the resolver, which sizes it, and the
|
|
77
|
+
* renderer, which draws it.
|
|
78
|
+
*
|
|
79
|
+
* The value is either a box outline or the name of a glyph. Those are the two
|
|
80
|
+
* things "what is this drawn as" can answer, and the author has no reason to
|
|
81
|
+
* care which category their answer fell into. The test that separates them is
|
|
82
|
+
* whether the node still sizes itself from its label: a `document` does, a
|
|
83
|
+
* glyph does not.
|
|
84
|
+
*/
|
|
85
|
+
export declare function shapeFor(appearance: Record<string, string>, line: number): NodeShape;
|
|
86
|
+
export declare function iconFor(appearance: Record<string, string>, line: number): Icon | undefined;
|