@dogsbay/format-dogsbay-md 0.2.0-beta.125 → 0.2.0-beta.127
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 +2 -1
- package/dist/browser.js +3 -1
- package/dist/container-rule.d.ts +87 -0
- package/dist/container-rule.js +292 -0
- package/dist/diagnostics.d.ts +11 -2
- package/dist/diagnostics.js +106 -9
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -1
- package/dist/parse.d.ts +9 -0
- package/dist/parse.js +120 -2
- package/dist/plugin-containers.d.ts +4 -4
- package/dist/plugin-containers.js +5 -3
- package/dist/plugin-inline-directives.d.ts +2 -0
- package/dist/plugin-inline-directives.js +7 -0
- package/dist/serialize.js +9 -1
- package/package.json +4 -5
package/dist/browser.d.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* (js-yaml only) rather than gray-matter, whose entry `require('fs')`s at
|
|
12
12
|
* module load and would drag `node:fs` back into this graph.
|
|
13
13
|
*/
|
|
14
|
-
export { dogsbayMdToTree, parseInline, type ParseResult, type ParseOptions } from "./parse.js";
|
|
14
|
+
export { dogsbayMdToTree, parseInline, containerOpenBefore, 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
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* (js-yaml only) rather than gray-matter, whose entry `require('fs')`s at
|
|
12
12
|
* module load and would drag `node:fs` back into this graph.
|
|
13
13
|
*/
|
|
14
|
-
export { dogsbayMdToTree, parseInline } from "./parse.js";
|
|
14
|
+
export { dogsbayMdToTree, parseInline, containerOpenBefore } 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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `:::name` container block rule: markdown-it-container 4.0.0 (MIT,
|
|
3
|
+
* Vitaly Puzrin, Alex Kocharin), adapted. Three changes, all 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
|
+
* 3. Blocks nest by a stack, not by colon count (NESTING below).
|
|
17
|
+
*
|
|
18
|
+
* Everything else (indentation, auto-close at the end of the parent) is
|
|
19
|
+
* markdown-it-container's.
|
|
20
|
+
*
|
|
21
|
+
* markdown-it-container's licence:
|
|
22
|
+
*
|
|
23
|
+
* Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
|
|
24
|
+
*
|
|
25
|
+
* Permission is hereby granted, free of charge, to any person
|
|
26
|
+
* obtaining a copy of this software and associated documentation
|
|
27
|
+
* files (the "Software"), to deal in the Software without
|
|
28
|
+
* restriction, including without limitation the rights to use,
|
|
29
|
+
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
30
|
+
* copies of the Software, and to permit persons to whom the
|
|
31
|
+
* Software is furnished to do so, subject to the following
|
|
32
|
+
* conditions:
|
|
33
|
+
*
|
|
34
|
+
* The above copyright notice and this permission notice shall be
|
|
35
|
+
* included in all copies or substantial portions of the Software.
|
|
36
|
+
*
|
|
37
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
38
|
+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
39
|
+
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
40
|
+
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
41
|
+
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
42
|
+
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
43
|
+
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
44
|
+
* OTHER DEALINGS IN THE SOFTWARE.
|
|
45
|
+
*/
|
|
46
|
+
import type MarkdownIt from "markdown-it";
|
|
47
|
+
/**
|
|
48
|
+
* NESTING. A closer with N colons closes the innermost open block opened with exactly N colons,
|
|
49
|
+
* else the innermost opened with fewer, and any block still open inside it. So every block can close with plain `:::`
|
|
50
|
+
* (`:::steps` … `:::tabs` … `:::` … `:::`), and today's scaled nesting (`::::steps` … `:::tabs`
|
|
51
|
+
* … `:::` … `::::`) and the inverse (inner blocks with more colons) read the same. Pandoc's fenced
|
|
52
|
+
* divs nest the same way. Before the stack, a closer ended the OUTERMOST block it was long
|
|
53
|
+
* enough for, so a nested block needed more colons on the outer one, and one `:::` could close
|
|
54
|
+
* several blocks (that case now leaves the outer one open, and the diagnostic says why). "Exactly
|
|
55
|
+
* N first" keeps the old reading where the author scaled: `::::note` … `:::tip` … `::::` closes the
|
|
56
|
+
* note (and the tip with it) as before, rather than the tip.
|
|
57
|
+
*
|
|
58
|
+
* Only a known directive name opens a block in the scan. An unknown `:::word` is a block only if
|
|
59
|
+
* it has a closer of its own and is otherwise text (markUnclosedGenericContainers), and the scan
|
|
60
|
+
* cannot know which, so it does not push one: an unknown block nested at the same colon count
|
|
61
|
+
* as its parent still needs more colons on the parent. A body-less block (`:::youtube{#id}`) is
|
|
62
|
+
* pushed only when a closer that fits it follows, or it would take its parent's.
|
|
63
|
+
*
|
|
64
|
+
* VERIFY. The scan reads raw lines, and markdown-it may not read an opener there as one: in raw
|
|
65
|
+
* HTML, a `$$` math block, a fence that never closes. Where that is depends on where the blocks
|
|
66
|
+
* around it end, so no pass over the page can say in advance. So after the body is tokenized, every
|
|
67
|
+
* opener the scan pushed must have opened a block in it; any that did not are left out and the
|
|
68
|
+
* body is scanned and tokenized again. Counted wrongly, an opener leaves the outer block open to
|
|
69
|
+
* the end of the page (the rule before the stack lost one closer at most). What a block learns is
|
|
70
|
+
* kept for the parse, by its opening line: when an outer block retries, the blocks inside it are
|
|
71
|
+
* tokenized again and start from what they learned, rather than repeating their own retries (which
|
|
72
|
+
* doubled the work at every level). A learned line can be stale in the new context, so the check
|
|
73
|
+
* runs both ways: a skipped line that markdown-it did open as a block is pushed again. Retried
|
|
74
|
+
* lines come out of one budget per parse (a few times the page's lines); past it, the scan stands.
|
|
75
|
+
*/
|
|
76
|
+
export interface ContainerOptions {
|
|
77
|
+
validate: (params: string, markup: string) => boolean;
|
|
78
|
+
render?: (tokens: unknown[], idx: number) => string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Make `name` an opener for the nesting scan (the known directives; not the generic container).
|
|
82
|
+
* A `leaf` is written with no body (`:::youtube{#id}`) and often with no closer.
|
|
83
|
+
*/
|
|
84
|
+
export declare function registerOpener(name: string, options?: {
|
|
85
|
+
leaf?: boolean;
|
|
86
|
+
}): void;
|
|
87
|
+
export declare function containerRule(md: MarkdownIt, name: string, options: ContainerOptions): void;
|
|
@@ -0,0 +1,292 @@
|
|
|
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
|
+
/** state.env keys for VERIFY: the parse's retry budget, and each block's learned openers to skip. */
|
|
11
|
+
const RETRY_BUDGET = "__dogsbayContainerRetries";
|
|
12
|
+
const LEARNED = "__dogsbayContainerNotOpeners";
|
|
13
|
+
/**
|
|
14
|
+
* The source lines inside a fenced code block, from one block-level pass over the whole source
|
|
15
|
+
* with containers switched off, made the first time a container needs it and kept in the parse's
|
|
16
|
+
* env. A `:::` line cannot change where a fence starts or ends (a fence ends only at its own
|
|
17
|
+
* closing fence or the end of its list item or quote), so the pass agrees with the real parse,
|
|
18
|
+
* and markdown-it reads fences in quotes, list items and definitions as a line scan does not.
|
|
19
|
+
* One pass per parse, so the cost is linear however many containers there are or how deep.
|
|
20
|
+
*
|
|
21
|
+
* Only a fence that CLOSES counts. With no container to end it, an unclosed fence in the pass runs
|
|
22
|
+
* to the end of the page, and every closer after it would be code: a ````` ```` ````` fence closed
|
|
23
|
+
* with three backticks would take the rest of the page into its container. Left out, it ends at
|
|
24
|
+
* the container's closer, as with markdown-it-container. (Still misread, and no worse than
|
|
25
|
+
* markdown-it-container: a `:::name` straight after a list item, lazily continuing it in the pass,
|
|
26
|
+
* so a fence indented four in the container is read in the item.)
|
|
27
|
+
*/
|
|
28
|
+
function codeLines(state) {
|
|
29
|
+
const env = state.env;
|
|
30
|
+
const cached = env?.[CODE_LINES];
|
|
31
|
+
if (cached && cached.src === state.src && cached.md === state.md)
|
|
32
|
+
return cached.lines;
|
|
33
|
+
const tokens = [];
|
|
34
|
+
state.md.block.parse(state.src, state.md, { [NO_CONTAINERS]: true }, tokens);
|
|
35
|
+
const source = state.src.split(/\r\n|\r|\n/);
|
|
36
|
+
const lines = new Set();
|
|
37
|
+
for (const t of tokens) {
|
|
38
|
+
if (t.type !== "fence" || !t.map || !closesFence(source[t.map[1] - 1], t.markup, t.map[1] - 1 > t.map[0]))
|
|
39
|
+
continue;
|
|
40
|
+
// A fence's opening line is not inside it; the lines after it, to its closing fence, are
|
|
41
|
+
for (let l = t.map[0] + 1; l < t.map[1]; l++)
|
|
42
|
+
lines.add(l);
|
|
43
|
+
}
|
|
44
|
+
if (env)
|
|
45
|
+
env[CODE_LINES] = { src: state.src, md: state.md, lines };
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
/** Whether `line` (as written, with any `>` and indentation) is the closing fence of a fence opened with `markup`. */
|
|
49
|
+
function closesFence(line, markup, after) {
|
|
50
|
+
if (!after || line === undefined)
|
|
51
|
+
return false;
|
|
52
|
+
const m = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
53
|
+
return m !== null && m[1][0] === markup[0] && m[1].length >= markup.length;
|
|
54
|
+
}
|
|
55
|
+
/** The directive names the scan treats as openers (plugin-containers registers them), and whether each is body-less. */
|
|
56
|
+
const KNOWN_OPENERS = new Map();
|
|
57
|
+
/** The known directive the text after an opener's colons starts with. */
|
|
58
|
+
function knownOpener(rest) {
|
|
59
|
+
const m = /^\s*([A-Za-z][\w-]*)(?=$|[\s{[])/.exec(rest);
|
|
60
|
+
return m === null ? undefined : KNOWN_OPENERS.get(m[1]);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Make `name` an opener for the nesting scan (the known directives; not the generic container).
|
|
64
|
+
* A `leaf` is written with no body (`:::youtube{#id}`) and often with no closer.
|
|
65
|
+
*/
|
|
66
|
+
export function registerOpener(name, options = {}) {
|
|
67
|
+
KNOWN_OPENERS.set(name, { leaf: options.leaf === true });
|
|
68
|
+
}
|
|
69
|
+
/** Whether the next non-blank line after `line`, before `endLine`, is a closer for a block opened with `colons`. */
|
|
70
|
+
function closerFollows(state, line, endLine, colons) {
|
|
71
|
+
for (let l = line + 1; l < endLine; l++) {
|
|
72
|
+
const start = state.bMarks[l] + state.tShift[l];
|
|
73
|
+
const max = state.eMarks[l];
|
|
74
|
+
if (start >= max)
|
|
75
|
+
continue;
|
|
76
|
+
const m = /^(:{3,})(.*)$/.exec(state.src.slice(start, max));
|
|
77
|
+
return m !== null && m[1].length >= colons && CLOSER_TAIL.test(m[2]);
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
/** Tokens whose lines are not block openers: an opener the scan pushed inside one is wrong (VERIFY). */
|
|
82
|
+
const READ_AS_OTHER = new Set(["paragraph_open", "heading_open", "html_block", "fence", "code_block", "math_block", "hr", "table_open"]);
|
|
83
|
+
/** The openers each block (by opening line) learned to skip in this parse (VERIFY). */
|
|
84
|
+
function learned(state) {
|
|
85
|
+
const env = state.env;
|
|
86
|
+
if (!env)
|
|
87
|
+
return new Map();
|
|
88
|
+
const kept = env[LEARNED];
|
|
89
|
+
if (kept && kept.src === state.src)
|
|
90
|
+
return kept.map;
|
|
91
|
+
const map = new Map();
|
|
92
|
+
env[LEARNED] = { src: state.src, map };
|
|
93
|
+
return map;
|
|
94
|
+
}
|
|
95
|
+
/** The lines left in this parse's retry budget (VERIFY), shared by every block in it. */
|
|
96
|
+
function retryBudget(state) {
|
|
97
|
+
const env = state.env;
|
|
98
|
+
const fresh = { lines: 4 * state.bMarks.length + 1000 };
|
|
99
|
+
if (!env)
|
|
100
|
+
return fresh;
|
|
101
|
+
const kept = env[RETRY_BUDGET];
|
|
102
|
+
if (kept && kept.src === state.src)
|
|
103
|
+
return kept.budget;
|
|
104
|
+
env[RETRY_BUDGET] = { src: state.src, budget: fresh };
|
|
105
|
+
return fresh;
|
|
106
|
+
}
|
|
107
|
+
export function containerRule(md, name, options) {
|
|
108
|
+
const MARKER = ":".charCodeAt(0);
|
|
109
|
+
function container(state, startLine, endLine, silent) {
|
|
110
|
+
const start = state.bMarks[startLine] + state.tShift[startLine];
|
|
111
|
+
const max = state.eMarks[startLine];
|
|
112
|
+
if (state.src.charCodeAt(start) !== MARKER)
|
|
113
|
+
return false;
|
|
114
|
+
if (state.env?.[NO_CONTAINERS])
|
|
115
|
+
return false;
|
|
116
|
+
let pos = start + 1;
|
|
117
|
+
while (pos <= max && state.src.charCodeAt(pos) === MARKER)
|
|
118
|
+
pos++;
|
|
119
|
+
const markerCount = pos - start;
|
|
120
|
+
if (markerCount < 3)
|
|
121
|
+
return false;
|
|
122
|
+
const markup = state.src.slice(start, pos);
|
|
123
|
+
const params = state.src.slice(pos, max);
|
|
124
|
+
if (!options.validate(params, markup))
|
|
125
|
+
return false;
|
|
126
|
+
if (silent)
|
|
127
|
+
return true;
|
|
128
|
+
const oldParent = state.parentType;
|
|
129
|
+
const oldLineMax = state.lineMax;
|
|
130
|
+
state.parentType = "container";
|
|
131
|
+
const tokensBefore = state.tokens.length;
|
|
132
|
+
// Openers markdown-it did not open, found by an earlier try (VERIFY above)
|
|
133
|
+
const known = learned(state);
|
|
134
|
+
const notOpeners = new Set(known.get(startLine));
|
|
135
|
+
let scan;
|
|
136
|
+
for (;;) {
|
|
137
|
+
scan = scanBody(state, startLine, endLine, markerCount, notOpeners);
|
|
138
|
+
// lazy continuations never run past the closer
|
|
139
|
+
state.lineMax = scan.nextLine;
|
|
140
|
+
const open = state.push(`container_${name}_open`, "div", 1);
|
|
141
|
+
open.markup = markup;
|
|
142
|
+
open.block = true;
|
|
143
|
+
open.info = params;
|
|
144
|
+
open.map = [startLine, scan.nextLine];
|
|
145
|
+
if (!scan.autoClosed && (scan.skippedCloser !== undefined || scan.legacyCloser !== undefined)) {
|
|
146
|
+
open.meta = {
|
|
147
|
+
...(open.meta ?? {}),
|
|
148
|
+
...(scan.skippedCloser !== undefined ? { skippedCloser: scan.skippedCloser } : {}),
|
|
149
|
+
...(scan.legacyCloser !== undefined ? { legacyCloser: scan.legacyCloser } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
state.md.block.tokenize(state, startLine + 1, scan.nextLine);
|
|
153
|
+
const close = state.push(`container_${name}_close`, "div", -1);
|
|
154
|
+
close.markup = scan.closeMarkup;
|
|
155
|
+
close.block = true;
|
|
156
|
+
close.info = scan.closeComment;
|
|
157
|
+
// Wrong: a pushed opener's line that markdown-it read as something else. A line it read as
|
|
158
|
+
// nothing (past markdown-it's nesting limit, where it drops the rest) is not evidence.
|
|
159
|
+
// Missed: a skipped line markdown-it did open as a block (a learned line, stale here).
|
|
160
|
+
const pushed = new Set(scan.pushed);
|
|
161
|
+
const wrong = [];
|
|
162
|
+
const missed = [];
|
|
163
|
+
if (pushed.size > 0 || notOpeners.size > 0) {
|
|
164
|
+
for (let i = tokensBefore + 1; i < state.tokens.length; i++) {
|
|
165
|
+
const t = state.tokens[i];
|
|
166
|
+
if (!t.map)
|
|
167
|
+
continue;
|
|
168
|
+
if (READ_AS_OTHER.has(t.type)) {
|
|
169
|
+
for (let l = t.map[0]; l < t.map[1]; l++)
|
|
170
|
+
if (pushed.delete(l))
|
|
171
|
+
wrong.push(l);
|
|
172
|
+
}
|
|
173
|
+
else if (t.nesting === 1 && t.type.startsWith("container_") && notOpeners.has(t.map[0]))
|
|
174
|
+
missed.push(t.map[0]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (wrong.length === 0 && missed.length === 0)
|
|
178
|
+
break;
|
|
179
|
+
const budget = retryBudget(state);
|
|
180
|
+
if (budget.lines < scan.nextLine - startLine)
|
|
181
|
+
break;
|
|
182
|
+
budget.lines -= scan.nextLine - startLine;
|
|
183
|
+
for (const l of wrong)
|
|
184
|
+
notOpeners.add(l);
|
|
185
|
+
for (const l of missed)
|
|
186
|
+
notOpeners.delete(l);
|
|
187
|
+
state.tokens.length = tokensBefore;
|
|
188
|
+
}
|
|
189
|
+
if (notOpeners.size > 0)
|
|
190
|
+
known.set(startLine, new Set(notOpeners));
|
|
191
|
+
else
|
|
192
|
+
known.delete(startLine);
|
|
193
|
+
state.parentType = oldParent;
|
|
194
|
+
state.lineMax = oldLineMax;
|
|
195
|
+
state.line = scan.nextLine + (scan.autoClosed ? 1 : 0);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
/** Find where the block opened at `startLine` ends (NESTING above), skipping the `notOpeners` lines as openers. */
|
|
199
|
+
function scanBody(state, startLine, endLine, markerCount, notOpeners) {
|
|
200
|
+
let code;
|
|
201
|
+
let nextLine = startLine;
|
|
202
|
+
const scan = { nextLine, autoClosed: false, closeMarkup: "", closeComment: "", pushed: [] };
|
|
203
|
+
// Blocks opened inside this one and not yet closed, innermost last (see NESTING above)
|
|
204
|
+
// `least` is the fewest colons from the outermost to this one, and `openCount` counts the open
|
|
205
|
+
// blocks by colons, so a closer that fits none is turned away without walking the stack
|
|
206
|
+
const inner = [];
|
|
207
|
+
const openCount = new Map();
|
|
208
|
+
const truncate = (k) => {
|
|
209
|
+
for (let j = k; j < inner.length; j++)
|
|
210
|
+
openCount.set(inner[j].colons, openCount.get(inner[j].colons) - 1);
|
|
211
|
+
inner.length = k;
|
|
212
|
+
};
|
|
213
|
+
for (;;) {
|
|
214
|
+
nextLine++;
|
|
215
|
+
// unclosed: closed by the end of the document or of the parent block
|
|
216
|
+
if (nextLine >= endLine)
|
|
217
|
+
break;
|
|
218
|
+
const start = state.bMarks[nextLine] + state.tShift[nextLine];
|
|
219
|
+
const max = state.eMarks[nextLine];
|
|
220
|
+
if (start >= max)
|
|
221
|
+
continue;
|
|
222
|
+
// a non-empty line indented less than the block ends it (a list item's container)
|
|
223
|
+
if (state.sCount[nextLine] < state.blkIndent)
|
|
224
|
+
break;
|
|
225
|
+
// a block opened deeper (in a list item) ended at a line indented less than its opener
|
|
226
|
+
while (inner.length > 0 && state.sCount[nextLine] < inner[inner.length - 1].indent)
|
|
227
|
+
truncate(inner.length - 1);
|
|
228
|
+
if (state.src.charCodeAt(start) !== MARKER)
|
|
229
|
+
continue;
|
|
230
|
+
// openers and closers are indented less than 4 spaces past the block
|
|
231
|
+
if (state.sCount[nextLine] - state.blkIndent >= 4)
|
|
232
|
+
continue;
|
|
233
|
+
let pos = start + 1;
|
|
234
|
+
while (pos < max && state.src.charCodeAt(pos) === MARKER)
|
|
235
|
+
pos++;
|
|
236
|
+
const colons = pos - start;
|
|
237
|
+
if (colons < 3)
|
|
238
|
+
continue;
|
|
239
|
+
const rest = state.src.slice(pos, max);
|
|
240
|
+
const tail = CLOSER_TAIL.exec(rest);
|
|
241
|
+
const opener = tail ? undefined : knownOpener(rest);
|
|
242
|
+
if (!tail && !opener)
|
|
243
|
+
continue;
|
|
244
|
+
// a `:::` in a code example is code
|
|
245
|
+
if ((code ??= codeLines(state)).has(nextLine)) {
|
|
246
|
+
if (tail && colons >= markerCount)
|
|
247
|
+
scan.skippedCloser ??= nextLine;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (opener) {
|
|
251
|
+
if (notOpeners.has(nextLine) || (opener.leaf && !closerFollows(state, nextLine, endLine, colons)))
|
|
252
|
+
continue;
|
|
253
|
+
const least = Math.min(colons, inner.length > 0 ? inner[inner.length - 1].least : colons);
|
|
254
|
+
inner.push({ colons, indent: state.sCount[nextLine], least });
|
|
255
|
+
openCount.set(colons, (openCount.get(colons) ?? 0) + 1);
|
|
256
|
+
scan.pushed.push(nextLine);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// Where the rule before the stack (the first closer with enough colons) would have ended this block
|
|
260
|
+
if (colons >= markerCount)
|
|
261
|
+
scan.legacyCloser ??= nextLine;
|
|
262
|
+
// The innermost open block opened with exactly this many colons, else the innermost opened
|
|
263
|
+
// with fewer; it closes, and any block still open inside it. Index -1 is this block.
|
|
264
|
+
const closes = (fits) => {
|
|
265
|
+
for (let k = inner.length - 1; k >= 0; k--)
|
|
266
|
+
if (fits(inner[k].colons))
|
|
267
|
+
return k;
|
|
268
|
+
return fits(markerCount) ? -1 : undefined;
|
|
269
|
+
};
|
|
270
|
+
const exact = (openCount.get(colons) ?? 0) > 0 || markerCount === colons;
|
|
271
|
+
const fewer = markerCount < colons || (inner.length > 0 && inner[inner.length - 1].least < colons);
|
|
272
|
+
const k = exact ? closes((n) => n === colons) : fewer ? closes((n) => n < colons) : undefined;
|
|
273
|
+
// shorter than every open block: it closes nothing here
|
|
274
|
+
if (k === undefined)
|
|
275
|
+
continue;
|
|
276
|
+
if (k >= 0) {
|
|
277
|
+
truncate(k);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
scan.autoClosed = true;
|
|
281
|
+
scan.closeMarkup = state.src.slice(start, pos);
|
|
282
|
+
scan.closeComment = tail?.[1] ?? "";
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
scan.nextLine = nextLine;
|
|
286
|
+
return scan;
|
|
287
|
+
}
|
|
288
|
+
md.block.ruler.before("fence", `container_${name}`, container, { alt: ["paragraph", "reference", "blockquote", "list"] });
|
|
289
|
+
const render = options.render ?? (() => "");
|
|
290
|
+
md.renderer.rules[`container_${name}_open`] = render;
|
|
291
|
+
md.renderer.rules[`container_${name}_close`] = render;
|
|
292
|
+
}
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -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,8 @@ type Token = {
|
|
|
23
30
|
children: Token[] | null;
|
|
24
31
|
meta: {
|
|
25
32
|
unclosed?: boolean;
|
|
33
|
+
skippedCloser?: number;
|
|
34
|
+
legacyCloser?: number;
|
|
26
35
|
} | null;
|
|
27
36
|
};
|
|
28
37
|
/**
|
package/dist/diagnostics.js
CHANGED
|
@@ -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
|
|
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,16 @@ 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
|
-
|
|
75
|
+
const skipped = tok.meta?.skippedCloser;
|
|
76
|
+
const legacy = tok.meta?.legacyCloser;
|
|
77
|
+
if (!closed && legacy !== undefined && !generic) {
|
|
78
|
+
// Before the nesting stack, the closer on that line ended this block and the ones inside it
|
|
79
|
+
add(tok.map[0], "unclosed-container", `:::${name} is never closed — the ${lines[legacy]?.trim()} on line ${lineOf(legacy) ?? "?"} closes only the innermost open block; each block needs its own closer (one \`:::\` used to close several)`);
|
|
80
|
+
}
|
|
81
|
+
else if (!closed && skipped !== undefined) {
|
|
82
|
+
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)`);
|
|
83
|
+
}
|
|
84
|
+
else if (!closed) {
|
|
63
85
|
add(tok.map[0], "unclosed-container", generic
|
|
64
86
|
? `:::${name} is never closed — it is shown as text (close it with a line of ${":".repeat(colons)})`
|
|
65
87
|
: `:::${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 +89,14 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
67
89
|
else if (generic) {
|
|
68
90
|
add(tok.map[0], "unknown-container", `:::${name} is not a known directive — it is shown as a plain box`);
|
|
69
91
|
}
|
|
92
|
+
if (closed) {
|
|
93
|
+
// The closer's end-tag label: it closes this container whatever it says, so a wrong
|
|
94
|
+
// name means the author thinks it closes another one
|
|
95
|
+
const label = closerLabel((lines[tok.map[1]] ?? "").replace(/^[\s>]*:+/, ""));
|
|
96
|
+
if (label && label !== name) {
|
|
97
|
+
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`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
70
100
|
const rest = info.slice(name.length).trim();
|
|
71
101
|
if (rest.startsWith("{")) {
|
|
72
102
|
const problem = attributeProblem(rest);
|
|
@@ -74,12 +104,11 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
74
104
|
add(tok.map[0], "bad-attributes", `the attributes of :::${name} ${problem} — they are ignored`);
|
|
75
105
|
}
|
|
76
106
|
}
|
|
77
|
-
// A `:::` line
|
|
78
|
-
// meant to close ended earlier (an inner block of the same fence closed it), or there is none
|
|
107
|
+
// A `:::` line no open block took: every block it could close is already closed
|
|
79
108
|
if (tok.type === "inline" && tok.map) {
|
|
80
109
|
(tok.content ?? "").split("\n").forEach((l, k) => {
|
|
81
|
-
if (/^\s*:{3,}\s
|
|
82
|
-
add(tok.map[0] + k, "stray-closer", `stray \`${l.trim()}\` — it closes nothing and is shown as text
|
|
110
|
+
if (/^\s*:{3,}[ \t]*(?:<!--(?:(?!-->)[\s\S])*-->[ \t]*)?$/.test(l)) {
|
|
111
|
+
add(tok.map[0] + k, "stray-closer", `stray \`${l.trim()}\` — it closes nothing and is shown as text: every block before it is already closed (each block takes one closer)`);
|
|
83
112
|
}
|
|
84
113
|
});
|
|
85
114
|
}
|
|
@@ -96,7 +125,75 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
96
125
|
from = at >= 0 ? at + form.length : from;
|
|
97
126
|
const offset = at >= 0 ? (text.slice(0, at).match(/\n/g) ?? []).length : 0;
|
|
98
127
|
const shown = child.type === "dogsbay_leaf_directive" ? `::${child.tag}` : `:${child.tag}[…]`;
|
|
99
|
-
|
|
128
|
+
if (child.type === "dogsbay_leaf_directive" && BLOCK_DIRECTIVES.has(child.tag)) {
|
|
129
|
+
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`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
add(tok.map[0] + offset, "unknown-inline", `${shown} is not a known inline directive — it is shown as text`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Attribute blocks markdown-it-attrs read and the tree has nowhere to keep
|
|
137
|
+
if (tok.type === "list_item_open" && tok.map && tok.attrs?.length) {
|
|
138
|
+
// parse.ts keepListItemBraces puts trailing braces back as text, or on a heading; what is left had nowhere to go
|
|
139
|
+
add(tok.map[0], "dropped-attributes", `the attribute block on this list item is dropped — list items take no attributes`);
|
|
140
|
+
}
|
|
141
|
+
// A table row that is only an attribute block, where the parser could not move it onto the
|
|
142
|
+
// table (not the last row, or text follows the table with no blank line): an empty row
|
|
143
|
+
if (tok.type === "tr_open" && tok.map) {
|
|
144
|
+
const cells = [];
|
|
145
|
+
for (let k = list.indexOf(tok) + 1; k < list.length && list[k].type !== "tr_close"; k++)
|
|
146
|
+
cells.push(list[k]);
|
|
147
|
+
const inlines = cells.filter((c) => c.type === "inline");
|
|
148
|
+
if (cells[0]?.attrs?.length && /^\{[^{}]*\}$/.test((inlines[0]?.content ?? "").trim()) && inlines.slice(1).every((c) => c.content.trim() === "")) {
|
|
149
|
+
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`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// 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
|
|
153
|
+
// Only real attribute names: markdown-it-attrs also reads a leftover include (`{* src.py hl[4] *}`, FastAPI) as keys
|
|
154
|
+
const own = tok.attrs?.filter(([k]) => k !== "start") ?? [];
|
|
155
|
+
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))) {
|
|
156
|
+
add(tok.map[0], "dropped-attributes", `the attribute block on this list is dropped — lists take no attributes`);
|
|
157
|
+
}
|
|
158
|
+
// An attribute block alone on its line after a blank line: an empty paragraph that carries it
|
|
159
|
+
if (tok.type === "paragraph_open" && tok.map && tok.attrs?.length) {
|
|
160
|
+
const inline = list[list.indexOf(tok) + 1];
|
|
161
|
+
// Only what an author writes as attributes: `{#id}`, `{.class}`, `{key=…}`. markdown-it-attrs also eats a
|
|
162
|
+
// leftover MDX expression (`{/* prettier-ignore */}`), which is an importer's problem, not the author's
|
|
163
|
+
const looksLikeAttrs = /^\{\s*(?:[#.][^\s{}]|[\w-]+\s*=)/.test(inline?.content.trim() ?? "");
|
|
164
|
+
if (inline?.type === "inline" && looksLikeAttrs && (inline.children ?? []).every((c) => c.type === "text" && c.content.trim() === "")) {
|
|
165
|
+
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`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (tok.type === "inline" && tok.map && tok.children) {
|
|
169
|
+
for (const child of tok.children) {
|
|
170
|
+
const what = ATTRLESS_INLINE[child.type];
|
|
171
|
+
if (what && child.attrs?.length)
|
|
172
|
+
add(tok.map[0], "dropped-attributes", `the attribute block on ${what} is dropped — only links, images and inline directives take attributes`);
|
|
173
|
+
}
|
|
174
|
+
// Inline directives that keep no attributes (only badge and button do)
|
|
175
|
+
for (const child of tok.children) {
|
|
176
|
+
if (child.type !== "dogsbay_inline_directive_open" || child.tag === "badge" || child.tag === "button" || !KNOWN_INLINE_DIRECTIVES.has(child.tag))
|
|
177
|
+
continue;
|
|
178
|
+
let parsed = {};
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(child.info || "{}");
|
|
181
|
+
}
|
|
182
|
+
catch { /* not ours */ }
|
|
183
|
+
if (parsed.id || parsed.classes?.length || Object.keys(parsed.props ?? {}).length) {
|
|
184
|
+
add(tok.map[0], "dropped-attributes", `the attribute block on :${child.tag}[…] is dropped — of the inline directives only :badge and :button take attributes`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// `:kbd{…}` with no `[label]` is not a directive: the label is what it shows
|
|
188
|
+
for (const child of tok.children) {
|
|
189
|
+
if (child.type !== "text")
|
|
190
|
+
continue;
|
|
191
|
+
for (const m of child.content.matchAll(/:([a-z]+)\{/g)) {
|
|
192
|
+
if (m.index > 0 && PRECEDES_TEXT.test(child.content[m.index - 1]))
|
|
193
|
+
continue;
|
|
194
|
+
if (KNOWN_INLINE_DIRECTIVES.has(m[1]))
|
|
195
|
+
add(tok.map[0], "missing-label", `:${m[1]}{…} has no [label] — it is shown as text; write :${m[1]}[…]{…}`);
|
|
196
|
+
}
|
|
100
197
|
}
|
|
101
198
|
}
|
|
102
199
|
// A heading's attribute block that markdown-it-attrs could not read stays in its text
|
package/dist/index.d.ts
CHANGED
|
@@ -2,12 +2,13 @@ export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
|
|
|
2
2
|
export type { SerializeOptions } from "./types.js";
|
|
3
3
|
export { DOGSBAY_MD_CAPABILITIES } from "./capabilities.js";
|
|
4
4
|
export { inlineToDogsbayMd } from "./inline.js";
|
|
5
|
-
export { dogsbayMdToTree, parseInline } from "./parse.js";
|
|
5
|
+
export { dogsbayMdToTree, parseInline, containerOpenBefore } from "./parse.js";
|
|
6
6
|
export { extractHeadings, slugifyHeadingText, type HeadingInfo } from "./headings.js";
|
|
7
7
|
export { transformCodeRegions, transformProseLines, transformProseSegments, fenceOpenBefore, createFenceTracker } from "./code-regions.js";
|
|
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
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
|
|
6
6
|
export { DOGSBAY_MD_CAPABILITIES } from "./capabilities.js";
|
|
7
7
|
export { inlineToDogsbayMd } from "./inline.js";
|
|
8
|
-
export { dogsbayMdToTree, parseInline } from "./parse.js";
|
|
8
|
+
export { dogsbayMdToTree, parseInline, containerOpenBefore } from "./parse.js";
|
|
9
9
|
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.d.ts
CHANGED
|
@@ -42,3 +42,12 @@ export declare function dogsbayMdToTree(source: string, options?: ParseOptions):
|
|
|
42
42
|
* Parse a single inline string to InlineNode[] (useful for testing).
|
|
43
43
|
*/
|
|
44
44
|
export declare function parseInline(source: string): InlineNode[];
|
|
45
|
+
/**
|
|
46
|
+
* Per line of `source` (the body, no front matter, split at `\n` as the commands engine splits
|
|
47
|
+
* it): whether the line is inside a `:::` block as the parser nests them. The opener's line is
|
|
48
|
+
* not, nor the closer's; the body between is. Read from the
|
|
49
|
+
* parse rather than re-derived, so the commands engine's outline (a heading in a block is never
|
|
50
|
+
* structure) cannot drift from the parser: a line scan cannot see where a list item ends a block,
|
|
51
|
+
* which names are blocks, or raw HTML. An unknown name never closed is text, so not a block.
|
|
52
|
+
*/
|
|
53
|
+
export declare function containerOpenBefore(source: string): boolean[];
|
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,115 @@ 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
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Per line of `source` (the body, no front matter, split at `\n` as the commands engine splits
|
|
393
|
+
* it): whether the line is inside a `:::` block as the parser nests them. The opener's line is
|
|
394
|
+
* not, nor the closer's; the body between is. Read from the
|
|
395
|
+
* parse rather than re-derived, so the commands engine's outline (a heading in a block is never
|
|
396
|
+
* structure) cannot drift from the parser: a line scan cannot see where a list item ends a block,
|
|
397
|
+
* which names are blocks, or raw HTML. An unknown name never closed is text, so not a block.
|
|
398
|
+
*/
|
|
399
|
+
export function containerOpenBefore(source) {
|
|
400
|
+
// As markdown-it's normalize step (which block.parse skips): \r\n is a line break. A lone \r is
|
|
401
|
+
// one to markdown-it but not to the engine's split at \n, so it becomes a space
|
|
402
|
+
const src = source.replace(/\r\n/g, "\n").replace(/\r/g, " ");
|
|
403
|
+
const inside = new Array(src.split("\n").length).fill(false);
|
|
404
|
+
// block rules only: the inline pass changes no line a block spans
|
|
405
|
+
const md = sharedParser();
|
|
406
|
+
const tokens = [];
|
|
407
|
+
md.block.parse(src, md, {}, tokens);
|
|
408
|
+
markUnclosedGenericContainers(tokens, src);
|
|
409
|
+
for (const tok of tokens) {
|
|
410
|
+
const map = tok.map;
|
|
411
|
+
if (tok.nesting !== 1 || !tok.type.startsWith("container_") || !map || tok.meta?.unclosed)
|
|
412
|
+
continue;
|
|
413
|
+
for (let l = map[0] + 1; l < map[1] && l < inside.length; l++)
|
|
414
|
+
inside[l] = true;
|
|
415
|
+
}
|
|
416
|
+
return inside;
|
|
417
|
+
}
|
|
307
418
|
/**
|
|
308
419
|
* markdown-it-container runs a block with no closing marker to the end of the document. For a
|
|
309
420
|
* generic (unknown-name) container that would turn any prose line starting `:::word` into a block
|
|
@@ -956,9 +1067,12 @@ function consumeContainer(ctx) {
|
|
|
956
1067
|
ctx.i++;
|
|
957
1068
|
const children = [];
|
|
958
1069
|
const closeType = `container_${registered}_close`;
|
|
1070
|
+
// The closer's end-tag label, `::: <!-- end note -->` (container-rule.ts): kept so it is written back
|
|
1071
|
+
let closer = "";
|
|
959
1072
|
while (ctx.i < ctx.tokens.length) {
|
|
960
1073
|
const tok = ctx.tokens[ctx.i];
|
|
961
1074
|
if (tok.type === closeType) {
|
|
1075
|
+
closer = tok.info ?? "";
|
|
962
1076
|
ctx.i++;
|
|
963
1077
|
break;
|
|
964
1078
|
}
|
|
@@ -988,10 +1102,11 @@ function consumeContainer(ctx) {
|
|
|
988
1102
|
type: "container",
|
|
989
1103
|
props: attrs ? attrsToProps(attrs) : {},
|
|
990
1104
|
children,
|
|
991
|
-
meta: { container: true, name, ...(title ? { title } : {}) },
|
|
1105
|
+
meta: { container: true, name, ...(title ? { title } : {}), ...(closer ? { closer } : {}) },
|
|
992
1106
|
};
|
|
993
1107
|
}
|
|
994
|
-
|
|
1108
|
+
const node = mapContainerToTreeNode(name, attrs, children);
|
|
1109
|
+
return closer && !Array.isArray(node) ? { ...node, meta: { ...(node.meta ?? {}), closer } } : node;
|
|
995
1110
|
}
|
|
996
1111
|
/**
|
|
997
1112
|
* Convert a container + children into the appropriate TreeNode(s).
|
|
@@ -1629,6 +1744,9 @@ function tokensToInline(tokens, inheritedFmt = []) {
|
|
|
1629
1744
|
const name = tok.tag;
|
|
1630
1745
|
const attrs = tok.info ? JSON.parse(tok.info) : null;
|
|
1631
1746
|
const node = inlineDirectiveToNode(name, "", [], attrs);
|
|
1747
|
+
// An unknown leaf stays as written (`::name{…}`), not in the text form `:name[]{…}`, which reads back as something else
|
|
1748
|
+
if (node?.type === "html-inline" && node.html.startsWith(`:${name}[]`))
|
|
1749
|
+
node.html = `::${name}${node.html.slice(name.length + 3)}`;
|
|
1632
1750
|
if (node)
|
|
1633
1751
|
result.push(node);
|
|
1634
1752
|
break;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Register markdown-it-container
|
|
2
|
+
* Register the container rule (container-rule.ts, adapted from markdown-it-container) for each
|
|
3
|
+
* known Dogsbay block directive.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* nest. Same-name nesting requires more colons (::::).
|
|
5
|
+
* Each directive name is registered separately. Blocks nest at any colon count, the same name
|
|
6
|
+
* too: the rule keeps a stack of the blocks open inside it (NESTING in container-rule.ts).
|
|
7
7
|
*/
|
|
8
8
|
import type MarkdownIt from "markdown-it";
|
|
9
9
|
export declare const KNOWN_BLOCK_DIRECTIVES: readonly ["tabs", "tab", "steps", "step", "cards", "card", "grid", "grid-item", "details", "callout", "note", "tip", "info", "important", "warning", "caution", "danger", "success", "hint", "attention", "error", "failure", "bug", "question", "abstract", "quote", "button", "figure", "math", "api-endpoint", "api-params", "api-response", "config", "files", "example", "accordion", "accordion-item", "link-card", "autodoc", "api-class", "api-member", "api-doc", "api-source", "youtube", "openapi", "theme"];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { containerRule, registerOpener } 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,9 @@ 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
|
-
|
|
75
|
+
// `:::youtube{#id}` has no body, and is often written with no closer
|
|
76
|
+
registerOpener(name, { leaf: name === "youtube" });
|
|
77
|
+
containerRule(md, name, {
|
|
76
78
|
validate: (params) => {
|
|
77
79
|
const trimmed = params.trim();
|
|
78
80
|
if (!trimmed.startsWith(name))
|
|
@@ -103,7 +105,7 @@ export function containersPlugin(md) {
|
|
|
103
105
|
});
|
|
104
106
|
}
|
|
105
107
|
const known = new Set(KNOWN_BLOCK_DIRECTIVES);
|
|
106
|
-
md
|
|
108
|
+
containerRule(md, GENERIC_CONTAINER, {
|
|
107
109
|
validate: (params) => {
|
|
108
110
|
const m = /^([A-Za-z][\w-]*)(?=$|[\s{[])/.exec(params.trim());
|
|
109
111
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0-beta.127",
|
|
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.
|
|
44
|
-
"@dogsbay/serialize-core": "0.2.0-beta.
|
|
45
|
-
"@dogsbay/types": "0.2.0-beta.
|
|
42
|
+
"@dogsbay/minja": "0.2.0-beta.127",
|
|
43
|
+
"@dogsbay/serialize-core": "0.2.0-beta.127",
|
|
44
|
+
"@dogsbay/types": "0.2.0-beta.127"
|
|
46
45
|
},
|
|
47
46
|
"license": "MIT",
|
|
48
47
|
"repository": {
|