@eriknaslund/skiss 0.4.0 → 0.6.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
@@ -41,6 +41,7 @@ Skiss is text you can type while someone talks. A field is one line. A change is
41
41
  - **What you leave out means "not yet".** Never "unknown", never "none". A sketch is silent about what you do not know.
42
42
  - **`@Catalog` says which system owns a thing.** On a class, or on a single field when the box is really a view over two systems.
43
43
  - **`~` and `=` are two different statements.** `~` says two classes are about the same thing. `=` says which field joins to which. The first is a domain conversation, the second an integration conversation.
44
+ - **`<` inherits, once.** `Jedi < Character` on the class line: the child has everything the parent has and adds its own. One parent, no mixins; it compiles to `is_a`.
44
45
  - **`#` describes, `?` doubts.** A description ends up in generated documentation. A doubt never does.
45
46
  - **Every line stands on its own.** A half-typed line breaks that line and nothing else, so the diagram keeps rendering while you type.
46
47
  - **Everything you can write compiles to valid LinkML.** Skiss is a strict subset. Nothing is lost on the way up.
@@ -62,11 +63,15 @@ Or without installing: `npx @eriknaslund/skiss diagram model.skiss`.
62
63
 
63
64
  `skiss diagram` writes a Mermaid class diagram to standard output; `-o diagram.mmd` writes it to a file, `--notes` includes the `?` doubts as notes, and `-` reads standard input. Anything the file gets wrong is reported on standard error, one line each, and the diagram is still drawn from the lines that parse. `--strict` makes that a non-zero exit. `skiss --help` lists the rest.
64
65
 
66
+ `skiss render model.skiss -o model.svg` writes a picture rather than the Mermaid text. A class diagram is laid out in a browser, so this one command needs a browser: it delegates to the official [Mermaid CLI](https://github.com/mermaid-js/mermaid-cli), which you install yourself with `npm install -g @mermaid-js/mermaid-cli`. Nothing in the package or the library depends on it, and `skiss render` says that line and exits 2 when it is not there. The format is the extension of `-o` — `.svg` or `.png` — and `--format svg|png` overrides it; with no `-o` the SVG goes to standard output, and a PNG always needs an `-o`. `--scale` is what the PNG is rasterised at and defaults to 2; `--notes`, `--strict` and the diagnostics work as they do for `diagram`. When `mmdc` itself fails, its exit status is the command's.
67
+
65
68
  `skiss compile model.skiss` writes a LinkML schema instead, as YAML on standard output. `--json` writes the same schema as JSON, `--name` sets the schema name (it defaults to the file name without its extension, and to `sketch` when reading standard input), and `-o`, `-`, `--strict` and the diagnostics work exactly as they do for `diagram`.
66
69
 
67
- `skiss import schema.linkml.yaml` goes the other way: it reads a LinkML schema, YAML or JSON, and writes Skiss. What the projection could not carry — `is_a`, patterns, `required`, the names it had to convert — is one line on standard error before the diagnostics, because a sketch is a smaller language than LinkML and that line says by how much. `-o`, `-` and the diagnostics work as they do for `compile`; `--strict` exits 1 when the projected sketch has a diagnostic, and also when anything was dropped. An input that is not a schema at all — not YAML, or YAML no schema can be read out of — produces no sketch, says why on standard error, and exits 2, the code every command uses for unreadable input.
70
+ `skiss import schema.linkml.yaml` goes the other way: it reads a LinkML schema, YAML or JSON, and writes Skiss. What the projection could not carry — patterns, `required`, `mixins`, the names it had to convert — is one line on standard error before the diagnostics, because a sketch is a smaller language than LinkML and that line says by how much. `-o`, `-` and the diagnostics work as they do for `compile`; `--strict` exits 1 when the projected sketch has a diagnostic, and also when anything was dropped. An input that is not a schema at all — not YAML, or YAML no schema can be read out of — produces no sketch, says why on standard error, and exits 2, the code every command uses for unreadable input.
71
+
72
+ `npm install @eriknaslund/skiss` also gives you the library: `compile`, `formatDiagnostic`, `parse`, `resolve`, `toLinkML`, `serialize`, `toMermaid`, `toSkiss`, `fromLinkML`, `formatDropped`, `importLinkML`, `tokenizeLine` and `VERSION`, browser-safe.
68
73
 
69
- `npm install @eriknaslund/skiss` also gives you the library: `compile`, `formatDiagnostic`, `parse`, `resolve`, `toLinkML`, `serialize`, `toMermaid`, `toSkiss`, `fromLinkML`, `formatDropped`, `importLinkML` and `VERSION`, browser-safe.
74
+ `tokenizeLine` is there for editors: it says where the words of one line are — `{ kind, from, to }` for each run of characters, so a host can colour them. It is line-based and stateless, as the language is: one line in, its tokens out, nothing carried from the line before, and nothing said about a run it does not recognise, so a half-typed line keeps the colours it has earned. It is not a parser and reports nothing; diagnostics stay `parse` and `resolve`'s ([ADR 0010](docs/adr/0010-tokeniser-in-the-package.md)).
70
75
 
71
76
  Editors that render Skiss live are separate projects built on this package. This repository is the language, the library and the command line.
72
77
 
@@ -84,9 +89,9 @@ Editors that render Skiss live are separate projects built on this package. This
84
89
 
85
90
  ## Status
86
91
 
87
- 0.4.0, published on npm as [`@eriknaslund/skiss`](https://www.npmjs.com/package/@eriknaslund/skiss). The language parses, resolves and diagnoses; `skiss diagram` draws it, `skiss compile` writes LinkML that the real LinkML toolchain validates in CI, and `skiss import` reads a LinkML schema back into a sketch. The library exports the same in both directions and is browser-safe.
92
+ 0.6.0, published on npm as [`@eriknaslund/skiss`](https://www.npmjs.com/package/@eriknaslund/skiss). The language parses, resolves and diagnoses; `skiss diagram` draws it, `skiss compile` writes LinkML that the real LinkML toolchain validates in CI, and `skiss import` reads a LinkML schema back into a sketch. The library exports the same in both directions and is browser-safe.
88
93
 
89
- The [specification](docs/SPEC.md) is at 0.2 and moves when the language does, which is not with every release. Not built: inheritance, and editing a projected schema so that what Skiss cannot express survives the edit (SPEC §8.1). [CHANGELOG.md](CHANGELOG.md) is what changed when.
94
+ The [specification](docs/SPEC.md) is at 0.3 and moves when the language does, which is not with every release. Not built: editing a projected schema so that what Skiss cannot express survives the edit (SPEC §8.1). [CHANGELOG.md](CHANGELOG.md) is what changed when.
90
95
 
91
96
  ## License
92
97
 
package/dist/cli.js CHANGED
@@ -1,10 +1,92 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFile, writeFile } from "fs/promises";
5
- import { basename, extname } from "path";
4
+ import { spawnSync } from "child_process";
5
+ import { constants } from "fs";
6
+ import { access, mkdtemp, readFile, rm, writeFile } from "fs/promises";
7
+ import { tmpdir } from "os";
8
+ import { basename, delimiter, extname, join } from "path";
6
9
  import { parseArgs } from "util";
7
10
 
11
+ // src/inheritance.ts
12
+ function inheritance(classes) {
13
+ const declared = /* @__PURE__ */ new Map();
14
+ for (const cls of classes) {
15
+ if (!declared.has(cls.name.text)) declared.set(cls.name.text, cls);
16
+ }
17
+ const circles = /* @__PURE__ */ new Map();
18
+ const parentOf = (cls) => {
19
+ if (cls.parent === void 0 || circles.has(cls)) return void 0;
20
+ return declared.get(cls.parent.text);
21
+ };
22
+ for (const cls of declared.values()) {
23
+ const path = [cls];
24
+ const seen = new Set(path);
25
+ for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
26
+ if (seen.has(next)) {
27
+ const circle = path.slice(path.indexOf(next));
28
+ let closer = circle[0];
29
+ for (const member2 of circle) {
30
+ if (member2.line > closer.line) closer = member2;
31
+ }
32
+ const at = circle.indexOf(closer);
33
+ circles.set(closer, [...circle.slice(at), ...circle.slice(0, at)]);
34
+ break;
35
+ }
36
+ path.push(next);
37
+ seen.add(next);
38
+ }
39
+ }
40
+ const ancestors = (cls) => {
41
+ const out = [];
42
+ const seen = /* @__PURE__ */ new Set([cls]);
43
+ for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
44
+ if (seen.has(next)) break;
45
+ out.push(next);
46
+ seen.add(next);
47
+ }
48
+ return out;
49
+ };
50
+ const find = (cls, matches) => {
51
+ for (const from of ancestors(cls)) {
52
+ const field = from.fields.find(matches);
53
+ if (field !== void 0) return { field, from };
54
+ }
55
+ return void 0;
56
+ };
57
+ return {
58
+ ancestors,
59
+ inherited: (cls, fieldName) => find(cls, (field) => field.name.text === fieldName),
60
+ inheritedIdentifier: (cls) => find(cls, (field) => field.identifier),
61
+ circle: (cls) => circles.get(cls)
62
+ };
63
+ }
64
+ function sameField(child, inheritedField) {
65
+ return meaning(child) === meaning(inheritedField);
66
+ }
67
+ var meaning = (field) => JSON.stringify([
68
+ field.name.text,
69
+ field.identifier,
70
+ typeMeaning(field),
71
+ field.system?.text,
72
+ field.joinsTo === void 0 ? void 0 : [field.joinsTo.className.text, field.joinsTo.fieldName.text],
73
+ field.description,
74
+ field.note
75
+ ]);
76
+ function typeMeaning(field) {
77
+ const type = field.type;
78
+ if (type === void 0) return void 0;
79
+ switch (type.kind) {
80
+ case "primitive":
81
+ return [type.kind, type.name, type.many];
82
+ case "class":
83
+ case "unknown":
84
+ return [type.kind, type.name.text, type.many];
85
+ case "enum":
86
+ return [type.kind, type.values.map((v) => v.text), type.many];
87
+ }
88
+ }
89
+
8
90
  // src/parse.ts
9
91
  var isWs = (c) => c === " " || c === " ";
10
92
  var trimWs = (s) => s.replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
@@ -28,7 +110,7 @@ function primitiveFor(word) {
28
110
  return Object.hasOwn(PRIMITIVES, word) ? PRIMITIVES[word] : void 0;
29
111
  }
30
112
  var FIELD_ORDER = "name, `*`, `: type`, `@System`, `= Class.field`, then `#` and `?`";
31
- var CLASS_ORDER = "name, `@System`, `~ Class`, then `#` and `?`";
113
+ var CLASS_ORDER = "name, `< Parent`, `@System`, `~ Class`, then `#` and `?`";
32
114
  function parse(source) {
33
115
  const classes = [];
34
116
  const diagnostics = [];
@@ -115,7 +197,11 @@ function splitTrailer(text) {
115
197
  return trailer;
116
198
  }
117
199
  var WORD = /[\p{L}\p{N}_-]+/uy;
118
- var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "["];
200
+ function wordAt(text, i) {
201
+ WORD.lastIndex = i;
202
+ return WORD.exec(text)?.[0];
203
+ }
204
+ var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "<", ",", "["];
119
205
  function tokenize(head) {
120
206
  const tokens = [];
121
207
  let i = 0;
@@ -125,11 +211,10 @@ function tokenize(head) {
125
211
  i++;
126
212
  continue;
127
213
  }
128
- WORD.lastIndex = i;
129
- const word = WORD.exec(head);
130
- if (word !== null) {
131
- tokens.push({ kind: "word", text: word[0], col: i, end: i + word[0].length });
132
- i += word[0].length;
214
+ const word = wordAt(head, i);
215
+ if (word !== void 0) {
216
+ tokens.push({ kind: "word", text: word, col: i, end: i + word.length });
217
+ i += word.length;
133
218
  continue;
134
219
  }
135
220
  if (c === "[" && head[i + 1] === "]") {
@@ -177,6 +262,13 @@ function parseClassLine(text, line) {
177
262
  );
178
263
  }
179
264
  const node = { name: name(head, line), line };
265
+ const lt = peek(c);
266
+ if (lt?.kind === "<") {
267
+ take(c);
268
+ const parent = parseParent(c, lt);
269
+ if (!parent.ok) return parent;
270
+ node.parent = parent.value;
271
+ }
180
272
  const at = peek(c);
181
273
  if (at?.kind === "@") {
182
274
  take(c);
@@ -306,6 +398,31 @@ function parseFieldLine(text, line) {
306
398
  if (trailer.note !== void 0) node.note = trailer.note;
307
399
  return { ok: true, value: node };
308
400
  }
401
+ function parseParent(c, lt) {
402
+ const parent = take(c);
403
+ if (parent === void 0 || parent.kind !== "word") {
404
+ return fail(
405
+ "E_UNPARSABLE",
406
+ "Expected a class name after `<`, e.g. `Jedi < Character`",
407
+ c.line,
408
+ parent ?? afterSpan(lt)
409
+ );
410
+ }
411
+ if (!CLASS_NAME.test(parent.text)) {
412
+ const message = Object.hasOwn(PRIMITIVES, parent.text) ? `\`${parent.text}\` is a primitive, not a class; a parent is a class` : `The class after \`<\` must be UpperCamelCase: \`${parent.text}\``;
413
+ return fail("E_BAD_NAME", message, c.line, parent);
414
+ }
415
+ const next = peek(c);
416
+ if (next?.kind === "," || next?.kind === "<") {
417
+ return fail(
418
+ "E_UNPARSABLE",
419
+ "A class has one parent; `<` takes a single class name",
420
+ c.line,
421
+ next
422
+ );
423
+ }
424
+ return { ok: true, value: name(parent, c.line) };
425
+ }
309
426
  function parseSystem(c, at) {
310
427
  const system = take(c);
311
428
  if (system === void 0 || system.kind !== "word") {
@@ -415,7 +532,7 @@ function parseType(c, colon) {
415
532
  );
416
533
  }
417
534
  function unexpected(token, line, kind, order) {
418
- const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["@", "~"];
535
+ const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["<", "@", "~"];
419
536
  if (modifiers.includes(token.kind)) {
420
537
  return fail(
421
538
  "E_UNPARSABLE",
@@ -424,10 +541,10 @@ function unexpected(token, line, kind, order) {
424
541
  token
425
542
  );
426
543
  }
427
- if (token.text === "<") {
544
+ if (token.kind === "<") {
428
545
  return fail(
429
546
  "E_UNPARSABLE",
430
- "`<` is reserved for inheritance and not yet part of the language",
547
+ "`<` marks inheritance and belongs on the class line: `Child < Parent`",
431
548
  line,
432
549
  token
433
550
  );
@@ -456,10 +573,14 @@ function resolve(doc) {
456
573
  };
457
574
  const classes = out.classes;
458
575
  const declared = new Set(classes.map((c) => c.name.text));
459
- const warnings = [];
576
+ const found = [];
460
577
  const undeclared = /* @__PURE__ */ new Map();
578
+ const tree = inheritance(classes);
461
579
  const warn = (code, message, at) => {
462
- warnings.push({ severity: "warning", code, message, line: at.line, col: at.col, end: at.end });
580
+ found.push({ severity: "warning", code, message, line: at.line, col: at.col, end: at.end });
581
+ };
582
+ const error = (code, message, at) => {
583
+ found.push({ severity: "error", code, message, line: at.line, col: at.col, end: at.end });
463
584
  };
464
585
  const reference = (name2) => {
465
586
  if (declared.has(name2.text)) return true;
@@ -480,9 +601,25 @@ function resolve(doc) {
480
601
  cls.name
481
602
  );
482
603
  }
604
+ const parent = cls.parent;
605
+ if (parent !== void 0) {
606
+ reference(parent);
607
+ const circle = tree.circle(cls);
608
+ if (circle !== void 0) {
609
+ const through = circle.slice(1).map((member2) => `\`${member2.name.text}\``);
610
+ const path = through.length === 0 ? "" : ` through ${through.join(", ")}`;
611
+ error(
612
+ "E_INHERITANCE_CYCLE",
613
+ `class \`${cls.name.text}\` inherits from itself${path}; this \`<\` is not carried`,
614
+ parent
615
+ );
616
+ cls.parent = void 0;
617
+ }
618
+ }
483
619
  if (cls.similarTo !== void 0) reference(cls.similarTo);
484
620
  const firstField = /* @__PURE__ */ new Map();
485
- let identifier;
621
+ const inheritedIdentifier = tree.inheritedIdentifier(cls);
622
+ let identifier = inheritedIdentifier?.field;
486
623
  for (const field of cls.fields) {
487
624
  const earlierField = firstField.get(field.name.text);
488
625
  if (earlierField === void 0) firstField.set(field.name.text, field);
@@ -496,14 +633,23 @@ function resolve(doc) {
496
633
  if (field.identifier) {
497
634
  if (identifier === void 0) identifier = field;
498
635
  else {
636
+ const from = identifier === inheritedIdentifier?.field ? ` inherits the identifier \`${identifier.name.text}\` from class \`${inheritedIdentifier.from.name.text}\`, line` : ` already has the identifier \`${identifier.name.text}\` on line`;
499
637
  warn(
500
638
  "W_MULTIPLE_IDENTIFIERS",
501
- `\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\` already has the identifier \`${identifier.name.text}\` on line ${identifier.line}`,
639
+ `\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\`${from} ${identifier.line}`,
502
640
  field.identifierAt ?? field.name
503
641
  );
504
642
  field.identifier = false;
505
643
  }
506
644
  }
645
+ const replaced = tree.inherited(cls, field.name.text);
646
+ if (replaced !== void 0 && sameField(field, replaced.field)) {
647
+ warn(
648
+ "W_REDUNDANT_OVERRIDE",
649
+ `field \`${field.name.text}\` is identical to the one inherited from class \`${replaced.from.name.text}\` on line ${replaced.field.line}`,
650
+ field.name
651
+ );
652
+ }
507
653
  if (field.type?.kind === "unknown") {
508
654
  const word = field.type.name.text;
509
655
  const suggestion = closestTypeWord(word);
@@ -541,8 +687,8 @@ function resolve(doc) {
541
687
  }
542
688
  }
543
689
  const present2 = new Set(out.diagnostics.map(key));
544
- for (const w of warnings) {
545
- if (!present2.has(key(w))) out.diagnostics.push(w);
690
+ for (const d of found) {
691
+ if (!present2.has(key(d))) out.diagnostics.push(d);
546
692
  }
547
693
  out.diagnostics.sort((a, b) => a.line - b.line);
548
694
  out.undeclared = [...undeclared.values()].map((n) => ({ ...n }));
@@ -573,6 +719,7 @@ function copyField(f) {
573
719
  }
574
720
  function copyClass(c) {
575
721
  const out = { ...c, name: copyName(c.name), fields: c.fields.map(copyField) };
722
+ if (c.parent !== void 0) out.parent = copyName(c.parent);
576
723
  if (c.system !== void 0) out.system = copyName(c.system);
577
724
  if (c.similarTo !== void 0) out.similarTo = copyName(c.similarTo);
578
725
  return out;
@@ -751,6 +898,7 @@ function buildClass(cls, mappingPrefix, enums) {
751
898
  const { node, fields } = cls;
752
899
  const out = {};
753
900
  if (node.description !== void 0) out.description = node.description;
901
+ if (node.parent !== void 0) out.is_a = node.parent.text;
754
902
  const annotations = {};
755
903
  if (node.system !== void 0) annotations.system = node.system.text;
756
904
  if (node.note !== void 0) annotations.note = node.note;
@@ -833,6 +981,7 @@ function toMermaid(doc, opts) {
833
981
  }
834
982
  for (const cls of resolved.classes) {
835
983
  const from = cls.name.text;
984
+ if (cls.parent !== void 0) lines.push(` ${cls.parent.text} <|-- ${from}`);
836
985
  if (cls.similarTo !== void 0) lines.push(` ${from} ..> ${cls.similarTo.text} : similar`);
837
986
  for (const field of cls.fields) {
838
987
  const type = field.type;
@@ -939,7 +1088,7 @@ function toSkiss(doc) {
939
1088
  `;
940
1089
  }
941
1090
  function classBlock(cls) {
942
- const head = cls.name.text + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
1091
+ const head = cls.name.text + (cls.parent === void 0 ? "" : ` < ${cls.parent.text}`) + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
943
1092
  return [withTrailer(head, cls), ...cls.fields.map(fieldLine)].join("\n");
944
1093
  }
945
1094
  function fieldLine(field) {
@@ -993,7 +1142,14 @@ var PRIMITIVE_RANGE = {
993
1142
  uri: "uri",
994
1143
  uriorcurie: "uri"
995
1144
  };
996
- var CLASS_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "close_mappings", "attributes", "slots"]);
1145
+ var CLASS_KEYS = /* @__PURE__ */ new Set([
1146
+ "description",
1147
+ "is_a",
1148
+ "annotations",
1149
+ "close_mappings",
1150
+ "attributes",
1151
+ "slots"
1152
+ ]);
997
1153
  var SLOT_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "range", "multivalued", "identifier"]);
998
1154
  var CLASS_TAGS = /* @__PURE__ */ new Set(["system", "note", "undeclared"]);
999
1155
  var SLOT_TAGS = /* @__PURE__ */ new Set(["system", "note", "joins_to"]);
@@ -1036,7 +1192,7 @@ function fromLinkML(schema) {
1036
1192
  for (const key2 of Object.keys(classDefs)) {
1037
1193
  const names = /* @__PURE__ */ new Map();
1038
1194
  const taken = /* @__PURE__ */ new Set();
1039
- for (const slot of slotKeysOf(asRecord(get(classDefs, key2)) ?? {})) {
1195
+ for (const slot of fieldKeysOf(key2)) {
1040
1196
  names.set(slot, unique(toFieldName(slot), taken));
1041
1197
  }
1042
1198
  fieldNames.set(key2, names);
@@ -1106,6 +1262,12 @@ function fromLinkML(schema) {
1106
1262
  const note = oneLine(rawNote);
1107
1263
  if (note !== void 0) node.note = note;
1108
1264
  } else if (rawNote !== void 0) report("annotation", name2, notText("note", rawNote));
1265
+ const parent = stringOf(get(def, "is_a"));
1266
+ if (parent !== void 0) {
1267
+ const parentName = Object.hasOwn(classDefs, parent) ? classNameOf(parent) : toClassName(parent);
1268
+ node.parent = at0(parentName);
1269
+ reference(name2, parent, parentName);
1270
+ }
1109
1271
  const mappings = stringList(get(def, "close_mappings"));
1110
1272
  const first = mappings[0];
1111
1273
  const target = first === void 0 ? "" : localName(first);
@@ -1128,6 +1290,9 @@ function fromLinkML(schema) {
1128
1290
  }
1129
1291
  }
1130
1292
  const block = get(def, other);
1293
+ if (other === "is_a" && present(block) && stringOf(block) === void 0) {
1294
+ report("is_a", name2, `${shown(block)} is not a class name`);
1295
+ }
1131
1296
  if (other === "attributes" && present(block) && asRecord(block) === void 0) {
1132
1297
  report("attributes", name2, `${shown(block)} is not a map`);
1133
1298
  }
@@ -1142,10 +1307,12 @@ function fromLinkML(schema) {
1142
1307
  report("slot_usage", name2);
1143
1308
  continue;
1144
1309
  }
1310
+ const written = slotKeysOf(def);
1311
+ const inherited = inheritedSlots(key2);
1145
1312
  for (const slot of Object.keys(usage)) {
1146
- if (fieldNames.get(key2)?.has(slot) === true) {
1313
+ if (written.includes(slot)) {
1147
1314
  report("slot_usage", `${name2}.${fieldNameOf(key2, slot)}`);
1148
- } else {
1315
+ } else if (!inherited.has(slot)) {
1149
1316
  report("slot_usage", name2, `\`${slot}\` is inherited and not in the sketch`);
1150
1317
  }
1151
1318
  }
@@ -1153,9 +1320,10 @@ function fromLinkML(schema) {
1153
1320
  }
1154
1321
  report(other, name2);
1155
1322
  }
1156
- for (const slot of slotKeysOf(def)) {
1323
+ const own = slotKeysOf(def);
1324
+ for (const slot of fieldKeysOf(key2)) {
1157
1325
  const attributes = asRecord(get(def, "attributes")) ?? {};
1158
- const rawSlot = Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
1326
+ const rawSlot = !own.includes(slot) ? get(asRecord(get(def, "slot_usage")) ?? {}, slot) : Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
1159
1327
  const slotDef = asRecord(rawSlot);
1160
1328
  if (slotDef === void 0 && present(rawSlot)) {
1161
1329
  report("slot", `${name2}.${fieldNameOf(key2, slot)}`, "is not a slot definition");
@@ -1292,6 +1460,34 @@ function fromLinkML(schema) {
1292
1460
  report("narrowed", element, `\`${range}\` is not a Skiss type; it falls back to string`);
1293
1461
  return { kind: "unknown", name: at0(word), many };
1294
1462
  }
1463
+ function parentKeyOf(classKey) {
1464
+ const def = asRecord(get(classDefs, classKey)) ?? {};
1465
+ const parent = stringOf(get(def, "is_a"));
1466
+ if (parent === void 0 || !Object.hasOwn(classDefs, parent)) return void 0;
1467
+ return parent;
1468
+ }
1469
+ function inheritedSlots(classKey) {
1470
+ const out = /* @__PURE__ */ new Set();
1471
+ const seen = /* @__PURE__ */ new Set([classKey]);
1472
+ let key2 = parentKeyOf(classKey);
1473
+ while (key2 !== void 0 && !seen.has(key2)) {
1474
+ seen.add(key2);
1475
+ for (const slot of slotKeysOf(asRecord(get(classDefs, key2)) ?? {})) out.add(slot);
1476
+ key2 = parentKeyOf(key2);
1477
+ }
1478
+ return out;
1479
+ }
1480
+ function fieldKeysOf(classKey) {
1481
+ const def = asRecord(get(classDefs, classKey)) ?? {};
1482
+ const keys = slotKeysOf(def);
1483
+ const usage = asRecord(get(def, "slot_usage"));
1484
+ if (usage === void 0) return keys;
1485
+ const inherited = inheritedSlots(classKey);
1486
+ for (const slot of Object.keys(usage)) {
1487
+ if (inherited.has(slot) && !keys.includes(slot)) keys.push(slot);
1488
+ }
1489
+ return keys;
1490
+ }
1295
1491
  function listedSomewhere(slot) {
1296
1492
  for (const key2 of Object.keys(classDefs)) {
1297
1493
  const def = asRecord(get(classDefs, key2)) ?? {};
@@ -1474,7 +1670,7 @@ function notYAML(error) {
1474
1670
  }
1475
1671
 
1476
1672
  // src/index.ts
1477
- var VERSION = "0.4.0";
1673
+ var VERSION = "0.6.0";
1478
1674
 
1479
1675
  // src/cli.ts
1480
1676
  var HELP = `Usage: skiss <command> [options]
@@ -1483,15 +1679,22 @@ Commands:
1483
1679
  diagram <file> Write a Mermaid class diagram for a .skiss file.
1484
1680
  compile <file> Write a LinkML schema for a .skiss file.
1485
1681
  import <file> Write Skiss for a LinkML schema file, YAML or JSON.
1682
+ render <file> Write an SVG or a PNG of the class diagram, through
1683
+ the Mermaid CLI (mmdc), which must be installed.
1486
1684
  <file> may be - to read standard input.
1487
1685
 
1488
1686
  Options:
1489
1687
  -o, --output <path> Write the output to <path> instead of standard output
1490
- --notes diagram: emit \`? text\` doubts as Mermaid notes
1688
+ --notes diagram, render: emit \`? text\` doubts as Mermaid notes
1491
1689
  --json compile: write the schema as JSON instead of YAML
1492
1690
  --name <name> compile: the schema name. Defaults to the file's
1493
1691
  basename without its extension, or \`sketch\` for
1494
1692
  standard input.
1693
+ --format <fmt> render: svg or png. Defaults to the extension of
1694
+ -o, and to svg when there is no -o. png needs an -o:
1695
+ only the SVG can go to standard output.
1696
+ --scale <n> render: the scale mmdc rasterises at. Defaults to 2
1697
+ for png, and to mmdc's own default for svg.
1495
1698
  --strict Exit 1 when the input has any diagnostic; for import,
1496
1699
  also when the projection dropped anything
1497
1700
  -h, --help Show this help and exit
@@ -1508,8 +1711,11 @@ Exit codes:
1508
1711
  0 the output was produced
1509
1712
  1 --strict and the input has diagnostics (the output is still written)
1510
1713
  2 usage error, missing file, or unreadable input: for import, a text no
1511
- schema could be read out of, which produces no sketch at all
1714
+ schema could be read out of, which produces no sketch at all; for
1715
+ render, the Mermaid CLI not being installed
1512
1716
  70 internal error, a bug in skiss
1717
+
1718
+ render exits with mmdc's own status when mmdc ran and failed.
1513
1719
  `;
1514
1720
  var EXIT_OK = 0;
1515
1721
  var EXIT_STRICT = 1;
@@ -1518,7 +1724,8 @@ var EXIT_INTERNAL = 70;
1518
1724
  var COMMANDS = {
1519
1725
  diagram: ["output", "notes", "strict"],
1520
1726
  compile: ["output", "json", "name", "strict"],
1521
- import: ["output", "strict"]
1727
+ import: ["output", "strict"],
1728
+ render: ["output", "notes", "format", "scale", "strict"]
1522
1729
  };
1523
1730
  var isCommand = (text) => Object.hasOwn(COMMANDS, text);
1524
1731
  var UsageError = class extends Error {
@@ -1540,6 +1747,8 @@ async function main(argv) {
1540
1747
  notes: { type: "boolean" },
1541
1748
  json: { type: "boolean" },
1542
1749
  name: { type: "string" },
1750
+ format: { type: "string" },
1751
+ scale: { type: "string" },
1543
1752
  strict: { type: "boolean" },
1544
1753
  help: { type: "boolean", short: "h" },
1545
1754
  version: { type: "boolean" }
@@ -1569,8 +1778,16 @@ async function main(argv) {
1569
1778
  }
1570
1779
  }
1571
1780
  const source = file === "-" ? await readStdin() : await readSource(file);
1572
- const { output, diagnostics, dropped } = runCommand(command, source, file, values);
1573
1781
  const label = file === "-" ? "<stdin>" : file;
1782
+ if (command === "render") {
1783
+ const picture = compile(source, { target: "mermaid", notes: values.notes });
1784
+ for (const d of picture.diagnostics) process.stderr.write(`${formatDiagnostic(d, label)}
1785
+ `);
1786
+ const status = await render(picture.output, values);
1787
+ if (status !== EXIT_OK) return status;
1788
+ return values.strict === true && picture.diagnostics.length > 0 ? EXIT_STRICT : EXIT_OK;
1789
+ }
1790
+ const { output, diagnostics, dropped } = runCommand(command, source, file, values);
1574
1791
  const report = formatDropped(dropped);
1575
1792
  if (report !== "") process.stderr.write(`${report}
1576
1793
  `);
@@ -1625,6 +1842,76 @@ async function writeOutput(path, text) {
1625
1842
  throw new UsageError(`cannot write \`${path}\`: ${reason(error)}`);
1626
1843
  }
1627
1844
  }
1845
+ var MMDC_MISSING = "skiss render needs the Mermaid CLI: npm install -g @mermaid-js/mermaid-cli";
1846
+ var isFormat = (text) => text === "svg" || text === "png";
1847
+ async function render(mermaid, values) {
1848
+ const format = renderFormat(values);
1849
+ const scale = renderScale(values.scale, format);
1850
+ const mmdc = await findMmdc();
1851
+ if (mmdc === void 0) {
1852
+ process.stderr.write(`${MMDC_MISSING}
1853
+ `);
1854
+ return EXIT_USAGE;
1855
+ }
1856
+ const dir = await mkdtemp(join(tmpdir(), "skiss-render-"));
1857
+ try {
1858
+ const input = join(dir, "diagram.mmd");
1859
+ await writeFile(input, mermaid, "utf8");
1860
+ const target = values.output ?? join(dir, `diagram.${format}`);
1861
+ const args = ["-i", input, "-o", target, "-e", format, "-b", "transparent"];
1862
+ if (scale !== void 0) args.push("--scale", scale);
1863
+ const run = spawnSync(mmdc, args, {
1864
+ stdio: ["ignore", "pipe", "inherit"],
1865
+ encoding: "utf8"
1866
+ });
1867
+ if (run.error !== void 0)
1868
+ throw new UsageError(`cannot run \`${mmdc}\`: ${reason(run.error)}`);
1869
+ if (run.stdout !== "") process.stderr.write(run.stdout);
1870
+ if (run.status !== EXIT_OK) return run.status ?? EXIT_INTERNAL;
1871
+ if (values.output === void 0) process.stdout.write(await readFile(target, "utf8"));
1872
+ return EXIT_OK;
1873
+ } finally {
1874
+ await rm(dir, { recursive: true, force: true });
1875
+ }
1876
+ }
1877
+ function renderFormat(values) {
1878
+ if (values.format !== void 0) {
1879
+ if (!isFormat(values.format)) {
1880
+ throw new UsageError(`unknown \`--format ${values.format}\`: svg or png`);
1881
+ }
1882
+ if (values.format === "png" && values.output === void 0) {
1883
+ throw new UsageError(
1884
+ "render --format png needs -o <path>: a PNG cannot go to standard output"
1885
+ );
1886
+ }
1887
+ return values.format;
1888
+ }
1889
+ if (values.output === void 0) return "svg";
1890
+ const extension = extname(values.output).slice(1).toLowerCase();
1891
+ if (isFormat(extension)) return extension;
1892
+ throw new UsageError(
1893
+ `cannot tell the format of \`${values.output}\`: give --format svg or --format png`
1894
+ );
1895
+ }
1896
+ function renderScale(scale, format) {
1897
+ if (scale === void 0) return format === "png" ? "2" : void 0;
1898
+ const value = Number(scale);
1899
+ if (!Number.isFinite(value) || value <= 0) {
1900
+ throw new UsageError(`\`--scale ${scale}\` is not a positive number`);
1901
+ }
1902
+ return scale;
1903
+ }
1904
+ async function findMmdc() {
1905
+ const onPath = (process.env.PATH ?? "").split(delimiter).filter((dir) => dir !== "").map((dir) => join(dir, "mmdc"));
1906
+ for (const candidate of [...onPath, join(process.cwd(), "node_modules", ".bin", "mmdc")]) {
1907
+ try {
1908
+ await access(candidate, constants.X_OK);
1909
+ return candidate;
1910
+ } catch {
1911
+ }
1912
+ }
1913
+ return void 0;
1914
+ }
1628
1915
  function reason(error) {
1629
1916
  if (error instanceof Error) {
1630
1917
  const code = error.code;