@miragon/design-iq-validator 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,8 +5,9 @@ structural checks (flow soundness, complete BPMNDI coverage), DMN structure and
5
5
  the generic cross-model reference rule (every required reference — `callActivity` calls,
6
6
  `businessRuleTask` decides — must resolve in the repo), and a per-mediaKind parse baseline for
7
7
  every other registered notation (a broken `.tt`/`.vc.json` is an ERROR; DSL notations are
8
- lenient by design; a Mermaid diagram that does not start with a diagram type such as
9
- `flowchart` or `sequenceDiagram` is a WARN, `mermaid/diagram-type`). Every finding carries a stable `ruleId` (e.g. `bpmn/flow`, `dmn/di`,
8
+ lenient by design; a `mermaid` fence in a Markdown document that does not start with a diagram
9
+ type such as `flowchart` or `sequenceDiagram` is a WARN, `mermaid/diagram-type`, naming the
10
+ document's line). Every finding carries a stable `ruleId` (e.g. `bpmn/flow`, `dmn/di`,
10
11
  `refs/dangling`). It treats the target repo as pure data — it never executes content-repo code.
11
12
  Exit code 0 = no errors (warnings allowed), 1 = errors.
12
13
 
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as runCli } from "./validate-Dy2lNoBR.js";
2
+ import { a as runCli } from "./validate-DjtZJTGk.js";
3
3
  //#region src/cli.ts
4
4
  /**
5
5
  * CLI entry for the platform validator. A dedicated entry file (instead of an
@@ -126,18 +126,6 @@ const NOTATIONS = [
126
126
  mediaKind: "markdown",
127
127
  docShape: "text",
128
128
  monacoLanguage: "markdown"
129
- },
130
- {
131
- id: "mermaid",
132
- label: "Mermaid",
133
- noun: {
134
- singular: "diagram",
135
- plural: "diagrams"
136
- },
137
- extensions: [".mmd", ".mermaid"],
138
- mediaKind: "dsl",
139
- docShape: "text",
140
- monacoLanguage: "plaintext"
141
129
  }
142
130
  ];
143
131
  function byId(id) {
@@ -1 +1 @@
1
- export { t as CONTENT_CONFIG_FILE, i as contentConfigConflict, a as discoverModels, o as legacyContentConfigFile, s as loadContentConfig } from "./content-DJ1x-ykR.js";
1
+ export { t as CONTENT_CONFIG_FILE, i as contentConfigConflict, a as discoverModels, o as legacyContentConfigFile, s as loadContentConfig } from "./content-Bsv9ZLFd.js";
@@ -1,4 +1,4 @@
1
- import { _ as byId, c as resolveContentConfigFile, d as extractModelGraph, f as parseXml, g as byExtension, h as kindOf, l as XMLValidator, m as requirementHref, p as readBpmn, r as CONTENT_CONFIG_NAMES, u as asArray } from "./content-DJ1x-ykR.js";
1
+ import { _ as byId, c as resolveContentConfigFile, d as extractModelGraph, f as parseXml, g as byExtension, h as kindOf, l as XMLValidator, m as requirementHref, p as readBpmn, r as CONTENT_CONFIG_NAMES, u as asArray } from "./content-Bsv9ZLFd.js";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { relative, resolve } from "node:path";
4
4
  //#region ../notations/refs.ts
@@ -84,6 +84,343 @@ const notContentRepoError = (root) => {
84
84
  return found === void 0 ? `[ERROR] ${root}: no ${CONTENT_CONFIG_NAMES} at the root — not a content repo (or wrong --root)` : `[ERROR] ${root}: ${found} at the root names no models folder — not a content repo (or wrong --root)`;
85
85
  };
86
86
  //#endregion
87
+ //#region ../notations/markdown.ts
88
+ const NONE = { kind: "none" };
89
+ const PARAGRAPH = { kind: "paragraph" };
90
+ const CODE = { kind: "code" };
91
+ const FENCE_OPEN = /^(`{3,}|~{3,})(.*)$/;
92
+ const THEMATIC_BREAK = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
93
+ const ATX_HEADING = /^#{1,6}(?:[ \t]|$)/;
94
+ const SETEXT_UNDERLINE = /^(?:=+|-+)[ \t]*$/;
95
+ const BULLET = /^[-+*](?=[ \t]|$)/;
96
+ const ORDERED = /^(\d{1,9})[.)](?=[ \t]|$)/;
97
+ const BLANK = /^[ \t]*$/;
98
+ /** a link reference definition on one line: [label]: destination "title" (a
99
+ * "[^" label is a footnote, which render.ts keeps as paragraph text; a bare
100
+ * destination ends at a space or a control character, as in markdown-it) */
101
+ const LINK_DEFINITION = /^\[(?!\^)((?:[^[\]\\]|\\.)*)\]:[ \t]*(<(?:[^<>\\]|\\.)*>|[^ <\x00-\x1f\x7f][^ \x00-\x1f\x7f]*)(?:[ \t]+(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\((?:[^()\\]|\\.)*\)))?[ \t]*$/;
102
+ /** the destinations markdown-it's validateLink refuses: no definition then */
103
+ const BAD_LINK = /^(?:vbscript|javascript|file|data):/i;
104
+ const GOOD_DATA_LINK = /^data:image\/(?:gif|png|jpeg|webp);/i;
105
+ /** markdown-it's maxNesting: content nested this deep (a Container's `level`)
106
+ * it does not parse, and drops the rest of the innermost quote around it,
107
+ * or of the document */
108
+ const MAX_NESTING = 100;
109
+ /** start, end and whether it may interrupt a paragraph; an end of /^$/ is the next blank line */
110
+ const HTML_BLOCKS = [
111
+ [
112
+ /^<(?:script|pre|style|textarea)(?=\s|>|$)/i,
113
+ /<\/(?:script|pre|style|textarea)>/i,
114
+ true
115
+ ],
116
+ [
117
+ /^<!--/,
118
+ /-->/,
119
+ true
120
+ ],
121
+ [
122
+ /^<\?/,
123
+ /\?>/,
124
+ true
125
+ ],
126
+ [
127
+ /^<![A-Za-z]/,
128
+ />/,
129
+ true
130
+ ],
131
+ [
132
+ /^<!\[CDATA\[/,
133
+ /\]\]>/,
134
+ true
135
+ ],
136
+ [
137
+ new RegExp(`^</?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?=\\s|/?>|$)`, "i"),
138
+ /^$/,
139
+ true
140
+ ],
141
+ [
142
+ new RegExp(`^(?:<[A-Za-z][A-Za-z0-9\\-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^"'=<>\`\\x00-\\x20]+|'[^']*'|"[^"]*"))?)*\\s*\\/?>|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>)\\s*$`),
143
+ /^$/,
144
+ false
145
+ ]
146
+ ];
147
+ /** the leading blanks of a rest */
148
+ function leading(rest) {
149
+ let col = rest.col;
150
+ let i = 0;
151
+ for (; i < rest.text.length; i++) {
152
+ const ch = rest.text[i];
153
+ if (ch === " ") col++;
154
+ else if (ch === " ") col += 4 - col % 4;
155
+ else break;
156
+ }
157
+ return {
158
+ indent: col - rest.col,
159
+ blank: i === rest.text.length
160
+ };
161
+ }
162
+ /** the columns of leading blanks */
163
+ const indentOf = (rest) => leading(rest).indent;
164
+ /** drop up to `n` columns of leading blanks; a tab cut in two leaves its other columns as spaces */
165
+ function dropIndent(rest, n) {
166
+ const target = rest.col + n;
167
+ let col = rest.col;
168
+ let i = 0;
169
+ while (i < rest.text.length && col < target) {
170
+ const ch = rest.text[i];
171
+ if (ch === " ") col++;
172
+ else if (ch === " ") {
173
+ const next = col + 4 - col % 4;
174
+ if (next > target) return {
175
+ text: " ".repeat(next - target) + rest.text.slice(i + 1),
176
+ col: target
177
+ };
178
+ col = next;
179
+ } else break;
180
+ i++;
181
+ }
182
+ return {
183
+ text: rest.text.slice(i),
184
+ col
185
+ };
186
+ }
187
+ /** the line after its indent, when that is at most three columns (more is indented code) */
188
+ function blockStart(rest) {
189
+ const indent = indentOf(rest);
190
+ return indent < 4 ? dropIndent(rest, indent) : void 0;
191
+ }
192
+ /** a quote's ">" and the one column of blank after it: a space, or the first
193
+ * column of a tab (which stays, a column narrower, as in markdown-it) */
194
+ function afterQuoteMarker(at) {
195
+ const rest = {
196
+ text: at.text.slice(1),
197
+ col: at.col + 1
198
+ };
199
+ if (rest.text.startsWith(" ") || rest.text.startsWith(" ") && rest.col % 4 === 3) return {
200
+ text: rest.text.slice(1),
201
+ col: rest.col + 1
202
+ };
203
+ return rest.text.startsWith(" ") ? {
204
+ text: rest.text,
205
+ col: rest.col + 1
206
+ } : rest;
207
+ }
208
+ /** the rest of the line when it continues the container, undefined when the
209
+ * container ends. `lead` is the rest's leading blanks, kept up to date across
210
+ * the columns a list item drops, so a line is not measured once per container */
211
+ function continues(container, rest, lead) {
212
+ if (container.kind === "quote") {
213
+ const at = dropIndent(rest, lead.indent);
214
+ return at.text.startsWith(">") ? afterQuoteMarker(at) : void 0;
215
+ }
216
+ const fresh = container.fresh;
217
+ container.fresh = false;
218
+ if (lead.blank ? fresh : lead.indent < container.indent) return void 0;
219
+ lead.indent = Math.max(0, lead.indent - container.indent);
220
+ return dropIndent(rest, container.indent);
221
+ }
222
+ /** a list item opening at `at` (the line after its indent of `indent` columns),
223
+ * inside containers nested `level` deep */
224
+ function listItem(at, indent, level, interruptsParagraph) {
225
+ const ordered = ORDERED.exec(at.text);
226
+ const marker = ordered ?? BULLET.exec(at.text);
227
+ if (!marker) return void 0;
228
+ const after = {
229
+ text: at.text.slice(marker[0].length),
230
+ col: at.col + marker[0].length
231
+ };
232
+ const empty = BLANK.test(after.text);
233
+ if (interruptsParagraph && (empty || ordered && Number(ordered[1]) !== 1)) return void 0;
234
+ const blanks = indentOf(after);
235
+ const gap = empty || blanks > 4 ? 1 : blanks;
236
+ return {
237
+ item: {
238
+ kind: "item",
239
+ level: level + 2,
240
+ indent: indent + marker[0].length + gap,
241
+ fresh: empty
242
+ },
243
+ rest: dropIndent(after, gap)
244
+ };
245
+ }
246
+ /** the HTML block starting at `text` (a line after its indent), if any */
247
+ function htmlBlock(text) {
248
+ return text.startsWith("<") ? HTML_BLOCKS.find(([start]) => start.test(text)) : void 0;
249
+ }
250
+ /** whether a line that does not continue a paragraph's containers starts a
251
+ * block of its own (else it is a lazy continuation line of the paragraph) */
252
+ function startsBlock(rest) {
253
+ const at = blockStart(rest);
254
+ if (!at) return false;
255
+ const text = at.text;
256
+ return text.startsWith(">") || fenceOpen(text) !== void 0 || THEMATIC_BREAK.test(text) || BULLET.test(text) || ORDERED.test(text) || ATX_HEADING.test(text) || htmlBlock(text)?.[2] === true;
257
+ }
258
+ /** whether `text` (a line after its indent) is a link reference definition that
259
+ * fits on it and that markdown-it takes: a label with more than blanks, a
260
+ * destination with balanced parentheses and a scheme its validateLink allows */
261
+ function linkDefinition(text) {
262
+ const definition = LINK_DEFINITION.exec(text);
263
+ if (!definition || definition[1].trim() === "") return false;
264
+ let destination = definition[2];
265
+ if (destination.startsWith("<")) destination = destination.slice(1, -1);
266
+ else {
267
+ let depth = 0;
268
+ for (let i = 0; i < destination.length && depth >= 0; i++) {
269
+ const ch = destination[i];
270
+ if (ch === "\\") i++;
271
+ else if (ch === "(") depth = depth < 32 ? depth + 1 : -1;
272
+ else if (ch === ")") depth--;
273
+ }
274
+ if (depth !== 0) return false;
275
+ }
276
+ destination = destination.trim();
277
+ return !BAD_LINK.test(destination) || GOOD_DATA_LINK.test(destination);
278
+ }
279
+ /** the opening run of a fence at `text` (a line after its indent), if any */
280
+ function fenceOpen(text) {
281
+ const open = FENCE_OPEN.exec(text);
282
+ if (!open) return void 0;
283
+ const run = open[1];
284
+ const info = open[2];
285
+ if (run.startsWith("`") && info.includes("`")) return void 0;
286
+ return {
287
+ char: run[0],
288
+ size: run.length,
289
+ info: info.trim()
290
+ };
291
+ }
292
+ /** whether the line is the closing run of `fence` */
293
+ function closes(rest, fence) {
294
+ const at = blockStart(rest);
295
+ if (!at) return false;
296
+ let n = 0;
297
+ while (at.text[n] === fence.char) n++;
298
+ return n >= fence.size && BLANK.test(at.text.slice(n));
299
+ }
300
+ /** every fenced code block of a Markdown document, in document order */
301
+ function markdownFences(source) {
302
+ const lines = source.split(/\r\n|\r|\n/);
303
+ const brokenLast = BLANK.test(lines[lines.length - 1]);
304
+ if (brokenLast) lines.pop();
305
+ const fences = [];
306
+ const stack = [];
307
+ const level = () => stack.length > 0 ? stack[stack.length - 1].level : 0;
308
+ let leaf = NONE;
309
+ let dropping;
310
+ const finish = (fence, closed) => {
311
+ fences.push({
312
+ line: fence.line,
313
+ endLine: fence.line + fence.content.length + (closed ? 1 : 0),
314
+ info: fence.info,
315
+ lang: (fence.info.split(/\s+/)[0] ?? "").toLowerCase(),
316
+ content: fence.content.join(""),
317
+ closed
318
+ });
319
+ };
320
+ for (let i = 0; i < lines.length; i++) {
321
+ let rest = {
322
+ text: lines[i],
323
+ col: 0
324
+ };
325
+ const lineBreak = i < lines.length - 1 || brokenLast ? "\n" : "";
326
+ let matched = 0;
327
+ let lead;
328
+ for (const container of stack) {
329
+ const next = continues(container, rest, lead ??= leading(rest));
330
+ if (!next) break;
331
+ rest = next;
332
+ if (container.kind === "quote") lead = void 0;
333
+ matched++;
334
+ }
335
+ const all = matched === stack.length;
336
+ if (dropping) {
337
+ if (all) {
338
+ dropping.lastBlank = BLANK.test(rest.text);
339
+ continue;
340
+ }
341
+ if (!dropping.lastBlank && !BLANK.test(rest.text) && !startsBlock(rest)) continue;
342
+ dropping = void 0;
343
+ }
344
+ if (leaf.kind === "fence" && all) {
345
+ if (closes(rest, leaf)) {
346
+ finish(leaf, true);
347
+ leaf = NONE;
348
+ } else leaf.content.push(dropIndent(rest, leaf.indent).text + lineBreak);
349
+ continue;
350
+ }
351
+ if (leaf.kind === "html" && all) {
352
+ if (leaf.end.test(dropIndent(rest, indentOf(rest)).text)) leaf = NONE;
353
+ continue;
354
+ }
355
+ if (!all) {
356
+ if (leaf.kind === "paragraph" && !BLANK.test(rest.text) && !startsBlock(rest)) continue;
357
+ if (leaf.kind === "fence") finish(leaf, false);
358
+ stack.length = matched;
359
+ leaf = NONE;
360
+ }
361
+ while (level() < MAX_NESTING && !BLANK.test(rest.text)) {
362
+ const at = blockStart(rest);
363
+ if (!at) break;
364
+ if (at.text.startsWith(">")) {
365
+ stack.push({
366
+ kind: "quote",
367
+ level: level() + 1
368
+ });
369
+ rest = afterQuoteMarker(at);
370
+ } else {
371
+ if (THEMATIC_BREAK.test(at.text)) break;
372
+ const opening = listItem(at, indentOf(rest), level(), leaf.kind === "paragraph");
373
+ if (!opening) break;
374
+ stack.push(opening.item);
375
+ rest = opening.rest;
376
+ }
377
+ leaf = NONE;
378
+ }
379
+ if (BLANK.test(rest.text)) {
380
+ if (leaf.kind === "paragraph") leaf = NONE;
381
+ continue;
382
+ }
383
+ if (level() >= MAX_NESTING) {
384
+ const quote = stack.findLastIndex((container) => container.kind === "quote");
385
+ if (quote < 0) break;
386
+ stack.length = quote + 1;
387
+ leaf = NONE;
388
+ dropping = { lastBlank: false };
389
+ continue;
390
+ }
391
+ const at = blockStart(rest);
392
+ if (!at) {
393
+ if (leaf.kind !== "paragraph") leaf = CODE;
394
+ continue;
395
+ }
396
+ const open = fenceOpen(at.text);
397
+ if (open) {
398
+ leaf = {
399
+ kind: "fence",
400
+ ...open,
401
+ indent: indentOf(rest),
402
+ line: i + 1,
403
+ content: []
404
+ };
405
+ continue;
406
+ }
407
+ const html = htmlBlock(at.text);
408
+ if (html && (html[2] || leaf.kind !== "paragraph")) {
409
+ leaf = html[1].test(at.text) ? NONE : {
410
+ kind: "html",
411
+ end: html[1]
412
+ };
413
+ continue;
414
+ }
415
+ if (leaf.kind !== "paragraph" && linkDefinition(at.text)) leaf = NONE;
416
+ else if (ATX_HEADING.test(at.text) || THEMATIC_BREAK.test(at.text)) leaf = NONE;
417
+ else if (leaf.kind === "paragraph" && SETEXT_UNDERLINE.test(at.text)) leaf = NONE;
418
+ else leaf = PARAGRAPH;
419
+ }
420
+ if (leaf.kind === "fence") finish(leaf, false);
421
+ return fences;
422
+ }
423
+ //#endregion
87
424
  //#region ../notations/mermaid.ts
88
425
  /**
89
426
  * Every diagram type MERMAID_VERSION renders, spelled as its header keyword
@@ -453,27 +790,20 @@ function checkDmnXml(raw, opts = {}) {
453
790
  const COMMON_MERMAID_TYPES = "flowchart, sequenceDiagram, classDiagram, stateDiagram-v2, erDiagram, gantt, pie, mindmap";
454
791
  /**
455
792
  * Mermaid's own first step (its type detection, @designiq/notations/mermaid
456
- * mirrors it): after the front matter, directives and comments, the first
457
- * line must start with a diagram type. Light on purpose: the grammar itself
458
- * needs mermaid and a DOM, which the validator never loads; the web viewer
459
- * shows those errors live. A WARN, not an ERROR: .mmd files a content repo
460
- * already keeps (snippets, a zenuml diagram mermaid does not bundle) turn
461
- * up as models with this notation, and must neither fail its CI nor block a
462
- * save.
793
+ * mirrors it) for a ```mermaid fence opening on document line `line`: after
794
+ * the front matter, directives and comments, the diagram's first line must
795
+ * start with a diagram type. Light on purpose: the grammar itself needs
796
+ * mermaid and a DOM, which the validator never loads; the Markdown view
797
+ * shows those errors live. The message names the document's line (a Finding
798
+ * has no line of its own).
463
799
  */
464
- function mermaidHeaderProblem(raw) {
465
- const header = mermaidHeader(raw);
466
- if (!header) return `empty Mermaid diagram: the first line must name a diagram type (${COMMON_MERMAID_TYPES}, …)`;
800
+ function mermaidFenceProblem(content, line) {
801
+ const header = mermaidHeader(content);
802
+ if (!header) return `line ${line}: empty Mermaid diagram: the first line must name a diagram type (${COMMON_MERMAID_TYPES}, …)`;
467
803
  if (header.diagramType) return void 0;
468
804
  const word = header.text.split(/[\s:;]/, 1)[0];
469
- return `line ${header.line}: '${word}' is not a Mermaid diagram type (${COMMON_MERMAID_TYPES}, …)`;
805
+ return `line ${line + header.line}: '${word}' is not a Mermaid diagram type (${COMMON_MERMAID_TYPES}, …)`;
470
806
  }
471
- /** notation id → the check of its DSL's header line (a WARN under its own
472
- * rule id); a DSL without one stays as lenient as its extractor */
473
- const DSL_HEADER_CHECKS = { mermaid: {
474
- ruleId: "mermaid/diagram-type",
475
- problem: mermaidHeaderProblem
476
- } };
477
807
  /**
478
808
  * Baseline parse gate for models WITHOUT a dedicated checker (any registered
479
809
  * notation that is not .bpmn/.dmn): a discovered model must at minimum parse
@@ -507,21 +837,21 @@ function checkModelBaseline(raw, opts) {
507
837
  if (wf !== true) err(`not well-formed XML: ${wf.err.msg}`);
508
838
  break;
509
839
  }
510
- case "dsl": {
840
+ case "dsl":
511
841
  try {
512
842
  extractModelGraph(opts.notation, raw);
513
843
  } catch (e) {
514
844
  err(`not parseable: ${e.message}`);
515
845
  }
516
- const header = DSL_HEADER_CHECKS[opts.notation];
517
- const problem = header?.problem(raw);
518
- if (header && problem) findings.push({
846
+ break;
847
+ case "markdown": for (const fence of markdownFences(raw)) {
848
+ const problem = fence.lang === "mermaid" ? mermaidFenceProblem(fence.content, fence.line) : void 0;
849
+ if (problem) findings.push({
519
850
  severity: "WARN",
520
- ruleId: header.ruleId,
851
+ ruleId: "mermaid/diagram-type",
521
852
  file,
522
853
  message: problem
523
854
  });
524
- break;
525
855
  }
526
856
  }
527
857
  return findings;
@@ -586,7 +916,7 @@ async function runCli() {
586
916
  /** optional single-model filter (a model file stem) */
587
917
  const only = argv[0];
588
918
  const rel = (p) => p.startsWith("/") ? relative(ROOT, p) : p;
589
- const { CONTENT_CONFIG_FILE, contentConfigConflict, discoverModels, legacyContentConfigFile, loadContentConfig } = await import("./content-Dld6ZUbs.js");
919
+ const { CONTENT_CONFIG_FILE, contentConfigConflict, discoverModels, legacyContentConfigFile, loadContentConfig } = await import("./content-wjC_epKd.js");
590
920
  const cfg = loadContentConfig(ROOT);
591
921
  if (!cfg) {
592
922
  console.error(notContentRepoError(ROOT));
package/dist/validate.js CHANGED
@@ -1 +1 @@
1
- export { t as checkBpmnXml, n as checkDmnXml, r as checkModel, i as checkModelBaseline, a as runCli } from "./validate-Dy2lNoBR.js";
1
+ export { t as checkBpmnXml, n as checkDmnXml, r as checkModel, i as checkModelBaseline, a as runCli } from "./validate-DjtZJTGk.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miragon/design-iq-validator",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "type": "module",
5
5
  "description": "Platform-owned validator for designIQ content repos — designiq.yml discovery, BPMN/DMN structure and diagram coverage, cross-model references, and a parse check for every other notation. Runs against any checkout via --root; never executes content-repo code.",
6
6
  "license": "MIT",