@eriknaslund/skiss 0.6.1 → 0.8.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
@@ -25,6 +25,10 @@ CharacterPage @Community ~ Character # the community wiki's version
25
25
 
26
26
  That is the whole language. Classes at column 0, fields indented under them, a handful of punctuation marks.
27
27
 
28
+ **Try it live:** <https://erik-naslund.github.io/skiss-playground/> draws a sketch as you type, shares it as a link and exports SVG, PNG and LinkML. Nothing to install, nothing leaves your browser.
29
+
30
+ **Let an assistant write it:** [`skills/skiss`](skills/skiss/SKILL.md) is an agent skill for Claude, a custom GPT or any assistant with a system prompt, so it writes valid Skiss and draws it, with or without the compiler at hand.
31
+
28
32
  ## Why
29
33
 
30
34
  You sit down with someone who knows the domain. Within the hour you want a shared picture of it: what the things are, what they are called, which system owns them, how they hang together.
@@ -42,7 +46,7 @@ Skiss is text you can type while someone talks. A field is one line. A change is
42
46
  - **`@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
47
  - **`~` 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
48
  - **`<` 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`.
45
- - **`#` describes, `?` doubts.** A description ends up in generated documentation. A doubt never does.
49
+ - **`#` describes, `?` doubts.** A description ends up in generated documentation. A doubt never does. A description too long for the line continues on indented `#` lines under it, and the `#` block at the top of the file describes the whole sketch.
46
50
  - **Names are ASCII.** `A`–`Z`, `a`–`z`, `0`–`9`, and nothing else: a name becomes an identifier and a URI in LinkML, a class in generated Python, a node in a diagram, and none of those take the rest of the alphabet. The words of the domain, in whatever alphabet they are spoken, go in the `#` description ([SPEC §4](docs/SPEC.md#4-grammar)).
47
51
  - **Every line stands on its own.** A half-typed line breaks that line and nothing else, so the diagram keeps rendering while you type.
48
52
  - **Everything you can write compiles to valid LinkML.** Skiss is a strict subset. Nothing is lost on the way up.
@@ -68,9 +72,26 @@ Or without installing: `npx @eriknaslund/skiss diagram model.skiss`.
68
72
 
69
73
  `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`.
70
74
 
75
+ `skiss glossary model.skiss` writes what the sketch says about the words in it, as Markdown: one section per class, with its fields in a table and the `#` descriptions as the definitions. A sketch is where each thing is named, so the terminology page is generated from it rather than kept beside it and allowed to drift.
76
+
77
+ ```
78
+ # A build and release pipeline
79
+
80
+ ## Environment
81
+
82
+ System: Platform. One installation of the platform, with its own data.
83
+
84
+ | Field | Type | Description |
85
+ |---|---|---|
86
+ | id | identifier | |
87
+ | purpose | build / test / playtest / live | |
88
+ ```
89
+
90
+ `--classes` writes the terms alone, as a `| Term | Definition |` table, which is the shape a glossary page usually wants; `--notes` adds the `? ` doubts as open questions, which nothing else in the output shows, because a doubt is never documentation. `--name` is the heading, and `-o`, `-`, `--strict` and the diagnostics work exactly as they do for `compile`.
91
+
71
92
  `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.
72
93
 
73
- `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.
94
+ `npm install @eriknaslund/skiss` also gives you the library: `compile`, `formatDiagnostic`, `parse`, `resolve`, `toLinkML`, `serialize`, `toMermaid`, `toGlossary`, `toSkiss`, `fromLinkML`, `formatDropped`, `importLinkML`, `tokenizeLine` and `VERSION`, browser-safe.
74
95
 
75
96
  `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)).
76
97
 
@@ -90,9 +111,9 @@ Editors that render Skiss live are separate projects built on this package. This
90
111
 
91
112
  ## Status
92
113
 
93
- 0.6.1, 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.
114
+ 0.8.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.
94
115
 
95
- The [specification](docs/SPEC.md) is at 0.3.1 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.
116
+ The [specification](docs/SPEC.md) is at 0.4.0 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.
96
117
 
97
118
  ## License
98
119
 
package/dist/cli.js CHANGED
@@ -127,19 +127,47 @@ function primitiveFor(word) {
127
127
  var DROPPED = "dropped-class";
128
128
  var FIELD_ORDER = "name, `*`, `: type`, `@System`, `= Class.field`, then `#` and `?`";
129
129
  var CLASS_ORDER = "name, `< Parent`, `@System`, `~ Class`, then `#` and `?`";
130
+ var NO_ELEMENT_ABOVE = "A description line needs a class or a field above it";
130
131
  function parse(source) {
131
132
  const classes = [];
132
133
  const diagnostics = [];
133
134
  let current;
135
+ let target;
136
+ const header = [];
137
+ let inHeader = true;
134
138
  const text0 = source.startsWith("\uFEFF") ? source.slice(1) : source;
135
139
  const lines = text0.split(/\r\n|\n|\r/);
136
140
  for (let i = 0; i < lines.length; i++) {
137
141
  const line = i + 1;
138
142
  const text = (lines[i] ?? "").replace(/[ \t]+$/, "");
139
143
  const first = text[0];
140
- if (first === void 0 || first === "#") continue;
144
+ if (first === void 0) {
145
+ inHeader = false;
146
+ continue;
147
+ }
148
+ if (first === "#") {
149
+ if (inHeader) header.push(headerLine(text));
150
+ continue;
151
+ }
152
+ inHeader = false;
141
153
  if (isWs(first)) {
142
154
  const start = text.search(/[^ \t]/);
155
+ if (text[start] === "#" || isDoubtMarker(text, start)) {
156
+ if (current === DROPPED) continue;
157
+ if (target === void 0) {
158
+ diagnostics.push({
159
+ severity: "error",
160
+ code: "E_UNPARSABLE",
161
+ message: NO_ELEMENT_ABOVE,
162
+ line,
163
+ col: start,
164
+ end: text.length
165
+ });
166
+ continue;
167
+ }
168
+ continueTrailer(target, splitTrailer(text));
169
+ continue;
170
+ }
143
171
  if (current === void 0) {
144
172
  diagnostics.push({
145
173
  severity: "error",
@@ -153,7 +181,10 @@ function parse(source) {
153
181
  }
154
182
  const result = parseFieldLine(text, line);
155
183
  if (!result.ok) diagnostics.push(result.diagnostic);
156
- else if (current !== DROPPED) current.fields.push(result.value);
184
+ else if (current !== DROPPED) {
185
+ current.fields.push(result.value);
186
+ target = result.value;
187
+ }
157
188
  continue;
158
189
  }
159
190
  if (/\p{L}/u.test(first)) {
@@ -161,9 +192,11 @@ function parse(source) {
161
192
  if (result.ok) {
162
193
  classes.push(result.value);
163
194
  current = result.value;
195
+ target = result.value;
164
196
  } else {
165
197
  diagnostics.push(result.diagnostic);
166
198
  current = result.atName ? DROPPED : void 0;
199
+ target = void 0;
167
200
  }
168
201
  continue;
169
202
  }
@@ -176,7 +209,14 @@ function parse(source) {
176
209
  end: text.length
177
210
  });
178
211
  }
179
- return { classes, diagnostics };
212
+ const document = { classes, diagnostics };
213
+ const description = header.filter((part) => part !== "").join(" ");
214
+ if (description !== "") document.description = description;
215
+ return document;
216
+ }
217
+ function headerLine(text) {
218
+ const rest = text.slice(1);
219
+ return isWs(rest[0]) ? rest.slice(1) : rest;
180
220
  }
181
221
  function isDoubtMarker(text, i) {
182
222
  return text[i] === "?" && isWs(text[i - 1]) && (i + 1 === text.length || isWs(text[i + 1]));
@@ -212,6 +252,15 @@ function splitTrailer(text) {
212
252
  }
213
253
  return trailer;
214
254
  }
255
+ function continueTrailer(node, trailer) {
256
+ if (trailer.description !== void 0) {
257
+ node.description = node.description === void 0 ? trailer.description : `${node.description} ${trailer.description}`;
258
+ }
259
+ if (trailer.note !== void 0) {
260
+ node.note = node.note === void 0 ? trailer.note : `${node.note}
261
+ ${trailer.note}`;
262
+ }
263
+ }
215
264
  var WORD = /[\p{L}\p{N}_-]+/uy;
216
265
  function wordAt(text, i) {
217
266
  WORD.lastIndex = i;
@@ -332,8 +381,10 @@ function parseFieldLine(text, line) {
332
381
  const head = take(c);
333
382
  if (head === void 0) {
334
383
  const col = text.search(/[^ \t]/);
335
- const message = text[col] === "#" ? "A `#` comment must start at column 0; an indented line is a field and needs a name" : "A field line needs a field name before its trailer";
336
- return fail("E_UNPARSABLE", message, line, { col, end: Math.max(col + 1, text.length) });
384
+ return fail("E_UNPARSABLE", "A field line needs a field name before its trailer", line, {
385
+ col,
386
+ end: Math.max(col + 1, text.length)
387
+ });
337
388
  }
338
389
  if (head.kind !== "word") return unexpected(head, line, "field", FIELD_ORDER);
339
390
  if (!FIELD_NAME.test(head.text)) {
@@ -793,6 +844,109 @@ function levenshtein(a, b) {
793
844
  return previous[b.length] ?? 0;
794
845
  }
795
846
 
847
+ // src/generators/glossary.ts
848
+ var DEFAULT_NAME = "Glossary";
849
+ function toGlossary(doc, opts) {
850
+ const resolved = resolve(doc);
851
+ const notes = opts?.notes === true;
852
+ const declared = new Set(resolved.classes.map((cls) => cls.name.text));
853
+ const blocks = [`# ${opts?.name ?? DEFAULT_NAME}`];
854
+ const description = resolved.description;
855
+ if (description !== void 0 && description !== "") blocks.push(description);
856
+ if (opts?.classes === true) blocks.push(termTable(resolved.classes, notes));
857
+ else for (const cls of resolved.classes) blocks.push(...section(cls, declared, notes));
858
+ return `${blocks.join("\n\n")}
859
+ `;
860
+ }
861
+ function section(cls, declared, notes) {
862
+ const out = [`## ${cls.name.text}`];
863
+ const paragraph = sentencesOf(cls).join(" ");
864
+ if (paragraph !== "") out.push(paragraph);
865
+ if (cls.fields.length > 0) out.push(fieldTable(cls, declared));
866
+ if (notes) {
867
+ const questions = openQuestions(cls);
868
+ if (questions.length > 0) out.push("**Open questions**", questions.join("\n"));
869
+ }
870
+ return out;
871
+ }
872
+ var FIELD_COLUMNS = ["Field", "Type", "Description"];
873
+ function fieldTable(cls, declared) {
874
+ const rows = [row(FIELD_COLUMNS), divider(FIELD_COLUMNS.length)];
875
+ for (const field of cls.fields) {
876
+ rows.push(row([field.name.text, typeCell(field, declared), descriptionCell(field)]));
877
+ }
878
+ return rows.join("\n");
879
+ }
880
+ function typeCell(field, declared) {
881
+ const parts2 = [];
882
+ if (field.identifier) parts2.push("identifier");
883
+ const type = typeText(field.type, declared);
884
+ if (type !== void 0) parts2.push(type);
885
+ return parts2.join(", ");
886
+ }
887
+ function typeText(type, declared) {
888
+ if (type === void 0) return void 0;
889
+ let text;
890
+ switch (type.kind) {
891
+ case "primitive":
892
+ text = type.written.text;
893
+ break;
894
+ case "unknown":
895
+ text = type.name.text;
896
+ break;
897
+ case "class":
898
+ text = declared.has(type.name.text) ? link(type.name.text) : type.name.text;
899
+ break;
900
+ case "enum":
901
+ text = type.values.map((value) => value.text).join(" / ");
902
+ break;
903
+ }
904
+ return type.many ? `list of ${text}` : text;
905
+ }
906
+ var link = (name2) => `[${name2}](#${name2.toLowerCase()})`;
907
+ function descriptionCell(field) {
908
+ const sentences = [];
909
+ if (field.system !== void 0) sentences.push(`System: ${field.system.text}.`);
910
+ if (field.joinsTo !== void 0) {
911
+ sentences.push(`Joins to ${field.joinsTo.className.text}.${field.joinsTo.fieldName.text}.`);
912
+ }
913
+ const description = field.description;
914
+ if (description === void 0 || description === "") return sentences.join(" ");
915
+ if (sentences.length === 0) return description;
916
+ return [stopped(description), ...sentences].join(" ");
917
+ }
918
+ function termTable(classes, notes) {
919
+ const columns = notes ? ["Term", "Definition", "Open questions"] : ["Term", "Definition"];
920
+ const rows = [row(columns), divider(columns.length)];
921
+ for (const cls of classes) {
922
+ const cells = [cls.name.text, sentencesOf(cls).join(" ")];
923
+ if (notes) cells.push(doubtsOf(cls.note).join("<br>"));
924
+ rows.push(row(cells));
925
+ }
926
+ return rows.join("\n");
927
+ }
928
+ function sentencesOf(cls) {
929
+ const out = [];
930
+ if (cls.system !== void 0) out.push(`System: ${cls.system.text}.`);
931
+ if (cls.parent !== void 0) out.push(`Extends ${cls.parent.text}.`);
932
+ if (cls.similarTo !== void 0) out.push(`Similar to ${cls.similarTo.text}.`);
933
+ if (cls.description !== void 0 && cls.description !== "") out.push(sentence(cls.description));
934
+ return out;
935
+ }
936
+ var sentence = (text) => stopped(text.charAt(0).toUpperCase() + text.slice(1));
937
+ var stopped = (text) => /[.!?]$/.test(text) ? text : `${text}.`;
938
+ var doubtsOf = (note) => note === void 0 ? [] : note.split("\n").filter((doubt) => doubt !== "");
939
+ function openQuestions(cls) {
940
+ const out = doubtsOf(cls.note).map((doubt) => `- ${doubt}`);
941
+ for (const field of cls.fields) {
942
+ for (const doubt of doubtsOf(field.note)) out.push(`- ${field.name.text}: ${doubt}`);
943
+ }
944
+ return out;
945
+ }
946
+ var row = (cells) => `|${cells.map((cell) => cell === "" ? " " : ` ${cellText(cell)} `).join("|")}|`;
947
+ var divider = (columns) => `|${"---|".repeat(columns)}`;
948
+ var cellText = (text) => text.replaceAll("|", "\\|").replaceAll("\n", " ");
949
+
796
950
  // src/generators/linkml.ts
797
951
  var BASE_URI = "https://example.org";
798
952
  var RANGE = {
@@ -835,9 +989,11 @@ function toLinkML(doc, opts) {
835
989
  if (system === void 0) return schemaName;
836
990
  return systemPrefix.get(system) ?? schemaName;
837
991
  };
992
+ const description = resolved.description;
838
993
  const schema = {
839
994
  id: `${BASE_URI}/${schemaName}`,
840
995
  name: schemaName,
996
+ ...description === void 0 || description === "" ? {} : { description },
841
997
  default_prefix: schemaName,
842
998
  default_range: "string",
843
999
  prefixes,
@@ -996,10 +1152,12 @@ function toMermaid(doc, opts) {
996
1152
  }
997
1153
  if (notes) {
998
1154
  for (const cls of resolved.classes) {
999
- if (cls.note !== void 0) lines.push(` note for ${cls.name.text} ${quote(cls.note)}`);
1155
+ for (const doubt of doubts(cls.note)) {
1156
+ lines.push(` note for ${cls.name.text} ${quote(doubt)}`);
1157
+ }
1000
1158
  for (const field of cls.fields) {
1001
- if (field.note !== void 0) {
1002
- lines.push(` note for ${cls.name.text} ${quote(`${field.name.text}: ${field.note}`)}`);
1159
+ for (const doubt of doubts(field.note)) {
1160
+ lines.push(` note for ${cls.name.text} ${quote(`${field.name.text}: ${doubt}`)}`);
1003
1161
  }
1004
1162
  }
1005
1163
  }
@@ -1011,9 +1169,7 @@ function toMermaid(doc, opts) {
1011
1169
  for (const field of cls.fields) {
1012
1170
  const type = field.type;
1013
1171
  if (type?.kind === "class") {
1014
- lines.push(
1015
- type.many ? ` ${from} "1" --> "*" ${type.name.text} : ${field.name.text}` : ` ${from} --> ${type.name.text} : ${field.name.text}`
1016
- );
1172
+ lines.push(` ${from} --> ${type.name.text} : ${field.name.text}`);
1017
1173
  }
1018
1174
  if (field.joinsTo !== void 0) {
1019
1175
  const { className, fieldName } = field.joinsTo;
@@ -1026,11 +1182,11 @@ function toMermaid(doc, opts) {
1026
1182
  }
1027
1183
  function member(field) {
1028
1184
  const marker = field.identifier ? "*" : "+";
1029
- const type = field.type === void 0 ? "" : `${typeText(field.type)} `;
1185
+ const type = field.type === void 0 ? "" : `${typeText2(field.type)} `;
1030
1186
  const system = field.system === void 0 ? "" : ` @${field.system.text}`;
1031
1187
  return `${marker}${type}${field.name.text}${system}`;
1032
1188
  }
1033
- function typeText(type) {
1189
+ function typeText2(type) {
1034
1190
  let text;
1035
1191
  switch (type.kind) {
1036
1192
  case "primitive":
@@ -1046,6 +1202,7 @@ function typeText(type) {
1046
1202
  }
1047
1203
  return type.many ? `${text}[]` : text;
1048
1204
  }
1205
+ var doubts = (note) => note === void 0 ? [] : note.split("\n").filter((line) => line !== "");
1049
1206
  var quote = (text) => `"${text.replaceAll('"', "'")}"`;
1050
1207
 
1051
1208
  // src/serialize.ts
@@ -1095,8 +1252,17 @@ function hasControlCharacter(text) {
1095
1252
  // src/compile.ts
1096
1253
  function compile(source, opts) {
1097
1254
  const doc = resolve(parse(source));
1098
- const output = opts.target === "linkml" ? serialize(toLinkML(doc, { schemaName: opts.schemaName }), opts.format ?? "yaml") : toMermaid(doc, { notes: opts.notes });
1099
- return { output, diagnostics: doc.diagnostics };
1255
+ return { output: generate(doc, opts), diagnostics: doc.diagnostics };
1256
+ }
1257
+ function generate(doc, opts) {
1258
+ switch (opts.target) {
1259
+ case "linkml":
1260
+ return serialize(toLinkML(doc, { schemaName: opts.schemaName }), opts.format ?? "yaml");
1261
+ case "glossary":
1262
+ return toGlossary(doc, { name: opts.name, classes: opts.classes, notes: opts.notes });
1263
+ case "mermaid":
1264
+ return toMermaid(doc, { notes: opts.notes });
1265
+ }
1100
1266
  }
1101
1267
 
1102
1268
  // src/diagnostics.ts
@@ -1108,20 +1274,27 @@ function formatDiagnostic(d, file) {
1108
1274
  // src/generators/skiss.ts
1109
1275
  var TRAILER_COLUMN = 40;
1110
1276
  function toSkiss(doc) {
1111
- if (doc.classes.length === 0) return "";
1112
- return `${doc.classes.map(classBlock).join("\n\n")}
1277
+ const description = doc.description;
1278
+ const blocks = description === void 0 || description === "" ? [] : [`# ${description}`];
1279
+ for (const cls of doc.classes) blocks.push(classBlock(cls));
1280
+ if (blocks.length === 0) return "";
1281
+ return `${blocks.join("\n\n")}
1113
1282
  `;
1114
1283
  }
1115
1284
  function classBlock(cls) {
1116
1285
  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}`);
1117
- return [withTrailer(head, cls), ...cls.fields.map(fieldLine)].join("\n");
1286
+ const lines = [withTrailer(head, cls), ...doubtLines(cls.note, " ")];
1287
+ for (const field of cls.fields) lines.push(fieldLine(field), ...doubtLines(field.note, " "));
1288
+ return lines.join("\n");
1118
1289
  }
1290
+ var doubtsOf2 = (note) => note === void 0 ? [] : note.split("\n").filter((doubt) => doubt !== "");
1291
+ var doubtLines = (note, indent) => doubtsOf2(note).slice(1).map((doubt) => `${indent}? ${doubt}`);
1119
1292
  function fieldLine(field) {
1120
- const type = typeText2(field.type);
1293
+ const type = typeText3(field.type);
1121
1294
  const head = ` ${field.name.text}` + (field.identifier ? "*" : "") + (type === void 0 ? "" : `: ${type}`) + (field.system === void 0 ? "" : ` @${field.system.text}`) + (field.joinsTo === void 0 ? "" : ` = ${field.joinsTo.className.text}.${field.joinsTo.fieldName.text}`);
1122
1295
  return withTrailer(head, field);
1123
1296
  }
1124
- function typeText2(type) {
1297
+ function typeText3(type) {
1125
1298
  if (type === void 0) return void 0;
1126
1299
  let text;
1127
1300
  switch (type.kind) {
@@ -1148,7 +1321,8 @@ function withTrailer(head, node) {
1148
1321
  const parts2 = [];
1149
1322
  if (node.description !== void 0 && node.description !== "")
1150
1323
  parts2.push(writeDescription(node.description));
1151
- if (node.note !== void 0 && node.note !== "") parts2.push(`? ${node.note}`);
1324
+ const [doubt] = doubtsOf2(node.note);
1325
+ if (doubt !== void 0) parts2.push(`? ${doubt}`);
1152
1326
  if (parts2.length === 0) return head;
1153
1327
  const gap = head.length < TRAILER_COLUMN - 1 ? " ".repeat(TRAILER_COLUMN - head.length) : " ";
1154
1328
  return head + gap + parts2.join(" ");
@@ -1181,6 +1355,8 @@ var SLOT_TAGS = /* @__PURE__ */ new Set(["system", "note", "joins_to"]);
1181
1355
  var QUIET_SCHEMA_KEYS = /* @__PURE__ */ new Set([
1182
1356
  "id",
1183
1357
  "name",
1358
+ // SPEC §8: the schema's own `description` is the document description.
1359
+ "description",
1184
1360
  "prefixes",
1185
1361
  "imports",
1186
1362
  "default_prefix",
@@ -1284,7 +1460,7 @@ function fromLinkML(schema) {
1284
1460
  } else if (system !== void 0) report("annotation", name2, notText("system", system));
1285
1461
  const rawNote = annotations.get("note");
1286
1462
  if (typeof rawNote === "string") {
1287
- const note = oneLine(rawNote);
1463
+ const note = doubtLines2(rawNote);
1288
1464
  if (note !== void 0) node.note = note;
1289
1465
  } else if (rawNote !== void 0) report("annotation", name2, notText("note", rawNote));
1290
1466
  const parent = stringOf(get(def, "is_a"));
@@ -1387,7 +1563,10 @@ function fromLinkML(schema) {
1387
1563
  for (const key2 of Object.keys(root)) {
1388
1564
  if (!QUIET_SCHEMA_KEYS.has(key2)) report("schema", schemaName, `\`${key2}\``);
1389
1565
  }
1390
- const source = toSkiss({ classes, diagnostics: [] });
1566
+ const projected = { classes, diagnostics: [] };
1567
+ const schemaDescription = oneLine(stringOf(get(root, "description")));
1568
+ if (schemaDescription !== void 0) projected.description = schemaDescription;
1569
+ const source = toSkiss(projected);
1391
1570
  return { document: parse(source), source, dropped };
1392
1571
  function buildField(classKey, className, slot, def) {
1393
1572
  const name2 = fieldNameOf(classKey, slot);
@@ -1430,7 +1609,7 @@ function fromLinkML(schema) {
1430
1609
  } else if (joins !== void 0) report("annotation", element, notText("joins_to", joins));
1431
1610
  const rawNote = annotations.get("note");
1432
1611
  if (typeof rawNote === "string") {
1433
- const note = oneLine(rawNote);
1612
+ const note = doubtLines2(rawNote);
1434
1613
  if (note !== void 0) field.note = note;
1435
1614
  } else if (rawNote !== void 0) report("annotation", element, notText("note", rawNote));
1436
1615
  for (const other of Object.keys(def)) {
@@ -1656,6 +1835,10 @@ function oneLine(text) {
1656
1835
  const line = text.replace(/\s*[\r\n]+\s*/g, " ").trim();
1657
1836
  return line === "" ? void 0 : line;
1658
1837
  }
1838
+ function doubtLines2(text) {
1839
+ const lines = text.split(/\r\n|\n|\r/).map((line) => line.trim()).filter((line) => line !== "");
1840
+ return lines.length === 0 ? void 0 : lines.join("\n");
1841
+ }
1659
1842
  var list = (items) => items.map((item) => `\`${item}\``).join(", ");
1660
1843
  var at0 = (text) => ({ text, line: 0, col: 0, end: 0 });
1661
1844
  var stringType = (many) => ({
@@ -1695,7 +1878,7 @@ function notYAML(error) {
1695
1878
  }
1696
1879
 
1697
1880
  // src/index.ts
1698
- var VERSION = "0.6.1";
1881
+ var VERSION = "0.8.0";
1699
1882
 
1700
1883
  // src/cli.ts
1701
1884
  var HELP = `Usage: skiss <command> [options]
@@ -1703,6 +1886,7 @@ var HELP = `Usage: skiss <command> [options]
1703
1886
  Commands:
1704
1887
  diagram <file> Write a Mermaid class diagram for a .skiss file.
1705
1888
  compile <file> Write a LinkML schema for a .skiss file.
1889
+ glossary <file> Write the descriptions of a .skiss file as Markdown.
1706
1890
  import <file> Write Skiss for a LinkML schema file, YAML or JSON.
1707
1891
  render <file> Write an SVG or a PNG of the class diagram, through
1708
1892
  the Mermaid CLI (mmdc), which must be installed.
@@ -1711,10 +1895,12 @@ Commands:
1711
1895
  Options:
1712
1896
  -o, --output <path> Write the output to <path> instead of standard output
1713
1897
  --notes diagram, render: emit \`? text\` doubts as Mermaid notes
1898
+ glossary: list the doubts as open questions
1714
1899
  --json compile: write the schema as JSON instead of YAML
1715
- --name <name> compile: the schema name. Defaults to the file's
1716
- basename without its extension, or \`sketch\` for
1717
- standard input.
1900
+ --classes glossary: write the classes alone, as a table of terms
1901
+ --name <name> compile: the schema name; glossary: the heading.
1902
+ Defaults to the file's basename without its
1903
+ extension, or \`sketch\` for standard input.
1718
1904
  --format <fmt> render: svg or png. Defaults to the extension of
1719
1905
  -o, and to svg when there is no -o. png needs an -o:
1720
1906
  only the SVG can go to standard output.
@@ -1749,6 +1935,7 @@ var EXIT_INTERNAL = 70;
1749
1935
  var COMMANDS = {
1750
1936
  diagram: ["output", "notes", "strict"],
1751
1937
  compile: ["output", "json", "name", "strict"],
1938
+ glossary: ["output", "classes", "notes", "name", "strict"],
1752
1939
  import: ["output", "strict"],
1753
1940
  render: ["output", "notes", "format", "scale", "strict"]
1754
1941
  };
@@ -1771,6 +1958,7 @@ async function main(argv) {
1771
1958
  output: { type: "string", short: "o" },
1772
1959
  notes: { type: "boolean" },
1773
1960
  json: { type: "boolean" },
1961
+ classes: { type: "boolean" },
1774
1962
  name: { type: "string" },
1775
1963
  format: { type: "string" },
1776
1964
  scale: { type: "string" },
@@ -1831,11 +2019,22 @@ function unreadableInput(command, diagnostics, dropped) {
1831
2019
  }
1832
2020
  function runCommand(command, source, file, values) {
1833
2021
  if (command === "import") return importLinkML(source);
2022
+ if (command === "glossary") {
2023
+ return {
2024
+ ...compile(source, {
2025
+ target: "glossary",
2026
+ name: values.name ?? defaultName(file),
2027
+ classes: values.classes,
2028
+ notes: values.notes
2029
+ }),
2030
+ dropped: []
2031
+ };
2032
+ }
1834
2033
  if (command === "compile") {
1835
2034
  return {
1836
2035
  ...compile(source, {
1837
2036
  target: "linkml",
1838
- schemaName: values.name ?? defaultSchemaName(file),
2037
+ schemaName: values.name ?? defaultName(file),
1839
2038
  format: values.json === true ? "json" : "yaml"
1840
2039
  }),
1841
2040
  dropped: []
@@ -1843,7 +2042,7 @@ function runCommand(command, source, file, values) {
1843
2042
  }
1844
2043
  return { ...compile(source, { target: "mermaid", notes: values.notes }), dropped: [] };
1845
2044
  }
1846
- var defaultSchemaName = (file) => file === "-" ? "sketch" : basename(file, extname(file));
2045
+ var defaultName = (file) => file === "-" ? "sketch" : basename(file, extname(file));
1847
2046
  async function readSource(file) {
1848
2047
  try {
1849
2048
  return await readFile(file, "utf8");
package/dist/index.d.ts CHANGED
@@ -87,6 +87,14 @@ interface Diagnostic {
87
87
  interface Document {
88
88
  classes: ClassNode[];
89
89
  diagnostics: Diagnostic[];
90
+ /**
91
+ * SPEC §3.1: the run of comment lines that starts on the first line of the
92
+ * file, each stripped of its `#` and one space, joined with a single space.
93
+ * Absent when the file does not start with one. It is the only column-0
94
+ * comment the document holds, and it compiles to the schema's own
95
+ * `description` (SPEC §5.1).
96
+ */
97
+ description?: string;
90
98
  /**
91
99
  * Class names referenced by `:`, `~` or `=` but never declared, each once,
92
100
  * in first-reference order and at its first reference's position. Absent
@@ -95,6 +103,22 @@ interface Document {
95
103
  undeclared?: Name[];
96
104
  }
97
105
 
106
+ /** What `resolve` returns: a `Document` whose `undeclared` list is always present. */
107
+ interface ResolvedDocument extends Document {
108
+ undeclared: Name[];
109
+ }
110
+ declare function resolve(doc: Document): ResolvedDocument;
111
+
112
+ interface GlossaryOptions {
113
+ /** The `#` heading. Normally the file name; defaults to `Glossary`. */
114
+ name?: string;
115
+ /** Write the terms alone: one row per class, no fields. Off by default. */
116
+ classes?: boolean;
117
+ /** Add the `? text` doubts as open questions. Off by default. */
118
+ notes?: boolean;
119
+ }
120
+ declare function toGlossary(doc: ResolvedDocument, opts?: GlossaryOptions): string;
121
+
98
122
  interface MermaidOptions {
99
123
  /** Emit `? text` doubts as `note for` lines. Off by default. */
100
124
  notes?: boolean;
@@ -129,6 +153,8 @@ interface LinkMLEnum {
129
153
  interface LinkMLSchema {
130
154
  id: string;
131
155
  name: string;
156
+ /** The document description (SPEC §3.1), written directly after `name`. */
157
+ description?: string;
132
158
  default_prefix: string;
133
159
  default_range: string;
134
160
  prefixes: Record<string, string>;
@@ -157,12 +183,18 @@ interface LinkMLCompileOptions {
157
183
  /** Defaults to `yaml`, the form LinkML itself reads. */
158
184
  format?: SerializeFormat;
159
185
  }
186
+ /** `toGlossary` text from the source (issue #90). */
187
+ interface GlossaryCompileOptions extends GlossaryOptions {
188
+ target: 'glossary';
189
+ /** The heading. Normally the file name, as `schemaName` is. */
190
+ name: string;
191
+ }
160
192
  /**
161
193
  * A union rather than one shape with optional keys, so `schemaName` is
162
194
  * required exactly when it is used and a `mermaid` call cannot carry a
163
195
  * `format` that would be ignored.
164
196
  */
165
- type CompileOptions = MermaidCompileOptions | LinkMLCompileOptions;
197
+ type CompileOptions = MermaidCompileOptions | LinkMLCompileOptions | GlossaryCompileOptions;
166
198
  interface CompileResult {
167
199
  /** The generated text, `\n`-terminated, whatever the platform. */
168
200
  output: string;
@@ -262,12 +294,6 @@ declare function importLinkML(text: string): ImportResult;
262
294
 
263
295
  declare function parse(source: string): Document;
264
296
 
265
- /** What `resolve` returns: a `Document` whose `undeclared` list is always present. */
266
- interface ResolvedDocument extends Document {
267
- undeclared: Name[];
268
- }
269
- declare function resolve(doc: Document): ResolvedDocument;
270
-
271
- declare const VERSION = "0.6.1";
297
+ declare const VERSION = "0.8.0";
272
298
 
273
- export { type ClassNode, type CompileOptions, type CompileResult, type Diagnostic, type DiagnosticCode, type Document, type Dropped, type FieldNode, type FromLinkMLResult, type ImportResult, type JoinRef, type LinkMLAnnotations, type LinkMLAttribute, type LinkMLClass, type LinkMLCompileOptions, type LinkMLEnum, type LinkMLOptions, type LinkMLSchema, type MermaidCompileOptions, type MermaidOptions, type Name, type Primitive, type ResolvedDocument, type SerializeFormat, type Severity, type Token, type TokenKind, type TypeRef, VERSION, compile, formatDiagnostic, formatDropped, fromLinkML, importLinkML, parse, resolve, serialize, toLinkML, toMermaid, toSkiss, tokenizeLine };
299
+ export { type ClassNode, type CompileOptions, type CompileResult, type Diagnostic, type DiagnosticCode, type Document, type Dropped, type FieldNode, type FromLinkMLResult, type GlossaryCompileOptions, type GlossaryOptions, type ImportResult, type JoinRef, type LinkMLAnnotations, type LinkMLAttribute, type LinkMLClass, type LinkMLCompileOptions, type LinkMLEnum, type LinkMLOptions, type LinkMLSchema, type MermaidCompileOptions, type MermaidOptions, type Name, type Primitive, type ResolvedDocument, type SerializeFormat, type Severity, type Token, type TokenKind, type TypeRef, VERSION, compile, formatDiagnostic, formatDropped, fromLinkML, importLinkML, parse, resolve, serialize, toGlossary, toLinkML, toMermaid, toSkiss, tokenizeLine };
package/dist/index.js CHANGED
@@ -117,19 +117,47 @@ function primitiveFor(word) {
117
117
  var DROPPED = "dropped-class";
118
118
  var FIELD_ORDER = "name, `*`, `: type`, `@System`, `= Class.field`, then `#` and `?`";
119
119
  var CLASS_ORDER = "name, `< Parent`, `@System`, `~ Class`, then `#` and `?`";
120
+ var NO_ELEMENT_ABOVE = "A description line needs a class or a field above it";
120
121
  function parse(source) {
121
122
  const classes = [];
122
123
  const diagnostics = [];
123
124
  let current;
125
+ let target;
126
+ const header = [];
127
+ let inHeader = true;
124
128
  const text0 = source.startsWith("\uFEFF") ? source.slice(1) : source;
125
129
  const lines = text0.split(/\r\n|\n|\r/);
126
130
  for (let i = 0; i < lines.length; i++) {
127
131
  const line = i + 1;
128
132
  const text = (lines[i] ?? "").replace(/[ \t]+$/, "");
129
133
  const first = text[0];
130
- if (first === void 0 || first === "#") continue;
134
+ if (first === void 0) {
135
+ inHeader = false;
136
+ continue;
137
+ }
138
+ if (first === "#") {
139
+ if (inHeader) header.push(headerLine(text));
140
+ continue;
141
+ }
142
+ inHeader = false;
131
143
  if (isWs(first)) {
132
144
  const start = text.search(/[^ \t]/);
145
+ if (text[start] === "#" || isDoubtMarker(text, start)) {
146
+ if (current === DROPPED) continue;
147
+ if (target === void 0) {
148
+ diagnostics.push({
149
+ severity: "error",
150
+ code: "E_UNPARSABLE",
151
+ message: NO_ELEMENT_ABOVE,
152
+ line,
153
+ col: start,
154
+ end: text.length
155
+ });
156
+ continue;
157
+ }
158
+ continueTrailer(target, splitTrailer(text));
159
+ continue;
160
+ }
133
161
  if (current === void 0) {
134
162
  diagnostics.push({
135
163
  severity: "error",
@@ -143,7 +171,10 @@ function parse(source) {
143
171
  }
144
172
  const result = parseFieldLine(text, line);
145
173
  if (!result.ok) diagnostics.push(result.diagnostic);
146
- else if (current !== DROPPED) current.fields.push(result.value);
174
+ else if (current !== DROPPED) {
175
+ current.fields.push(result.value);
176
+ target = result.value;
177
+ }
147
178
  continue;
148
179
  }
149
180
  if (/\p{L}/u.test(first)) {
@@ -151,9 +182,11 @@ function parse(source) {
151
182
  if (result.ok) {
152
183
  classes.push(result.value);
153
184
  current = result.value;
185
+ target = result.value;
154
186
  } else {
155
187
  diagnostics.push(result.diagnostic);
156
188
  current = result.atName ? DROPPED : void 0;
189
+ target = void 0;
157
190
  }
158
191
  continue;
159
192
  }
@@ -166,7 +199,14 @@ function parse(source) {
166
199
  end: text.length
167
200
  });
168
201
  }
169
- return { classes, diagnostics };
202
+ const document = { classes, diagnostics };
203
+ const description = header.filter((part) => part !== "").join(" ");
204
+ if (description !== "") document.description = description;
205
+ return document;
206
+ }
207
+ function headerLine(text) {
208
+ const rest = text.slice(1);
209
+ return isWs(rest[0]) ? rest.slice(1) : rest;
170
210
  }
171
211
  function isDoubtMarker(text, i) {
172
212
  return text[i] === "?" && isWs(text[i - 1]) && (i + 1 === text.length || isWs(text[i + 1]));
@@ -202,6 +242,15 @@ function splitTrailer(text) {
202
242
  }
203
243
  return trailer;
204
244
  }
245
+ function continueTrailer(node, trailer) {
246
+ if (trailer.description !== void 0) {
247
+ node.description = node.description === void 0 ? trailer.description : `${node.description} ${trailer.description}`;
248
+ }
249
+ if (trailer.note !== void 0) {
250
+ node.note = node.note === void 0 ? trailer.note : `${node.note}
251
+ ${trailer.note}`;
252
+ }
253
+ }
205
254
  var WORD = /[\p{L}\p{N}_-]+/uy;
206
255
  function wordAt(text, i) {
207
256
  WORD.lastIndex = i;
@@ -322,8 +371,10 @@ function parseFieldLine(text, line) {
322
371
  const head = take(c);
323
372
  if (head === void 0) {
324
373
  const col = text.search(/[^ \t]/);
325
- const message = text[col] === "#" ? "A `#` comment must start at column 0; an indented line is a field and needs a name" : "A field line needs a field name before its trailer";
326
- return fail("E_UNPARSABLE", message, line, { col, end: Math.max(col + 1, text.length) });
374
+ return fail("E_UNPARSABLE", "A field line needs a field name before its trailer", line, {
375
+ col,
376
+ end: Math.max(col + 1, text.length)
377
+ });
327
378
  }
328
379
  if (head.kind !== "word") return unexpected(head, line, "field", FIELD_ORDER);
329
380
  if (!FIELD_NAME.test(head.text)) {
@@ -783,6 +834,109 @@ function levenshtein(a, b) {
783
834
  return previous[b.length] ?? 0;
784
835
  }
785
836
 
837
+ // src/generators/glossary.ts
838
+ var DEFAULT_NAME = "Glossary";
839
+ function toGlossary(doc, opts) {
840
+ const resolved = resolve(doc);
841
+ const notes = opts?.notes === true;
842
+ const declared = new Set(resolved.classes.map((cls) => cls.name.text));
843
+ const blocks = [`# ${opts?.name ?? DEFAULT_NAME}`];
844
+ const description = resolved.description;
845
+ if (description !== void 0 && description !== "") blocks.push(description);
846
+ if (opts?.classes === true) blocks.push(termTable(resolved.classes, notes));
847
+ else for (const cls of resolved.classes) blocks.push(...section(cls, declared, notes));
848
+ return `${blocks.join("\n\n")}
849
+ `;
850
+ }
851
+ function section(cls, declared, notes) {
852
+ const out = [`## ${cls.name.text}`];
853
+ const paragraph = sentencesOf(cls).join(" ");
854
+ if (paragraph !== "") out.push(paragraph);
855
+ if (cls.fields.length > 0) out.push(fieldTable(cls, declared));
856
+ if (notes) {
857
+ const questions = openQuestions(cls);
858
+ if (questions.length > 0) out.push("**Open questions**", questions.join("\n"));
859
+ }
860
+ return out;
861
+ }
862
+ var FIELD_COLUMNS = ["Field", "Type", "Description"];
863
+ function fieldTable(cls, declared) {
864
+ const rows = [row(FIELD_COLUMNS), divider(FIELD_COLUMNS.length)];
865
+ for (const field of cls.fields) {
866
+ rows.push(row([field.name.text, typeCell(field, declared), descriptionCell(field)]));
867
+ }
868
+ return rows.join("\n");
869
+ }
870
+ function typeCell(field, declared) {
871
+ const parts2 = [];
872
+ if (field.identifier) parts2.push("identifier");
873
+ const type = typeText(field.type, declared);
874
+ if (type !== void 0) parts2.push(type);
875
+ return parts2.join(", ");
876
+ }
877
+ function typeText(type, declared) {
878
+ if (type === void 0) return void 0;
879
+ let text;
880
+ switch (type.kind) {
881
+ case "primitive":
882
+ text = type.written.text;
883
+ break;
884
+ case "unknown":
885
+ text = type.name.text;
886
+ break;
887
+ case "class":
888
+ text = declared.has(type.name.text) ? link(type.name.text) : type.name.text;
889
+ break;
890
+ case "enum":
891
+ text = type.values.map((value) => value.text).join(" / ");
892
+ break;
893
+ }
894
+ return type.many ? `list of ${text}` : text;
895
+ }
896
+ var link = (name2) => `[${name2}](#${name2.toLowerCase()})`;
897
+ function descriptionCell(field) {
898
+ const sentences = [];
899
+ if (field.system !== void 0) sentences.push(`System: ${field.system.text}.`);
900
+ if (field.joinsTo !== void 0) {
901
+ sentences.push(`Joins to ${field.joinsTo.className.text}.${field.joinsTo.fieldName.text}.`);
902
+ }
903
+ const description = field.description;
904
+ if (description === void 0 || description === "") return sentences.join(" ");
905
+ if (sentences.length === 0) return description;
906
+ return [stopped(description), ...sentences].join(" ");
907
+ }
908
+ function termTable(classes, notes) {
909
+ const columns = notes ? ["Term", "Definition", "Open questions"] : ["Term", "Definition"];
910
+ const rows = [row(columns), divider(columns.length)];
911
+ for (const cls of classes) {
912
+ const cells = [cls.name.text, sentencesOf(cls).join(" ")];
913
+ if (notes) cells.push(doubtsOf(cls.note).join("<br>"));
914
+ rows.push(row(cells));
915
+ }
916
+ return rows.join("\n");
917
+ }
918
+ function sentencesOf(cls) {
919
+ const out = [];
920
+ if (cls.system !== void 0) out.push(`System: ${cls.system.text}.`);
921
+ if (cls.parent !== void 0) out.push(`Extends ${cls.parent.text}.`);
922
+ if (cls.similarTo !== void 0) out.push(`Similar to ${cls.similarTo.text}.`);
923
+ if (cls.description !== void 0 && cls.description !== "") out.push(sentence(cls.description));
924
+ return out;
925
+ }
926
+ var sentence = (text) => stopped(text.charAt(0).toUpperCase() + text.slice(1));
927
+ var stopped = (text) => /[.!?]$/.test(text) ? text : `${text}.`;
928
+ var doubtsOf = (note) => note === void 0 ? [] : note.split("\n").filter((doubt) => doubt !== "");
929
+ function openQuestions(cls) {
930
+ const out = doubtsOf(cls.note).map((doubt) => `- ${doubt}`);
931
+ for (const field of cls.fields) {
932
+ for (const doubt of doubtsOf(field.note)) out.push(`- ${field.name.text}: ${doubt}`);
933
+ }
934
+ return out;
935
+ }
936
+ var row = (cells) => `|${cells.map((cell) => cell === "" ? " " : ` ${cellText(cell)} `).join("|")}|`;
937
+ var divider = (columns) => `|${"---|".repeat(columns)}`;
938
+ var cellText = (text) => text.replaceAll("|", "\\|").replaceAll("\n", " ");
939
+
786
940
  // src/generators/linkml.ts
787
941
  var BASE_URI = "https://example.org";
788
942
  var RANGE = {
@@ -825,9 +979,11 @@ function toLinkML(doc, opts) {
825
979
  if (system === void 0) return schemaName;
826
980
  return systemPrefix.get(system) ?? schemaName;
827
981
  };
982
+ const description = resolved.description;
828
983
  const schema = {
829
984
  id: `${BASE_URI}/${schemaName}`,
830
985
  name: schemaName,
986
+ ...description === void 0 || description === "" ? {} : { description },
831
987
  default_prefix: schemaName,
832
988
  default_range: "string",
833
989
  prefixes,
@@ -986,10 +1142,12 @@ function toMermaid(doc, opts) {
986
1142
  }
987
1143
  if (notes) {
988
1144
  for (const cls of resolved.classes) {
989
- if (cls.note !== void 0) lines.push(` note for ${cls.name.text} ${quote(cls.note)}`);
1145
+ for (const doubt of doubts(cls.note)) {
1146
+ lines.push(` note for ${cls.name.text} ${quote(doubt)}`);
1147
+ }
990
1148
  for (const field of cls.fields) {
991
- if (field.note !== void 0) {
992
- lines.push(` note for ${cls.name.text} ${quote(`${field.name.text}: ${field.note}`)}`);
1149
+ for (const doubt of doubts(field.note)) {
1150
+ lines.push(` note for ${cls.name.text} ${quote(`${field.name.text}: ${doubt}`)}`);
993
1151
  }
994
1152
  }
995
1153
  }
@@ -1001,9 +1159,7 @@ function toMermaid(doc, opts) {
1001
1159
  for (const field of cls.fields) {
1002
1160
  const type = field.type;
1003
1161
  if (type?.kind === "class") {
1004
- lines.push(
1005
- type.many ? ` ${from} "1" --> "*" ${type.name.text} : ${field.name.text}` : ` ${from} --> ${type.name.text} : ${field.name.text}`
1006
- );
1162
+ lines.push(` ${from} --> ${type.name.text} : ${field.name.text}`);
1007
1163
  }
1008
1164
  if (field.joinsTo !== void 0) {
1009
1165
  const { className, fieldName } = field.joinsTo;
@@ -1016,11 +1172,11 @@ function toMermaid(doc, opts) {
1016
1172
  }
1017
1173
  function member(field) {
1018
1174
  const marker = field.identifier ? "*" : "+";
1019
- const type = field.type === void 0 ? "" : `${typeText(field.type)} `;
1175
+ const type = field.type === void 0 ? "" : `${typeText2(field.type)} `;
1020
1176
  const system = field.system === void 0 ? "" : ` @${field.system.text}`;
1021
1177
  return `${marker}${type}${field.name.text}${system}`;
1022
1178
  }
1023
- function typeText(type) {
1179
+ function typeText2(type) {
1024
1180
  let text;
1025
1181
  switch (type.kind) {
1026
1182
  case "primitive":
@@ -1036,6 +1192,7 @@ function typeText(type) {
1036
1192
  }
1037
1193
  return type.many ? `${text}[]` : text;
1038
1194
  }
1195
+ var doubts = (note) => note === void 0 ? [] : note.split("\n").filter((line) => line !== "");
1039
1196
  var quote = (text) => `"${text.replaceAll('"', "'")}"`;
1040
1197
 
1041
1198
  // src/serialize.ts
@@ -1085,8 +1242,17 @@ function hasControlCharacter(text) {
1085
1242
  // src/compile.ts
1086
1243
  function compile(source, opts) {
1087
1244
  const doc = resolve(parse(source));
1088
- const output = opts.target === "linkml" ? serialize(toLinkML(doc, { schemaName: opts.schemaName }), opts.format ?? "yaml") : toMermaid(doc, { notes: opts.notes });
1089
- return { output, diagnostics: doc.diagnostics };
1245
+ return { output: generate(doc, opts), diagnostics: doc.diagnostics };
1246
+ }
1247
+ function generate(doc, opts) {
1248
+ switch (opts.target) {
1249
+ case "linkml":
1250
+ return serialize(toLinkML(doc, { schemaName: opts.schemaName }), opts.format ?? "yaml");
1251
+ case "glossary":
1252
+ return toGlossary(doc, { name: opts.name, classes: opts.classes, notes: opts.notes });
1253
+ case "mermaid":
1254
+ return toMermaid(doc, { notes: opts.notes });
1255
+ }
1090
1256
  }
1091
1257
 
1092
1258
  // src/diagnostics.ts
@@ -1098,20 +1264,27 @@ function formatDiagnostic(d, file) {
1098
1264
  // src/generators/skiss.ts
1099
1265
  var TRAILER_COLUMN = 40;
1100
1266
  function toSkiss(doc) {
1101
- if (doc.classes.length === 0) return "";
1102
- return `${doc.classes.map(classBlock).join("\n\n")}
1267
+ const description = doc.description;
1268
+ const blocks = description === void 0 || description === "" ? [] : [`# ${description}`];
1269
+ for (const cls of doc.classes) blocks.push(classBlock(cls));
1270
+ if (blocks.length === 0) return "";
1271
+ return `${blocks.join("\n\n")}
1103
1272
  `;
1104
1273
  }
1105
1274
  function classBlock(cls) {
1106
1275
  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}`);
1107
- return [withTrailer(head, cls), ...cls.fields.map(fieldLine)].join("\n");
1276
+ const lines = [withTrailer(head, cls), ...doubtLines(cls.note, " ")];
1277
+ for (const field of cls.fields) lines.push(fieldLine(field), ...doubtLines(field.note, " "));
1278
+ return lines.join("\n");
1108
1279
  }
1280
+ var doubtsOf2 = (note) => note === void 0 ? [] : note.split("\n").filter((doubt) => doubt !== "");
1281
+ var doubtLines = (note, indent) => doubtsOf2(note).slice(1).map((doubt) => `${indent}? ${doubt}`);
1109
1282
  function fieldLine(field) {
1110
- const type = typeText2(field.type);
1283
+ const type = typeText3(field.type);
1111
1284
  const head = ` ${field.name.text}` + (field.identifier ? "*" : "") + (type === void 0 ? "" : `: ${type}`) + (field.system === void 0 ? "" : ` @${field.system.text}`) + (field.joinsTo === void 0 ? "" : ` = ${field.joinsTo.className.text}.${field.joinsTo.fieldName.text}`);
1112
1285
  return withTrailer(head, field);
1113
1286
  }
1114
- function typeText2(type) {
1287
+ function typeText3(type) {
1115
1288
  if (type === void 0) return void 0;
1116
1289
  let text;
1117
1290
  switch (type.kind) {
@@ -1138,7 +1311,8 @@ function withTrailer(head, node) {
1138
1311
  const parts2 = [];
1139
1312
  if (node.description !== void 0 && node.description !== "")
1140
1313
  parts2.push(writeDescription(node.description));
1141
- if (node.note !== void 0 && node.note !== "") parts2.push(`? ${node.note}`);
1314
+ const [doubt] = doubtsOf2(node.note);
1315
+ if (doubt !== void 0) parts2.push(`? ${doubt}`);
1142
1316
  if (parts2.length === 0) return head;
1143
1317
  const gap = head.length < TRAILER_COLUMN - 1 ? " ".repeat(TRAILER_COLUMN - head.length) : " ";
1144
1318
  return head + gap + parts2.join(" ");
@@ -1171,6 +1345,8 @@ var SLOT_TAGS = /* @__PURE__ */ new Set(["system", "note", "joins_to"]);
1171
1345
  var QUIET_SCHEMA_KEYS = /* @__PURE__ */ new Set([
1172
1346
  "id",
1173
1347
  "name",
1348
+ // SPEC §8: the schema's own `description` is the document description.
1349
+ "description",
1174
1350
  "prefixes",
1175
1351
  "imports",
1176
1352
  "default_prefix",
@@ -1274,7 +1450,7 @@ function fromLinkML(schema) {
1274
1450
  } else if (system !== void 0) report("annotation", name2, notText("system", system));
1275
1451
  const rawNote = annotations.get("note");
1276
1452
  if (typeof rawNote === "string") {
1277
- const note = oneLine(rawNote);
1453
+ const note = doubtLines2(rawNote);
1278
1454
  if (note !== void 0) node.note = note;
1279
1455
  } else if (rawNote !== void 0) report("annotation", name2, notText("note", rawNote));
1280
1456
  const parent = stringOf(get(def, "is_a"));
@@ -1377,7 +1553,10 @@ function fromLinkML(schema) {
1377
1553
  for (const key2 of Object.keys(root)) {
1378
1554
  if (!QUIET_SCHEMA_KEYS.has(key2)) report("schema", schemaName, `\`${key2}\``);
1379
1555
  }
1380
- const source = toSkiss({ classes, diagnostics: [] });
1556
+ const projected = { classes, diagnostics: [] };
1557
+ const schemaDescription = oneLine(stringOf(get(root, "description")));
1558
+ if (schemaDescription !== void 0) projected.description = schemaDescription;
1559
+ const source = toSkiss(projected);
1381
1560
  return { document: parse(source), source, dropped };
1382
1561
  function buildField(classKey, className, slot, def) {
1383
1562
  const name2 = fieldNameOf(classKey, slot);
@@ -1420,7 +1599,7 @@ function fromLinkML(schema) {
1420
1599
  } else if (joins !== void 0) report("annotation", element, notText("joins_to", joins));
1421
1600
  const rawNote = annotations.get("note");
1422
1601
  if (typeof rawNote === "string") {
1423
- const note = oneLine(rawNote);
1602
+ const note = doubtLines2(rawNote);
1424
1603
  if (note !== void 0) field.note = note;
1425
1604
  } else if (rawNote !== void 0) report("annotation", element, notText("note", rawNote));
1426
1605
  for (const other of Object.keys(def)) {
@@ -1646,6 +1825,10 @@ function oneLine(text) {
1646
1825
  const line = text.replace(/\s*[\r\n]+\s*/g, " ").trim();
1647
1826
  return line === "" ? void 0 : line;
1648
1827
  }
1828
+ function doubtLines2(text) {
1829
+ const lines = text.split(/\r\n|\n|\r/).map((line) => line.trim()).filter((line) => line !== "");
1830
+ return lines.length === 0 ? void 0 : lines.join("\n");
1831
+ }
1649
1832
  var list = (items) => items.map((item) => `\`${item}\``).join(", ");
1650
1833
  var at0 = (text) => ({ text, line: 0, col: 0, end: 0 });
1651
1834
  var stringType = (many) => ({
@@ -1883,7 +2066,7 @@ function notYAML(error) {
1883
2066
  }
1884
2067
 
1885
2068
  // src/index.ts
1886
- var VERSION = "0.6.1";
2069
+ var VERSION = "0.8.0";
1887
2070
  export {
1888
2071
  VERSION,
1889
2072
  compile,
@@ -1894,6 +2077,7 @@ export {
1894
2077
  parse,
1895
2078
  resolve,
1896
2079
  serialize,
2080
+ toGlossary,
1897
2081
  toLinkML,
1898
2082
  toMermaid,
1899
2083
  toSkiss,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eriknaslund/skiss",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "A text notation for sketching data models, compiled to LinkML",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "devDependencies": {
43
43
  "@biomejs/biome": "^2.5.13",
44
- "@mermaid-js/mermaid-cli": "^11.17.0",
44
+ "@mermaid-js/mermaid-cli": "^12.0.0",
45
45
  "@types/node": "^26.6.1",
46
46
  "jsdom": "^30.0.1",
47
47
  "mermaid": "^12.0.0",