@dogsbay/format-dogsbay-md 0.2.0-beta.125 → 0.2.0-beta.126

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/dist/browser.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
  export { dogsbayMdToTree, parseInline, type ParseResult, type ParseOptions } from "./parse.js";
15
15
  export { KNOWN_INLINE_DIRECTIVES, type ParseDiagnostic } from "./diagnostics.js";
16
+ export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
16
17
  export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
17
18
  export type { SerializeOptions } from "./types.js";
18
19
  export { inlineToDogsbayMd } from "./inline.js";
package/dist/browser.js CHANGED
@@ -13,6 +13,8 @@
13
13
  */
14
14
  export { dogsbayMdToTree, parseInline } from "./parse.js";
15
15
  export { KNOWN_INLINE_DIRECTIVES } from "./diagnostics.js";
16
+ // The `:::name` blocks the parser knows (an editor completes names from it)
17
+ export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
16
18
  export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
17
19
  export { inlineToDogsbayMd } from "./inline.js";
18
20
  export { transformCodeRegions, transformProseLines, transformProseSegments, fenceOpenBefore, createFenceTracker, } from "./code-regions.js";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The `:::name` container block rule: markdown-it-container 4.0.0 (MIT,
3
+ * Vitaly Puzrin, Alex Kocharin), adapted. Two changes, both in how the
4
+ * closing line is found:
5
+ *
6
+ * 1. A closer may carry an HTML comment, `::: <!-- end note -->`, the way an
7
+ * author labels an XML end tag. The comment changes nothing about which
8
+ * container closes; it rides on the close token (`info`) so the parser can
9
+ * keep it and diagnostics can check the name.
10
+ * 2. A `:::` line inside a code block does not close the container.
11
+ * markdown-it-container scanned raw lines, so a code example showing a
12
+ * directive closed the container around it and the rest of the page became
13
+ * one code block, with no message. Which lines are code comes from one
14
+ * block-level pass over the source with containers off (codeLines).
15
+ *
16
+ * Everything else (the colon rule, indentation, auto-close at the end of the
17
+ * parent) is markdown-it-container's.
18
+ *
19
+ * markdown-it-container's licence:
20
+ *
21
+ * Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
22
+ *
23
+ * Permission is hereby granted, free of charge, to any person
24
+ * obtaining a copy of this software and associated documentation
25
+ * files (the "Software"), to deal in the Software without
26
+ * restriction, including without limitation the rights to use,
27
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
28
+ * copies of the Software, and to permit persons to whom the
29
+ * Software is furnished to do so, subject to the following
30
+ * conditions:
31
+ *
32
+ * The above copyright notice and this permission notice shall be
33
+ * included in all copies or substantial portions of the Software.
34
+ *
35
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
36
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
37
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
38
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
39
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
40
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
41
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
42
+ * OTHER DEALINGS IN THE SOFTWARE.
43
+ */
44
+ import type MarkdownIt from "markdown-it";
45
+ export interface ContainerOptions {
46
+ validate: (params: string, markup: string) => boolean;
47
+ render?: (tokens: unknown[], idx: number) => string;
48
+ }
49
+ export declare function containerRule(md: MarkdownIt, name: string, options: ContainerOptions): void;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * What may follow a closer's colons: nothing, or spaces and one HTML comment, then spaces. The
3
+ * comment is not optional inside the group: `[ \t]*(…)?[ \t]*` backtracks quadratically on a long
4
+ * run of spaces followed by anything else.
5
+ */
6
+ const CLOSER_TAIL = /^(?:[ \t]*|[ \t]*(<!--(?:(?!-->)[\s\S])*-->)[ \t]*)$/;
7
+ /** state.env keys: the pre-pass that finds the code, and its result. */
8
+ const NO_CONTAINERS = "__dogsbayNoContainers";
9
+ const CODE_LINES = "__dogsbayCodeLines";
10
+ /**
11
+ * The source lines inside a fenced code block, from one block-level pass over the whole source
12
+ * with containers switched off, made the first time a container needs it and kept in the parse's
13
+ * env. A `:::` line cannot change where a fence starts or ends (a fence ends only at its own
14
+ * closing fence or the end of its list item or quote), so the pass agrees with the real parse,
15
+ * and markdown-it reads fences in quotes, list items and definitions as a line scan does not.
16
+ * One pass per parse, so the cost is linear however many containers there are or how deep.
17
+ *
18
+ * Only a fence that CLOSES counts. With no container to end it, an unclosed fence in the pass runs
19
+ * to the end of the page, and every closer after it would be code: a ````` ```` ````` fence closed
20
+ * with three backticks would take the rest of the page into its container. Left out, it ends at
21
+ * the container's closer, as with markdown-it-container. (Still misread, and no worse than
22
+ * markdown-it-container: a `:::name` straight after a list item, lazily continuing it in the pass,
23
+ * so a fence indented four in the container is read in the item.)
24
+ */
25
+ function codeLines(state) {
26
+ const env = state.env;
27
+ const cached = env?.[CODE_LINES];
28
+ if (cached && cached.src === state.src && cached.md === state.md)
29
+ return cached.lines;
30
+ const tokens = [];
31
+ state.md.block.parse(state.src, state.md, { [NO_CONTAINERS]: true }, tokens);
32
+ const source = state.src.split(/\r\n|\r|\n/);
33
+ const lines = new Set();
34
+ for (const t of tokens) {
35
+ if (t.type !== "fence" || !t.map || !closesFence(source[t.map[1] - 1], t.markup, t.map[1] - 1 > t.map[0]))
36
+ continue;
37
+ // A fence's opening line is not inside it; the lines after it, to its closing fence, are
38
+ for (let l = t.map[0] + 1; l < t.map[1]; l++)
39
+ lines.add(l);
40
+ }
41
+ if (env)
42
+ env[CODE_LINES] = { src: state.src, md: state.md, lines };
43
+ return lines;
44
+ }
45
+ /** Whether `line` (as written, with any `>` and indentation) is the closing fence of a fence opened with `markup`. */
46
+ function closesFence(line, markup, after) {
47
+ if (!after || line === undefined)
48
+ return false;
49
+ const m = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/.exec(line);
50
+ return m !== null && m[1][0] === markup[0] && m[1].length >= markup.length;
51
+ }
52
+ export function containerRule(md, name, options) {
53
+ const MARKER = ":".charCodeAt(0);
54
+ function container(state, startLine, endLine, silent) {
55
+ let start = state.bMarks[startLine] + state.tShift[startLine];
56
+ let max = state.eMarks[startLine];
57
+ if (state.src.charCodeAt(start) !== MARKER)
58
+ return false;
59
+ if (state.env?.[NO_CONTAINERS])
60
+ return false;
61
+ let pos = start + 1;
62
+ while (pos <= max && state.src.charCodeAt(pos) === MARKER)
63
+ pos++;
64
+ const markerCount = pos - start;
65
+ if (markerCount < 3)
66
+ return false;
67
+ const markup = state.src.slice(start, pos);
68
+ const params = state.src.slice(pos, max);
69
+ if (!options.validate(params, markup))
70
+ return false;
71
+ if (silent)
72
+ return true;
73
+ const oldParent = state.parentType;
74
+ const oldLineMax = state.lineMax;
75
+ state.parentType = "container";
76
+ let code;
77
+ let nextLine = startLine;
78
+ let autoClosed = false;
79
+ let closeMarkup = "";
80
+ let closeComment = "";
81
+ let skippedCloser;
82
+ for (;;) {
83
+ nextLine++;
84
+ // unclosed: closed by the end of the document or of the parent block
85
+ if (nextLine >= endLine)
86
+ break;
87
+ start = state.bMarks[nextLine] + state.tShift[nextLine];
88
+ max = state.eMarks[nextLine];
89
+ // a non-empty line indented less than the block ends it (a list item's container)
90
+ if (start < max && state.sCount[nextLine] < state.blkIndent)
91
+ break;
92
+ if (state.src.charCodeAt(start) !== MARKER)
93
+ continue;
94
+ // a closer is indented less than 4 spaces
95
+ if (state.sCount[nextLine] - state.blkIndent >= 4)
96
+ continue;
97
+ pos = start + 1;
98
+ while (pos < max && state.src.charCodeAt(pos) === MARKER)
99
+ pos++;
100
+ // a closer has at least as many colons as the opener
101
+ if (pos - start < markerCount)
102
+ continue;
103
+ const tail = CLOSER_TAIL.exec(state.src.slice(pos, max));
104
+ if (!tail)
105
+ continue;
106
+ // a `:::` in a code example is code
107
+ if ((code ??= codeLines(state)).has(nextLine)) {
108
+ skippedCloser ??= nextLine;
109
+ continue;
110
+ }
111
+ autoClosed = true;
112
+ closeMarkup = state.src.slice(start, pos);
113
+ closeComment = tail[1] ?? "";
114
+ break;
115
+ }
116
+ // lazy continuations never run past the closer
117
+ state.lineMax = nextLine;
118
+ const open = state.push(`container_${name}_open`, "div", 1);
119
+ open.markup = markup;
120
+ open.block = true;
121
+ open.info = params;
122
+ open.map = [startLine, nextLine];
123
+ if (skippedCloser !== undefined && !autoClosed)
124
+ open.meta = { ...(open.meta ?? {}), skippedCloser };
125
+ state.md.block.tokenize(state, startLine + 1, nextLine);
126
+ const close = state.push(`container_${name}_close`, "div", -1);
127
+ close.markup = closeMarkup;
128
+ close.block = true;
129
+ close.info = closeComment;
130
+ state.parentType = oldParent;
131
+ state.lineMax = oldLineMax;
132
+ state.line = nextLine + (autoClosed ? 1 : 0);
133
+ return true;
134
+ }
135
+ md.block.ruler.before("fence", `container_${name}`, container, { alt: ["paragraph", "reference", "blockquote", "list"] });
136
+ const render = options.render ?? (() => "");
137
+ md.renderer.rules[`container_${name}_open`] = render;
138
+ md.renderer.rules[`container_${name}_close`] = render;
139
+ }
@@ -2,19 +2,26 @@ export interface ParseDiagnostic {
2
2
  /** 1-based line in the source (front matter counted). */
3
3
  line: number;
4
4
  /** Stable kind, for filtering. */
5
- code: "unclosed-container" | "stray-closer" | "unknown-container" | "unknown-inline" | "bad-attributes";
5
+ code: "unclosed-container" | "stray-closer" | "unknown-container" | "unknown-inline" | "block-directive-inline" | "bad-attributes" | "closer-mismatch" | "dropped-attributes" | "missing-label";
6
6
  message: string;
7
7
  }
8
8
  /**
9
9
  * Whether a source line closes a container opened with `colons` colons. The
10
10
  * line may sit in a blockquote or a list item: its `>` markers and
11
- * indentation come first.
11
+ * indentation come first. A closer may carry one HTML comment, an end-tag
12
+ * label: `::: <!-- end note -->` (container-rule.ts).
12
13
  */
13
14
  export declare const closesContainer: (line: string | undefined, colons: number) => boolean;
15
+ /**
16
+ * The container a closer's comment names, when it names one: `<!-- end note -->`,
17
+ * `<!-- /note -->` or `<!-- note -->`. Any other comment is the author's own words.
18
+ */
19
+ export declare function closerLabel(comment: string): string | undefined;
14
20
  /** The inline directives the parser turns into nodes (parse.ts inlineDirectiveToNode); any other name stays literal text. */
15
21
  export declare const KNOWN_INLINE_DIRECTIVES: ReadonlySet<string>;
16
22
  type Token = {
17
23
  type: string;
24
+ attrs?: Array<[string, string]> | null;
18
25
  tag: string;
19
26
  info: string;
20
27
  markup: string;
@@ -23,6 +30,7 @@ type Token = {
23
30
  children: Token[] | null;
24
31
  meta: {
25
32
  unclosed?: boolean;
33
+ skippedCloser?: number;
26
34
  } | null;
27
35
  };
28
36
  /**
@@ -8,15 +8,28 @@
8
8
  * here: the parser passes any leaf through as a node of that type, and only a
9
9
  * renderer knows whether it can draw it (render-html reports the ones it can't).
10
10
  */
11
- import { GENERIC_CONTAINER } from "./plugin-containers.js";
11
+ import { GENERIC_CONTAINER, KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
12
+ import { PRECEDES_TEXT } from "./plugin-inline-directives.js";
12
13
  /**
13
14
  * Whether a source line closes a container opened with `colons` colons. The
14
15
  * line may sit in a blockquote or a list item: its `>` markers and
15
- * indentation come first.
16
+ * indentation come first. A closer may carry one HTML comment, an end-tag
17
+ * label: `::: <!-- end note -->` (container-rule.ts).
16
18
  */
17
- export const closesContainer = (line, colons) => line !== undefined && new RegExp(`^:{${colons},}\\s*$`).test(line.replace(/^[\s>]*/, ""));
19
+ export const closesContainer = (line, colons) => line !== undefined && new RegExp(`^:{${colons},}[ \\t]*(?:<!--(?:(?!-->)[\\s\\S])*-->[ \\t]*)?$`).test(line.replace(/^[\s>]*/, ""));
20
+ /**
21
+ * The container a closer's comment names, when it names one: `<!-- end note -->`,
22
+ * `<!-- /note -->` or `<!-- note -->`. Any other comment is the author's own words.
23
+ */
24
+ export function closerLabel(comment) {
25
+ return /^<!--\s*(?:end\s+|\/)?:*([A-Za-z][\w-]*)\s*-->$/.exec(comment.trim())?.[1];
26
+ }
18
27
  /** The inline directives the parser turns into nodes (parse.ts inlineDirectiveToNode); any other name stays literal text. */
19
28
  export const KNOWN_INLINE_DIRECTIVES = new Set(["kbd", "icon", "math", "mark", "highlight", "button", "badge"]);
29
+ /** Directives that are blocks: `::name{…}` draws one only on a line of its own (parse.ts mapContainerToTreeNode). */
30
+ const BLOCK_DIRECTIVES = new Set([...KNOWN_BLOCK_DIRECTIVES, "avatar", "child-links", "link-card", "openapi", "autodoc"]);
31
+ /** Inline tokens markdown-it-attrs can give attributes to that the parser has no field for (links and images keep theirs). */
32
+ const ATTRLESS_INLINE = { strong_open: "bold text", em_open: "italic text", s_open: "struck-through text", code_inline: "inline code" };
20
33
  /**
21
34
  * Why an attribute block `{…}` cannot be read, or undefined when it can.
22
35
  * parseAttrs never fails: it reads what it can and drops the rest, so a
@@ -59,7 +72,11 @@ export function collectDiagnostics(tokens, lines, lineOf) {
59
72
  const name = generic ? (/^[A-Za-z][\w-]*/.exec(info)?.[0] ?? info) : open[1];
60
73
  const colons = (tok.markup ?? ":::").length;
61
74
  const closed = closesContainer(lines[tok.map[1]], colons);
62
- if (!closed) {
75
+ const skipped = tok.meta?.skippedCloser;
76
+ if (!closed && skipped !== undefined) {
77
+ add(tok.map[0], "unclosed-container", `:::${name} is never closed — the ${lines[skipped]?.trim()} on line ${lineOf(skipped) ?? "?"} is inside a code block, so it does not close it (add a closer after the code)`);
78
+ }
79
+ else if (!closed) {
63
80
  add(tok.map[0], "unclosed-container", generic
64
81
  ? `:::${name} is never closed — it is shown as text (close it with a line of ${":".repeat(colons)})`
65
82
  : `:::${name} is never closed — it takes in everything after it, to the end of the page or of the block it sits in (close it with a line of ${":".repeat(colons)})`);
@@ -67,6 +84,14 @@ export function collectDiagnostics(tokens, lines, lineOf) {
67
84
  else if (generic) {
68
85
  add(tok.map[0], "unknown-container", `:::${name} is not a known directive — it is shown as a plain box`);
69
86
  }
87
+ if (closed) {
88
+ // The closer's end-tag label: it closes this container whatever it says, so a wrong
89
+ // name means the author thinks it closes another one
90
+ const label = closerLabel((lines[tok.map[1]] ?? "").replace(/^[\s>]*:+/, ""));
91
+ if (label && label !== name) {
92
+ add(tok.map[1], "closer-mismatch", `this closer is labelled ${label}, but it closes :::${name} (opened on line ${lineOf(tok.map[0]) ?? "?"}) — check which block you meant to end here`);
93
+ }
94
+ }
70
95
  const rest = info.slice(name.length).trim();
71
96
  if (rest.startsWith("{")) {
72
97
  const problem = attributeProblem(rest);
@@ -78,7 +103,7 @@ export function collectDiagnostics(tokens, lines, lineOf) {
78
103
  // meant to close ended earlier (an inner block of the same fence closed it), or there is none
79
104
  if (tok.type === "inline" && tok.map) {
80
105
  (tok.content ?? "").split("\n").forEach((l, k) => {
81
- if (/^\s*:{3,}\s*$/.test(l)) {
106
+ if (/^\s*:{3,}[ \t]*(?:<!--(?:(?!-->)[\s\S])*-->[ \t]*)?$/.test(l)) {
82
107
  add(tok.map[0] + k, "stray-closer", `stray \`${l.trim()}\` — it closes nothing and is shown as text; to nest a directive, give the outer one more colons (::::steps around :::tabs)`);
83
108
  }
84
109
  });
@@ -96,7 +121,75 @@ export function collectDiagnostics(tokens, lines, lineOf) {
96
121
  from = at >= 0 ? at + form.length : from;
97
122
  const offset = at >= 0 ? (text.slice(0, at).match(/\n/g) ?? []).length : 0;
98
123
  const shown = child.type === "dogsbay_leaf_directive" ? `::${child.tag}` : `:${child.tag}[…]`;
99
- add(tok.map[0] + offset, "unknown-inline", `${shown} is not a known inline directive — it is shown as text`);
124
+ if (child.type === "dogsbay_leaf_directive" && BLOCK_DIRECTIVES.has(child.tag)) {
125
+ add(tok.map[0] + offset, "block-directive-inline", `${shown} is a block directive — inside a line of text it is shown as text; put it on a line of its own`);
126
+ }
127
+ else {
128
+ add(tok.map[0] + offset, "unknown-inline", `${shown} is not a known inline directive — it is shown as text`);
129
+ }
130
+ }
131
+ }
132
+ // Attribute blocks markdown-it-attrs read and the tree has nowhere to keep
133
+ if (tok.type === "list_item_open" && tok.map && tok.attrs?.length) {
134
+ // parse.ts keepListItemBraces puts trailing braces back as text, or on a heading; what is left had nowhere to go
135
+ add(tok.map[0], "dropped-attributes", `the attribute block on this list item is dropped — list items take no attributes`);
136
+ }
137
+ // A table row that is only an attribute block, where the parser could not move it onto the
138
+ // table (not the last row, or text follows the table with no blank line): an empty row
139
+ if (tok.type === "tr_open" && tok.map) {
140
+ const cells = [];
141
+ for (let k = list.indexOf(tok) + 1; k < list.length && list[k].type !== "tr_close"; k++)
142
+ cells.push(list[k]);
143
+ const inlines = cells.filter((c) => c.type === "inline");
144
+ if (cells[0]?.attrs?.length && /^\{[^{}]*\}$/.test((inlines[0]?.content ?? "").trim()) && inlines.slice(1).every((c) => c.content.trim() === "")) {
145
+ add(tok.map[0], "dropped-attributes", `this attribute block is read as an empty table row — put it on the line directly under the table's last row, or after a blank line`);
146
+ }
147
+ }
148
+ // markdown-it-attrs gives a list a block on the line after it (or after its last item's text); the tree keeps no list attributes
149
+ // Only real attribute names: markdown-it-attrs also reads a leftover include (`{* src.py hl[4] *}`, FastAPI) as keys
150
+ const own = tok.attrs?.filter(([k]) => k !== "start") ?? [];
151
+ if ((tok.type === "bullet_list_open" || tok.type === "ordered_list_open") && tok.map && own.length > 0 && own.every(([k]) => /^[A-Za-z_][\w:.-]*$/.test(k))) {
152
+ add(tok.map[0], "dropped-attributes", `the attribute block on this list is dropped — lists take no attributes`);
153
+ }
154
+ // An attribute block alone on its line after a blank line: an empty paragraph that carries it
155
+ if (tok.type === "paragraph_open" && tok.map && tok.attrs?.length) {
156
+ const inline = list[list.indexOf(tok) + 1];
157
+ // Only what an author writes as attributes: `{#id}`, `{.class}`, `{key=…}`. markdown-it-attrs also eats a
158
+ // leftover MDX expression (`{/* prettier-ignore */}`), which is an importer's problem, not the author's
159
+ const looksLikeAttrs = /^\{\s*(?:[#.][^\s{}]|[\w-]+\s*=)/.test(inline?.content.trim() ?? "");
160
+ if (inline?.type === "inline" && looksLikeAttrs && (inline.children ?? []).every((c) => c.type === "text" && c.content.trim() === "")) {
161
+ add(tok.map[0], "dropped-attributes", `this attribute block is on a line of its own and applies to nothing — put it at the end of the block's last line`);
162
+ }
163
+ }
164
+ if (tok.type === "inline" && tok.map && tok.children) {
165
+ for (const child of tok.children) {
166
+ const what = ATTRLESS_INLINE[child.type];
167
+ if (what && child.attrs?.length)
168
+ add(tok.map[0], "dropped-attributes", `the attribute block on ${what} is dropped — only links, images and inline directives take attributes`);
169
+ }
170
+ // Inline directives that keep no attributes (only badge and button do)
171
+ for (const child of tok.children) {
172
+ if (child.type !== "dogsbay_inline_directive_open" || child.tag === "badge" || child.tag === "button" || !KNOWN_INLINE_DIRECTIVES.has(child.tag))
173
+ continue;
174
+ let parsed = {};
175
+ try {
176
+ parsed = JSON.parse(child.info || "{}");
177
+ }
178
+ catch { /* not ours */ }
179
+ if (parsed.id || parsed.classes?.length || Object.keys(parsed.props ?? {}).length) {
180
+ add(tok.map[0], "dropped-attributes", `the attribute block on :${child.tag}[…] is dropped — of the inline directives only :badge and :button take attributes`);
181
+ }
182
+ }
183
+ // `:kbd{…}` with no `[label]` is not a directive: the label is what it shows
184
+ for (const child of tok.children) {
185
+ if (child.type !== "text")
186
+ continue;
187
+ for (const m of child.content.matchAll(/:([a-z]+)\{/g)) {
188
+ if (m.index > 0 && PRECEDES_TEXT.test(child.content[m.index - 1]))
189
+ continue;
190
+ if (KNOWN_INLINE_DIRECTIVES.has(m[1]))
191
+ add(tok.map[0], "missing-label", `:${m[1]}{…} has no [label] — it is shown as text; write :${m[1]}[…]{…}`);
192
+ }
100
193
  }
101
194
  }
102
195
  // A heading's attribute block that markdown-it-attrs could not read stays in its text
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export { transformCodeRegions, transformProseLines, transformProseSegments, fenc
8
8
  export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcessedFence, stripInlineMinjaMarkers, stripFenceMinjaMarkers } from "./minja-mask.js";
9
9
  export type { ParseResult, ParseOptions } from "./parse.js";
10
10
  export { KNOWN_INLINE_DIRECTIVES, type ParseDiagnostic } from "./diagnostics.js";
11
+ export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
11
12
  export { buildNavFromDirectory } from "./nav.js";
12
13
  export type { BuildNavOptions } from "./nav.js";
13
14
  export { loadNavFile } from "./nav-file.js";
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@ export { extractHeadings, slugifyHeadingText } from "./headings.js";
10
10
  export { transformCodeRegions, transformProseLines, transformProseSegments, fenceOpenBefore, createFenceTracker } from "./code-regions.js";
11
11
  export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcessedFence, stripInlineMinjaMarkers, stripFenceMinjaMarkers } from "./minja-mask.js";
12
12
  export { KNOWN_INLINE_DIRECTIVES } from "./diagnostics.js";
13
+ // The `:::name` blocks the parser knows (an editor completes names from it)
14
+ export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
13
15
  export { buildNavFromDirectory } from "./nav.js";
14
16
  export { loadNavFile } from "./nav-file.js";
15
17
  export { renderDiagrams } from "./renderers/diagrams.js";
package/dist/parse.js CHANGED
@@ -87,6 +87,8 @@ export function dogsbayMdToTree(source, options) {
87
87
  const md = sharedParser();
88
88
  const tokens = md.parse(content, {});
89
89
  markUnclosedGenericContainers(tokens, content);
90
+ keepListItemBraces(tokens, content);
91
+ attachTableAttrsRow(tokens, content);
90
92
  // Before tokensToTree: it changes nothing these read, but read the parse as markdown-it left it
91
93
  const diagnostics = options?.diagnostics
92
94
  ? collectDiagnostics(tokens,
@@ -304,6 +306,88 @@ function createParser() {
304
306
  });
305
307
  return md;
306
308
  }
309
+ /**
310
+ * `{.compact-table}` on the line straight under a table, with no blank line, is the table's
311
+ * attribute block, as Pandoc and kramdown write it. markdown-it reads the line as one more row, and
312
+ * markdown-it-attrs gives the attributes to that row's first cell, so the table got an empty last
313
+ * row and no class. Such a row is moved onto the table: the last row, written without a `|`
314
+ * (a row the author wrote as `| {x} |` is a row), its first cell only an attribute block. Once
315
+ * per table. After a blank line the block already reached the table.
316
+ */
317
+ function attachTableAttrsRow(tokens, src) {
318
+ const lines = src.split(/\r\n|\r|\n/);
319
+ for (let i = 0; i < tokens.length; i++) {
320
+ if (tokens[i].type !== "table_close")
321
+ continue;
322
+ let open = i - 1;
323
+ while (open >= 0 && tokens[open].type !== "table_open")
324
+ open--;
325
+ // the last row: tr_close just before tbody_close
326
+ if (tokens[i - 1]?.type !== "tbody_close" || tokens[i - 2]?.type !== "tr_close")
327
+ continue;
328
+ let tr = i - 2;
329
+ while (tr > open && tokens[tr].type !== "tr_open")
330
+ tr--;
331
+ const map = tokens[tr].map;
332
+ if (!map || /^[\s>]*\|/.test(lines[map[0]] ?? ""))
333
+ continue;
334
+ const cells = tokens.slice(tr + 1, i - 2);
335
+ const inlines = cells.filter((t) => t.type === "inline");
336
+ const first = cells[0];
337
+ if (!first?.attrs?.length || !/^\{[^{}]*\}$/.test((inlines[0]?.content ?? "").trim()))
338
+ continue;
339
+ if (inlines.slice(1).some((t) => t.content.trim() !== ""))
340
+ continue;
341
+ // Classes join; any other key from the row wins, as the later block does in markdown-it-attrs
342
+ const attrs = new Map((tokens[open].attrs ?? []).map(([k, v]) => [k, v]));
343
+ for (const [k, v] of first.attrs)
344
+ attrs.set(k, k === "class" && attrs.has("class") ? `${attrs.get("class")} ${v}` : v);
345
+ tokens[open].attrs = [...attrs];
346
+ // The row, and its tbody when it was the only row (the serializer writes no empty tbody)
347
+ const onlyRow = tokens[tr - 1]?.type === "tbody_open";
348
+ const from = onlyRow ? tr - 1 : tr;
349
+ tokens.splice(from, (onlyRow ? i : i - 1) - from);
350
+ i = from;
351
+ }
352
+ }
353
+ /**
354
+ * markdown-it-attrs reads a trailing `{…}` on a list item's text as the item's attributes, and the
355
+ * tree has nowhere to keep them, so the braces were lost: `- e.g. { minutes: 3 }` and
356
+ * `- mcp__{server}__{tool}` lost their ends (both in real docs). Trailing braces on a list item are
357
+ * literal text, as the spec says: they are put back from the source line. An item that starts
358
+ * with a heading gives them to the heading.
359
+ */
360
+ function keepListItemBraces(tokens, src) {
361
+ const lines = src.split(/\r\n|\r|\n/);
362
+ tokens.forEach((tok, i) => {
363
+ if (tok.type !== "list_item_open" || !tok.attrs?.length)
364
+ return;
365
+ // An item that starts with a heading: `1. ### Title {#id}` is the heading's id, and a step
366
+ // heading's anchor was lost with it
367
+ if (tokens[i + 1]?.type === "heading_open") {
368
+ const heading = tokens[i + 1];
369
+ heading.attrs = [...(heading.attrs ?? []), ...tok.attrs];
370
+ tok.attrs = null;
371
+ return;
372
+ }
373
+ // Otherwise only an item that starts with text (diagnostics report any other)
374
+ if (tokens[i + 1]?.type !== "paragraph_open")
375
+ return;
376
+ const inline = tokens.slice(i + 1).find((t) => t.type === "inline" || t.type === "list_item_open" || t.type === "list_item_close");
377
+ const map = inline?.map;
378
+ if (inline?.type !== "inline" || !map)
379
+ return;
380
+ const tail = /\s*\{[^{}]*\}[ \t]*$/.exec(lines[map[1] - 1] ?? "");
381
+ const last = [...(inline.children ?? [])].reverse().find((c) => c.type === "text");
382
+ if (!tail || !last)
383
+ return;
384
+ // The source's own spacing: markdown-it-attrs may or may not have trimmed the text before it
385
+ const braces = tail[0].trimEnd();
386
+ last.content = last.content.trimEnd() + braces;
387
+ inline.content = inline.content.trimEnd() + braces;
388
+ tok.attrs = null;
389
+ });
390
+ }
307
391
  /**
308
392
  * markdown-it-container runs a block with no closing marker to the end of the document. For a
309
393
  * generic (unknown-name) container that would turn any prose line starting `:::word` into a block
@@ -956,9 +1040,12 @@ function consumeContainer(ctx) {
956
1040
  ctx.i++;
957
1041
  const children = [];
958
1042
  const closeType = `container_${registered}_close`;
1043
+ // The closer's end-tag label, `::: <!-- end note -->` (container-rule.ts): kept so it is written back
1044
+ let closer = "";
959
1045
  while (ctx.i < ctx.tokens.length) {
960
1046
  const tok = ctx.tokens[ctx.i];
961
1047
  if (tok.type === closeType) {
1048
+ closer = tok.info ?? "";
962
1049
  ctx.i++;
963
1050
  break;
964
1051
  }
@@ -988,10 +1075,11 @@ function consumeContainer(ctx) {
988
1075
  type: "container",
989
1076
  props: attrs ? attrsToProps(attrs) : {},
990
1077
  children,
991
- meta: { container: true, name, ...(title ? { title } : {}) },
1078
+ meta: { container: true, name, ...(title ? { title } : {}), ...(closer ? { closer } : {}) },
992
1079
  };
993
1080
  }
994
- return mapContainerToTreeNode(name, attrs, children);
1081
+ const node = mapContainerToTreeNode(name, attrs, children);
1082
+ return closer && !Array.isArray(node) ? { ...node, meta: { ...(node.meta ?? {}), closer } } : node;
995
1083
  }
996
1084
  /**
997
1085
  * Convert a container + children into the appropriate TreeNode(s).
@@ -1629,6 +1717,9 @@ function tokensToInline(tokens, inheritedFmt = []) {
1629
1717
  const name = tok.tag;
1630
1718
  const attrs = tok.info ? JSON.parse(tok.info) : null;
1631
1719
  const node = inlineDirectiveToNode(name, "", [], attrs);
1720
+ // An unknown leaf stays as written (`::name{…}`), not in the text form `:name[]{…}`, which reads back as something else
1721
+ if (node?.type === "html-inline" && node.html.startsWith(`:${name}[]`))
1722
+ node.html = `::${name}${node.html.slice(name.length + 3)}`;
1632
1723
  if (node)
1633
1724
  result.push(node);
1634
1725
  break;
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Register markdown-it-container plugin for each known Dogsbay block directive.
2
+ * Register the container rule (container-rule.ts, adapted from markdown-it-container) for each
3
+ * known Dogsbay block directive.
3
4
  *
4
- * Uses the existing @markdown-it/container library. Each directive name is
5
+ * Each directive name is
5
6
  * registered separately; within same colon count, different-name directives can
6
7
  * nest. Same-name nesting requires more colons (::::).
7
8
  */
@@ -1,4 +1,4 @@
1
- import container from "markdown-it-container";
1
+ import { containerRule } from "./container-rule.js";
2
2
  import { parseAttrs } from "./parse-attrs.js";
3
3
  export const KNOWN_BLOCK_DIRECTIVES = [
4
4
  "tabs",
@@ -72,7 +72,7 @@ export const KNOWN_BLOCK_DIRECTIVES = [
72
72
  export const GENERIC_CONTAINER = "dogsbay_generic";
73
73
  export function containersPlugin(md) {
74
74
  for (const name of KNOWN_BLOCK_DIRECTIVES) {
75
- md.use(container, name, {
75
+ containerRule(md, name, {
76
76
  validate: (params) => {
77
77
  const trimmed = params.trim();
78
78
  if (!trimmed.startsWith(name))
@@ -103,7 +103,7 @@ export function containersPlugin(md) {
103
103
  });
104
104
  }
105
105
  const known = new Set(KNOWN_BLOCK_DIRECTIVES);
106
- md.use(container, GENERIC_CONTAINER, {
106
+ containerRule(md, GENERIC_CONTAINER, {
107
107
  validate: (params) => {
108
108
  const m = /^([A-Za-z][\w-]*)(?=$|[\s{[])/.exec(params.trim());
109
109
  return m !== null && !known.has(m[1]);
@@ -14,4 +14,6 @@
14
14
  * The inline directive body contains nested inline tokens parsed recursively.
15
15
  */
16
16
  import type MarkdownIt from "markdown-it";
17
+ /** A character a directive cannot follow directly (see inlineDirectiveRule). */
18
+ export declare const PRECEDES_TEXT: RegExp;
17
19
  export declare function inlineDirectivesPlugin(md: MarkdownIt): void;
@@ -1,4 +1,6 @@
1
1
  import { parseAttrs } from "./parse-attrs.js";
2
+ /** A character a directive cannot follow directly (see inlineDirectiveRule). */
3
+ export const PRECEDES_TEXT = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}0-9:]/u;
2
4
  export function inlineDirectivesPlugin(md) {
3
5
  md.inline.ruler.before("emphasis", "dogsbay_inline_directive", inlineDirectiveRule);
4
6
  }
@@ -7,6 +9,11 @@ function inlineDirectiveRule(state, silent) {
7
9
  const src = state.src;
8
10
  if (src.charCodeAt(start) !== 0x3a /* : */)
9
11
  return false;
12
+ // Not straight after a word in a spaced script, a digit or a colon: `std::vector`, `x:Type[]`
13
+ // and a Tailwind `md:grid-cols-[…]` in an attribute block are text, not directives. CJK text
14
+ // has no spaces, so a directive may follow it directly (`按:kbd[Ctrl]键`)
15
+ if (start > 0 && PRECEDES_TEXT.test(src[start - 1]))
16
+ return false;
10
17
  // Determine leaf (::) vs text (:) directive
11
18
  const isLeaf = src.charCodeAt(start + 1) === 0x3a;
12
19
  const nameStart = isLeaf ? start + 2 : start + 1;
package/dist/serialize.js CHANGED
@@ -29,6 +29,12 @@ export function nodeToDogsbayMd(node, options) {
29
29
  return renderNode(node, resolveOptions(options));
30
30
  }
31
31
  function renderNode(node, ctx) {
32
+ const out = renderNodeBody(node, ctx);
33
+ // A closer's end-tag label (`::: <!-- end note -->`), as the author wrote it
34
+ const closer = node.meta?.closer;
35
+ return typeof closer === "string" && /^<!--(?:(?!-->)[\s\S])*-->$/.test(closer) && /\n:{3,}$/.test(out) ? `${out} ${closer}` : out;
36
+ }
37
+ function renderNodeBody(node, ctx) {
32
38
  switch (node.type) {
33
39
  case "heading":
34
40
  return renderHeading(node);
@@ -423,7 +429,9 @@ function renderCallout(node, ctx) {
423
429
  // panel.
424
430
  const props = node.props ?? {};
425
431
  const hasOnlyTypeProp = Object.keys(props).every((k) => k === "type");
426
- if (ctx.githubCallouts && hasOnlyTypeProp && GITHUB_CALLOUT_TYPES.has(type)) {
432
+ // Nor a closer's end-tag label: a `:::note` the author labelled stays a directive
433
+ const labelled = typeof node.meta?.closer === "string";
434
+ if (ctx.githubCallouts && hasOnlyTypeProp && !labelled && GITHUB_CALLOUT_TYPES.has(type)) {
427
435
  const tag = `[!${type.toUpperCase()}]`;
428
436
  return [tag, body]
429
437
  .join("\n")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-dogsbay-md",
3
- "version": "0.2.0-beta.125",
3
+ "version": "0.2.0-beta.126",
4
4
  "description": "Dogsbay Markdown dialect — canonical format for TreeNode[] with directives, YAML bodies, and intelligent fallbacks",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,13 +36,12 @@
36
36
  "js-yaml": "^5.2.1",
37
37
  "markdown-it": "^14.3.0",
38
38
  "markdown-it-attrs": "^5.0.0",
39
- "markdown-it-container": "^4.0.0",
40
39
  "markdown-it-deflist": "^3.0.1",
41
40
  "markdown-it-footnote": "^4.0.0",
42
41
  "markdown-it-front-matter": "^0.2.4",
43
- "@dogsbay/minja": "0.2.0-beta.125",
44
- "@dogsbay/serialize-core": "0.2.0-beta.125",
45
- "@dogsbay/types": "0.2.0-beta.125"
42
+ "@dogsbay/minja": "0.2.0-beta.126",
43
+ "@dogsbay/serialize-core": "0.2.0-beta.126",
44
+ "@dogsbay/types": "0.2.0-beta.126"
46
45
  },
47
46
  "license": "MIT",
48
47
  "repository": {