reladraw 0.4.0 → 0.5.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/NOTICE CHANGED
@@ -12,3 +12,40 @@ any other trade names, trademarks, service marks, or product names of the
12
12
  project, except as required for reasonable and customary use in describing the
13
13
  origin of the work (see section 6 of the License). Forks and derived works are
14
14
  welcome and must be distributed under a different name.
15
+
16
+ ---
17
+
18
+ Color palettes
19
+
20
+ Several of reladraw's themes (src/themes.ts) are adapted from the color
21
+ palettes below. Each is distributed under the MIT License, whose text follows
22
+ the list of copyright notices and applies to each of them.
23
+
24
+ Solarized Copyright (c) 2011 Ethan Schoonover
25
+ https://github.com/altercation/solarized
26
+ Gruvbox Copyright (c) 2018 Pavel Pertsev
27
+ https://github.com/morhetz/gruvbox
28
+ Catppuccin Copyright (c) 2021 Catppuccin
29
+ https://github.com/catppuccin/catppuccin
30
+ Nord Copyright (c) 2016-present Sven Greb
31
+ https://github.com/nordtheme/nord
32
+ Dracula Copyright (c) 2023 Dracula Theme
33
+ https://github.com/dracula/dracula-theme
34
+
35
+ Permission is hereby granted, free of charge, to any person obtaining a copy
36
+ of this software and associated documentation files (the "Software"), to deal
37
+ in the Software without restriction, including without limitation the rights
38
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
39
+ copies of the Software, and to permit persons to whom the Software is
40
+ furnished to do so, subject to the following conditions:
41
+
42
+ The above copyright notice and this permission notice shall be included in
43
+ all copies or substantial portions of the Software.
44
+
45
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
46
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
47
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
48
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
49
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
50
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
51
+ THE SOFTWARE.
package/README.md CHANGED
@@ -68,7 +68,7 @@ It is [plain Markdown](.claude/skills/reladraw/SKILL.md) with the syntax referen
68
68
 
69
69
  ## Status
70
70
 
71
- Version 0.4.0. Early, but it runs: a parser, resolver and SVG renderer in TypeScript with no runtime dependencies, and a command-line tool that takes a text file and writes a standalone SVG. The comparison at the top of this page is that pipeline run on [`examples/arch.reladraw`](examples/arch.reladraw). What is still visibly off there is typography, not placement.
71
+ Version 0.5.0. Early, but it runs: a parser, resolver and SVG renderer in TypeScript with no runtime dependencies, and a command-line tool that takes a text file and writes a standalone SVG. The comparison at the top of this page is that pipeline run on [`examples/arch.reladraw`](examples/arch.reladraw). What is still visibly off there is typography, not placement.
72
72
 
73
73
  ```
74
74
  npm install -g reladraw
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.4.0
1
+ # Syntax reference — 0.5.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
 
@@ -630,7 +630,7 @@ Every attribute, and what takes one. The kinds here are what a node's **body** i
630
630
  | `line` | | | | ✓ | color |
631
631
  | `url` | ✓ | ✓ | ✓ | ✓ | a destination to open when the thing is clicked |
632
632
 
633
- The `diagram` statement has a vocabulary of its own — `background`, and so far nothing else — which is checked the same way. Writing `background:` on a node is an error that points at `fill:`.
633
+ The `diagram` statement has a vocabulary of its own — `theme`, `background` and `text` — which is checked the same way. Writing `background:` on a node is an error that points at `fill:`, and `theme:` on one points at `diagram theme:`. A `default` takes a narrower list for each kind; see "Defaults".
634
634
 
635
635
  **A word this table does not give the kind is an error.** The two ways of being wrong get different answers, because they have different remedies. A word that is an attribute nowhere is a misspelling, and the error lists what the kind does take. A word that is an attribute *somewhere else* is usually a real statement written on the wrong half of the diagram, so the error says where it belongs:
636
636
 
@@ -762,12 +762,66 @@ diagram <attributes>
762
762
  Settings that belong to the drawing as a whole rather than to anything in it. There is no name, because a file holds one diagram, and a second `diagram` statement is an error rather than a second opinion.
763
763
 
764
764
  ```
765
- diagram background: #111111
765
+ diagram theme: nord background: #1e2229 text: (color: #e0e0e0)
766
766
  ```
767
767
 
768
- One attribute so far. `background` takes a color the same way `fill` does, and it colors the page behind everything, including the strip an edge text knocks out of whatever it crosses. Say nothing and the theme's own background stands.
768
+ Three attributes:
769
769
 
770
- An unknown key is refused by name — `diagram has no "backround" — it takes background` — rather than quietly ignored, the same as every other attribute. See "Attributes".
770
+ - `theme` names the theme that supplies every color the file does not state. See "Themes" below.
771
+ - `background` takes a color the same way `fill` does, and it colors the page behind everything, including the strip an edge text knocks out of whatever it crosses. Say nothing and the theme's own background stands.
772
+ - `text: (color: …)` sets the color of every text, on nodes and edges alike. It takes a color and nothing else; a text size is about one kind of text and belongs on a `default`.
773
+
774
+ An unknown key is refused by name — `diagram has no "backround" — it takes theme, background, text` — rather than quietly ignored, the same as every other attribute. See "Attributes".
775
+
776
+ ### Themes
777
+
778
+ ```
779
+ diagram theme: solarized-dark
780
+ ```
781
+
782
+ | theme | |
783
+ |---|---|
784
+ | `dark` | the default: the palette of the benchmark drawing |
785
+ | `light` | the same roles on a white page |
786
+ | `solarized-dark`, `solarized-light` | [Solarized](https://ethanschoonover.com/solarized/) |
787
+ | `gruvbox-dark`, `gruvbox-light` | [Gruvbox](https://github.com/morhetz/gruvbox) |
788
+ | `catppuccin-mocha`, `catppuccin-latte` | [Catppuccin](https://catppuccin.com) |
789
+ | `nord` | [Nord](https://www.nordtheme.com) |
790
+ | `dracula` | [Dracula](https://draculatheme.com) |
791
+ | `high-contrast-dark`, `high-contrast-light` | no fills to lean on, every line at full strength |
792
+ | `print` | a white page, black lines and no fills, for paper |
793
+
794
+ The borrowed palettes were made for code, where a color marks a keyword or a string. A diagram needs a page, a box, a border, text and a line, so each theme is its scheme read as a diagram rather than a copy of it. Their licenses are in `NOTICE`.
795
+
796
+ A theme only ever supplies a color, so changing it never moves anything. A color you write yourself — `fill: #14532d`, or a style's — stays that color under every theme, which means a hand-picked color can clash with a theme it was not chosen against. `muted` is the one word that follows the theme.
797
+
798
+ An unknown name is an error that lists the themes.
799
+
800
+ The command line can render a file in another theme without editing it — `reladraw arch.reladraw --theme light` — and the flag wins over the file's `theme:`. That is how one source becomes a light and a dark image for a README that shows whichever matches the reader's settings. The file's own `background:` and `text:` still win over the flag's theme, as a hand-written color wins over any theme.
801
+
802
+ ### Defaults
803
+
804
+ ```
805
+ default node border: #4a8a5c
806
+ default leaf fill: #2e5d3a
807
+ default container fill: #1a2620 border: #2a3a30
808
+ default edge line: #7a8a99 text: (color: #9aa5b1, size: small)
809
+ ```
810
+
811
+ A default is a style that every thing of one kind wears without naming it. `node` covers every node; `leaf` a node with no children, and `container` a node with some — the same "has children" the theme's quieter backdrop colors follow, so setting a leaf's fill leaves containers looking like containers. `edge` covers every edge.
812
+
813
+ The more specific word wins, all the way down:
814
+
815
+ 1. the theme
816
+ 2. `diagram`
817
+ 3. `default node`
818
+ 4. `default leaf` or `default container`, or `default edge`
819
+ 5. the thing's styles, a later one winning where two set the same key
820
+ 6. what the thing's own line says
821
+
822
+ A default takes the words a style does, and may name a style of its own with `style:`, which it lays beneath its own words. Unlike a style, it is strict about kind: it names the kind it is for, so a word that kind has no use for can only be a mistake. `default edge fill: red` is refused and points at `line:`. `badge:` is refused on `default leaf` and `default node`, since a badge is a child and would make every leaf a container, and `icon:` is a leaf's alone, since a picture cannot hold children. A `url:` belongs to one thing, never to a kind.
823
+
824
+ Each kind is written at most once. `default leaf` twice is an error naming both lines, and `default leaf node` is refused with `default leaf` — a leaf is already a node.
771
825
 
772
826
  ## What the language refuses
773
827
 
@@ -832,6 +886,12 @@ That one was found by testing the lexer, not by rendering — and it could not h
832
886
 
833
887
  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.
834
888
 
889
+ **0.5.0**
890
+
891
+ - Named themes: `diagram theme: nord`, one of thirteen, and `--theme` on the command line to render a file in another without editing it. The playground has a theme picker, which writes the `theme:` line into the source.
892
+ - `diagram text: (color: …)` sets every text's color at once.
893
+ - `default node | leaf | container | edge` sets what every thing of that kind looks like unless it says otherwise.
894
+
835
895
  **0.4.0**
836
896
 
837
897
  - A node with any children takes the theme's container colors, however the children are placed. A node with a `badge:` or a single child in a corner now draws as a container too; in 0.3.0 only a node with a title band did. Its text still sits where it did. Say `fill:` and `border:` to keep one looking like a leaf.
package/dist/ast.d.ts CHANGED
@@ -282,8 +282,38 @@ export interface DiagramStmt {
282
282
  attrs: Attrs;
283
283
  line: number;
284
284
  }
285
- /** The attributes a `diagram` statement understands. */
286
- export declare const DIAGRAM_KEYS: readonly ["background"];
285
+ /**
286
+ * The attributes a `diagram` statement understands. `text` takes a bracket,
287
+ * `text: (color: …)`, and sets the text color of everything at once — the
288
+ * theme has one text color, shared by nodes and edges, and this is that.
289
+ */
290
+ export declare const DIAGRAM_KEYS: readonly ["theme", "background", "text"];
291
+ /**
292
+ * `default leaf fill: #2e5d3a` — a style that applies to every thing of one
293
+ * kind without being named. The more specific wins: `node` covers every node,
294
+ * `leaf` and `container` beat it for the nodes they cover, and a thing's own
295
+ * styles and words beat any default.
296
+ */
297
+ export interface DefaultStmt {
298
+ kind: 'default';
299
+ target: DefaultTarget;
300
+ attrs: Attrs;
301
+ line: number;
302
+ }
303
+ export declare const DEFAULT_TARGETS: readonly ["node", "leaf", "container", "edge"];
304
+ export type DefaultTarget = (typeof DEFAULT_TARGETS)[number];
305
+ /**
306
+ * What each default may say. A default carries a style's vocabulary, less the
307
+ * words that would contradict the kind it is written for:
308
+ *
309
+ * - A default names its kind, so it is strict where a style is permissive —
310
+ * `default edge fill:` can only be a mistake.
311
+ * - `badge:` gives a box a child, which makes it a container, so every leaf
312
+ * given one by `default leaf` or `default node` would stop being a leaf.
313
+ * - `icon:` draws a picture, and a picture cannot hold children, so it is a
314
+ * leaf's word and not a container's or every node's.
315
+ */
316
+ export declare const DEFAULT_KEYS: Record<DefaultTarget, readonly string[]>;
287
317
  /**
288
318
  * The attributes whose value is a color rather than text. A color is written
289
319
  * as the viewer will receive it and the renderer keeps no list of color words
@@ -365,7 +395,7 @@ export interface StyleStmt {
365
395
  attrs: Attrs;
366
396
  line: number;
367
397
  }
368
- export type Stmt = NodeStmt | EdgeStmt | StyleStmt | DiagramStmt;
398
+ export type Stmt = NodeStmt | EdgeStmt | StyleStmt | DiagramStmt | DefaultStmt;
369
399
  export interface Document {
370
400
  statements: Stmt[];
371
401
  }
package/dist/ast.js CHANGED
@@ -222,8 +222,30 @@ export function describeAxis(axis) {
222
222
  * `align` is how its lines range against each other once it is there.
223
223
  */
224
224
  export const TEXT_KEYS = ['color', 'size', 'wrap', 'align', 'at'];
225
- /** The attributes a `diagram` statement understands. */
226
- export const DIAGRAM_KEYS = ['background'];
225
+ /**
226
+ * The attributes a `diagram` statement understands. `text` takes a bracket,
227
+ * `text: (color: …)`, and sets the text color of everything at once — the
228
+ * theme has one text color, shared by nodes and edges, and this is that.
229
+ */
230
+ export const DIAGRAM_KEYS = ['theme', 'background', 'text'];
231
+ export const DEFAULT_TARGETS = ['node', 'leaf', 'container', 'edge'];
232
+ /**
233
+ * What each default may say. A default carries a style's vocabulary, less the
234
+ * words that would contradict the kind it is written for:
235
+ *
236
+ * - A default names its kind, so it is strict where a style is permissive —
237
+ * `default edge fill:` can only be a mistake.
238
+ * - `badge:` gives a box a child, which makes it a container, so every leaf
239
+ * given one by `default leaf` or `default node` would stop being a leaf.
240
+ * - `icon:` draws a picture, and a picture cannot hold children, so it is a
241
+ * leaf's word and not a container's or every node's.
242
+ */
243
+ export const DEFAULT_KEYS = {
244
+ node: ['style', 'shape', 'fill', 'border', 'text'],
245
+ leaf: ['style', 'shape', 'icon', 'fill', 'border', 'text'],
246
+ container: ['style', 'shape', 'badge', 'fill', 'border', 'text'],
247
+ edge: ['style', 'line', 'text'],
248
+ };
227
249
  /**
228
250
  * The attributes whose value is a color rather than text. A color is written
229
251
  * as the viewer will receive it and the renderer keeps no list of color words
package/dist/cli.js CHANGED
@@ -3,12 +3,15 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { dirname, resolve as resolvePath } from 'node:path';
4
4
  import { SourceError } from './errors.js';
5
5
  import { compile } from './index.js';
6
+ import { THEME_NAMES, THEMES } from './themes.js';
6
7
  const USAGE = `reladraw — render a diagram from stated placement
7
8
 
8
- reladraw <input.reladraw> [-o <output.svg>]
9
+ reladraw <input.reladraw> [-o <output.svg>] [--theme <name>]
9
10
 
10
11
  -o, --out where to write the SVG. Defaults to the input path with
11
12
  its extension replaced by .svg. Use - for standard output.
13
+ --theme render in this theme, whatever the file's \`diagram theme:\`
14
+ says. One of ${THEME_NAMES.join(', ')}.
12
15
  -h, --help print this.
13
16
  `;
14
17
  async function main(argv) {
@@ -18,6 +21,7 @@ async function main(argv) {
18
21
  }
19
22
  let input;
20
23
  let out;
24
+ let theme;
21
25
  for (let i = 0; i < argv.length; i += 1) {
22
26
  const arg = argv[i];
23
27
  if (arg === '-o' || arg === '--out') {
@@ -28,6 +32,18 @@ async function main(argv) {
28
32
  }
29
33
  i += 1;
30
34
  }
35
+ else if (arg === '--theme') {
36
+ theme = argv[i + 1];
37
+ if (theme === undefined) {
38
+ process.stderr.write(`reladraw: --theme needs a name: ${THEME_NAMES.join(', ')}\n`);
39
+ return 1;
40
+ }
41
+ if (THEMES[theme] === undefined) {
42
+ process.stderr.write(`reladraw: there is no theme called "${theme}" — the themes are ${THEME_NAMES.join(', ')}\n`);
43
+ return 1;
44
+ }
45
+ i += 1;
46
+ }
31
47
  else if (arg.startsWith('-') && arg !== '-') {
32
48
  process.stderr.write(`reladraw: unknown option ${arg}\n`);
33
49
  return 1;
@@ -47,7 +63,7 @@ async function main(argv) {
47
63
  const source = await readFile(input, 'utf8');
48
64
  let svg;
49
65
  try {
50
- svg = compile(source);
66
+ svg = compile(source, theme === undefined ? {} : { theme: THEMES[theme] });
51
67
  }
52
68
  catch (error) {
53
69
  if (error instanceof SourceError) {
package/dist/grammar.d.ts CHANGED
@@ -61,7 +61,7 @@ export interface Span {
61
61
  * it. A word missing here is a word that draws in the plain color, which is a
62
62
  * dull page rather than a wrong one.
63
63
  */
64
- export declare const STATEMENT_KEYWORDS: readonly ["node", "edge", "style", "diagram"];
64
+ export declare const STATEMENT_KEYWORDS: readonly ["node", "edge", "style", "diagram", "default"];
65
65
  /**
66
66
  * Every word that says something about where a thing goes. Assembled from the
67
67
  * lists the parser itself reads, so a direction or a passage axis added there
package/dist/grammar.js CHANGED
@@ -30,7 +30,7 @@ import { DIRECTIONS, POSITIONS, SIDES, PASSAGE_AXES } from './ast.js';
30
30
  * it. A word missing here is a word that draws in the plain color, which is a
31
31
  * dull page rather than a wrong one.
32
32
  */
33
- export const STATEMENT_KEYWORDS = ['node', 'edge', 'style', 'diagram'];
33
+ export const STATEMENT_KEYWORDS = ['node', 'edge', 'style', 'diagram', 'default'];
34
34
  /** Statements whose second word declares a name. `diagram` has none. */
35
35
  const DECLARES_NAME = ['node', 'style'];
36
36
  /**
package/dist/index.d.ts CHANGED
@@ -6,7 +6,8 @@ export * from './measure.js';
6
6
  export * from './model.js';
7
7
  export { parse } from './parser.js';
8
8
  export { resolve, type ResolveOptions } from './resolve.js';
9
- export { render, DARK_THEME, type RenderOptions, type Theme } from './render.js';
9
+ export { render, type RenderOptions } from './render.js';
10
+ export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES, type Theme } from './themes.js';
10
11
  import { type RenderOptions } from './render.js';
11
12
  import { type ResolveOptions } from './resolve.js';
12
13
  /** Source text in, SVG out. The whole pipeline in one call. */
package/dist/index.js CHANGED
@@ -6,7 +6,8 @@ export * from './measure.js';
6
6
  export * from './model.js';
7
7
  export { parse } from './parser.js';
8
8
  export { resolve } from './resolve.js';
9
- export { render, DARK_THEME } from './render.js';
9
+ export { render } from './render.js';
10
+ export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES } from './themes.js';
10
11
  import { parse } from './parser.js';
11
12
  import { render } from './render.js';
12
13
  import { resolve } from './resolve.js';
package/dist/parser.js CHANGED
@@ -1,6 +1,7 @@
1
- import { COLOR_KEYS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
1
+ import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { isAttrKey, tokenizeLine } from './lexer.js';
4
+ import { THEME_NAMES, THEMES } from './themes.js';
4
5
  /** Parse a whole source file. One statement per line; blanks and comments drop out. */
5
6
  export function parse(source) {
6
7
  const statements = [];
@@ -27,6 +28,8 @@ function parseStatement(tokens, line) {
27
28
  return parseStyle(tokens, line);
28
29
  case 'diagram':
29
30
  return parseDiagram(tokens, line);
31
+ case 'default':
32
+ return parseDefault(tokens, line);
30
33
  default:
31
34
  throw new SourceError(substitution(keyword.text, tokens), line);
32
35
  }
@@ -502,12 +505,80 @@ function parseDiagram(head, line) {
502
505
  throw new SourceError('diagram sets nothing', line);
503
506
  }
504
507
  for (const key of Object.keys(attrs)) {
508
+ if (key.startsWith('text.')) {
509
+ // The theme has one text color, shared by everything, and that is all
510
+ // the diagram's text sets. A size or a wrap is about one kind of text.
511
+ if (key !== 'text.color') {
512
+ const inner = key.slice('text.'.length);
513
+ throw new SourceError(`diagram text: sets only a color — \`${inner}\` is about one kind of text, so write ` +
514
+ `\`text: (${inner}: ${attrs[key]})\` on a \`default node\` or \`default edge\``, line);
515
+ }
516
+ continue;
517
+ }
505
518
  if (!DIAGRAM_KEYS.includes(key)) {
506
519
  throw new SourceError(`diagram has no "${key}" — it takes ${DIAGRAM_KEYS.join(', ')}`, line);
507
520
  }
508
521
  }
522
+ if (attrs['text'] !== undefined) {
523
+ throw new SourceError('diagram text: takes a bracket — `text: (color: #e0e0e0)`', line);
524
+ }
525
+ const theme = attrs['theme'];
526
+ if (theme !== undefined && THEMES[theme] === undefined) {
527
+ throw new SourceError(`there is no theme called "${theme}" — the themes are ${THEME_NAMES.join(', ')}`, line);
528
+ }
509
529
  return { kind: 'diagram', attrs, line };
510
530
  }
531
+ /**
532
+ * `default <node | leaf | container | edge> <attributes>` — a style every thing
533
+ * of that kind wears without naming it. It is strict about kind where a style
534
+ * is permissive, because it names the kind it is for: a word that kind has no
535
+ * use for can only be a mistake.
536
+ */
537
+ function parseDefault(head, line) {
538
+ const target = head[1];
539
+ if (!target || target.quoted || !DEFAULT_TARGETS.includes(target.text)) {
540
+ throw new SourceError(`default needs the kind it is for — ${DEFAULT_TARGETS.join(', ')}` +
541
+ (target && !isAttrKey(target) ? `, not "${target.text}"` : ''), line);
542
+ }
543
+ const kind = target.text;
544
+ const subject = `default ${kind}`;
545
+ // `default leaf node` reads naturally and says nothing `default leaf` does not.
546
+ const extra = head[2];
547
+ if (extra && !extra.quoted && !isAttrKey(extra) && extra.text === 'node' && kind !== 'node') {
548
+ throw new SourceError(`write \`default ${kind}\` — a ${kind} is already a node`, line);
549
+ }
550
+ const attrs = attrsOnly(head, 2, line, subject);
551
+ if (Object.keys(attrs).length === 0) {
552
+ throw new SourceError(`${subject} sets nothing`, line);
553
+ }
554
+ const allowed = DEFAULT_KEYS[kind];
555
+ for (const key of new Set(Object.keys(attrs).map((k) => k.split('.')[0]))) {
556
+ if (allowed.includes(key))
557
+ continue;
558
+ throw new SourceError(`${subject} has ${key}:, ${defaultRefusal(kind, key)}`, line);
559
+ }
560
+ return { kind: 'default', target: kind, attrs, line };
561
+ }
562
+ /** Why a default cannot carry a word, and what to write instead. */
563
+ function defaultRefusal(kind, key) {
564
+ const takes = `it takes ${DEFAULT_KEYS[kind].map((k) => `${k}:`).join(', ')}`;
565
+ if (key === 'url')
566
+ return `and a destination belongs to one thing rather than to every one of a kind`;
567
+ if (key === 'badge') {
568
+ return 'and a badge is a child, so every leaf given one would become a container. ' +
569
+ 'Put the badge in a style, or write `default container badge:`';
570
+ }
571
+ if (key === 'icon') {
572
+ return 'and a picture cannot hold children, so an icon is a leaf\'s word — write `default leaf icon:`';
573
+ }
574
+ if (kind === 'edge' && (key === 'fill' || key === 'border')) {
575
+ return `which an edge does not have — an edge is colored by line: and text: (color: …)`;
576
+ }
577
+ if (kind !== 'edge' && key === 'line') {
578
+ return `which is an edge's — a node is colored by fill:, border: and text: (color: …)`;
579
+ }
580
+ return `which a default does not set — ${takes}`;
581
+ }
511
582
  function requireName(token, keyword, line) {
512
583
  if (!token || token.quoted) {
513
584
  throw new SourceError(`${keyword} needs a name`, line);
package/dist/render.d.ts CHANGED
@@ -1,31 +1,10 @@
1
1
  import { type Measurer } from './measure.js';
2
2
  import type { Layout } from './model.js';
3
+ import { type Theme } from './themes.js';
3
4
  export interface RenderOptions {
4
5
  measurer?: Measurer;
5
6
  fontSize?: number;
6
7
  theme?: Theme;
7
8
  }
8
- export interface Theme {
9
- background: string;
10
- boxFill: string;
11
- boxStroke: string;
12
- containerFill: string;
13
- /** A container is a region rather than a thing, so its outline is quieter. */
14
- containerStroke: string;
15
- text: string;
16
- mutedText: string;
17
- edge: string;
18
- /** An icon's drawn line. */
19
- iconInk: string;
20
- /** The body an icon's lines enclose. */
21
- iconShade: string;
22
- }
23
- /**
24
- * Sampled out of `examples/reference/arch.png` rather than invented,
25
- * so the benchmark render and the drawing it is measured against differ by
26
- * geometry and typography alone. A container is a shade off the page and barely
27
- * outlined; a leaf is the navy that carries the diagram's weight.
28
- */
29
- export declare const DARK_THEME: Theme;
30
9
  /** Turn solved geometry into a standalone SVG document. */
31
10
  export declare function render(layout: Layout, options?: RenderOptions): string;
package/dist/render.js CHANGED
@@ -3,40 +3,25 @@ import { describeAxis } from './ast.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { ICON_STROKE } from './icons.js';
5
5
  import { monospaceMeasurer } from './measure.js';
6
+ import { DARK_THEME, THEMES } from './themes.js';
6
7
  import { plain } from './text.js';
7
- /**
8
- * Sampled out of `examples/reference/arch.png` rather than invented,
9
- * so the benchmark render and the drawing it is measured against differ by
10
- * geometry and typography alone. A container is a shade off the page and barely
11
- * outlined; a leaf is the navy that carries the diagram's weight.
12
- */
13
- export const DARK_THEME = {
14
- background: '#111111',
15
- boxFill: '#191728',
16
- boxStroke: '#4f5367',
17
- containerFill: '#191920',
18
- containerStroke: '#25242f',
19
- text: '#d9d9d9',
20
- mutedText: '#8b8b8b',
21
- edge: '#5c5c7c',
22
- // Both sampled off the reference's machine glyphs. Note that the reference
23
- // gives each icon its own hue — the drive is gray, the laptop periwinkle, the
24
- // workstation violet — which is a drawing tool's per-shape default and not a
25
- // system. One pair for the whole set is the deliberate difference: an icon
26
- // should read as part of the diagram's palette, not as clip art dropped in.
27
- iconInk: '#8d8d8e',
28
- iconShade: '#3e3d58',
29
- };
30
8
  const CORNER = 8;
31
9
  /** Turn solved geometry into a standalone SVG document. */
32
10
  export function render(layout, options = {}) {
33
11
  const measurer = options.measurer ?? monospaceMeasurer();
34
12
  const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
35
- // `diagram background:` is the author overruling the theme for this one
36
- // drawing, so it is folded in here and everything downstream sees one theme.
37
- const base = options.theme ?? DARK_THEME;
38
- const stated = layout.diagram['background'];
39
- const theme = stated === undefined ? base : { ...base, background: stated };
13
+ // A theme passed in — the command line's `--theme` — beats the one the file
14
+ // names, so one source renders in either. `diagram background:` and `text:`
15
+ // are the author overruling a color of whichever theme that is, and a color
16
+ // written by hand wins over any theme, so they are folded in afterwards and
17
+ // everything downstream sees one theme.
18
+ const named = layout.diagram['theme'];
19
+ const base = options.theme ?? (named === undefined ? undefined : THEMES[named]) ?? DARK_THEME;
20
+ const theme = {
21
+ ...base,
22
+ ...(layout.diagram['background'] !== undefined && { background: layout.diagram['background'] }),
23
+ ...(layout.diagram['text.color'] !== undefined && { text: layout.diagram['text.color'] }),
24
+ };
40
25
  const body = [];
41
26
  for (const root of layout.roots) {
42
27
  body.push(drawNode(root, theme, measurer, fontSize, layout.markup));
package/dist/resolve.js CHANGED
@@ -23,11 +23,12 @@ export function resolve(doc, options = {}) {
23
23
  const margin = options.margin ?? DEFAULT_MARGIN;
24
24
  const styles = collectStyles(doc.statements);
25
25
  checkStyleKeys(doc.statements);
26
- const { nodes, byName, roots } = buildTree(doc.statements, styles);
26
+ const defaults = collectDefaults(doc.statements, styles);
27
+ const { nodes, byName, roots } = buildTree(doc.statements, styles, defaults);
27
28
  // Edges are resolved to nodes before anything is sized, because a labeled
28
29
  // edge claims room in the gap it crosses and so has to be in hand while the
29
30
  // gaps are being worked out. Nothing here reads geometry.
30
- const edges = buildEdges(doc.statements, byName, styles);
31
+ const edges = buildEdges(doc.statements, byName, styles, defaults);
31
32
  const local = new Map();
32
33
  for (const root of roots)
33
34
  sizeNode(root, edges, measurer, fontSize, local);
@@ -58,6 +59,40 @@ function collectStyles(statements) {
58
59
  }
59
60
  return styles;
60
61
  }
62
+ /**
63
+ * Each kind's default, with any style it names already folded in beneath its
64
+ * own words, so a default arrives at a node as one flat bundle. The same kind
65
+ * written twice is refused, as a second `diagram` is: nothing says which was
66
+ * meant.
67
+ */
68
+ function collectDefaults(statements, styles) {
69
+ const defaults = new Map();
70
+ const seen = new Map();
71
+ for (const stmt of statements) {
72
+ if (stmt.kind !== 'default')
73
+ continue;
74
+ const earlier = seen.get(stmt.target);
75
+ if (earlier !== undefined) {
76
+ throw new SourceError(`default ${stmt.target} is written twice, here and on line ${earlier} — keep one`, stmt.line);
77
+ }
78
+ seen.set(stmt.target, stmt.line);
79
+ const { style: _named, ...flat } = appearanceOf(stmt.attrs, styles, stmt.line);
80
+ defaults.set(stmt.target, flat);
81
+ }
82
+ return defaults;
83
+ }
84
+ /**
85
+ * One bundle laid over a weaker one, key by key — except the body, which is
86
+ * one thing said by either of two words. A default's `shape: cylinder` under
87
+ * a style's `icon: disk` is not a node with two bodies; the style said what
88
+ * this node is drawn as, and that settles it.
89
+ */
90
+ function over(base, top) {
91
+ if (top['shape'] === undefined && top['icon'] === undefined)
92
+ return { ...base, ...top };
93
+ const { shape: _shape, icon: _icon, ...rest } = base;
94
+ return { ...rest, ...top };
95
+ }
61
96
  /** A file holds one diagram, so a second `diagram` statement is a mistake. */
62
97
  function collectDiagram(statements) {
63
98
  let found;
@@ -70,12 +105,27 @@ function collectDiagram(statements) {
70
105
  }
71
106
  return found ?? {};
72
107
  }
73
- function buildTree(statements, styles) {
108
+ function buildTree(statements, styles, defaults) {
74
109
  const nodes = [];
75
110
  const byName = new Map();
76
111
  const roots = [];
77
112
  /** Each badge child's name, and the node whose `badge:` it was written out from. */
78
113
  const badges = new Map();
114
+ // Which nodes will hold children has to be known before any node is built,
115
+ // because it decides which default a node wears, and its children are
116
+ // declared after it. A child is a dotted name, or a badge the node's own
117
+ // line or styles give it; a default cannot give a leaf a badge, so what
118
+ // the defaults say cannot change the answer.
119
+ const parents = new Set();
120
+ for (const stmt of statements) {
121
+ if (stmt.kind !== 'node')
122
+ continue;
123
+ const cut = stmt.name.lastIndexOf('.');
124
+ if (cut !== -1)
125
+ parents.add(stmt.name.slice(0, cut));
126
+ if (appearanceOf(stmt.attrs, styles, stmt.line)['badge'] !== undefined)
127
+ parents.add(stmt.name);
128
+ }
79
129
  for (const stmt of statements) {
80
130
  if (stmt.kind !== 'node')
81
131
  continue;
@@ -87,7 +137,9 @@ function buildTree(statements, styles) {
87
137
  }
88
138
  throw new SourceError(`"${stmt.name}" is declared twice`, stmt.line);
89
139
  }
90
- const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
140
+ // Weakest first: every node's default, then the leaf's or the container's,
141
+ // then the node's own styles and words over both.
142
+ const appearance = over(over(defaults.get('node') ?? {}, defaults.get(parents.has(stmt.name) ? 'container' : 'leaf') ?? {}), appearanceOf(stmt.attrs, styles, stmt.line));
91
143
  const body = bodyFor(stmt.attrs, appearance, stmt.line);
92
144
  const kind = KIND_OF_BODY[body.kind];
93
145
  // A node with no text of its own is labelled with its name, because the
@@ -275,6 +327,11 @@ function checkAttrs(kind, name, attrs, line) {
275
327
  const parts = COLOR_PARTS[kind].map((part) => `\`${part}\``).join(', ');
276
328
  throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. ${capital(article(KIND_WORD[kind]))} is ${KIND_PARTS[kind]}, so it has no ${key} — it takes ${parts}`, line);
277
329
  }
330
+ // A word only the whole drawing takes, such as a theme.
331
+ if (belongTo(key).length === 0) {
332
+ throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. \`${key}:\` is said about the ` +
333
+ `whole drawing — write \`diagram ${key}: ${shown}\``, line);
334
+ }
278
335
  // Anything else names no part, so what the kind is made of explains
279
336
  // nothing. What does explain it is where the word *does* belong, which is
280
337
  // also the more useful thing to be told: the author has usually written a
@@ -373,7 +430,7 @@ function appearanceOf(attrs, styles, line) {
373
430
  }
374
431
  return { ...merged, ...attrs };
375
432
  }
376
- function buildEdges(statements, byName, styles) {
433
+ function buildEdges(statements, byName, styles, defaults) {
377
434
  const edges = [];
378
435
  for (const stmt of statements) {
379
436
  if (stmt.kind !== 'edge')
@@ -400,7 +457,7 @@ function buildEdges(statements, byName, styles) {
400
457
  }),
401
458
  ...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
402
459
  };
403
- const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
460
+ const appearance = { ...defaults.get('edge'), ...appearanceOf(stmt.attrs, styles, stmt.line) };
404
461
  const what = `${stmt.from} -> ${stmt.to}`;
405
462
  checkAttrs('edge', what, stmt.attrs, stmt.line);
406
463
  checkStyleUse('edge', what, stmt.attrs, styles, stmt.line);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The named themes a file chooses with `diagram theme: <name>`, and the one it
3
+ * gets when it says nothing.
4
+ *
5
+ * A theme supplies every color the file does not state. It never supplies a
6
+ * size or a distance, so switching themes cannot move anything — geometry may
7
+ * not depend on appearance.
8
+ *
9
+ * Most of the palettes are borrowed from editor color schemes, all of them MIT
10
+ * licensed and credited in NOTICE. Those palettes were made for code, where a
11
+ * color marks a keyword or a string; a diagram needs a page, two box fills, a
12
+ * border, text and a line. So each is the scheme read as a diagram rather than
13
+ * a transfer of it: the page is the scheme's background, a leaf is its raised
14
+ * surface, a container sits between the two, and the lines take one of its
15
+ * accents.
16
+ */
17
+ export interface Theme {
18
+ background: string;
19
+ boxFill: string;
20
+ boxStroke: string;
21
+ containerFill: string;
22
+ /** A container is a region rather than a thing, so its outline is quieter. */
23
+ containerStroke: string;
24
+ text: string;
25
+ mutedText: string;
26
+ edge: string;
27
+ /** An icon's drawn line. */
28
+ iconInk: string;
29
+ /** The body an icon's lines enclose. */
30
+ iconShade: string;
31
+ }
32
+ /**
33
+ * Sampled out of `examples/reference/arch.png` rather than invented,
34
+ * so the benchmark render and the drawing it is measured against differ by
35
+ * geometry and typography alone. A container is a shade off the page and barely
36
+ * outlined; a leaf is the navy that carries the diagram's weight.
37
+ */
38
+ export declare const DARK_THEME: Theme;
39
+ /**
40
+ * Every theme a file may name, in the order they are offered. Pairs sit
41
+ * together, dark first, and the two that have no light half follow them.
42
+ */
43
+ export declare const THEMES: Readonly<Record<string, Theme>>;
44
+ /** The theme a file gets when it names none. */
45
+ export declare const DEFAULT_THEME = "dark";
46
+ export declare const THEME_NAMES: readonly string[];
package/dist/themes.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Sampled out of `examples/reference/arch.png` rather than invented,
3
+ * so the benchmark render and the drawing it is measured against differ by
4
+ * geometry and typography alone. A container is a shade off the page and barely
5
+ * outlined; a leaf is the navy that carries the diagram's weight.
6
+ */
7
+ export const DARK_THEME = {
8
+ background: '#111111',
9
+ boxFill: '#191728',
10
+ boxStroke: '#4f5367',
11
+ containerFill: '#191920',
12
+ containerStroke: '#25242f',
13
+ text: '#d9d9d9',
14
+ mutedText: '#8b8b8b',
15
+ edge: '#5c5c7c',
16
+ // Both sampled off the reference's machine glyphs. Note that the reference
17
+ // gives each icon its own hue — the drive is gray, the laptop periwinkle, the
18
+ // workstation violet — which is a drawing tool's per-shape default and not a
19
+ // system. One pair for the whole set is the deliberate difference: an icon
20
+ // should read as part of the diagram's palette, not as clip art dropped in.
21
+ iconInk: '#8d8d8e',
22
+ iconShade: '#3e3d58',
23
+ };
24
+ /** The dark theme's counterpart: the same roles, on a white page. */
25
+ const LIGHT_THEME = {
26
+ background: '#ffffff',
27
+ boxFill: '#eef0f7',
28
+ boxStroke: '#8a90a8',
29
+ containerFill: '#f6f7fa',
30
+ containerStroke: '#dcdfe7',
31
+ text: '#1f2328',
32
+ mutedText: '#6e7781',
33
+ edge: '#7c83a0',
34
+ iconInk: '#57606a',
35
+ iconShade: '#d6d9e6',
36
+ };
37
+ /**
38
+ * Every theme a file may name, in the order they are offered. Pairs sit
39
+ * together, dark first, and the two that have no light half follow them.
40
+ */
41
+ export const THEMES = {
42
+ dark: DARK_THEME,
43
+ light: LIGHT_THEME,
44
+ // Solarized, Ethan Schoonover. base03 page, base02 leaves, blue lines.
45
+ 'solarized-dark': {
46
+ background: '#002b36',
47
+ boxFill: '#073642',
48
+ boxStroke: '#586e75',
49
+ containerFill: '#03313c',
50
+ containerStroke: '#0b3f4c',
51
+ text: '#93a1a1',
52
+ mutedText: '#657b83',
53
+ edge: '#268bd2',
54
+ iconInk: '#839496',
55
+ iconShade: '#0f4a58',
56
+ },
57
+ // base3 page, base2 leaves, the same blue.
58
+ 'solarized-light': {
59
+ background: '#fdf6e3',
60
+ boxFill: '#eee8d5',
61
+ boxStroke: '#93a1a1',
62
+ containerFill: '#f6efdc',
63
+ containerStroke: '#e3dcc7',
64
+ text: '#586e75',
65
+ mutedText: '#93a1a1',
66
+ edge: '#268bd2',
67
+ iconInk: '#657b83',
68
+ iconShade: '#e0d9c3',
69
+ },
70
+ // Gruvbox, Pavel Pertsev. bg0 page, bg1 leaves, the warm yellow for lines.
71
+ 'gruvbox-dark': {
72
+ background: '#282828',
73
+ boxFill: '#3c3836',
74
+ boxStroke: '#665c54',
75
+ containerFill: '#32302f',
76
+ containerStroke: '#3c3836',
77
+ text: '#ebdbb2',
78
+ mutedText: '#a89984',
79
+ edge: '#d79921',
80
+ iconInk: '#a89984',
81
+ iconShade: '#504945',
82
+ },
83
+ 'gruvbox-light': {
84
+ background: '#fbf1c7',
85
+ boxFill: '#ebdbb2',
86
+ boxStroke: '#bdae93',
87
+ containerFill: '#f2e5bc',
88
+ containerStroke: '#e5d4a7',
89
+ text: '#3c3836',
90
+ mutedText: '#7c6f64',
91
+ edge: '#b57614',
92
+ iconInk: '#7c6f64',
93
+ iconShade: '#d5c4a1',
94
+ },
95
+ // Catppuccin. Mocha's base page and surface leaves, blue lines.
96
+ 'catppuccin-mocha': {
97
+ background: '#1e1e2e',
98
+ boxFill: '#313244',
99
+ boxStroke: '#6c7086',
100
+ containerFill: '#25253a',
101
+ containerStroke: '#313244',
102
+ text: '#cdd6f4',
103
+ mutedText: '#9399b2',
104
+ edge: '#89b4fa',
105
+ iconInk: '#a6adc8',
106
+ iconShade: '#45475a',
107
+ },
108
+ // Latte's base page and crust leaves, lavender lines.
109
+ 'catppuccin-latte': {
110
+ background: '#eff1f5',
111
+ boxFill: '#dce0e8',
112
+ boxStroke: '#9ca0b0',
113
+ containerFill: '#e6e9ef',
114
+ containerStroke: '#ccd0da',
115
+ text: '#4c4f69',
116
+ mutedText: '#7c7f93',
117
+ edge: '#7287fd',
118
+ iconInk: '#6c6f85',
119
+ iconShade: '#ccd0da',
120
+ },
121
+ // Nord, Sven Greb. Polar Night page and leaves, Frost lines.
122
+ nord: {
123
+ background: '#2e3440',
124
+ boxFill: '#3b4252',
125
+ boxStroke: '#4c566a',
126
+ containerFill: '#333a47',
127
+ containerStroke: '#3b4252',
128
+ text: '#d8dee9',
129
+ mutedText: '#7b88a1',
130
+ edge: '#81a1c1',
131
+ iconInk: '#aeb7c6',
132
+ iconShade: '#434c5e',
133
+ },
134
+ // Dracula, the free palette. Current-line leaves, comment borders, purple lines.
135
+ dracula: {
136
+ background: '#282a36',
137
+ boxFill: '#44475a',
138
+ boxStroke: '#6272a4',
139
+ containerFill: '#21222c',
140
+ containerStroke: '#343746',
141
+ text: '#f8f8f2',
142
+ mutedText: '#6272a4',
143
+ edge: '#bd93f9',
144
+ iconInk: '#b6b9cc',
145
+ iconShade: '#565a70',
146
+ },
147
+ // For low vision and projectors: no fills to lean on, every line at full
148
+ // strength, and a container told apart by a gray outline alone.
149
+ 'high-contrast-dark': {
150
+ background: '#000000',
151
+ boxFill: '#000000',
152
+ boxStroke: '#ffffff',
153
+ containerFill: '#000000',
154
+ containerStroke: '#9a9a9a',
155
+ text: '#ffffff',
156
+ mutedText: '#c8c8c8',
157
+ edge: '#ffffff',
158
+ iconInk: '#ffffff',
159
+ iconShade: '#3a3a3a',
160
+ },
161
+ 'high-contrast-light': {
162
+ background: '#ffffff',
163
+ boxFill: '#ffffff',
164
+ boxStroke: '#000000',
165
+ containerFill: '#ffffff',
166
+ containerStroke: '#6a6a6a',
167
+ text: '#000000',
168
+ mutedText: '#3d3d3d',
169
+ edge: '#000000',
170
+ iconInk: '#000000',
171
+ iconShade: '#d0d0d0',
172
+ },
173
+ // For paper: no fill anywhere an ink cartridge would notice, black lines,
174
+ // gray only where the dark theme is quiet.
175
+ print: {
176
+ background: '#ffffff',
177
+ boxFill: '#ffffff',
178
+ boxStroke: '#000000',
179
+ containerFill: '#ffffff',
180
+ containerStroke: '#8c8c8c',
181
+ text: '#000000',
182
+ mutedText: '#666666',
183
+ edge: '#333333',
184
+ iconInk: '#000000',
185
+ iconShade: '#ffffff',
186
+ },
187
+ };
188
+ /** The theme a file gets when it names none. */
189
+ export const DEFAULT_THEME = 'dark';
190
+ export const THEME_NAMES = Object.keys(THEMES);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {