@rse/gradia 1.1.8 → 1.2.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/dst/gradia-cli.js CHANGED
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  /* external dependencies */
12
12
  import { Command, Option } from "commander";
13
13
  /* internal dependencies */
14
- import { configDefaults } from "./gradia-api-config.js";
14
+ import { validateConfig } from "./gradia-api-config.js";
15
15
  import { Gradia, diagramTypes, diagramTypeDefault, diagramFormats, diagramFormatDefault } from "./gradia-api.js";
16
16
  /* internal package meta-information */
17
17
  const pkg = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
@@ -30,31 +30,14 @@ program.name("gradia")
30
30
  .argument("[input]", "input graph description file (omitted in MCP service mode)")
31
31
  .action(async (input, options) => {
32
32
  /* parse and validate the rendering configuration options */
33
- const config = {};
34
- const store = config;
33
+ const store = {};
35
34
  for (const nv of options.config) {
36
35
  const m = /^([a-z][a-z0-9-]*)=(.*)$/.exec(nv);
37
36
  if (m === null)
38
37
  throw new Error(`invalid configuration option "${nv}" (expected "<name>=<value>")`);
39
- if (!Object.hasOwn(configDefaults, m[1]))
40
- throw new Error(`unknown configuration option "${m[1]}"`);
41
- const key = m[1];
42
- if (typeof configDefaults[key] === "boolean") {
43
- if (m[2] !== "true" && m[2] !== "false")
44
- throw new Error(`invalid value "${m[2]}" for boolean configuration option "${key}" (expected "true" or "false")`);
45
- store[key] = m[2] === "true";
46
- }
47
- else if (typeof configDefaults[key] === "number") {
48
- const num = Number(m[2]);
49
- if (m[2] === "" || !Number.isFinite(num) || num < 0)
50
- throw new Error(`invalid value "${m[2]}" for numeric configuration option "${key}" (expected a non-negative number)`);
51
- store[key] = num;
52
- }
53
- else if (m[2] === "")
54
- throw new Error(`invalid empty value for string configuration option "${key}"`);
55
- else
56
- store[key] = m[2];
38
+ store[m[1]] = m[2];
57
39
  }
40
+ const config = validateConfig(store, { trusted: true });
58
41
  /* optionally run as an MCP service instead of rendering once,
59
42
  with the command-line options acting as the defaults of the
60
43
  tool calls (and hence being the only trusted source of the
package/dst/gradia-mcp.js CHANGED
@@ -8,8 +8,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { z } from "zod";
10
10
  /* internal dependencies */
11
- import { configDefaults } from "./gradia-api-config.js";
12
- import { Gradia, diagramTypes, diagramFormats, diagramFormatDefault } from "./gradia-api.js";
11
+ import { configDefaults, validateConfig } from "./gradia-api-config.js";
12
+ import { Gradia, diagramTypes, diagramTypeDefault, diagramFormats, diagramFormatDefault } from "./gradia-api.js";
13
13
  /* the rendering configuration options offered to the MCP client
14
14
  ("font-embed" is intentionally omitted, as the arguments of a tool
15
15
  call are treated as untrusted and it would allow the reading of
@@ -128,7 +128,7 @@ as <name>, terminals as "...", optional parts as [ ... ], repeatable parts as
128
128
  following node, i.e., the chain is equivalent to "a --> b" and "b --> c".
129
129
  - The <edge-name> is rendered as the label of the edge and the <edge-arity> as
130
130
  its cardinality label.
131
- - Four attribute keys are reserved and consumed by the renderer instead of being
131
+ - Six attribute keys are reserved and consumed by the renderer instead of being
132
132
  displayed as a regular "<key>: <val>" line:
133
133
  - "url: <url>" renders the node box as a hyperlink. Only relative URLs and
134
134
  the schemes "http", "https", and "mailto" are honored; any other URL is
@@ -137,13 +137,37 @@ as <name>, terminals as "...", optional parts as [ ... ], repeatable parts as
137
137
  - "primary: true" renders the node in the primary node colors (and, for the
138
138
  diagram type "hub", marks the single central hub node). The value has to
139
139
  be the literal string "true",
140
- - "group: <name>" places the node into the surrounding group box <name>.
140
+ - "group: <name>" places the node into the surrounding group box <name>,
141
+ - "parent: <id>" nests the node into the container node <id> (a node which
142
+ is implicitly declared if never referenced otherwise),
143
+ - "container: <type>" marks the node as a container whose members are laid
144
+ out with the diagram type <type> ("graph", "hub", or "grid"), even if it
145
+ has no members at all.
141
146
  - A node cannot be a member of more than one group. As soon as at least one node
142
147
  carries a "group" attribute, all nodes without one implicitly belong to the
143
- group "default", and every edge has to stay within a single group.
148
+ group "default", and every edge has to stay within a single group. A nested
149
+ node belongs to the group of its outermost container.
144
150
  - The groups are laid out individually with the selected diagram type and then
145
151
  stacked vertically as decorated group boxes. For the type "hub" this means
146
152
  that every single group needs its own primary node.
153
+ - A node referenced as the "parent" of another node is a container: it is
154
+ rendered as a dashed grey box surrounding its members instead of as a node
155
+ box, with its label, "type", and "url" in the box head (its remaining
156
+ attributes are not rendered). Containers nest to any depth, but a node cannot
157
+ be a member of more than one container, and cannot be nested into itself.
158
+ - The members of a container are laid out as a diagram of their own, with the
159
+ diagram type of the enclosing level unless overridden by "container: <type>",
160
+ and the container then takes part in the layout of the enclosing level as a
161
+ single box. Edges may connect any two nodes, across container boundaries and
162
+ to container nodes themselves, except a container with one of its own
163
+ members. An edge crossing the boundary of a container laid out as "graph" is
164
+ routed through the box border to the inner node (entering on the west and
165
+ leaving on the east side), while an edge crossing the boundary of a container
166
+ laid out as "hub" or "grid" ends at the box border instead. The diagram type
167
+ constraints apply to every container level (e.g., a "hub" container needs its
168
+ own primary node, and a "grid" container permits no edges among its members).
169
+ - Use containers (not groups) to visualize zones, tiers, deployment units, or
170
+ packages whose members are connected to the outside world.
147
171
  - All remaining attributes are displayed as "<key>: <val>" lines inside the node
148
172
  box.
149
173
 
@@ -155,8 +179,9 @@ as <name>, terminals as "...", optional parts as [ ... ], repeatable parts as
155
179
  #config color-edge-line #999999
156
180
 
157
181
  Animal: animal [ type: "abstract class", kind: base, url: "#animal", primary: true ]
158
- Dog: dog
159
- Cat: cat
182
+ Dog: dog [ parent: Pets ]
183
+ Cat: cat [ parent: Pets ]
184
+ Pets: "Domestic Pets" [ type: package ]
160
185
 
161
186
  Dog --(isa)--> Animal
162
187
  Cat --(isa)--> Animal
@@ -170,11 +195,11 @@ over them. The recognized options, with their types and built-in defaults, are:
170
195
 
171
196
  ${configOptions}
172
197
 
173
- The "size-*", "group-*", "graph-*", "hub-*", and "grid-*" options control the
174
- rendering geometry (canvas margin, node box sizing, edge routing, group box
175
- spacing, and the per-diagram-type layout) and take non-negative numbers, except
176
- the boolean "grid-node-width-equal", which forces all node boxes of a "grid"
177
- diagram to the width of the widest one.
198
+ The "size-*", "group-*", "container-*", "graph-*", "hub-*", and "grid-*" options
199
+ control the rendering geometry (canvas margin, node box sizing, edge routing,
200
+ group and container box spacing, and the per-diagram-type layout) and take
201
+ non-negative numbers, except the boolean "grid-node-width-equal", which forces
202
+ all node boxes of a "grid" diagram to the width of the widest one.
178
203
 
179
204
  The "size-node-width-max" option additionally enables the word-wrapping of the
180
205
  node box texts: given a positive value, the node name, its type, and its
@@ -215,57 +240,26 @@ On a malformed input, a violated diagram type constraint, or an invalid "config"
215
240
  argument, the tool instead returns an error result whose text starts with
216
241
  "gradia: ERROR: ", followed by the reason.
217
242
  `.trim();
218
- /* the arguments of the "gradia_render" tool */
219
- const toolArguments = {
243
+ /* the arguments of the "gradia_render" tool (with their descriptions
244
+ reflecting the service defaults underlying the arguments) */
245
+ const toolArgumentsOf = (defaults) => ({
220
246
  input: z.string()
221
247
  .describe("The graph description in the Gradia input language (see the tool description for its grammar)."),
222
248
  type: z.enum(diagramTypes).optional()
223
249
  .describe("The diagram type. Takes precedence over a \"#type\" directive inside the input. " +
224
- "Defaults to the \"#type\" directive of the input, else to \"graph\"."),
250
+ (defaults.type !== undefined ?
251
+ `Defaults to "${defaults.type}".` :
252
+ `Defaults to the "#type" directive of the input, else to "${diagramTypeDefault}".`)),
225
253
  format: z.enum(diagramFormats).optional()
226
254
  .describe("The output format: \"svg:standalone\" (a standalone SVG/XML document), " +
227
255
  "\"svg:embedded\" (the SVG without the \"<?xml?>\" declaration, for embedding into HTML), " +
228
256
  "\"url:xml\" (a \"data:image/svg+xml\" URL with URL-encoded XML), or " +
229
257
  "\"url:base64\" (a \"data:image/svg+xml\" URL with Base64-encoded XML). " +
230
- `Defaults to "${diagramFormatDefault}".`),
258
+ `Defaults to "${defaults.format ?? diagramFormatDefault}".`),
231
259
  config: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
232
260
  .describe("The rendering configuration options (see the tool description for their names, " +
233
261
  "types, and defaults). Takes precedence over the \"#config\" directives inside the input.")
234
- };
235
- /* validate and coerce the rendering configuration options of a tool
236
- call (the arguments are treated as untrusted, so an unknown option
237
- or an invalid value is rejected instead of being silently applied) */
238
- const validateConfig = (raw) => {
239
- const config = {};
240
- for (const [key, val] of Object.entries(raw)) {
241
- if (!Object.hasOwn(configDefaults, key))
242
- throw new Error(`unknown configuration option "${key}"`);
243
- const k = key;
244
- if (k === "font-embed")
245
- throw new Error(`configuration option "${key}" is not available in this context`);
246
- else if (typeof configDefaults[k] === "boolean") {
247
- if (typeof val !== "boolean" && val !== "true" && val !== "false")
248
- throw new Error(`invalid value "${String(val)}" for boolean configuration option "${key}" ` +
249
- "(expected \"true\" or \"false\")");
250
- config[k] = typeof val === "boolean" ? val : val === "true";
251
- }
252
- else if (typeof configDefaults[k] === "number") {
253
- const num = Number(val);
254
- if (typeof val === "boolean" || val === "" || !Number.isFinite(num) || num < 0)
255
- throw new Error(`invalid value "${String(val)}" for numeric configuration option "${key}" ` +
256
- "(expected a non-negative number)");
257
- config[k] = num;
258
- }
259
- else {
260
- if (typeof val !== "string")
261
- throw new Error(`invalid value "${String(val)}" for string configuration option "${key}" (expected a string)`);
262
- if (k === "font-family" && val.endsWith(".woff2"))
263
- throw new Error("configuration option \"font-family\" must not be the path to a WOFF2 file in this context");
264
- config[k] = val;
265
- }
266
- }
267
- return config;
268
- };
262
+ });
269
263
  /* run the MCP service on stdio */
270
264
  export const serve = async (meta, defaults = {}) => {
271
265
  /* establish the MCP server and its single tool */
@@ -277,11 +271,11 @@ export const serve = async (meta, defaults = {}) => {
277
271
  server.registerTool("gradia_render", {
278
272
  title: "Render an object graph as an SVG diagram",
279
273
  description: toolDescription,
280
- inputSchema: toolArguments,
274
+ inputSchema: toolArgumentsOf(defaults),
281
275
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
282
276
  }, async (args) => {
283
277
  try {
284
- const config = { ...defaults.config, ...validateConfig(args.config ?? {}) };
278
+ const config = { ...defaults.config, ...validateConfig(args.config ?? {}, { trusted: false }) };
285
279
  const out = await Gradia.render(args.input, {
286
280
  type: args.type ?? defaults.type,
287
281
  format: args.format ?? defaults.format,
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Object Graph Diagram Rendering",
4
4
  "keywords": [ "object", "graph", "diagram", "rendering", "svg", "layout", "api", "cli" ],
5
5
  "publishConfig": { "access": "public" },
6
- "version": "1.1.8",
6
+ "version": "1.2.0",
7
7
  "license": "MIT",
8
8
  "author": {
9
9
  "name": "Dr. Ralf S. Engelschall",