md-verified 0.1.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/LICENSE +21 -0
- package/README.md +493 -0
- package/dist/check.d.ts +2 -0
- package/dist/check.js +342 -0
- package/dist/src/assertions.d.ts +25 -0
- package/dist/src/assertions.js +84 -0
- package/dist/src/coerce.d.ts +19 -0
- package/dist/src/coerce.js +122 -0
- package/dist/src/covers.d.ts +38 -0
- package/dist/src/covers.js +59 -0
- package/dist/src/framework.d.ts +64 -0
- package/dist/src/framework.js +73 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +13 -0
- package/dist/src/mdast-gfm.d.ts +22 -0
- package/dist/src/mdast-gfm.js +90 -0
- package/dist/src/mermaid.d.ts +17 -0
- package/dist/src/mermaid.js +304 -0
- package/dist/src/parser.d.ts +26 -0
- package/dist/src/parser.js +423 -0
- package/dist/src/references.d.ts +33 -0
- package/dist/src/references.js +198 -0
- package/dist/src/report.d.ts +52 -0
- package/dist/src/report.js +278 -0
- package/dist/src/reviews.d.ts +19 -0
- package/dist/src/reviews.js +137 -0
- package/dist/src/runner.d.ts +94 -0
- package/dist/src/runner.js +353 -0
- package/dist/src/symbols.d.ts +15 -0
- package/dist/src/symbols.js +112 -0
- package/dist/src/types.d.ts +278 -0
- package/dist/src/types.js +27 -0
- package/package.json +70 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user-facing API.
|
|
3
|
+
*
|
|
4
|
+
* Glue code registers handlers against the ids used in the Markdown:
|
|
5
|
+
*
|
|
6
|
+
* verify.table('validateOrder', async (row) => { ... });
|
|
7
|
+
* verify.mermaid.edges('validateFlow', async (edge) => { ... });
|
|
8
|
+
*
|
|
9
|
+
* A handler either returns normally (pass) or throws (fail). That is the whole
|
|
10
|
+
* contract -- any assertion library works, including none.
|
|
11
|
+
*/
|
|
12
|
+
import { type Coercer } from './coerce.ts';
|
|
13
|
+
import type { AnchorKind, AnchorMeta, ListItem, MermaidEdge, MermaidGraph, ParsedList, ParsedTable, TableRow } from './types.ts';
|
|
14
|
+
/** Second argument to every handler: where in the document we are. */
|
|
15
|
+
export interface VerifyContext {
|
|
16
|
+
/** The anchor id. */
|
|
17
|
+
id: string;
|
|
18
|
+
kind: AnchorKind;
|
|
19
|
+
/** The label as written, e.g. `Data`. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Markdown file the anchor came from. */
|
|
22
|
+
file: string;
|
|
23
|
+
/** 1-based line of the anchor blockquote. */
|
|
24
|
+
line: number;
|
|
25
|
+
/** Extra `**Key:** value` lines from the blockquote. */
|
|
26
|
+
meta: AnchorMeta;
|
|
27
|
+
}
|
|
28
|
+
export type RowHandler = (row: TableRow, ctx: VerifyContext) => unknown;
|
|
29
|
+
export type TableHandler = (table: ParsedTable, ctx: VerifyContext) => unknown;
|
|
30
|
+
export type GraphHandler = (graph: MermaidGraph, ctx: VerifyContext) => unknown;
|
|
31
|
+
export type EdgeHandler = (edge: MermaidEdge, ctx: VerifyContext) => unknown;
|
|
32
|
+
export type ItemHandler = (item: ListItem, ctx: VerifyContext) => unknown;
|
|
33
|
+
export type ListHandler = (list: ParsedList, ctx: VerifyContext) => unknown;
|
|
34
|
+
/** `each` fans the asset out into one case per row/edge/item. */
|
|
35
|
+
export type HandlerMode = 'each' | 'all';
|
|
36
|
+
export interface Registration {
|
|
37
|
+
id: string;
|
|
38
|
+
kind: AnchorKind;
|
|
39
|
+
mode: HandlerMode;
|
|
40
|
+
fn: (payload: any, ctx: VerifyContext) => unknown;
|
|
41
|
+
}
|
|
42
|
+
export declare const verify: {
|
|
43
|
+
table: {
|
|
44
|
+
(id: string, fn: RowHandler): void;
|
|
45
|
+
all(id: string, fn: TableHandler): void;
|
|
46
|
+
};
|
|
47
|
+
mermaid: {
|
|
48
|
+
(id: string, fn: GraphHandler): void;
|
|
49
|
+
edges(id: string, fn: EdgeHandler): void;
|
|
50
|
+
};
|
|
51
|
+
list: {
|
|
52
|
+
(id: string, fn: ItemHandler): void;
|
|
53
|
+
all(id: string, fn: ListHandler): void;
|
|
54
|
+
};
|
|
55
|
+
/** Teach `**Schema:**` a new value type. */
|
|
56
|
+
type(name: string, coercer: Coercer): void;
|
|
57
|
+
/** Drop every registration. Mainly for tests that re-import glue code. */
|
|
58
|
+
reset(): void;
|
|
59
|
+
};
|
|
60
|
+
/** Every handler bound to an anchor id: at most one `each` and one `all`. */
|
|
61
|
+
export declare function getRegistrations(id: string): Registration[];
|
|
62
|
+
/** Every registration, in declaration order. */
|
|
63
|
+
export declare function registrations(): Registration[];
|
|
64
|
+
export { assert, equals, oneOf } from './assertions.ts';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user-facing API.
|
|
3
|
+
*
|
|
4
|
+
* Glue code registers handlers against the ids used in the Markdown:
|
|
5
|
+
*
|
|
6
|
+
* verify.table('validateOrder', async (row) => { ... });
|
|
7
|
+
* verify.mermaid.edges('validateFlow', async (edge) => { ... });
|
|
8
|
+
*
|
|
9
|
+
* A handler either returns normally (pass) or throws (fail). That is the whole
|
|
10
|
+
* contract -- any assertion library works, including none.
|
|
11
|
+
*/
|
|
12
|
+
import { registerType } from "./coerce.js";
|
|
13
|
+
/**
|
|
14
|
+
* Keyed by `id:mode`. One anchor may carry both an `each` and an `all`
|
|
15
|
+
* handler -- per-element checks and a whole-asset check such as `covers()`
|
|
16
|
+
* answer different questions about the same table or diagram. Registering the
|
|
17
|
+
* same mode twice is still an error, so typos are still caught.
|
|
18
|
+
*/
|
|
19
|
+
const registry = new Map();
|
|
20
|
+
function register(id, kind, mode, fn) {
|
|
21
|
+
if (typeof id !== 'string' || !id.trim()) {
|
|
22
|
+
throw new TypeError('verify: id must be a non-empty string');
|
|
23
|
+
}
|
|
24
|
+
if (typeof fn !== 'function') {
|
|
25
|
+
throw new TypeError(`verify: handler for \`${id}\` must be a function`);
|
|
26
|
+
}
|
|
27
|
+
const clash = [...registry.values()].find((r) => r.id === id && r.kind !== kind);
|
|
28
|
+
if (clash) {
|
|
29
|
+
throw new Error(`verify: \`${id}\` is already registered as verify.${clash.kind}; one anchor cannot be two kinds`);
|
|
30
|
+
}
|
|
31
|
+
const key = `${id}:${mode}`;
|
|
32
|
+
if (registry.has(key)) {
|
|
33
|
+
throw new Error(`verify: \`${id}\` already has a ${kind}.${mode} handler.\n` +
|
|
34
|
+
`Anchor ids are unique per *document*, not per project, so two documents may both use \`${id}\`. ` +
|
|
35
|
+
`If that is what happened, load each document with loadDocument() rather than importing their glue files into one process.`);
|
|
36
|
+
}
|
|
37
|
+
registry.set(key, { id, kind, mode, fn: fn });
|
|
38
|
+
}
|
|
39
|
+
/** Register a table handler, called once per data row. */
|
|
40
|
+
const table = (id, fn) => register(id, 'table', 'each', fn);
|
|
41
|
+
/** Register a table handler, called once with the whole table. */
|
|
42
|
+
table.all = (id, fn) => register(id, 'table', 'all', fn);
|
|
43
|
+
/** Register a diagram handler, called once with the whole graph. */
|
|
44
|
+
const mermaid = (id, fn) => register(id, 'mermaid', 'all', fn);
|
|
45
|
+
/** Register a diagram handler, called once per edge. */
|
|
46
|
+
mermaid.edges = (id, fn) => register(id, 'mermaid', 'each', fn);
|
|
47
|
+
/** Register a list handler, called once per item (nested items included). */
|
|
48
|
+
const list = (id, fn) => register(id, 'list', 'each', fn);
|
|
49
|
+
/** Register a list handler, called once with the whole list. */
|
|
50
|
+
list.all = (id, fn) => register(id, 'list', 'all', fn);
|
|
51
|
+
export const verify = {
|
|
52
|
+
table,
|
|
53
|
+
mermaid,
|
|
54
|
+
list,
|
|
55
|
+
/** Teach `**Schema:**` a new value type. */
|
|
56
|
+
type(name, coercer) {
|
|
57
|
+
registerType(name, coercer);
|
|
58
|
+
},
|
|
59
|
+
/** Drop every registration. Mainly for tests that re-import glue code. */
|
|
60
|
+
reset() {
|
|
61
|
+
registry.clear();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
/** Every handler bound to an anchor id: at most one `each` and one `all`. */
|
|
65
|
+
export function getRegistrations(id) {
|
|
66
|
+
return [...registry.values()].filter((r) => r.id === id);
|
|
67
|
+
}
|
|
68
|
+
/** Every registration, in declaration order. */
|
|
69
|
+
export function registrations() {
|
|
70
|
+
return [...registry.values()];
|
|
71
|
+
}
|
|
72
|
+
// Assertions live in `assertions.ts`; re-exported here for convenience.
|
|
73
|
+
export { assert, equals, oneOf } from "./assertions.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Public entry point. */
|
|
2
|
+
export { verify, getRegistrations, registrations } from './framework.ts';
|
|
3
|
+
export { assert, equals, oneOf, format } from './assertions.ts';
|
|
4
|
+
export type { VerifyContext, RowHandler, TableHandler, GraphHandler, EdgeHandler, ItemHandler, ListHandler, } from './framework.ts';
|
|
5
|
+
export { parseMarkdown, parseSchema, parseCovers, findGlueHint, LABEL_KINDS } from './parser.ts';
|
|
6
|
+
export { covers } from './covers.ts';
|
|
7
|
+
export { checkReviews, digestOf } from './reviews.ts';
|
|
8
|
+
export type { ReviewOptions } from './reviews.ts';
|
|
9
|
+
export { exportedNames, exportedSymbol, exportedSymbols, clearSymbolCache } from './symbols.ts';
|
|
10
|
+
export type { SymbolInfo } from './symbols.ts';
|
|
11
|
+
export type { CoversOptions } from './covers.ts';
|
|
12
|
+
export { checkReferences, collectReferences, headingSlugs, slugify, clearReferenceCache, } from './references.ts';
|
|
13
|
+
export type { Reference, ReferenceOptions } from './references.ts';
|
|
14
|
+
export { parseMermaid, MermaidParseError } from './mermaid.ts';
|
|
15
|
+
export { coerce, registerType, knownTypes, hasType, CoercionError } from './coerce.ts';
|
|
16
|
+
export { runFile, runParsed, runAnchor, planCases, loadDocument, resolveGlue, loadGlue } from './runner.ts';
|
|
17
|
+
export type { RunOptions, Plan, PlannedCase, LoadedDocument, DocumentSuite } from './runner.ts';
|
|
18
|
+
export { rewriteMarkdown, rewriteFromRun, formatRun, setColor, c } from './report.ts';
|
|
19
|
+
export * from './types.ts';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Public entry point. */
|
|
2
|
+
export { verify, getRegistrations, registrations } from "./framework.js";
|
|
3
|
+
export { assert, equals, oneOf, format } from "./assertions.js";
|
|
4
|
+
export { parseMarkdown, parseSchema, parseCovers, findGlueHint, LABEL_KINDS } from "./parser.js";
|
|
5
|
+
export { covers } from "./covers.js";
|
|
6
|
+
export { checkReviews, digestOf } from "./reviews.js";
|
|
7
|
+
export { exportedNames, exportedSymbol, exportedSymbols, clearSymbolCache } from "./symbols.js";
|
|
8
|
+
export { checkReferences, collectReferences, headingSlugs, slugify, clearReferenceCache, } from "./references.js";
|
|
9
|
+
export { parseMermaid, MermaidParseError } from "./mermaid.js";
|
|
10
|
+
export { coerce, registerType, knownTypes, hasType, CoercionError } from "./coerce.js";
|
|
11
|
+
export { runFile, runParsed, runAnchor, planCases, loadDocument, resolveGlue, loadGlue } from "./runner.js";
|
|
12
|
+
export { rewriteMarkdown, rewriteFromRun, formatRun, setColor, c } from "./report.js";
|
|
13
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal `mdast-util-from-markdown` extensions for GFM tables and task-list
|
|
3
|
+
* items.
|
|
4
|
+
*
|
|
5
|
+
* These are normally provided by `mdast-util-gfm-table` /
|
|
6
|
+
* `mdast-util-gfm-task-list-item`, but those packages also ship the
|
|
7
|
+
* *serialisation* half, which pulls in `mdast-util-to-markdown` ->
|
|
8
|
+
* `unist-util-visit-parents`. Bun 1.3.x cannot resolve that package's
|
|
9
|
+
* self-referencing `./do-not-use-color` subpath export, so importing them
|
|
10
|
+
* crashes at startup.
|
|
11
|
+
*
|
|
12
|
+
* We only ever *read* Markdown -- status rewriting is done as a surgical
|
|
13
|
+
* string splice against the original source (see `report.ts`), never by
|
|
14
|
+
* re-serialising the AST -- so the from-markdown handlers below are all we
|
|
15
|
+
* need. They are ported from the upstream implementations, minus `devlop`
|
|
16
|
+
* asserts.
|
|
17
|
+
*/
|
|
18
|
+
import type { Extension } from 'mdast-util-from-markdown';
|
|
19
|
+
/** Enable `table` / `tableRow` / `tableCell` nodes. */
|
|
20
|
+
export declare function gfmTableFromMarkdown(): Extension;
|
|
21
|
+
/** Enable `listItem.checked` for `- [x]` / `- [ ]` items. */
|
|
22
|
+
export declare function gfmTaskListItemFromMarkdown(): Extension;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Enable `table` / `tableRow` / `tableCell` nodes. */
|
|
2
|
+
export function gfmTableFromMarkdown() {
|
|
3
|
+
return {
|
|
4
|
+
enter: {
|
|
5
|
+
table: enterTable,
|
|
6
|
+
tableData: enterCell,
|
|
7
|
+
tableHeader: enterCell,
|
|
8
|
+
tableRow: enterRow,
|
|
9
|
+
},
|
|
10
|
+
exit: {
|
|
11
|
+
codeText: exitCodeText,
|
|
12
|
+
table: exitNode,
|
|
13
|
+
tableData: exitNode,
|
|
14
|
+
tableHeader: exitNode,
|
|
15
|
+
tableRow: exitNode,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const enterTable = function (token) {
|
|
20
|
+
const align = token._align;
|
|
21
|
+
this.enter({
|
|
22
|
+
type: 'table',
|
|
23
|
+
align: (align ?? []).map((d) => (d === 'none' ? null : d)),
|
|
24
|
+
children: [],
|
|
25
|
+
}, token);
|
|
26
|
+
this.data.inTable = true;
|
|
27
|
+
};
|
|
28
|
+
const exitTable = function (token) {
|
|
29
|
+
this.exit(token);
|
|
30
|
+
this.data.inTable = undefined;
|
|
31
|
+
};
|
|
32
|
+
const enterRow = function (token) {
|
|
33
|
+
this.enter({ type: 'tableRow', children: [] }, token);
|
|
34
|
+
};
|
|
35
|
+
const enterCell = function (token) {
|
|
36
|
+
this.enter({ type: 'tableCell', children: [] }, token);
|
|
37
|
+
};
|
|
38
|
+
function exitNode(token) {
|
|
39
|
+
if (token.type === 'table')
|
|
40
|
+
return exitTable.call(this, token);
|
|
41
|
+
this.exit(token);
|
|
42
|
+
}
|
|
43
|
+
/** Inside a table, `\|` inside inline code means a literal pipe. */
|
|
44
|
+
const exitCodeText = function (token) {
|
|
45
|
+
let value = this.resume();
|
|
46
|
+
if (this.data.inTable) {
|
|
47
|
+
value = value.replace(/\\([\\|])/g, (whole, char) => (char === '|' ? char : whole));
|
|
48
|
+
}
|
|
49
|
+
const node = this.stack[this.stack.length - 1];
|
|
50
|
+
node.value = value;
|
|
51
|
+
this.exit(token);
|
|
52
|
+
};
|
|
53
|
+
/** Enable `listItem.checked` for `- [x]` / `- [ ]` items. */
|
|
54
|
+
export function gfmTaskListItemFromMarkdown() {
|
|
55
|
+
return {
|
|
56
|
+
exit: {
|
|
57
|
+
taskListCheckValueChecked: exitCheck,
|
|
58
|
+
taskListCheckValueUnchecked: exitCheck,
|
|
59
|
+
paragraph: exitParagraphWithTaskListItem,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const exitCheck = function (token) {
|
|
64
|
+
// Always inside a paragraph, inside a list item.
|
|
65
|
+
const node = this.stack[this.stack.length - 2];
|
|
66
|
+
node.checked = token.type === 'taskListCheckValueChecked';
|
|
67
|
+
};
|
|
68
|
+
/** Strip the space that followed the `[x]` marker from the item's text. */
|
|
69
|
+
const exitParagraphWithTaskListItem = function (token) {
|
|
70
|
+
const parent = this.stack[this.stack.length - 2];
|
|
71
|
+
if (parent && parent.type === 'listItem' && typeof parent.checked === 'boolean') {
|
|
72
|
+
const node = this.stack[this.stack.length - 1];
|
|
73
|
+
const head = node.children[0];
|
|
74
|
+
if (head && head.type === 'text') {
|
|
75
|
+
const firstParagraph = parent.children.find((c) => c.type === 'paragraph');
|
|
76
|
+
if (firstParagraph === node) {
|
|
77
|
+
head.value = head.value.slice(1);
|
|
78
|
+
if (head.value.length === 0) {
|
|
79
|
+
node.children.shift();
|
|
80
|
+
}
|
|
81
|
+
else if (node.position && head.position && typeof head.position.start.offset === 'number') {
|
|
82
|
+
head.position.start.column++;
|
|
83
|
+
head.position.start.offset++;
|
|
84
|
+
node.position.start = { ...head.position.start };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
this.exit(token);
|
|
90
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, dependency-free Mermaid flowchart parser.
|
|
3
|
+
*
|
|
4
|
+
* Scope is deliberately the flowchart/graph family (and anything else built
|
|
5
|
+
* from `A --> B` statements, such as `stateDiagram-v2`). It resolves the one
|
|
6
|
+
* genuinely ambiguous piece of the grammar the same way Mermaid's own lexer
|
|
7
|
+
* does: a *complete* link is matched greedily before a link-with-label is
|
|
8
|
+
* considered, so `A --- B --- C` is two open links rather than one link
|
|
9
|
+
* labelled `B`.
|
|
10
|
+
*/
|
|
11
|
+
import type { MermaidGraph } from './types.ts';
|
|
12
|
+
/** Raised for diagram source we cannot make sense of. */
|
|
13
|
+
export declare class MermaidParseError extends Error {
|
|
14
|
+
readonly line: number;
|
|
15
|
+
constructor(message: string, line: number);
|
|
16
|
+
}
|
|
17
|
+
export declare function parseMermaid(source: string): MermaidGraph;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/** Raised for diagram source we cannot make sense of. */
|
|
2
|
+
export class MermaidParseError extends Error {
|
|
3
|
+
// Not a constructor parameter property: those are unsupported by Node's
|
|
4
|
+
// strip-only type stripping, and glue code may make Node load this as TS.
|
|
5
|
+
line;
|
|
6
|
+
constructor(message, line) {
|
|
7
|
+
super(`${message} (diagram line ${line})`);
|
|
8
|
+
this.name = 'MermaidParseError';
|
|
9
|
+
this.line = line;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** A complete link: `-->`, `---`, `==>`, `-.->`, `~~~`. */
|
|
13
|
+
const FULL_LINK = /^(?<lhead>[xo<])?(?<stem>-{2,}[-xo>]|={2,}[=xo>]|-\.+-[xo>]?|~{3,})/;
|
|
14
|
+
/** The opening half of a labelled link: `-- text -->`. */
|
|
15
|
+
const OPEN_LINK = /^(?<lhead>[xo<])?(?<stem>-{2,}|={2,}|-\.+)/;
|
|
16
|
+
/** The closing half of a labelled link. */
|
|
17
|
+
const CLOSE_LINK = /^(?<stem>-{2,}|={2,}|\.+-)(?<rhead>[xo>])?/;
|
|
18
|
+
/** `-->|label|` */
|
|
19
|
+
const PIPE_LABEL = /^\|(?<label>[^|]*)\|/;
|
|
20
|
+
/** Shapes, longest delimiter first so `((x))` wins over `(x)`. */
|
|
21
|
+
const SHAPES = [
|
|
22
|
+
[/^\(\(\((?<label>[\s\S]*?)\)\)\)/, 'doublecircle'],
|
|
23
|
+
[/^\(\((?<label>[\s\S]*?)\)\)/, 'circle'],
|
|
24
|
+
[/^\{\{(?<label>[\s\S]*?)\}\}/, 'hexagon'],
|
|
25
|
+
[/^\[\[(?<label>[\s\S]*?)\]\]/, 'subroutine'],
|
|
26
|
+
[/^\[\((?<label>[\s\S]*?)\)\]/, 'cylinder'],
|
|
27
|
+
[/^\(\[(?<label>[\s\S]*?)\]\)/, 'stadium'],
|
|
28
|
+
[/^\[\/(?<label>[\s\S]*?)\/\]/, 'parallelogram'],
|
|
29
|
+
[/^\[\\(?<label>[\s\S]*?)\\\]/, 'parallelogram-alt'],
|
|
30
|
+
[/^\[\/(?<label>[\s\S]*?)\\\]/, 'trapezoid'],
|
|
31
|
+
[/^\[\\(?<label>[\s\S]*?)\/\]/, 'trapezoid-alt'],
|
|
32
|
+
[/^\[(?<label>[\s\S]*?)\]/, 'rect'],
|
|
33
|
+
[/^\((?<label>[\s\S]*?)\)/, 'round'],
|
|
34
|
+
[/^\{(?<label>[\s\S]*?)\}/, 'diamond'],
|
|
35
|
+
[/^>(?<label>[\s\S]*?)\]/, 'asymmetric'],
|
|
36
|
+
];
|
|
37
|
+
const NODE_ID = /^(?:"(?<quoted>[^"]+)"|(?<bare>[A-Za-z0-9_][A-Za-z0-9_.:-]*))/;
|
|
38
|
+
/** Statement keywords that carry styling, not structure. */
|
|
39
|
+
const IGNORED = /^(?:style|classDef|class|click|linkStyle|direction|accTitle|accDescr)\b/;
|
|
40
|
+
export function parseMermaid(source) {
|
|
41
|
+
const nodes = new Map();
|
|
42
|
+
const edges = [];
|
|
43
|
+
const subgraphs = [];
|
|
44
|
+
const stack = [];
|
|
45
|
+
let type = 'graph';
|
|
46
|
+
let direction = 'TB';
|
|
47
|
+
let headerSeen = false;
|
|
48
|
+
const statements = splitStatements(source);
|
|
49
|
+
for (const stmt of statements) {
|
|
50
|
+
const text = stmt.text;
|
|
51
|
+
if (!headerSeen) {
|
|
52
|
+
const header = /^(?<type>graph|flowchart(?:-v2)?|stateDiagram(?:-v2)?|erDiagram|classDiagram)\b\s*(?<dir>TB|TD|BT|RL|LR)?/i.exec(text);
|
|
53
|
+
if (header) {
|
|
54
|
+
type = header.groups.type.toLowerCase().replace(/-v2$/, '');
|
|
55
|
+
direction = (header.groups.dir ?? 'TB').toUpperCase();
|
|
56
|
+
headerSeen = true;
|
|
57
|
+
// A header line may carry a first statement after it on the same line.
|
|
58
|
+
const rest = text.slice(header[0].length).trim();
|
|
59
|
+
if (!rest)
|
|
60
|
+
continue;
|
|
61
|
+
parseStatement({ text: rest, pos: 0, line: stmt.line });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// No recognisable header: treat the whole block as a bare flowchart.
|
|
65
|
+
headerSeen = true;
|
|
66
|
+
}
|
|
67
|
+
if (IGNORED.test(text))
|
|
68
|
+
continue;
|
|
69
|
+
const sub = /^subgraph\s+(?<rest>.+)$/i.exec(text);
|
|
70
|
+
if (sub) {
|
|
71
|
+
const { id, label } = parseSubgraphHeader(sub.groups.rest);
|
|
72
|
+
const entry = { id, label, nodes: [] };
|
|
73
|
+
subgraphs.push(entry);
|
|
74
|
+
stack.push(entry);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (/^end$/i.test(text)) {
|
|
78
|
+
stack.pop();
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
parseStatement({ text, pos: 0, line: stmt.line });
|
|
82
|
+
}
|
|
83
|
+
/** Parse one `A --> B --> C` chain, including `&` fan-out. */
|
|
84
|
+
function parseStatement(cur) {
|
|
85
|
+
skipSpace(cur);
|
|
86
|
+
if (cur.pos >= cur.text.length)
|
|
87
|
+
return;
|
|
88
|
+
let left = readNodeGroup(cur);
|
|
89
|
+
if (!left) {
|
|
90
|
+
throw new MermaidParseError(`expected a node, found ${JSON.stringify(remainder(cur))}`, cur.line);
|
|
91
|
+
}
|
|
92
|
+
// A lone `A[Label]` statement just declares a node.
|
|
93
|
+
skipSpace(cur);
|
|
94
|
+
if (cur.pos >= cur.text.length)
|
|
95
|
+
return;
|
|
96
|
+
while (cur.pos < cur.text.length) {
|
|
97
|
+
const link = readLink(cur);
|
|
98
|
+
if (!link) {
|
|
99
|
+
throw new MermaidParseError(`expected a link, found ${JSON.stringify(remainder(cur))}`, cur.line);
|
|
100
|
+
}
|
|
101
|
+
skipSpace(cur);
|
|
102
|
+
const right = readNodeGroup(cur);
|
|
103
|
+
if (!right) {
|
|
104
|
+
throw new MermaidParseError(`link ${JSON.stringify(link.raw)} has no target node`, cur.line);
|
|
105
|
+
}
|
|
106
|
+
for (const from of left) {
|
|
107
|
+
for (const to of right) {
|
|
108
|
+
edges.push({
|
|
109
|
+
from,
|
|
110
|
+
to,
|
|
111
|
+
label: link.label,
|
|
112
|
+
style: link.style,
|
|
113
|
+
directed: link.directed,
|
|
114
|
+
raw: link.raw,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
left = right;
|
|
119
|
+
skipSpace(cur);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** `A` or `A & B` -- returns the ids, registering any inline declarations. */
|
|
123
|
+
function readNodeGroup(cur) {
|
|
124
|
+
const ids = [];
|
|
125
|
+
for (;;) {
|
|
126
|
+
skipSpace(cur);
|
|
127
|
+
const id = readNode(cur);
|
|
128
|
+
if (!id)
|
|
129
|
+
return ids.length ? ids : null;
|
|
130
|
+
ids.push(id);
|
|
131
|
+
skipSpace(cur);
|
|
132
|
+
if (cur.text[cur.pos] === '&') {
|
|
133
|
+
cur.pos++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
return ids;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Read `id` plus an optional shape, and record the node. */
|
|
140
|
+
function readNode(cur) {
|
|
141
|
+
const idMatch = NODE_ID.exec(cur.text.slice(cur.pos));
|
|
142
|
+
if (!idMatch)
|
|
143
|
+
return null;
|
|
144
|
+
const id = (idMatch.groups.quoted ?? idMatch.groups.bare).trim();
|
|
145
|
+
cur.pos += idMatch[0].length;
|
|
146
|
+
let label = null;
|
|
147
|
+
let shape = 'rect';
|
|
148
|
+
const rest = cur.text.slice(cur.pos);
|
|
149
|
+
for (const [re, name] of SHAPES) {
|
|
150
|
+
const m = re.exec(rest);
|
|
151
|
+
if (m) {
|
|
152
|
+
label = stripQuotes(m.groups.label ?? '').trim();
|
|
153
|
+
shape = name;
|
|
154
|
+
cur.pos += m[0].length;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const existing = nodes.get(id);
|
|
159
|
+
if (existing) {
|
|
160
|
+
// A later declaration with a real label wins over a bare mention.
|
|
161
|
+
if (label !== null) {
|
|
162
|
+
existing.label = label;
|
|
163
|
+
existing.shape = shape;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
nodes.set(id, {
|
|
168
|
+
id,
|
|
169
|
+
label: label ?? id,
|
|
170
|
+
shape,
|
|
171
|
+
subgraph: stack.length ? stack[stack.length - 1].id : null,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const owner = stack[stack.length - 1];
|
|
175
|
+
if (owner && !owner.nodes.includes(id))
|
|
176
|
+
owner.nodes.push(id);
|
|
177
|
+
return id;
|
|
178
|
+
}
|
|
179
|
+
function readLink(cur) {
|
|
180
|
+
skipSpace(cur);
|
|
181
|
+
const rest = cur.text.slice(cur.pos);
|
|
182
|
+
// Greedy first: a complete link beats a labelled one, matching Mermaid's
|
|
183
|
+
// lexer. This is what keeps `A --- B --- C` from reading B as a label.
|
|
184
|
+
const full = FULL_LINK.exec(rest);
|
|
185
|
+
if (full) {
|
|
186
|
+
const raw = full[0];
|
|
187
|
+
cur.pos += raw.length;
|
|
188
|
+
let label = null;
|
|
189
|
+
const pipe = PIPE_LABEL.exec(cur.text.slice(cur.pos));
|
|
190
|
+
if (pipe) {
|
|
191
|
+
label = stripQuotes(pipe.groups.label ?? '').trim() || null;
|
|
192
|
+
cur.pos += pipe[0].length;
|
|
193
|
+
}
|
|
194
|
+
return { raw, label, style: styleOf(raw), directed: directedOf(raw, full.groups.lhead) };
|
|
195
|
+
}
|
|
196
|
+
const open = OPEN_LINK.exec(rest);
|
|
197
|
+
if (!open)
|
|
198
|
+
return null;
|
|
199
|
+
// `-- text -->`: scan forward for the closing half.
|
|
200
|
+
const after = cur.pos + open[0].length;
|
|
201
|
+
for (let i = after; i <= cur.text.length; i++) {
|
|
202
|
+
const close = CLOSE_LINK.exec(cur.text.slice(i));
|
|
203
|
+
if (!close)
|
|
204
|
+
continue;
|
|
205
|
+
const label = stripQuotes(cur.text.slice(after, i)).trim();
|
|
206
|
+
const raw = cur.text.slice(cur.pos, i + close[0].length);
|
|
207
|
+
cur.pos = i + close[0].length;
|
|
208
|
+
return {
|
|
209
|
+
raw,
|
|
210
|
+
label: label || null,
|
|
211
|
+
style: styleOf(raw),
|
|
212
|
+
directed: directedOf(raw, open.groups.lhead),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const graph = {
|
|
218
|
+
type,
|
|
219
|
+
direction,
|
|
220
|
+
nodes: [...nodes.values()],
|
|
221
|
+
edges,
|
|
222
|
+
subgraphs,
|
|
223
|
+
raw: source,
|
|
224
|
+
node: (id) => nodes.get(id),
|
|
225
|
+
from: (id) => edges.filter((e) => e.from === id),
|
|
226
|
+
to: (id) => edges.filter((e) => e.to === id),
|
|
227
|
+
hasEdge: (a, b) => edges.some((e) => e.from === a && e.to === b),
|
|
228
|
+
hasPath(a, b) {
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
const queue = [a];
|
|
231
|
+
while (queue.length) {
|
|
232
|
+
const cur = queue.shift();
|
|
233
|
+
if (cur === b && cur !== a)
|
|
234
|
+
return true;
|
|
235
|
+
if (seen.has(cur))
|
|
236
|
+
continue;
|
|
237
|
+
seen.add(cur);
|
|
238
|
+
for (const e of edges) {
|
|
239
|
+
if (e.from === cur)
|
|
240
|
+
queue.push(e.to);
|
|
241
|
+
else if (!e.directed && e.to === cur)
|
|
242
|
+
queue.push(e.from);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
},
|
|
247
|
+
roots: () => [...nodes.values()].filter((n) => !edges.some((e) => e.to === n.id)),
|
|
248
|
+
leaves: () => [...nodes.values()].filter((n) => !edges.some((e) => e.from === n.id)),
|
|
249
|
+
};
|
|
250
|
+
return graph;
|
|
251
|
+
}
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// helpers
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
function splitStatements(source) {
|
|
256
|
+
const out = [];
|
|
257
|
+
const lines = source.split('\n');
|
|
258
|
+
lines.forEach((raw, i) => {
|
|
259
|
+
const withoutComment = raw.replace(/%%.*$/, '');
|
|
260
|
+
for (const part of withoutComment.split(';')) {
|
|
261
|
+
const text = part.trim();
|
|
262
|
+
if (text)
|
|
263
|
+
out.push({ text, line: i + 1 });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
function parseSubgraphHeader(rest) {
|
|
269
|
+
// `subgraph id[Label]`, `subgraph id [Label]`, or `subgraph Just A Label`
|
|
270
|
+
const m = /^(?<id>[A-Za-z0-9_][A-Za-z0-9_.-]*)\s*(?:\[(?<label>[\s\S]*?)\]|\((?<round>[\s\S]*?)\))?$/.exec(rest.trim());
|
|
271
|
+
if (m) {
|
|
272
|
+
const id = m.groups.id;
|
|
273
|
+
const label = m.groups.label ?? m.groups.round;
|
|
274
|
+
return { id, label: stripQuotes(label ?? id).trim() };
|
|
275
|
+
}
|
|
276
|
+
const label = stripQuotes(rest.trim());
|
|
277
|
+
return { id: label, label };
|
|
278
|
+
}
|
|
279
|
+
function styleOf(raw) {
|
|
280
|
+
if (raw.includes('~'))
|
|
281
|
+
return 'invisible';
|
|
282
|
+
if (raw.includes('='))
|
|
283
|
+
return 'thick';
|
|
284
|
+
if (raw.includes('.'))
|
|
285
|
+
return 'dotted';
|
|
286
|
+
return 'normal';
|
|
287
|
+
}
|
|
288
|
+
function directedOf(raw, leftHead) {
|
|
289
|
+
return Boolean(leftHead) || /[>ox]$/.test(raw);
|
|
290
|
+
}
|
|
291
|
+
function stripQuotes(s) {
|
|
292
|
+
const t = s.trim();
|
|
293
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
294
|
+
return t.slice(1, -1);
|
|
295
|
+
}
|
|
296
|
+
return t;
|
|
297
|
+
}
|
|
298
|
+
function skipSpace(cur) {
|
|
299
|
+
while (cur.pos < cur.text.length && /\s/.test(cur.text[cur.pos]))
|
|
300
|
+
cur.pos++;
|
|
301
|
+
}
|
|
302
|
+
function remainder(cur) {
|
|
303
|
+
return cur.text.slice(cur.pos, cur.pos + 24);
|
|
304
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { KNOWN_GLYPHS, type AnchorKind, type ParseResult, type SchemaField } from './types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Comments the lookahead steps over when binding an anchor to its asset.
|
|
4
|
+
*
|
|
5
|
+
* Broader than the set we are allowed to *rewrite* (see `MANAGED_LINE_RE` in
|
|
6
|
+
* `report.ts`): an author's `<!-- verify: ./glue.ts -->` hint is skipped here
|
|
7
|
+
* so it cannot break a binding, but it is never ours to delete.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SKIPPABLE_COMMENT_RE: RegExp;
|
|
10
|
+
/** Human labels -> the asset kind they bind to. */
|
|
11
|
+
export declare const LABEL_KINDS: Record<string, AnchorKind>;
|
|
12
|
+
export declare function parseMarkdown(source: string, file?: string): ParseResult;
|
|
13
|
+
/**
|
|
14
|
+
* Read a `<!-- verify: ./glue.ts -->` hint, if the document carries one.
|
|
15
|
+
*
|
|
16
|
+
* Scans HTML nodes rather than the raw source, so a hint shown as an *example*
|
|
17
|
+
* inside a fenced code block is not mistaken for a real one. Documentation
|
|
18
|
+
* about this tool is the obvious case, and it is exactly the kind of thing a
|
|
19
|
+
* regex over the whole file gets wrong.
|
|
20
|
+
*/
|
|
21
|
+
export declare function findGlueHint(source: string): string | null;
|
|
22
|
+
/** `[itemsTotal: Currency, tax: Percentage]` -> fields. */
|
|
23
|
+
export declare function parseSchema(raw: string): SchemaField[];
|
|
24
|
+
/** `` `./a.ts#x`, `./b.ts` `` -> targets. */
|
|
25
|
+
export declare function parseCovers(raw: string): string[];
|
|
26
|
+
export { KNOWN_GLYPHS };
|