@dogsbay/format-dogsbay-md 0.2.0-beta.126 → 0.2.0-beta.128
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 +1 -1
- package/dist/browser.js +1 -1
- package/dist/capabilities.js +1 -1
- package/dist/cli.js +3 -1
- package/dist/container-rule.d.ts +37 -3
- package/dist/container-rule.js +178 -37
- package/dist/diagnostics.d.ts +3 -5
- package/dist/diagnostics.js +133 -13
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/parse-attrs.d.ts +9 -0
- package/dist/parse-attrs.js +28 -0
- package/dist/parse.d.ts +9 -0
- package/dist/parse.js +27 -0
- package/dist/plugin-block-leaf.js +3 -15
- package/dist/plugin-containers.d.ts +2 -3
- package/dist/plugin-containers.js +5 -3
- package/dist/plugin-inline-directives.js +3 -15
- package/dist/renderers/diagrams.d.ts +4 -0
- package/dist/renderers/diagrams.js +23 -1
- package/dist/renderers/pele.d.ts +13 -0
- package/dist/renderers/pele.js +42 -0
- package/dist/serialize.js +3 -2
- package/package.json +7 -6
package/dist/browser.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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
16
|
export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
|
|
17
17
|
export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
|
package/dist/browser.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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
16
|
// The `:::name` blocks the parser knows (an editor completes names from it)
|
|
17
17
|
export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
|
package/dist/capabilities.js
CHANGED
|
@@ -30,7 +30,7 @@ export const DOGSBAY_MD_CAPABILITIES = [
|
|
|
30
30
|
rendersAs: "a ```mermaid fence with the diagram source",
|
|
31
31
|
note: "The source round-trips (it re-parses as a code fence, which the build turns back into a diagram); a pre-rendered SVG is not kept.",
|
|
32
32
|
},
|
|
33
|
-
{ feature: "youtube", support: "full", rendersAs: "
|
|
33
|
+
{ feature: "youtube", support: "full", rendersAs: "`::youtube{#id title=\"…\"}`" },
|
|
34
34
|
// ── Components ───────────────────────────────────────────
|
|
35
35
|
{ feature: "callout", support: "full", rendersAs: "GitHub alert `> [!NOTE]`; `:::type[Title]{…}` for a custom title or a non-GitHub type" },
|
|
36
36
|
{ feature: "details", support: "full", rendersAs: "`:::details{title=\"…\"}`" },
|
package/dist/cli.js
CHANGED
|
@@ -131,11 +131,13 @@ async function importDogsbayMd(source, opts) {
|
|
|
131
131
|
// (mermaid-cli) isn't installed the code block is left as-is and
|
|
132
132
|
// a one-line warning is printed. See plans/mermaid-dogsbay-md.md.
|
|
133
133
|
const diagramsOn = opts.diagrams !== false;
|
|
134
|
+
// `content.diagrams: pele` draws them with Pele; `true` (the default) with mmdc
|
|
135
|
+
const diagramEngine = opts.diagrams === "pele" ? "pele" : "mermaid-cli";
|
|
134
136
|
for (const { filePath, slug, sourcePath } of files) {
|
|
135
137
|
const content = readFileSync(filePath, "utf-8");
|
|
136
138
|
const { tree, frontmatter } = dogsbayMdToTree(content);
|
|
137
139
|
if (diagramsOn) {
|
|
138
|
-
await renderDiagrams(tree);
|
|
140
|
+
await renderDiagrams(tree, { engine: diagramEngine });
|
|
139
141
|
}
|
|
140
142
|
const title = String(frontmatter.title
|
|
141
143
|
?? prettifyName(basename(slug)));
|
package/dist/container-rule.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `:::name` container block rule: markdown-it-container 4.0.0 (MIT,
|
|
3
|
-
* Vitaly Puzrin, Alex Kocharin), adapted.
|
|
3
|
+
* Vitaly Puzrin, Alex Kocharin), adapted. Three changes, all in how the
|
|
4
4
|
* closing line is found:
|
|
5
5
|
*
|
|
6
6
|
* 1. A closer may carry an HTML comment, `::: <!-- end note -->`, the way an
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* one code block, with no message. Which lines are code comes from one
|
|
14
14
|
* block-level pass over the source with containers off (codeLines).
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
18
20
|
*
|
|
19
21
|
* markdown-it-container's licence:
|
|
20
22
|
*
|
|
@@ -42,8 +44,40 @@
|
|
|
42
44
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
43
45
|
*/
|
|
44
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. `:::` always opens a block that needs a
|
|
62
|
+
* closer; a directive with no body is a leaf, `::youtube{#id}` (plugin-block-leaf.ts), so no
|
|
63
|
+
* opener depends on the line after it.
|
|
64
|
+
*
|
|
65
|
+
* VERIFY. The scan reads raw lines, and markdown-it may not read an opener there as one: in raw
|
|
66
|
+
* HTML, a `$$` math block, a fence that never closes. Where that is depends on where the blocks
|
|
67
|
+
* around it end, so no pass over the page can say in advance. So after the body is tokenized, every
|
|
68
|
+
* opener the scan pushed must have opened a block in it; any that did not are left out and the
|
|
69
|
+
* body is scanned and tokenized again. Counted wrongly, an opener leaves the outer block open to
|
|
70
|
+
* the end of the page (the rule before the stack lost one closer at most). What a block learns is
|
|
71
|
+
* kept for the parse, by its opening line: when an outer block retries, the blocks inside it are
|
|
72
|
+
* tokenized again and start from what they learned, rather than repeating their own retries (which
|
|
73
|
+
* doubled the work at every level). A learned line can be stale in the new context, so the check
|
|
74
|
+
* runs both ways: a skipped line that markdown-it did open as a block is pushed again. Retried
|
|
75
|
+
* lines come out of one budget per parse (a few times the page's lines); past it, the scan stands.
|
|
76
|
+
*/
|
|
45
77
|
export interface ContainerOptions {
|
|
46
78
|
validate: (params: string, markup: string) => boolean;
|
|
47
79
|
render?: (tokens: unknown[], idx: number) => string;
|
|
48
80
|
}
|
|
81
|
+
/** Make `name` an opener for the nesting scan (the known directives; not the generic container). */
|
|
82
|
+
export declare function registerOpener(name: string): void;
|
|
49
83
|
export declare function containerRule(md: MarkdownIt, name: string, options: ContainerOptions): void;
|
package/dist/container-rule.js
CHANGED
|
@@ -7,6 +7,9 @@ const CLOSER_TAIL = /^(?:[ \t]*|[ \t]*(<!--(?:(?!-->)[\s\S])*-->)[ \t]*)$/;
|
|
|
7
7
|
/** state.env keys: the pre-pass that finds the code, and its result. */
|
|
8
8
|
const NO_CONTAINERS = "__dogsbayNoContainers";
|
|
9
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";
|
|
10
13
|
/**
|
|
11
14
|
* The source lines inside a fenced code block, from one block-level pass over the whole source
|
|
12
15
|
* with containers switched off, made the first time a container needs it and kept in the parse's
|
|
@@ -49,11 +52,48 @@ function closesFence(line, markup, after) {
|
|
|
49
52
|
const m = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
50
53
|
return m !== null && m[1][0] === markup[0] && m[1].length >= markup.length;
|
|
51
54
|
}
|
|
55
|
+
/** The directive names the scan treats as openers (plugin-containers registers them). */
|
|
56
|
+
const KNOWN_OPENERS = new Set();
|
|
57
|
+
/** Whether the text after an opener's colons starts with a known directive name. */
|
|
58
|
+
function isKnownOpener(rest) {
|
|
59
|
+
const m = /^\s*([A-Za-z][\w-]*)(?=$|[\s{[])/.exec(rest);
|
|
60
|
+
return m !== null && KNOWN_OPENERS.has(m[1]);
|
|
61
|
+
}
|
|
62
|
+
/** Make `name` an opener for the nesting scan (the known directives; not the generic container). */
|
|
63
|
+
export function registerOpener(name) {
|
|
64
|
+
KNOWN_OPENERS.add(name);
|
|
65
|
+
}
|
|
66
|
+
/** Tokens whose lines are not block openers: an opener the scan pushed inside one is wrong (VERIFY). */
|
|
67
|
+
const READ_AS_OTHER = new Set(["paragraph_open", "heading_open", "html_block", "fence", "code_block", "math_block", "hr", "table_open"]);
|
|
68
|
+
/** The openers each block (by opening line) learned to skip in this parse (VERIFY). */
|
|
69
|
+
function learned(state) {
|
|
70
|
+
const env = state.env;
|
|
71
|
+
if (!env)
|
|
72
|
+
return new Map();
|
|
73
|
+
const kept = env[LEARNED];
|
|
74
|
+
if (kept && kept.src === state.src)
|
|
75
|
+
return kept.map;
|
|
76
|
+
const map = new Map();
|
|
77
|
+
env[LEARNED] = { src: state.src, map };
|
|
78
|
+
return map;
|
|
79
|
+
}
|
|
80
|
+
/** The lines left in this parse's retry budget (VERIFY), shared by every block in it. */
|
|
81
|
+
function retryBudget(state) {
|
|
82
|
+
const env = state.env;
|
|
83
|
+
const fresh = { lines: 4 * state.bMarks.length + 1000 };
|
|
84
|
+
if (!env)
|
|
85
|
+
return fresh;
|
|
86
|
+
const kept = env[RETRY_BUDGET];
|
|
87
|
+
if (kept && kept.src === state.src)
|
|
88
|
+
return kept.budget;
|
|
89
|
+
env[RETRY_BUDGET] = { src: state.src, budget: fresh };
|
|
90
|
+
return fresh;
|
|
91
|
+
}
|
|
52
92
|
export function containerRule(md, name, options) {
|
|
53
93
|
const MARKER = ":".charCodeAt(0);
|
|
54
94
|
function container(state, startLine, endLine, silent) {
|
|
55
|
-
|
|
56
|
-
|
|
95
|
+
const start = state.bMarks[startLine] + state.tShift[startLine];
|
|
96
|
+
const max = state.eMarks[startLine];
|
|
57
97
|
if (state.src.charCodeAt(start) !== MARKER)
|
|
58
98
|
return false;
|
|
59
99
|
if (state.env?.[NO_CONTAINERS])
|
|
@@ -73,64 +113,165 @@ export function containerRule(md, name, options) {
|
|
|
73
113
|
const oldParent = state.parentType;
|
|
74
114
|
const oldLineMax = state.lineMax;
|
|
75
115
|
state.parentType = "container";
|
|
116
|
+
const tokensBefore = state.tokens.length;
|
|
117
|
+
// Openers markdown-it did not open, found by an earlier try (VERIFY above)
|
|
118
|
+
const known = learned(state);
|
|
119
|
+
const notOpeners = new Set(known.get(startLine));
|
|
120
|
+
let scan;
|
|
121
|
+
for (;;) {
|
|
122
|
+
scan = scanBody(state, startLine, endLine, markerCount, notOpeners);
|
|
123
|
+
// lazy continuations never run past the closer
|
|
124
|
+
state.lineMax = scan.nextLine;
|
|
125
|
+
const open = state.push(`container_${name}_open`, "div", 1);
|
|
126
|
+
open.markup = markup;
|
|
127
|
+
open.block = true;
|
|
128
|
+
open.info = params;
|
|
129
|
+
open.map = [startLine, scan.nextLine];
|
|
130
|
+
if (!scan.autoClosed && (scan.skippedCloser !== undefined || scan.legacyCloser !== undefined)) {
|
|
131
|
+
open.meta = {
|
|
132
|
+
...(open.meta ?? {}),
|
|
133
|
+
...(scan.skippedCloser !== undefined ? { skippedCloser: scan.skippedCloser } : {}),
|
|
134
|
+
...(scan.legacyCloser !== undefined ? { legacyCloser: scan.legacyCloser } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
state.md.block.tokenize(state, startLine + 1, scan.nextLine);
|
|
138
|
+
const close = state.push(`container_${name}_close`, "div", -1);
|
|
139
|
+
close.markup = scan.closeMarkup;
|
|
140
|
+
close.block = true;
|
|
141
|
+
close.info = scan.closeComment;
|
|
142
|
+
// Wrong: a pushed opener's line that markdown-it read as something else. A line it read as
|
|
143
|
+
// nothing (past markdown-it's nesting limit, where it drops the rest) is not evidence.
|
|
144
|
+
// Missed: a skipped line markdown-it did open as a block (a learned line, stale here).
|
|
145
|
+
const pushed = new Set(scan.pushed);
|
|
146
|
+
const wrong = [];
|
|
147
|
+
const missed = [];
|
|
148
|
+
if (pushed.size > 0 || notOpeners.size > 0) {
|
|
149
|
+
for (let i = tokensBefore + 1; i < state.tokens.length; i++) {
|
|
150
|
+
const t = state.tokens[i];
|
|
151
|
+
if (!t.map)
|
|
152
|
+
continue;
|
|
153
|
+
if (READ_AS_OTHER.has(t.type)) {
|
|
154
|
+
for (let l = t.map[0]; l < t.map[1]; l++)
|
|
155
|
+
if (pushed.delete(l))
|
|
156
|
+
wrong.push(l);
|
|
157
|
+
}
|
|
158
|
+
else if (t.nesting === 1 && t.type.startsWith("container_") && notOpeners.has(t.map[0]))
|
|
159
|
+
missed.push(t.map[0]);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (wrong.length === 0 && missed.length === 0)
|
|
163
|
+
break;
|
|
164
|
+
const budget = retryBudget(state);
|
|
165
|
+
if (budget.lines < scan.nextLine - startLine) {
|
|
166
|
+
// Out of budget: this reading stands, with an opener known to be wrong; say so (diagnostics)
|
|
167
|
+
open.meta = { ...(open.meta ?? {}), unverified: true };
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
budget.lines -= scan.nextLine - startLine;
|
|
171
|
+
for (const l of wrong)
|
|
172
|
+
notOpeners.add(l);
|
|
173
|
+
for (const l of missed)
|
|
174
|
+
notOpeners.delete(l);
|
|
175
|
+
state.tokens.length = tokensBefore;
|
|
176
|
+
}
|
|
177
|
+
if (notOpeners.size > 0)
|
|
178
|
+
known.set(startLine, new Set(notOpeners));
|
|
179
|
+
else
|
|
180
|
+
known.delete(startLine);
|
|
181
|
+
state.parentType = oldParent;
|
|
182
|
+
state.lineMax = oldLineMax;
|
|
183
|
+
state.line = scan.nextLine + (scan.autoClosed ? 1 : 0);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
/** Find where the block opened at `startLine` ends (NESTING above), skipping the `notOpeners` lines as openers. */
|
|
187
|
+
function scanBody(state, startLine, endLine, markerCount, notOpeners) {
|
|
76
188
|
let code;
|
|
77
189
|
let nextLine = startLine;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
190
|
+
const scan = { nextLine, autoClosed: false, closeMarkup: "", closeComment: "", pushed: [] };
|
|
191
|
+
// Blocks opened inside this one and not yet closed, innermost last (see NESTING above)
|
|
192
|
+
// `least` is the fewest colons from the outermost to this one, and `openCount` counts the open
|
|
193
|
+
// blocks by colons, so a closer that fits none is turned away without walking the stack
|
|
194
|
+
const inner = [];
|
|
195
|
+
const openCount = new Map();
|
|
196
|
+
const truncate = (k) => {
|
|
197
|
+
for (let j = k; j < inner.length; j++)
|
|
198
|
+
openCount.set(inner[j].colons, openCount.get(inner[j].colons) - 1);
|
|
199
|
+
inner.length = k;
|
|
200
|
+
};
|
|
82
201
|
for (;;) {
|
|
83
202
|
nextLine++;
|
|
84
203
|
// unclosed: closed by the end of the document or of the parent block
|
|
85
204
|
if (nextLine >= endLine)
|
|
86
205
|
break;
|
|
87
|
-
start = state.bMarks[nextLine] + state.tShift[nextLine];
|
|
88
|
-
max = state.eMarks[nextLine];
|
|
206
|
+
const start = state.bMarks[nextLine] + state.tShift[nextLine];
|
|
207
|
+
const max = state.eMarks[nextLine];
|
|
208
|
+
if (start >= max)
|
|
209
|
+
continue;
|
|
89
210
|
// a non-empty line indented less than the block ends it (a list item's container)
|
|
90
|
-
if (
|
|
211
|
+
if (state.sCount[nextLine] < state.blkIndent)
|
|
91
212
|
break;
|
|
213
|
+
// a block opened deeper (in a list item) ended at a line indented less than its opener
|
|
214
|
+
while (inner.length > 0 && state.sCount[nextLine] < inner[inner.length - 1].indent)
|
|
215
|
+
truncate(inner.length - 1);
|
|
92
216
|
if (state.src.charCodeAt(start) !== MARKER)
|
|
93
217
|
continue;
|
|
94
|
-
//
|
|
218
|
+
// openers and closers are indented less than 4 spaces past the block
|
|
95
219
|
if (state.sCount[nextLine] - state.blkIndent >= 4)
|
|
96
220
|
continue;
|
|
97
|
-
pos = start + 1;
|
|
221
|
+
let pos = start + 1;
|
|
98
222
|
while (pos < max && state.src.charCodeAt(pos) === MARKER)
|
|
99
223
|
pos++;
|
|
100
|
-
|
|
101
|
-
if (
|
|
224
|
+
const colons = pos - start;
|
|
225
|
+
if (colons < 3)
|
|
102
226
|
continue;
|
|
103
|
-
const
|
|
104
|
-
|
|
227
|
+
const rest = state.src.slice(pos, max);
|
|
228
|
+
const tail = CLOSER_TAIL.exec(rest);
|
|
229
|
+
const opener = !tail && isKnownOpener(rest);
|
|
230
|
+
if (!tail && !opener)
|
|
105
231
|
continue;
|
|
106
232
|
// a `:::` in a code example is code
|
|
107
233
|
if ((code ??= codeLines(state)).has(nextLine)) {
|
|
108
|
-
|
|
234
|
+
if (tail && colons >= markerCount)
|
|
235
|
+
scan.skippedCloser ??= nextLine;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (opener) {
|
|
239
|
+
if (notOpeners.has(nextLine))
|
|
240
|
+
continue;
|
|
241
|
+
const least = Math.min(colons, inner.length > 0 ? inner[inner.length - 1].least : colons);
|
|
242
|
+
inner.push({ colons, indent: state.sCount[nextLine], least });
|
|
243
|
+
openCount.set(colons, (openCount.get(colons) ?? 0) + 1);
|
|
244
|
+
scan.pushed.push(nextLine);
|
|
109
245
|
continue;
|
|
110
246
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
247
|
+
// Where the rule before the stack (the first closer with enough colons) would have ended this block
|
|
248
|
+
if (colons >= markerCount)
|
|
249
|
+
scan.legacyCloser ??= nextLine;
|
|
250
|
+
// The innermost open block opened with exactly this many colons, else the innermost opened
|
|
251
|
+
// with fewer; it closes, and any block still open inside it. Index -1 is this block.
|
|
252
|
+
const closes = (fits) => {
|
|
253
|
+
for (let k = inner.length - 1; k >= 0; k--)
|
|
254
|
+
if (fits(inner[k].colons))
|
|
255
|
+
return k;
|
|
256
|
+
return fits(markerCount) ? -1 : undefined;
|
|
257
|
+
};
|
|
258
|
+
const exact = (openCount.get(colons) ?? 0) > 0 || markerCount === colons;
|
|
259
|
+
const fewer = markerCount < colons || (inner.length > 0 && inner[inner.length - 1].least < colons);
|
|
260
|
+
const k = exact ? closes((n) => n === colons) : fewer ? closes((n) => n < colons) : undefined;
|
|
261
|
+
// shorter than every open block: it closes nothing here
|
|
262
|
+
if (k === undefined)
|
|
263
|
+
continue;
|
|
264
|
+
if (k >= 0) {
|
|
265
|
+
truncate(k);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
scan.autoClosed = true;
|
|
269
|
+
scan.closeMarkup = state.src.slice(start, pos);
|
|
270
|
+
scan.closeComment = tail?.[1] ?? "";
|
|
114
271
|
break;
|
|
115
272
|
}
|
|
116
|
-
|
|
117
|
-
|
|
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;
|
|
273
|
+
scan.nextLine = nextLine;
|
|
274
|
+
return scan;
|
|
134
275
|
}
|
|
135
276
|
md.block.ruler.before("fence", `container_${name}`, container, { alt: ["paragraph", "reference", "blockquote", "list"] });
|
|
136
277
|
const render = options.render ?? (() => "");
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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" | "block-directive-inline" | "bad-attributes" | "closer-mismatch" | "dropped-attributes" | "missing-label";
|
|
5
|
+
code: "unclosed-container" | "stray-closer" | "unknown-container" | "unknown-inline" | "block-directive-inline" | "bad-attributes" | "closer-mismatch" | "dropped-attributes" | "missing-label" | "leaf-as-block" | "nesting-unchecked";
|
|
6
6
|
message: string;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
@@ -31,6 +31,8 @@ type Token = {
|
|
|
31
31
|
meta: {
|
|
32
32
|
unclosed?: boolean;
|
|
33
33
|
skippedCloser?: number;
|
|
34
|
+
legacyCloser?: number;
|
|
35
|
+
unverified?: boolean;
|
|
34
36
|
} | null;
|
|
35
37
|
};
|
|
36
38
|
/**
|
|
@@ -39,9 +41,5 @@ type Token = {
|
|
|
39
41
|
* missing brace or an unterminated quote loses attributes silently.
|
|
40
42
|
*/
|
|
41
43
|
export declare function attributeProblem(block: string): string | undefined;
|
|
42
|
-
/**
|
|
43
|
-
* Diagnostics for a token stream. `lines` is the parsed text split at `\n`;
|
|
44
|
-
* `lineOf` maps a 0-based line of it to a 1-based source line.
|
|
45
|
-
*/
|
|
46
44
|
export declare function collectDiagnostics(tokens: Token[], lines: string[], lineOf: (line0: number) => number | undefined): ParseDiagnostic[];
|
|
47
45
|
export {};
|
package/dist/diagnostics.js
CHANGED
|
@@ -24,6 +24,50 @@ export const closesContainer = (line, colons) => line !== undefined && new RegEx
|
|
|
24
24
|
export function closerLabel(comment) {
|
|
25
25
|
return /^<!--\s*(?:end\s+|\/)?:*([A-Za-z][\w-]*)\s*-->$/.exec(comment.trim())?.[1];
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The line of an attribute block a list or list item was given, so the diagnostic points at the
|
|
29
|
+
* braces rather than the first item. In `from`..`to`, the last line that is only `{…}`, else the
|
|
30
|
+
* last that ends with one (code lines skipped). With `after` (a list's block may follow it, past
|
|
31
|
+
* blank lines), only when the list has none: then a line that is only `{…}`. Else `from`.
|
|
32
|
+
*/
|
|
33
|
+
function braceLine(lines, code, from, to, after = false) {
|
|
34
|
+
const only = (l) => !code.has(l) && /^[\s>]*\{[^{}]*\}\s*$/.test(lines[l] ?? "");
|
|
35
|
+
const ends = (l) => !code.has(l) && /\{[^{}]*\}\s*$/.test(lines[l] ?? "");
|
|
36
|
+
const last = Math.min(to, lines.length - 1);
|
|
37
|
+
for (let l = last; l >= from; l--)
|
|
38
|
+
if (only(l))
|
|
39
|
+
return l;
|
|
40
|
+
for (let l = last; l >= from; l--)
|
|
41
|
+
if (ends(l))
|
|
42
|
+
return l;
|
|
43
|
+
if (after) {
|
|
44
|
+
let l = to;
|
|
45
|
+
while (l < lines.length && (lines[l] ?? "").trim() === "")
|
|
46
|
+
l++;
|
|
47
|
+
if (only(l))
|
|
48
|
+
return l;
|
|
49
|
+
}
|
|
50
|
+
return from;
|
|
51
|
+
}
|
|
52
|
+
/** A line with its inline code spans and `<…>` (raw HTML, autolinks) blanked, keeping offsets. */
|
|
53
|
+
function blankCodeAndTags(line) {
|
|
54
|
+
return line.replace(/(`+)[\s\S]*?\1|<[^<>]*>/g, (c) => " ".repeat(c.length));
|
|
55
|
+
}
|
|
56
|
+
/** Lines inside fenced or indented code, from the tokens. */
|
|
57
|
+
function codeLineSet(tokens) {
|
|
58
|
+
const out = new Set();
|
|
59
|
+
const walk = (list) => {
|
|
60
|
+
for (const t of list) {
|
|
61
|
+
if ((t.type === "fence" || t.type === "code_block") && t.map)
|
|
62
|
+
for (let l = t.map[0]; l < t.map[1]; l++)
|
|
63
|
+
out.add(l);
|
|
64
|
+
if (t.children)
|
|
65
|
+
walk(t.children);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
walk(tokens);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
27
71
|
/** The inline directives the parser turns into nodes (parse.ts inlineDirectiveToNode); any other name stays literal text. */
|
|
28
72
|
export const KNOWN_INLINE_DIRECTIVES = new Set(["kbd", "icon", "math", "mark", "highlight", "button", "badge"]);
|
|
29
73
|
/** Directives that are blocks: `::name{…}` draws one only on a line of its own (parse.ts mapContainerToTreeNode). */
|
|
@@ -56,6 +100,20 @@ export function attributeProblem(block) {
|
|
|
56
100
|
* Diagnostics for a token stream. `lines` is the parsed text split at `\n`;
|
|
57
101
|
* `lineOf` maps a 0-based line of it to a 1-based source line.
|
|
58
102
|
*/
|
|
103
|
+
/** Why a `:::youtube` block is wrong (its own body, or another block's closer), or undefined if it is the empty block with its own closer. */
|
|
104
|
+
function youtubeAsBlock(map, closed, lines, around, lineOf) {
|
|
105
|
+
if (!closed)
|
|
106
|
+
return "is never closed, so it takes in everything after it";
|
|
107
|
+
if (lines.slice(map[0] + 1, map[1]).some((l) => l.replace(/^[\s>]*/, "") !== "")) {
|
|
108
|
+
return "is a block, and the lines up to its closer are dropped (a video shows no content)";
|
|
109
|
+
}
|
|
110
|
+
const parent = around[around.length - 1];
|
|
111
|
+
if (around.some((a) => a.map[1] === map[1]))
|
|
112
|
+
return `has no closer of its own: it ends with the :::${parent?.name ?? "block"} around it`;
|
|
113
|
+
if (parent && !parent.closed)
|
|
114
|
+
return `takes the closer on line ${lineOf(map[1]) ?? "?"}, which leaves :::${parent.name} open`;
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
59
117
|
export function collectDiagnostics(tokens, lines, lineOf) {
|
|
60
118
|
const out = [];
|
|
61
119
|
const add = (line0, code, message) => {
|
|
@@ -63,8 +121,16 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
63
121
|
if (line !== undefined)
|
|
64
122
|
out.push({ line, code, message });
|
|
65
123
|
};
|
|
124
|
+
const code = codeLineSet(tokens);
|
|
125
|
+
let row;
|
|
126
|
+
// The `:::` blocks around the current token, outermost first
|
|
127
|
+
const around = [];
|
|
66
128
|
const walk = (list) => {
|
|
67
|
-
|
|
129
|
+
// Indexed: i per token made the diagnostics quadratic in the page's length
|
|
130
|
+
for (let i = 0; i < list.length; i++) {
|
|
131
|
+
const tok = list[i];
|
|
132
|
+
if (/^container_.+_close$/.test(tok.type))
|
|
133
|
+
around.pop();
|
|
68
134
|
const open = /^container_(.+)_open$/.exec(tok.type);
|
|
69
135
|
if (open && tok.map) {
|
|
70
136
|
const generic = open[1] === GENERIC_CONTAINER;
|
|
@@ -73,7 +139,19 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
73
139
|
const colons = (tok.markup ?? ":::").length;
|
|
74
140
|
const closed = closesContainer(lines[tok.map[1]], colons);
|
|
75
141
|
const skipped = tok.meta?.skippedCloser;
|
|
76
|
-
|
|
142
|
+
const legacy = tok.meta?.legacyCloser;
|
|
143
|
+
// A video has no body, so `:::youtube` is a block that has taken in what follows it, or a
|
|
144
|
+
// closer meant for another block. The leaf form says "no body" in the syntax
|
|
145
|
+
// (plans/youtube-leaf.md). A blank line in a quote is `>`, so quote markers are not content.
|
|
146
|
+
const leafAsBlock = name === "youtube" ? youtubeAsBlock(tok.map, closed, lines, around, lineOf) : undefined;
|
|
147
|
+
if (leafAsBlock) {
|
|
148
|
+
add(tok.map[0], "leaf-as-block", `a video has no body: write ::youtube{…} on one line, with no closer — as :::youtube it ${leafAsBlock}`);
|
|
149
|
+
}
|
|
150
|
+
else if (!closed && legacy !== undefined && !generic) {
|
|
151
|
+
// Before the nesting stack, the closer on that line ended this block and the ones inside it
|
|
152
|
+
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)`);
|
|
153
|
+
}
|
|
154
|
+
else if (!closed && skipped !== undefined) {
|
|
77
155
|
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
156
|
}
|
|
79
157
|
else if (!closed) {
|
|
@@ -84,6 +162,9 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
84
162
|
else if (generic) {
|
|
85
163
|
add(tok.map[0], "unknown-container", `:::${name} is not a known directive — it is shown as a plain box`);
|
|
86
164
|
}
|
|
165
|
+
if (tok.meta?.unverified) {
|
|
166
|
+
add(tok.map[0], "nesting-unchecked", `the parser stopped re-checking the nested blocks on this page here, so where :::${name} ends may be wrong — check that each block inside it has its own closer, or split the page`);
|
|
167
|
+
}
|
|
87
168
|
if (closed) {
|
|
88
169
|
// The closer's end-tag label: it closes this container whatever it says, so a wrong
|
|
89
170
|
// name means the author thinks it closes another one
|
|
@@ -98,13 +179,14 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
98
179
|
if (problem)
|
|
99
180
|
add(tok.map[0], "bad-attributes", `the attributes of :::${name} ${problem} — they are ignored`);
|
|
100
181
|
}
|
|
182
|
+
// An unknown block never closed is read back as text, so it is no parent (but still popped)
|
|
183
|
+
around.push({ map: tok.map, closed: closed || (generic && tok.meta?.unclosed === true), name });
|
|
101
184
|
}
|
|
102
|
-
// A `:::` line
|
|
103
|
-
// meant to close ended earlier (an inner block of the same fence closed it), or there is none
|
|
185
|
+
// A `:::` line no open block took: every block it could close is already closed
|
|
104
186
|
if (tok.type === "inline" && tok.map) {
|
|
105
187
|
(tok.content ?? "").split("\n").forEach((l, k) => {
|
|
106
188
|
if (/^\s*:{3,}[ \t]*(?:<!--(?:(?!-->)[\s\S])*-->[ \t]*)?$/.test(l)) {
|
|
107
|
-
add(tok.map[0] + k, "stray-closer", `stray \`${l.trim()}\` — it closes nothing and is shown as text
|
|
189
|
+
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)`);
|
|
108
190
|
}
|
|
109
191
|
});
|
|
110
192
|
}
|
|
@@ -132,13 +214,13 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
132
214
|
// Attribute blocks markdown-it-attrs read and the tree has nowhere to keep
|
|
133
215
|
if (tok.type === "list_item_open" && tok.map && tok.attrs?.length) {
|
|
134
216
|
// 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`);
|
|
217
|
+
add(braceLine(lines, code, tok.map[0], tok.map[1] - 1), "dropped-attributes", `the attribute block on this list item is dropped — list items take no attributes`);
|
|
136
218
|
}
|
|
137
219
|
// A table row that is only an attribute block, where the parser could not move it onto the
|
|
138
220
|
// table (not the last row, or text follows the table with no blank line): an empty row
|
|
139
221
|
if (tok.type === "tr_open" && tok.map) {
|
|
140
222
|
const cells = [];
|
|
141
|
-
for (let k =
|
|
223
|
+
for (let k = i + 1; k < list.length && list[k].type !== "tr_close"; k++)
|
|
142
224
|
cells.push(list[k]);
|
|
143
225
|
const inlines = cells.filter((c) => c.type === "inline");
|
|
144
226
|
if (cells[0]?.attrs?.length && /^\{[^{}]*\}$/.test((inlines[0]?.content ?? "").trim()) && inlines.slice(1).every((c) => c.content.trim() === "")) {
|
|
@@ -149,11 +231,11 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
149
231
|
// Only real attribute names: markdown-it-attrs also reads a leftover include (`{* src.py hl[4] *}`, FastAPI) as keys
|
|
150
232
|
const own = tok.attrs?.filter(([k]) => k !== "start") ?? [];
|
|
151
233
|
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`);
|
|
234
|
+
add(braceLine(lines, code, tok.map[0], tok.map[1], true), "dropped-attributes", `the attribute block on this list is dropped — lists take no attributes`);
|
|
153
235
|
}
|
|
154
236
|
// An attribute block alone on its line after a blank line: an empty paragraph that carries it
|
|
155
237
|
if (tok.type === "paragraph_open" && tok.map && tok.attrs?.length) {
|
|
156
|
-
const inline = list[
|
|
238
|
+
const inline = list[i + 1];
|
|
157
239
|
// Only what an author writes as attributes: `{#id}`, `{.class}`, `{key=…}`. markdown-it-attrs also eats a
|
|
158
240
|
// leftover MDX expression (`{/* prettier-ignore */}`), which is an importer's problem, not the author's
|
|
159
241
|
const looksLikeAttrs = /^\{\s*(?:[#.][^\s{}]|[\w-]+\s*=)/.test(inline?.content.trim() ?? "");
|
|
@@ -181,20 +263,58 @@ export function collectDiagnostics(tokens, lines, lineOf) {
|
|
|
181
263
|
}
|
|
182
264
|
}
|
|
183
265
|
// `:kbd{…}` with no `[label]` is not a directive: the label is what it shows
|
|
266
|
+
const missing = (name) => `:${name}{…} has no [label] — it is shown as text; write :${name}[…]{…}`;
|
|
267
|
+
const inText = new Set();
|
|
268
|
+
// An autolink's text is its URL, not prose
|
|
269
|
+
let autolink = 0;
|
|
184
270
|
for (const child of tok.children) {
|
|
185
|
-
if (child.type
|
|
271
|
+
if (child.type === "link_open" && (child.markup === "autolink" || child.markup === "linkify"))
|
|
272
|
+
autolink++;
|
|
273
|
+
if (child.type === "link_close" && autolink > 0)
|
|
274
|
+
autolink--;
|
|
275
|
+
if (child.type !== "text" || autolink > 0)
|
|
186
276
|
continue;
|
|
187
277
|
for (const m of child.content.matchAll(/:([a-z]+)\{/g)) {
|
|
188
278
|
if (m.index > 0 && PRECEDES_TEXT.test(child.content[m.index - 1]))
|
|
189
279
|
continue;
|
|
190
|
-
if (KNOWN_INLINE_DIRECTIVES.has(m[1]))
|
|
191
|
-
add(
|
|
280
|
+
if (KNOWN_INLINE_DIRECTIVES.has(m[1])) {
|
|
281
|
+
inText.add(m[1]);
|
|
282
|
+
let at = tok.map[0];
|
|
283
|
+
while (at < tok.map[1] - 1 && !(lines[at] ?? "").includes(`:${m[1]}{`))
|
|
284
|
+
at++;
|
|
285
|
+
add(at, "missing-label", missing(m[1]));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// At the end of a paragraph, heading or cell, markdown-it-attrs takes the braces for the
|
|
290
|
+
// block's own, leaving `:kbd` in the text: read the source lines for what is no longer there
|
|
291
|
+
if (list[i - 1]?.attrs?.length) {
|
|
292
|
+
for (let l = tok.map[0]; l < tok.map[1]; l++) {
|
|
293
|
+
const line = blankCodeAndTags(lines[l] ?? "");
|
|
294
|
+
for (const m of line.matchAll(/:([a-z]+)\{/g)) {
|
|
295
|
+
if (m.index > 0 && PRECEDES_TEXT.test(line[m.index - 1]))
|
|
296
|
+
continue;
|
|
297
|
+
if (KNOWN_INLINE_DIRECTIVES.has(m[1]) && !inText.has(m[1]) && !line.startsWith(`:${m[1]}[`, m.index)) {
|
|
298
|
+
inText.add(m[1]);
|
|
299
|
+
add(l, "missing-label", missing(m[1]));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
192
302
|
}
|
|
193
303
|
}
|
|
194
304
|
}
|
|
305
|
+
// A table cell's inline token has no line map: its row's has
|
|
306
|
+
if (tok.type === "tr_open" && tok.map)
|
|
307
|
+
row = tok.map[0];
|
|
308
|
+
if (tok.type === "inline" && !tok.map && row !== undefined && /^t[hd]_open$/.test(list[i - 1]?.type ?? "") && list[i - 1]?.attrs?.length) {
|
|
309
|
+
const cell = blankCodeAndTags(tok.content);
|
|
310
|
+
const m = /:([a-z]+)\{/.exec(cell);
|
|
311
|
+
if (m && KNOWN_INLINE_DIRECTIVES.has(m[1]) && !(m.index > 0 && PRECEDES_TEXT.test(cell[m.index - 1]))) {
|
|
312
|
+
add(row, "missing-label", `:${m[1]}{…} has no [label] — it is shown as text; write :${m[1]}[…]{…}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
195
315
|
// A heading's attribute block that markdown-it-attrs could not read stays in its text
|
|
196
316
|
if (tok.type === "heading_open" && tok.map) {
|
|
197
|
-
const inline = list[
|
|
317
|
+
const inline = list[i + 1];
|
|
198
318
|
// markdown-it-attrs takes a block it reads out of the text tokens (not out of `content`)
|
|
199
319
|
const last = [...(inline?.children ?? [])].reverse().find((c) => c.type === "text");
|
|
200
320
|
const tail = /\{\s*(?:[#.][^{}]*|[\w-]+\s*=[^{}]*)\}?\s*$/.exec(last?.content ?? "");
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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";
|
|
@@ -14,6 +14,7 @@ export type { BuildNavOptions } from "./nav.js";
|
|
|
14
14
|
export { loadNavFile } from "./nav-file.js";
|
|
15
15
|
export type { NavFile, NavFileItem, LoadNavFileOptions, MissingNavTarget, } from "./nav-file.js";
|
|
16
16
|
export { renderDiagrams } from "./renderers/diagrams.js";
|
|
17
|
-
export type { RenderDiagramsOptions } from "./renderers/diagrams.js";
|
|
17
|
+
export type { RenderDiagramsOptions, DiagramEngine } from "./renderers/diagrams.js";
|
|
18
|
+
export { renderPeleSvg, diagramId } from "./renderers/pele.js";
|
|
18
19
|
export { renderMermaidSvg, setMmdcOverride, findMmdc } from "./renderers/mermaid.js";
|
|
19
20
|
export { TemplatePages, TemplatePageError, declaresParams, ATTRIBUTE_BLOCK_OPEN, type TemplatePagesOptions } from "./template-pages.js";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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";
|
|
@@ -15,5 +15,6 @@ export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
|
|
|
15
15
|
export { buildNavFromDirectory } from "./nav.js";
|
|
16
16
|
export { loadNavFile } from "./nav-file.js";
|
|
17
17
|
export { renderDiagrams } from "./renderers/diagrams.js";
|
|
18
|
+
export { renderPeleSvg, diagramId } from "./renderers/pele.js";
|
|
18
19
|
export { renderMermaidSvg, setMmdcOverride, findMmdc } from "./renderers/mermaid.js";
|
|
19
20
|
export { TemplatePages, TemplatePageError, declaresParams, ATTRIBUTE_BLOCK_OPEN } from "./template-pages.js";
|
package/dist/parse-attrs.d.ts
CHANGED
|
@@ -21,3 +21,12 @@ export declare function parseAttrs(inner: string): ParsedAttrs;
|
|
|
21
21
|
* `id` and `classes` become `id` and `class`; other props flow through.
|
|
22
22
|
*/
|
|
23
23
|
export declare function attrsToProps(attrs: ParsedAttrs): Record<string, unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* The index of the `}` that closes an attribute block whose `{` is just before `start`, or -1.
|
|
26
|
+
* Braces inside a double-quoted value (`title="a } b"`, with `\"` escapes, as escapeAttrValue
|
|
27
|
+
* writes them) do not count: a brace-only count ended a leaf `::youtube{#a title="a } b"}` at the
|
|
28
|
+
* first `}` and lost the video. A `"` opens a value only straight after `=`, as parseAttrs reads
|
|
29
|
+
* it: a stray `"` in an unquoted value (`title=5"`) is a character, or the scan would run on through
|
|
30
|
+
* the paragraph. Single quotes are not quotes here, so an apostrophe reads as before.
|
|
31
|
+
*/
|
|
32
|
+
export declare function attrBlockEnd(src: string, start: number, max: number): number;
|
package/dist/parse-attrs.js
CHANGED
|
@@ -114,3 +114,31 @@ export function attrsToProps(attrs) {
|
|
|
114
114
|
}
|
|
115
115
|
return props;
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The index of the `}` that closes an attribute block whose `{` is just before `start`, or -1.
|
|
119
|
+
* Braces inside a double-quoted value (`title="a } b"`, with `\"` escapes, as escapeAttrValue
|
|
120
|
+
* writes them) do not count: a brace-only count ended a leaf `::youtube{#a title="a } b"}` at the
|
|
121
|
+
* first `}` and lost the video. A `"` opens a value only straight after `=`, as parseAttrs reads
|
|
122
|
+
* it: a stray `"` in an unquoted value (`title=5"`) is a character, or the scan would run on through
|
|
123
|
+
* the paragraph. Single quotes are not quotes here, so an apostrophe reads as before.
|
|
124
|
+
*/
|
|
125
|
+
export function attrBlockEnd(src, start, max) {
|
|
126
|
+
let depth = 1;
|
|
127
|
+
let quoted = false;
|
|
128
|
+
for (let i = start; i < max; i++) {
|
|
129
|
+
const c = src.charCodeAt(i);
|
|
130
|
+
if (quoted) {
|
|
131
|
+
if (c === 0x5c /* \ */)
|
|
132
|
+
i++;
|
|
133
|
+
else if (c === 0x22 /* " */)
|
|
134
|
+
quoted = false;
|
|
135
|
+
}
|
|
136
|
+
else if (c === 0x22 && i > start && src.charCodeAt(i - 1) === 0x3d /* = */)
|
|
137
|
+
quoted = true;
|
|
138
|
+
else if (c === 0x7b /* { */)
|
|
139
|
+
depth++;
|
|
140
|
+
else if (c === 0x7d /* } */ && --depth === 0)
|
|
141
|
+
return i;
|
|
142
|
+
}
|
|
143
|
+
return -1;
|
|
144
|
+
}
|
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
|
@@ -388,6 +388,33 @@ function keepListItemBraces(tokens, src) {
|
|
|
388
388
|
tok.attrs = null;
|
|
389
389
|
});
|
|
390
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
|
+
}
|
|
391
418
|
/**
|
|
392
419
|
* markdown-it-container runs a block with no closing marker to the end of the document. For a
|
|
393
420
|
* generic (unknown-name) container that would turn any prose line starting `:::word` into a block
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseAttrs } from "./parse-attrs.js";
|
|
1
|
+
import { attrBlockEnd, parseAttrs } from "./parse-attrs.js";
|
|
2
2
|
export function blockLeafDirectivePlugin(md) {
|
|
3
3
|
md.block.ruler.before("paragraph", "dogsbay_block_leaf_directive", blockLeafRule);
|
|
4
4
|
}
|
|
@@ -42,20 +42,8 @@ function blockLeafRule(state, startLine, endLine, silent) {
|
|
|
42
42
|
let attrsStr = "";
|
|
43
43
|
if (state.src.charCodeAt(pos) === 0x7b /* { */) {
|
|
44
44
|
const attrStart = pos + 1;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
while (ap < max) {
|
|
48
|
-
const c = state.src.charCodeAt(ap);
|
|
49
|
-
if (c === 0x7b)
|
|
50
|
-
depth++;
|
|
51
|
-
else if (c === 0x7d) {
|
|
52
|
-
depth--;
|
|
53
|
-
if (depth === 0)
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
56
|
-
ap++;
|
|
57
|
-
}
|
|
58
|
-
if (ap >= max)
|
|
45
|
+
const ap = attrBlockEnd(state.src, attrStart, max);
|
|
46
|
+
if (ap < 0)
|
|
59
47
|
return false; // unterminated
|
|
60
48
|
attrsStr = state.src.slice(attrStart, ap);
|
|
61
49
|
pos = ap + 1;
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* Register the container rule (container-rule.ts, adapted from markdown-it-container) for each
|
|
3
3
|
* known Dogsbay block directive.
|
|
4
4
|
*
|
|
5
|
-
* Each directive name is
|
|
6
|
-
*
|
|
7
|
-
* 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).
|
|
8
7
|
*/
|
|
9
8
|
import type MarkdownIt from "markdown-it";
|
|
10
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 { containerRule } from "./container-rule.js";
|
|
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",
|
|
@@ -54,8 +54,9 @@ export const KNOWN_BLOCK_DIRECTIVES = [
|
|
|
54
54
|
"api-member",
|
|
55
55
|
"api-doc",
|
|
56
56
|
"api-source",
|
|
57
|
-
// Lazy-loading video embed (YouTube).
|
|
58
|
-
//
|
|
57
|
+
// Lazy-loading video embed (YouTube). No body, so written as the leaf
|
|
58
|
+
// `::youtube{#<videoId> title="…"}`; the block form here is the empty
|
|
59
|
+
// `:::youtube{…}` + `:::` earlier serializers wrote. Renders via @ui/youtube/YouTube.astro.
|
|
59
60
|
"youtube",
|
|
60
61
|
// An OpenAPI spec embedded in a page: `:::openapi{spec="api.yaml"}`,
|
|
61
62
|
// expanded into endpoint cards from the spec at build time (the
|
|
@@ -72,6 +73,7 @@ export const KNOWN_BLOCK_DIRECTIVES = [
|
|
|
72
73
|
export const GENERIC_CONTAINER = "dogsbay_generic";
|
|
73
74
|
export function containersPlugin(md) {
|
|
74
75
|
for (const name of KNOWN_BLOCK_DIRECTIVES) {
|
|
76
|
+
registerOpener(name);
|
|
75
77
|
containerRule(md, name, {
|
|
76
78
|
validate: (params) => {
|
|
77
79
|
const trimmed = params.trim();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseAttrs } from "./parse-attrs.js";
|
|
1
|
+
import { attrBlockEnd, parseAttrs } from "./parse-attrs.js";
|
|
2
2
|
/** A character a directive cannot follow directly (see inlineDirectiveRule). */
|
|
3
3
|
export const PRECEDES_TEXT = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}0-9:]/u;
|
|
4
4
|
export function inlineDirectivesPlugin(md) {
|
|
@@ -78,20 +78,8 @@ function inlineDirectiveRule(state, silent) {
|
|
|
78
78
|
let attrs = "";
|
|
79
79
|
if (src.charCodeAt(pos) === 0x7b /* { */) {
|
|
80
80
|
const attrStart = pos + 1;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
while (ap < src.length) {
|
|
84
|
-
const c = src.charCodeAt(ap);
|
|
85
|
-
if (c === 0x7b)
|
|
86
|
-
depth++;
|
|
87
|
-
else if (c === 0x7d) {
|
|
88
|
-
depth--;
|
|
89
|
-
if (depth === 0)
|
|
90
|
-
break;
|
|
91
|
-
}
|
|
92
|
-
ap++;
|
|
93
|
-
}
|
|
94
|
-
if (ap < src.length) {
|
|
81
|
+
const ap = attrBlockEnd(src, attrStart, src.length);
|
|
82
|
+
if (ap >= 0) {
|
|
95
83
|
attrs = src.slice(attrStart, ap);
|
|
96
84
|
pos = ap + 1;
|
|
97
85
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { TreeNode } from "@dogsbay/types";
|
|
2
|
+
/** Who draws Mermaid fences (`content.diagrams`): mmdc, as Mermaid draws them, or Pele, themed. */
|
|
3
|
+
export type DiagramEngine = "mermaid-cli" | "pele";
|
|
2
4
|
export interface RenderDiagramsOptions {
|
|
5
|
+
/** Default `mermaid-cli`. */
|
|
6
|
+
engine?: DiagramEngine;
|
|
3
7
|
/**
|
|
4
8
|
* Suppress the one-time "mmdc not found" warning printed when
|
|
5
9
|
* the binary can't be resolved. Default false. Tests pass true
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderMermaidSvg } from "./mermaid.js";
|
|
2
|
+
import { diagramId, renderPeleSvg } from "./pele.js";
|
|
2
3
|
const DIAGRAM_LANGS = new Set(["mermaid"]);
|
|
3
4
|
/**
|
|
4
5
|
* Walk a TreeNode tree and replace `code` blocks with a diagram
|
|
@@ -16,6 +17,10 @@ const DIAGRAM_LANGS = new Set(["mermaid"]);
|
|
|
16
17
|
*/
|
|
17
18
|
export async function renderDiagrams(tree, options) {
|
|
18
19
|
let warned = false;
|
|
20
|
+
const pele = options?.engine === "pele";
|
|
21
|
+
// Pele: a diagram shown twice on the page gets its own ids the second time
|
|
22
|
+
const seen = new Map();
|
|
23
|
+
const failed = [];
|
|
19
24
|
async function walk(nodes) {
|
|
20
25
|
for (const node of nodes) {
|
|
21
26
|
if (node.children)
|
|
@@ -42,7 +47,20 @@ export async function renderDiagrams(tree, options) {
|
|
|
42
47
|
: "";
|
|
43
48
|
if (!code)
|
|
44
49
|
continue;
|
|
45
|
-
|
|
50
|
+
let svg;
|
|
51
|
+
if (pele) {
|
|
52
|
+
const base = diagramId(code);
|
|
53
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
54
|
+
seen.set(base, n);
|
|
55
|
+
const r = await renderPeleSvg(code, n === 1 ? base : `${base}-${n}`);
|
|
56
|
+
if ("error" in r) {
|
|
57
|
+
failed.push(r.error);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
svg = r.svg;
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
svg = await renderMermaidSvg(code);
|
|
46
64
|
if (svg === null) {
|
|
47
65
|
if (!warned && !options?.silent) {
|
|
48
66
|
console.warn("[diagrams] mmdc (mermaid-cli) not installed or render " +
|
|
@@ -67,4 +85,8 @@ export async function renderDiagrams(tree, options) {
|
|
|
67
85
|
}
|
|
68
86
|
}
|
|
69
87
|
await walk(tree);
|
|
88
|
+
if (failed.length > 0 && !options?.silent) {
|
|
89
|
+
console.warn(`[diagrams] Pele could not draw ${failed.length} diagram(s), left as code blocks: ` +
|
|
90
|
+
failed[0].split("\n")[0].slice(0, 160));
|
|
91
|
+
}
|
|
70
92
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Longest diagram source drawn; a longer one is refused rather than laid out (as dogsbay-ssg). */
|
|
2
|
+
export declare const MAX_DIAGRAM_CHARS = 100000;
|
|
3
|
+
/**
|
|
4
|
+
* Draw one diagram. `id` prefixes the ids Pele writes (a title and description, for
|
|
5
|
+
* aria-labelledby), so two diagrams on a page do not share them.
|
|
6
|
+
*/
|
|
7
|
+
export declare function renderPeleSvg(code: string, id: string): Promise<{
|
|
8
|
+
svg: string;
|
|
9
|
+
} | {
|
|
10
|
+
error: string;
|
|
11
|
+
}>;
|
|
12
|
+
/** A stable id from the source, valid in XML (`dg-` and hex). */
|
|
13
|
+
export declare function diagramId(code: string): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid drawn by Pele (`content.diagrams: pele`; plans/pele-diagrams.md): plain JavaScript,
|
|
3
|
+
* synchronous, no DOM and no browser, unlike mmdc. Its colours and fonts are CSS variables
|
|
4
|
+
* (`--pele-*`), which DocsLayout maps to the site theme, so a diagram follows light and dark mode
|
|
5
|
+
* instead of being inverted.
|
|
6
|
+
*
|
|
7
|
+
* Loaded with import() on the first diagram: a site without diagrams, or one drawn by mmdc,
|
|
8
|
+
* never loads it. dogsbay-ssg draws diagrams the same way (its core/pele.ts).
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
/** Longest diagram source drawn; a longer one is refused rather than laid out (as dogsbay-ssg). */
|
|
12
|
+
export const MAX_DIAGRAM_CHARS = 100_000;
|
|
13
|
+
let pele;
|
|
14
|
+
/**
|
|
15
|
+
* Draw one diagram. `id` prefixes the ids Pele writes (a title and description, for
|
|
16
|
+
* aria-labelledby), so two diagrams on a page do not share them.
|
|
17
|
+
*/
|
|
18
|
+
export async function renderPeleSvg(code, id) {
|
|
19
|
+
if (code.length > MAX_DIAGRAM_CHARS)
|
|
20
|
+
return { error: `the diagram is longer than ${MAX_DIAGRAM_CHARS} characters` };
|
|
21
|
+
let p;
|
|
22
|
+
try {
|
|
23
|
+
p = await (pele ??= import("pele"));
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
pele = undefined;
|
|
27
|
+
return { error: `could not load Pele (pele): ${e instanceof Error ? e.message : String(e)}` };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
// No images: an `img:` node would have every reader fetch its URL (a third-party one too),
|
|
31
|
+
// and a local path is neither copied as an asset nor given the site's base path
|
|
32
|
+
const { svg } = p.render(code, { idPrefix: id, limit: MAX_DIAGRAM_CHARS, images: false });
|
|
33
|
+
return svg ? { svg } : { error: "Pele returned no SVG" };
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** A stable id from the source, valid in XML (`dg-` and hex). */
|
|
40
|
+
export function diagramId(code) {
|
|
41
|
+
return "dg-" + createHash("sha256").update(code).digest("hex").slice(0, 12);
|
|
42
|
+
}
|
package/dist/serialize.js
CHANGED
|
@@ -116,8 +116,9 @@ function renderNodeBody(node, ctx) {
|
|
|
116
116
|
case "button":
|
|
117
117
|
return renderButton(node, ctx);
|
|
118
118
|
case "youtube":
|
|
119
|
-
//
|
|
120
|
-
|
|
119
|
+
// A video has no body, so it is a leaf: `::youtube{#<id> title="…"}`. `:::` always opens a
|
|
120
|
+
// block that needs a closer (the empty `:::youtube` … `:::` form still reads).
|
|
121
|
+
return `::youtube${renderAttrs(splitProps({ ...(node.props ?? {}), id: node.props?.id }, []))}`;
|
|
121
122
|
case "deflist":
|
|
122
123
|
return renderDeflist(node, ctx);
|
|
123
124
|
case "theme":
|
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.128",
|
|
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",
|
|
@@ -24,12 +24,12 @@
|
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
26
|
"devDependencies": {
|
|
27
|
+
"@mermaid-js/mermaid-cli": "^11.16.0",
|
|
27
28
|
"@types/js-yaml": "^4.0.9",
|
|
28
29
|
"@types/markdown-it": "^14.1.2",
|
|
29
30
|
"@types/node": "^25.9.5",
|
|
30
31
|
"typescript": "^5.9.3",
|
|
31
|
-
"vitest": "^4.1.10"
|
|
32
|
-
"@mermaid-js/mermaid-cli": "^11.16.0"
|
|
32
|
+
"vitest": "^4.1.10"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"gray-matter": "^4.0.3",
|
|
@@ -39,9 +39,10 @@
|
|
|
39
39
|
"markdown-it-deflist": "^3.0.1",
|
|
40
40
|
"markdown-it-footnote": "^4.0.0",
|
|
41
41
|
"markdown-it-front-matter": "^0.2.4",
|
|
42
|
-
"
|
|
43
|
-
"@dogsbay/
|
|
44
|
-
"@dogsbay/
|
|
42
|
+
"pele": "1.6.0",
|
|
43
|
+
"@dogsbay/minja": "0.2.0-beta.128",
|
|
44
|
+
"@dogsbay/serialize-core": "0.2.0-beta.128",
|
|
45
|
+
"@dogsbay/types": "0.2.0-beta.128"
|
|
45
46
|
},
|
|
46
47
|
"license": "MIT",
|
|
47
48
|
"repository": {
|