@dogsbay/format-dogsbay-md 0.2.0-beta.124 → 0.2.0-beta.126
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +3 -0
- package/dist/container-rule.d.ts +49 -0
- package/dist/container-rule.js +139 -0
- package/dist/diagnostics.d.ts +47 -0
- package/dist/diagnostics.js +210 -0
- package/dist/directive-preserve.d.ts +6 -0
- package/dist/directive-preserve.js +23 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/parse.d.ts +16 -0
- package/dist/parse.js +149 -11
- package/dist/plugin-containers.d.ts +3 -2
- package/dist/plugin-containers.js +3 -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
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* module load and would drag `node:fs` back into this graph.
|
|
13
13
|
*/
|
|
14
14
|
export { dogsbayMdToTree, parseInline, type ParseResult, type ParseOptions } from "./parse.js";
|
|
15
|
+
export { KNOWN_INLINE_DIRECTIVES, type ParseDiagnostic } from "./diagnostics.js";
|
|
16
|
+
export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
|
|
15
17
|
export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
|
|
16
18
|
export type { SerializeOptions } from "./types.js";
|
|
17
19
|
export { inlineToDogsbayMd } from "./inline.js";
|
package/dist/browser.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* module load and would drag `node:fs` back into this graph.
|
|
13
13
|
*/
|
|
14
14
|
export { dogsbayMdToTree, parseInline } from "./parse.js";
|
|
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";
|
|
15
18
|
export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
|
|
16
19
|
export { inlineToDogsbayMd } from "./inline.js";
|
|
17
20
|
export { transformCodeRegions, transformProseLines, transformProseSegments, fenceOpenBefore, createFenceTracker, } from "./code-regions.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `:::name` container block rule: markdown-it-container 4.0.0 (MIT,
|
|
3
|
+
* Vitaly Puzrin, Alex Kocharin), adapted. Two changes, both in how the
|
|
4
|
+
* closing line is found:
|
|
5
|
+
*
|
|
6
|
+
* 1. A closer may carry an HTML comment, `::: <!-- end note -->`, the way an
|
|
7
|
+
* author labels an XML end tag. The comment changes nothing about which
|
|
8
|
+
* container closes; it rides on the close token (`info`) so the parser can
|
|
9
|
+
* keep it and diagnostics can check the name.
|
|
10
|
+
* 2. A `:::` line inside a code block does not close the container.
|
|
11
|
+
* markdown-it-container scanned raw lines, so a code example showing a
|
|
12
|
+
* directive closed the container around it and the rest of the page became
|
|
13
|
+
* one code block, with no message. Which lines are code comes from one
|
|
14
|
+
* block-level pass over the source with containers off (codeLines).
|
|
15
|
+
*
|
|
16
|
+
* Everything else (the colon rule, indentation, auto-close at the end of the
|
|
17
|
+
* parent) is markdown-it-container's.
|
|
18
|
+
*
|
|
19
|
+
* markdown-it-container's licence:
|
|
20
|
+
*
|
|
21
|
+
* Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
|
|
22
|
+
*
|
|
23
|
+
* Permission is hereby granted, free of charge, to any person
|
|
24
|
+
* obtaining a copy of this software and associated documentation
|
|
25
|
+
* files (the "Software"), to deal in the Software without
|
|
26
|
+
* restriction, including without limitation the rights to use,
|
|
27
|
+
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
28
|
+
* copies of the Software, and to permit persons to whom the
|
|
29
|
+
* Software is furnished to do so, subject to the following
|
|
30
|
+
* conditions:
|
|
31
|
+
*
|
|
32
|
+
* The above copyright notice and this permission notice shall be
|
|
33
|
+
* included in all copies or substantial portions of the Software.
|
|
34
|
+
*
|
|
35
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
36
|
+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
37
|
+
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
38
|
+
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
39
|
+
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
40
|
+
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
41
|
+
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
42
|
+
* OTHER DEALINGS IN THE SOFTWARE.
|
|
43
|
+
*/
|
|
44
|
+
import type MarkdownIt from "markdown-it";
|
|
45
|
+
export interface ContainerOptions {
|
|
46
|
+
validate: (params: string, markup: string) => boolean;
|
|
47
|
+
render?: (tokens: unknown[], idx: number) => string;
|
|
48
|
+
}
|
|
49
|
+
export declare function containerRule(md: MarkdownIt, name: string, options: ContainerOptions): void;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What may follow a closer's colons: nothing, or spaces and one HTML comment, then spaces. The
|
|
3
|
+
* comment is not optional inside the group: `[ \t]*(…)?[ \t]*` backtracks quadratically on a long
|
|
4
|
+
* run of spaces followed by anything else.
|
|
5
|
+
*/
|
|
6
|
+
const CLOSER_TAIL = /^(?:[ \t]*|[ \t]*(<!--(?:(?!-->)[\s\S])*-->)[ \t]*)$/;
|
|
7
|
+
/** state.env keys: the pre-pass that finds the code, and its result. */
|
|
8
|
+
const NO_CONTAINERS = "__dogsbayNoContainers";
|
|
9
|
+
const CODE_LINES = "__dogsbayCodeLines";
|
|
10
|
+
/**
|
|
11
|
+
* The source lines inside a fenced code block, from one block-level pass over the whole source
|
|
12
|
+
* with containers switched off, made the first time a container needs it and kept in the parse's
|
|
13
|
+
* env. A `:::` line cannot change where a fence starts or ends (a fence ends only at its own
|
|
14
|
+
* closing fence or the end of its list item or quote), so the pass agrees with the real parse,
|
|
15
|
+
* and markdown-it reads fences in quotes, list items and definitions as a line scan does not.
|
|
16
|
+
* One pass per parse, so the cost is linear however many containers there are or how deep.
|
|
17
|
+
*
|
|
18
|
+
* Only a fence that CLOSES counts. With no container to end it, an unclosed fence in the pass runs
|
|
19
|
+
* to the end of the page, and every closer after it would be code: a ````` ```` ````` fence closed
|
|
20
|
+
* with three backticks would take the rest of the page into its container. Left out, it ends at
|
|
21
|
+
* the container's closer, as with markdown-it-container. (Still misread, and no worse than
|
|
22
|
+
* markdown-it-container: a `:::name` straight after a list item, lazily continuing it in the pass,
|
|
23
|
+
* so a fence indented four in the container is read in the item.)
|
|
24
|
+
*/
|
|
25
|
+
function codeLines(state) {
|
|
26
|
+
const env = state.env;
|
|
27
|
+
const cached = env?.[CODE_LINES];
|
|
28
|
+
if (cached && cached.src === state.src && cached.md === state.md)
|
|
29
|
+
return cached.lines;
|
|
30
|
+
const tokens = [];
|
|
31
|
+
state.md.block.parse(state.src, state.md, { [NO_CONTAINERS]: true }, tokens);
|
|
32
|
+
const source = state.src.split(/\r\n|\r|\n/);
|
|
33
|
+
const lines = new Set();
|
|
34
|
+
for (const t of tokens) {
|
|
35
|
+
if (t.type !== "fence" || !t.map || !closesFence(source[t.map[1] - 1], t.markup, t.map[1] - 1 > t.map[0]))
|
|
36
|
+
continue;
|
|
37
|
+
// A fence's opening line is not inside it; the lines after it, to its closing fence, are
|
|
38
|
+
for (let l = t.map[0] + 1; l < t.map[1]; l++)
|
|
39
|
+
lines.add(l);
|
|
40
|
+
}
|
|
41
|
+
if (env)
|
|
42
|
+
env[CODE_LINES] = { src: state.src, md: state.md, lines };
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
/** Whether `line` (as written, with any `>` and indentation) is the closing fence of a fence opened with `markup`. */
|
|
46
|
+
function closesFence(line, markup, after) {
|
|
47
|
+
if (!after || line === undefined)
|
|
48
|
+
return false;
|
|
49
|
+
const m = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
50
|
+
return m !== null && m[1][0] === markup[0] && m[1].length >= markup.length;
|
|
51
|
+
}
|
|
52
|
+
export function containerRule(md, name, options) {
|
|
53
|
+
const MARKER = ":".charCodeAt(0);
|
|
54
|
+
function container(state, startLine, endLine, silent) {
|
|
55
|
+
let start = state.bMarks[startLine] + state.tShift[startLine];
|
|
56
|
+
let max = state.eMarks[startLine];
|
|
57
|
+
if (state.src.charCodeAt(start) !== MARKER)
|
|
58
|
+
return false;
|
|
59
|
+
if (state.env?.[NO_CONTAINERS])
|
|
60
|
+
return false;
|
|
61
|
+
let pos = start + 1;
|
|
62
|
+
while (pos <= max && state.src.charCodeAt(pos) === MARKER)
|
|
63
|
+
pos++;
|
|
64
|
+
const markerCount = pos - start;
|
|
65
|
+
if (markerCount < 3)
|
|
66
|
+
return false;
|
|
67
|
+
const markup = state.src.slice(start, pos);
|
|
68
|
+
const params = state.src.slice(pos, max);
|
|
69
|
+
if (!options.validate(params, markup))
|
|
70
|
+
return false;
|
|
71
|
+
if (silent)
|
|
72
|
+
return true;
|
|
73
|
+
const oldParent = state.parentType;
|
|
74
|
+
const oldLineMax = state.lineMax;
|
|
75
|
+
state.parentType = "container";
|
|
76
|
+
let code;
|
|
77
|
+
let nextLine = startLine;
|
|
78
|
+
let autoClosed = false;
|
|
79
|
+
let closeMarkup = "";
|
|
80
|
+
let closeComment = "";
|
|
81
|
+
let skippedCloser;
|
|
82
|
+
for (;;) {
|
|
83
|
+
nextLine++;
|
|
84
|
+
// unclosed: closed by the end of the document or of the parent block
|
|
85
|
+
if (nextLine >= endLine)
|
|
86
|
+
break;
|
|
87
|
+
start = state.bMarks[nextLine] + state.tShift[nextLine];
|
|
88
|
+
max = state.eMarks[nextLine];
|
|
89
|
+
// a non-empty line indented less than the block ends it (a list item's container)
|
|
90
|
+
if (start < max && state.sCount[nextLine] < state.blkIndent)
|
|
91
|
+
break;
|
|
92
|
+
if (state.src.charCodeAt(start) !== MARKER)
|
|
93
|
+
continue;
|
|
94
|
+
// a closer is indented less than 4 spaces
|
|
95
|
+
if (state.sCount[nextLine] - state.blkIndent >= 4)
|
|
96
|
+
continue;
|
|
97
|
+
pos = start + 1;
|
|
98
|
+
while (pos < max && state.src.charCodeAt(pos) === MARKER)
|
|
99
|
+
pos++;
|
|
100
|
+
// a closer has at least as many colons as the opener
|
|
101
|
+
if (pos - start < markerCount)
|
|
102
|
+
continue;
|
|
103
|
+
const tail = CLOSER_TAIL.exec(state.src.slice(pos, max));
|
|
104
|
+
if (!tail)
|
|
105
|
+
continue;
|
|
106
|
+
// a `:::` in a code example is code
|
|
107
|
+
if ((code ??= codeLines(state)).has(nextLine)) {
|
|
108
|
+
skippedCloser ??= nextLine;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
autoClosed = true;
|
|
112
|
+
closeMarkup = state.src.slice(start, pos);
|
|
113
|
+
closeComment = tail[1] ?? "";
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
// lazy continuations never run past the closer
|
|
117
|
+
state.lineMax = nextLine;
|
|
118
|
+
const open = state.push(`container_${name}_open`, "div", 1);
|
|
119
|
+
open.markup = markup;
|
|
120
|
+
open.block = true;
|
|
121
|
+
open.info = params;
|
|
122
|
+
open.map = [startLine, nextLine];
|
|
123
|
+
if (skippedCloser !== undefined && !autoClosed)
|
|
124
|
+
open.meta = { ...(open.meta ?? {}), skippedCloser };
|
|
125
|
+
state.md.block.tokenize(state, startLine + 1, nextLine);
|
|
126
|
+
const close = state.push(`container_${name}_close`, "div", -1);
|
|
127
|
+
close.markup = closeMarkup;
|
|
128
|
+
close.block = true;
|
|
129
|
+
close.info = closeComment;
|
|
130
|
+
state.parentType = oldParent;
|
|
131
|
+
state.lineMax = oldLineMax;
|
|
132
|
+
state.line = nextLine + (autoClosed ? 1 : 0);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
md.block.ruler.before("fence", `container_${name}`, container, { alt: ["paragraph", "reference", "blockquote", "list"] });
|
|
136
|
+
const render = options.render ?? (() => "");
|
|
137
|
+
md.renderer.rules[`container_${name}_open`] = render;
|
|
138
|
+
md.renderer.rules[`container_${name}_close`] = render;
|
|
139
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface ParseDiagnostic {
|
|
2
|
+
/** 1-based line in the source (front matter counted). */
|
|
3
|
+
line: number;
|
|
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";
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Whether a source line closes a container opened with `colons` colons. The
|
|
10
|
+
* line may sit in a blockquote or a list item: its `>` markers and
|
|
11
|
+
* indentation come first. A closer may carry one HTML comment, an end-tag
|
|
12
|
+
* label: `::: <!-- end note -->` (container-rule.ts).
|
|
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;
|
|
20
|
+
/** The inline directives the parser turns into nodes (parse.ts inlineDirectiveToNode); any other name stays literal text. */
|
|
21
|
+
export declare const KNOWN_INLINE_DIRECTIVES: ReadonlySet<string>;
|
|
22
|
+
type Token = {
|
|
23
|
+
type: string;
|
|
24
|
+
attrs?: Array<[string, string]> | null;
|
|
25
|
+
tag: string;
|
|
26
|
+
info: string;
|
|
27
|
+
markup: string;
|
|
28
|
+
content: string;
|
|
29
|
+
map?: [number, number] | null;
|
|
30
|
+
children: Token[] | null;
|
|
31
|
+
meta: {
|
|
32
|
+
unclosed?: boolean;
|
|
33
|
+
skippedCloser?: number;
|
|
34
|
+
} | null;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Why an attribute block `{…}` cannot be read, or undefined when it can.
|
|
38
|
+
* parseAttrs never fails: it reads what it can and drops the rest, so a
|
|
39
|
+
* missing brace or an unterminated quote loses attributes silently.
|
|
40
|
+
*/
|
|
41
|
+
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
|
+
export declare function collectDiagnostics(tokens: Token[], lines: string[], lineOf: (line0: number) => number | undefined): ParseDiagnostic[];
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Problems in Dogsbay MD source that the parser recovers from without a word
|
|
3
|
+
* (ParseOptions.diagnostics): each would otherwise reach the page as literal
|
|
4
|
+
* text, a plain box, or a block that swallows the rest of the document.
|
|
5
|
+
*
|
|
6
|
+
* Read from markdown-it's tokens, so they describe what the parser actually
|
|
7
|
+
* did, not a second grammar's guess. A `::leaf` with an unknown name is not
|
|
8
|
+
* here: the parser passes any leaf through as a node of that type, and only a
|
|
9
|
+
* renderer knows whether it can draw it (render-html reports the ones it can't).
|
|
10
|
+
*/
|
|
11
|
+
import { GENERIC_CONTAINER, KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
|
|
12
|
+
import { PRECEDES_TEXT } from "./plugin-inline-directives.js";
|
|
13
|
+
/**
|
|
14
|
+
* Whether a source line closes a container opened with `colons` colons. The
|
|
15
|
+
* line may sit in a blockquote or a list item: its `>` markers and
|
|
16
|
+
* indentation come first. A closer may carry one HTML comment, an end-tag
|
|
17
|
+
* label: `::: <!-- end note -->` (container-rule.ts).
|
|
18
|
+
*/
|
|
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
|
+
}
|
|
27
|
+
/** The inline directives the parser turns into nodes (parse.ts inlineDirectiveToNode); any other name stays literal text. */
|
|
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" };
|
|
33
|
+
/**
|
|
34
|
+
* Why an attribute block `{…}` cannot be read, or undefined when it can.
|
|
35
|
+
* parseAttrs never fails: it reads what it can and drops the rest, so a
|
|
36
|
+
* missing brace or an unterminated quote loses attributes silently.
|
|
37
|
+
*/
|
|
38
|
+
export function attributeProblem(block) {
|
|
39
|
+
if (!block.trimEnd().endsWith("}"))
|
|
40
|
+
return "has no closing }";
|
|
41
|
+
const inner = block.trim().slice(1, -1);
|
|
42
|
+
let quote;
|
|
43
|
+
for (const c of inner) {
|
|
44
|
+
if (quote) {
|
|
45
|
+
if (c === quote)
|
|
46
|
+
quote = undefined;
|
|
47
|
+
}
|
|
48
|
+
else if (c === '"' || c === "'")
|
|
49
|
+
quote = c;
|
|
50
|
+
}
|
|
51
|
+
if (quote)
|
|
52
|
+
return `has an unterminated ${quote === '"' ? "double" : "single"} quote`;
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Diagnostics for a token stream. `lines` is the parsed text split at `\n`;
|
|
57
|
+
* `lineOf` maps a 0-based line of it to a 1-based source line.
|
|
58
|
+
*/
|
|
59
|
+
export function collectDiagnostics(tokens, lines, lineOf) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const add = (line0, code, message) => {
|
|
62
|
+
const line = line0 === undefined ? undefined : lineOf(line0);
|
|
63
|
+
if (line !== undefined)
|
|
64
|
+
out.push({ line, code, message });
|
|
65
|
+
};
|
|
66
|
+
const walk = (list) => {
|
|
67
|
+
for (const tok of list) {
|
|
68
|
+
const open = /^container_(.+)_open$/.exec(tok.type);
|
|
69
|
+
if (open && tok.map) {
|
|
70
|
+
const generic = open[1] === GENERIC_CONTAINER;
|
|
71
|
+
const info = (tok.info ?? "").trim();
|
|
72
|
+
const name = generic ? (/^[A-Za-z][\w-]*/.exec(info)?.[0] ?? info) : open[1];
|
|
73
|
+
const colons = (tok.markup ?? ":::").length;
|
|
74
|
+
const closed = closesContainer(lines[tok.map[1]], colons);
|
|
75
|
+
const skipped = tok.meta?.skippedCloser;
|
|
76
|
+
if (!closed && skipped !== undefined) {
|
|
77
|
+
add(tok.map[0], "unclosed-container", `:::${name} is never closed — the ${lines[skipped]?.trim()} on line ${lineOf(skipped) ?? "?"} is inside a code block, so it does not close it (add a closer after the code)`);
|
|
78
|
+
}
|
|
79
|
+
else if (!closed) {
|
|
80
|
+
add(tok.map[0], "unclosed-container", generic
|
|
81
|
+
? `:::${name} is never closed — it is shown as text (close it with a line of ${":".repeat(colons)})`
|
|
82
|
+
: `:::${name} is never closed — it takes in everything after it, to the end of the page or of the block it sits in (close it with a line of ${":".repeat(colons)})`);
|
|
83
|
+
}
|
|
84
|
+
else if (generic) {
|
|
85
|
+
add(tok.map[0], "unknown-container", `:::${name} is not a known directive — it is shown as a plain box`);
|
|
86
|
+
}
|
|
87
|
+
if (closed) {
|
|
88
|
+
// The closer's end-tag label: it closes this container whatever it says, so a wrong
|
|
89
|
+
// name means the author thinks it closes another one
|
|
90
|
+
const label = closerLabel((lines[tok.map[1]] ?? "").replace(/^[\s>]*:+/, ""));
|
|
91
|
+
if (label && label !== name) {
|
|
92
|
+
add(tok.map[1], "closer-mismatch", `this closer is labelled ${label}, but it closes :::${name} (opened on line ${lineOf(tok.map[0]) ?? "?"}) — check which block you meant to end here`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const rest = info.slice(name.length).trim();
|
|
96
|
+
if (rest.startsWith("{")) {
|
|
97
|
+
const problem = attributeProblem(rest);
|
|
98
|
+
if (problem)
|
|
99
|
+
add(tok.map[0], "bad-attributes", `the attributes of :::${name} ${problem} — they are ignored`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// A `:::` line markdown-it-container did not take as a closer: the container it was
|
|
103
|
+
// meant to close ended earlier (an inner block of the same fence closed it), or there is none
|
|
104
|
+
if (tok.type === "inline" && tok.map) {
|
|
105
|
+
(tok.content ?? "").split("\n").forEach((l, k) => {
|
|
106
|
+
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; to nest a directive, give the outer one more colons (::::steps around :::tabs)`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (tok.type === "inline" && tok.map && tok.children) {
|
|
112
|
+
const text = tok.content ?? "";
|
|
113
|
+
let from = 0;
|
|
114
|
+
for (const child of tok.children) {
|
|
115
|
+
if (child.type !== "dogsbay_inline_directive_open" && child.type !== "dogsbay_leaf_directive")
|
|
116
|
+
continue;
|
|
117
|
+
if (KNOWN_INLINE_DIRECTIVES.has(child.tag))
|
|
118
|
+
continue;
|
|
119
|
+
const form = child.type === "dogsbay_leaf_directive" ? `::${child.tag}` : `:${child.tag}[`;
|
|
120
|
+
const at = text.indexOf(form, from);
|
|
121
|
+
from = at >= 0 ? at + form.length : from;
|
|
122
|
+
const offset = at >= 0 ? (text.slice(0, at).match(/\n/g) ?? []).length : 0;
|
|
123
|
+
const shown = child.type === "dogsbay_leaf_directive" ? `::${child.tag}` : `:${child.tag}[…]`;
|
|
124
|
+
if (child.type === "dogsbay_leaf_directive" && BLOCK_DIRECTIVES.has(child.tag)) {
|
|
125
|
+
add(tok.map[0] + offset, "block-directive-inline", `${shown} is a block directive — inside a line of text it is shown as text; put it on a line of its own`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
add(tok.map[0] + offset, "unknown-inline", `${shown} is not a known inline directive — it is shown as text`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Attribute blocks markdown-it-attrs read and the tree has nowhere to keep
|
|
133
|
+
if (tok.type === "list_item_open" && tok.map && tok.attrs?.length) {
|
|
134
|
+
// parse.ts keepListItemBraces puts trailing braces back as text, or on a heading; what is left had nowhere to go
|
|
135
|
+
add(tok.map[0], "dropped-attributes", `the attribute block on this list item is dropped — list items take no attributes`);
|
|
136
|
+
}
|
|
137
|
+
// A table row that is only an attribute block, where the parser could not move it onto the
|
|
138
|
+
// table (not the last row, or text follows the table with no blank line): an empty row
|
|
139
|
+
if (tok.type === "tr_open" && tok.map) {
|
|
140
|
+
const cells = [];
|
|
141
|
+
for (let k = list.indexOf(tok) + 1; k < list.length && list[k].type !== "tr_close"; k++)
|
|
142
|
+
cells.push(list[k]);
|
|
143
|
+
const inlines = cells.filter((c) => c.type === "inline");
|
|
144
|
+
if (cells[0]?.attrs?.length && /^\{[^{}]*\}$/.test((inlines[0]?.content ?? "").trim()) && inlines.slice(1).every((c) => c.content.trim() === "")) {
|
|
145
|
+
add(tok.map[0], "dropped-attributes", `this attribute block is read as an empty table row — put it on the line directly under the table's last row, or after a blank line`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// markdown-it-attrs gives a list a block on the line after it (or after its last item's text); the tree keeps no list attributes
|
|
149
|
+
// Only real attribute names: markdown-it-attrs also reads a leftover include (`{* src.py hl[4] *}`, FastAPI) as keys
|
|
150
|
+
const own = tok.attrs?.filter(([k]) => k !== "start") ?? [];
|
|
151
|
+
if ((tok.type === "bullet_list_open" || tok.type === "ordered_list_open") && tok.map && own.length > 0 && own.every(([k]) => /^[A-Za-z_][\w:.-]*$/.test(k))) {
|
|
152
|
+
add(tok.map[0], "dropped-attributes", `the attribute block on this list is dropped — lists take no attributes`);
|
|
153
|
+
}
|
|
154
|
+
// An attribute block alone on its line after a blank line: an empty paragraph that carries it
|
|
155
|
+
if (tok.type === "paragraph_open" && tok.map && tok.attrs?.length) {
|
|
156
|
+
const inline = list[list.indexOf(tok) + 1];
|
|
157
|
+
// Only what an author writes as attributes: `{#id}`, `{.class}`, `{key=…}`. markdown-it-attrs also eats a
|
|
158
|
+
// leftover MDX expression (`{/* prettier-ignore */}`), which is an importer's problem, not the author's
|
|
159
|
+
const looksLikeAttrs = /^\{\s*(?:[#.][^\s{}]|[\w-]+\s*=)/.test(inline?.content.trim() ?? "");
|
|
160
|
+
if (inline?.type === "inline" && looksLikeAttrs && (inline.children ?? []).every((c) => c.type === "text" && c.content.trim() === "")) {
|
|
161
|
+
add(tok.map[0], "dropped-attributes", `this attribute block is on a line of its own and applies to nothing — put it at the end of the block's last line`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (tok.type === "inline" && tok.map && tok.children) {
|
|
165
|
+
for (const child of tok.children) {
|
|
166
|
+
const what = ATTRLESS_INLINE[child.type];
|
|
167
|
+
if (what && child.attrs?.length)
|
|
168
|
+
add(tok.map[0], "dropped-attributes", `the attribute block on ${what} is dropped — only links, images and inline directives take attributes`);
|
|
169
|
+
}
|
|
170
|
+
// Inline directives that keep no attributes (only badge and button do)
|
|
171
|
+
for (const child of tok.children) {
|
|
172
|
+
if (child.type !== "dogsbay_inline_directive_open" || child.tag === "badge" || child.tag === "button" || !KNOWN_INLINE_DIRECTIVES.has(child.tag))
|
|
173
|
+
continue;
|
|
174
|
+
let parsed = {};
|
|
175
|
+
try {
|
|
176
|
+
parsed = JSON.parse(child.info || "{}");
|
|
177
|
+
}
|
|
178
|
+
catch { /* not ours */ }
|
|
179
|
+
if (parsed.id || parsed.classes?.length || Object.keys(parsed.props ?? {}).length) {
|
|
180
|
+
add(tok.map[0], "dropped-attributes", `the attribute block on :${child.tag}[…] is dropped — of the inline directives only :badge and :button take attributes`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// `:kbd{…}` with no `[label]` is not a directive: the label is what it shows
|
|
184
|
+
for (const child of tok.children) {
|
|
185
|
+
if (child.type !== "text")
|
|
186
|
+
continue;
|
|
187
|
+
for (const m of child.content.matchAll(/:([a-z]+)\{/g)) {
|
|
188
|
+
if (m.index > 0 && PRECEDES_TEXT.test(child.content[m.index - 1]))
|
|
189
|
+
continue;
|
|
190
|
+
if (KNOWN_INLINE_DIRECTIVES.has(m[1]))
|
|
191
|
+
add(tok.map[0], "missing-label", `:${m[1]}{…} has no [label] — it is shown as text; write :${m[1]}[…]{…}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// A heading's attribute block that markdown-it-attrs could not read stays in its text
|
|
196
|
+
if (tok.type === "heading_open" && tok.map) {
|
|
197
|
+
const inline = list[list.indexOf(tok) + 1];
|
|
198
|
+
// markdown-it-attrs takes a block it reads out of the text tokens (not out of `content`)
|
|
199
|
+
const last = [...(inline?.children ?? [])].reverse().find((c) => c.type === "text");
|
|
200
|
+
const tail = /\{\s*(?:[#.][^{}]*|[\w-]+\s*=[^{}]*)\}?\s*$/.exec(last?.content ?? "");
|
|
201
|
+
if (tail) {
|
|
202
|
+
const problem = attributeProblem(tail[0]) ?? "could not be read";
|
|
203
|
+
add(tok.map[0], "bad-attributes", `the heading's attribute block ${problem} — it is shown as text`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
walk(tokens);
|
|
209
|
+
return out.sort((a, b) => a.line - b.line);
|
|
210
|
+
}
|
|
@@ -11,6 +11,12 @@ export interface DirectiveSpan {
|
|
|
11
11
|
export interface ProtectResult {
|
|
12
12
|
source: string;
|
|
13
13
|
table: DirectiveSpan[];
|
|
14
|
+
/**
|
|
15
|
+
* For each line of `source`, the 0-based line of the input it came from: a
|
|
16
|
+
* stashed construct that spanned lines is one placeholder line, and an
|
|
17
|
+
* isolating blank line maps to the line it was inserted before.
|
|
18
|
+
*/
|
|
19
|
+
lineMap: number[];
|
|
14
20
|
}
|
|
15
21
|
/**
|
|
16
22
|
* Replace directive constructs in prose with placeholders and isolate
|
|
@@ -72,7 +72,18 @@ export function protectDirectives(source, options = {}) {
|
|
|
72
72
|
// lines, so markdown-it gives each its own paragraph. Variables stay
|
|
73
73
|
// inline (a lone `{{ x }}` line is inline content, not structure).
|
|
74
74
|
const lines = out.split("\n");
|
|
75
|
+
// Each placeholder stands for its construct's lines, so the input line of
|
|
76
|
+
// every output line is the count of line breaks consumed before it
|
|
77
|
+
const inputLine = [];
|
|
78
|
+
let at = 0;
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
inputLine.push(at);
|
|
81
|
+
at += 1;
|
|
82
|
+
for (const m of line.matchAll(/\uE000(\d+)\uE001/g))
|
|
83
|
+
at += (table[Number(m[1])]?.raw.match(/\n/g) ?? []).length;
|
|
84
|
+
}
|
|
75
85
|
const isolated = [];
|
|
86
|
+
const lineMap = [];
|
|
76
87
|
for (let i = 0; i < lines.length; i++) {
|
|
77
88
|
const line = lines[i];
|
|
78
89
|
const only = line.match(/^\s*\uE000(\d+)\uE001\s*$/);
|
|
@@ -82,14 +93,17 @@ export function protectDirectives(source, options = {}) {
|
|
|
82
93
|
((options.isolate ?? "all") === "all" || entry.keyword === "include");
|
|
83
94
|
if (isBlockish && isolated.length && isolated[isolated.length - 1].trim() !== "") {
|
|
84
95
|
isolated.push("");
|
|
96
|
+
lineMap.push(inputLine[i]);
|
|
85
97
|
}
|
|
86
98
|
isolated.push(line);
|
|
99
|
+
lineMap.push(inputLine[i]);
|
|
87
100
|
if (isBlockish && i + 1 < lines.length && lines[i + 1].trim() !== "") {
|
|
88
101
|
isolated.push("");
|
|
102
|
+
lineMap.push(inputLine[i + 1]);
|
|
89
103
|
}
|
|
90
104
|
}
|
|
91
105
|
out = isolated.join("\n");
|
|
92
|
-
return { source: out, table };
|
|
106
|
+
return { source: out, table, lineMap };
|
|
93
107
|
}
|
|
94
108
|
/** Stash whole `{% raw %}…{% endraw %}` regions found in prose. */
|
|
95
109
|
function stashRawRegions(source, stash) {
|
|
@@ -170,29 +184,32 @@ function foldBlocks(nodes, table, nodesFor = "all") {
|
|
|
170
184
|
continue;
|
|
171
185
|
}
|
|
172
186
|
const entry = table[index];
|
|
187
|
+
// The node keeps the source line of the paragraph it replaces (ParseOptions.sourceLines)
|
|
188
|
+
const line = node.meta?.line;
|
|
189
|
+
const at = (made) => (line === undefined ? made : { ...made, meta: { ...made.meta, line } });
|
|
173
190
|
// resolve-first: only an include becomes a node; the rest is literal text.
|
|
174
191
|
if (nodesFor === "include" && entry.keyword !== "include") {
|
|
175
|
-
target().push({ type: "paragraph", inline: [{ type: "text", text: entry.raw }] });
|
|
192
|
+
target().push(at({ type: "paragraph", inline: [{ type: "text", text: entry.raw }] }));
|
|
176
193
|
continue;
|
|
177
194
|
}
|
|
178
195
|
const test = (entry.rest ?? "").trim();
|
|
179
196
|
if (entry.keyword === "if" && test) {
|
|
180
|
-
stack.push({ node: { type: "conditional", props: { test }, children: [] }, opener: entry });
|
|
197
|
+
stack.push({ node: at({ type: "conditional", props: { test }, children: [] }), opener: entry });
|
|
181
198
|
}
|
|
182
199
|
else if (entry.keyword === "endif") {
|
|
183
200
|
const open = stack.pop();
|
|
184
201
|
if (open)
|
|
185
202
|
target().push(open.node);
|
|
186
203
|
else
|
|
187
|
-
target().push(rawBlock(entry)); // orphan endif — preserved verbatim
|
|
204
|
+
target().push(at(rawBlock(entry))); // orphan endif — preserved verbatim
|
|
188
205
|
}
|
|
189
206
|
else if (entry.keyword === "elif" || entry.keyword === "else") {
|
|
190
207
|
// Not modeled first-class: preserved as raw chips inside (or beside)
|
|
191
208
|
// the conditional; the serializer emits them in place, byte-exact.
|
|
192
|
-
target().push(rawBlock(entry));
|
|
209
|
+
target().push(at(rawBlock(entry)));
|
|
193
210
|
}
|
|
194
211
|
else {
|
|
195
|
-
target().push(tagToBlock(entry));
|
|
212
|
+
target().push(at(tagToBlock(entry)));
|
|
196
213
|
}
|
|
197
214
|
}
|
|
198
215
|
// Unclosed conditionals: demote each opener to directive-raw and splice
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { extractHeadings, slugifyHeadingText, type HeadingInfo } from "./heading
|
|
|
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
|
+
export { KNOWN_INLINE_DIRECTIVES, type ParseDiagnostic } from "./diagnostics.js";
|
|
11
|
+
export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
|
|
10
12
|
export { buildNavFromDirectory } from "./nav.js";
|
|
11
13
|
export type { BuildNavOptions } from "./nav.js";
|
|
12
14
|
export { loadNavFile } from "./nav-file.js";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,9 @@ export { dogsbayMdToTree, parseInline } 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
|
+
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";
|
|
12
15
|
export { buildNavFromDirectory } from "./nav.js";
|
|
13
16
|
export { loadNavFile } from "./nav-file.js";
|
|
14
17
|
export { renderDiagrams } from "./renderers/diagrams.js";
|
package/dist/parse.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { TreeNode, InlineNode } from "@dogsbay/types";
|
|
2
|
+
import { type ParseDiagnostic } from "./diagnostics.js";
|
|
2
3
|
export interface ParseResult {
|
|
3
4
|
tree: TreeNode[];
|
|
4
5
|
frontmatter: Record<string, unknown>;
|
|
6
|
+
/** With ParseOptions.diagnostics: what the parser recovered from, by line. */
|
|
7
|
+
diagnostics?: ParseDiagnostic[];
|
|
5
8
|
}
|
|
6
9
|
export interface ParseOptions {
|
|
7
10
|
/** Strip surrounding whitespace from block content. Default: true */
|
|
@@ -17,6 +20,19 @@ export interface ParseOptions {
|
|
|
17
20
|
* plans/editor-directive-model.md.
|
|
18
21
|
*/
|
|
19
22
|
directives?: "resolve-first" | "preserve";
|
|
23
|
+
/**
|
|
24
|
+
* Put each block node's 1-based line in the source (front matter counted)
|
|
25
|
+
* on `meta.line`, so a host can map the rendered page back to the source
|
|
26
|
+
* (render-html writes it as `data-line` when asked). Off by default. Lines
|
|
27
|
+
* end at `\n` or `\r\n`; a file with lone `\r` line breaks gets none.
|
|
28
|
+
*/
|
|
29
|
+
sourceLines?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Report what the parser recovers from silently, with source lines, in
|
|
32
|
+
* `diagnostics`: an unclosed `:::container`, an unknown `:::name` or inline
|
|
33
|
+
* `:name[]`, an attribute block it cannot read. Off by default.
|
|
34
|
+
*/
|
|
35
|
+
diagnostics?: boolean;
|
|
20
36
|
}
|
|
21
37
|
/**
|
|
22
38
|
* Parse Dogsbay Markdown into TreeNode[] plus extracted frontmatter.
|
package/dist/parse.js
CHANGED
|
@@ -21,6 +21,7 @@ import { mathPlugin } from "./plugin-math.js";
|
|
|
21
21
|
import { attrsToProps } from "./parse-attrs.js";
|
|
22
22
|
import { splitFrontmatter } from "./frontmatter.js";
|
|
23
23
|
import { protectDirectives, restoreDirectives } from "./directive-preserve.js";
|
|
24
|
+
import { closesContainer, collectDiagnostics } from "./diagnostics.js";
|
|
24
25
|
import { treeToDogsbayMd } from "./serialize.js";
|
|
25
26
|
/**
|
|
26
27
|
* Parse Dogsbay Markdown into TreeNode[] plus extracted frontmatter.
|
|
@@ -52,6 +53,11 @@ export function dogsbayMdToTree(source, options) {
|
|
|
52
53
|
// resolves them in a preprocessing pass beforehand. Protecting in both modes
|
|
53
54
|
// makes the contract true; the RESTORE below is what differs.
|
|
54
55
|
let directiveTable = null;
|
|
56
|
+
// Source lines: the line of the full source each line of `content` came from,
|
|
57
|
+
// through the front matter split and each pre-pass below
|
|
58
|
+
const wantLines = (options?.sourceLines === true || options?.diagnostics === true) && !/\r(?!\n)/.test(source);
|
|
59
|
+
const bodyStart = wantLines ? (source.slice(0, source.length - content.length).match(/\n/g) ?? []).length : 0;
|
|
60
|
+
let lineMap = null;
|
|
55
61
|
const preserveMode = options?.directives === "preserve";
|
|
56
62
|
{
|
|
57
63
|
// resolve-first leaves `{% raw %}` regions alone — see ProtectOptions.
|
|
@@ -61,17 +67,35 @@ export function dogsbayMdToTree(source, options) {
|
|
|
61
67
|
});
|
|
62
68
|
content = protectedResult.source;
|
|
63
69
|
directiveTable = protectedResult.table;
|
|
70
|
+
if (wantLines)
|
|
71
|
+
lineMap = protectedResult.lineMap;
|
|
64
72
|
}
|
|
65
73
|
// Pre-pass: lift YAML body forms (e.g. `:::card\n---\n...\n---\n:::`)
|
|
66
74
|
// into a synthetic `__yaml__` attr on the directive opener. Done at
|
|
67
75
|
// the source level because markdown-it would otherwise mangle the
|
|
68
76
|
// YAML — the leading `---` becomes a horizontal rule and the body's
|
|
69
77
|
// newlines collapse into a single inline run. See preprocessYamlBodies.
|
|
70
|
-
|
|
78
|
+
const yamlLifted = preprocessYamlBodies(content);
|
|
79
|
+
content = yamlLifted.source;
|
|
80
|
+
const lineOf = lineMap
|
|
81
|
+
? (line0) => {
|
|
82
|
+
const protectedLine = yamlLifted.lineMap[line0];
|
|
83
|
+
const input = protectedLine === undefined ? undefined : lineMap[protectedLine];
|
|
84
|
+
return input === undefined ? undefined : bodyStart + input + 1;
|
|
85
|
+
}
|
|
86
|
+
: undefined;
|
|
71
87
|
const md = sharedParser();
|
|
72
88
|
const tokens = md.parse(content, {});
|
|
73
89
|
markUnclosedGenericContainers(tokens, content);
|
|
74
|
-
|
|
90
|
+
keepListItemBraces(tokens, content);
|
|
91
|
+
attachTableAttrsRow(tokens, content);
|
|
92
|
+
// Before tokensToTree: it changes nothing these read, but read the parse as markdown-it left it
|
|
93
|
+
const diagnostics = options?.diagnostics
|
|
94
|
+
? collectDiagnostics(tokens,
|
|
95
|
+
// markdown-it splits at \r too; a lone-CR file has no line map, so its lines are counted here
|
|
96
|
+
content.split(/\r\n|\r|\n/), lineOf ?? ((line0) => (source.slice(0, source.length - content.length).match(/\r\n|\r|\n/g) ?? []).length + line0 + 1))
|
|
97
|
+
: undefined;
|
|
98
|
+
let tree = foldRelated(tokensToTree(tokens, options?.sourceLines ? lineOf : undefined));
|
|
75
99
|
if (directiveTable) {
|
|
76
100
|
// `preserve` restores every construct as a first-class node. `resolve-first`
|
|
77
101
|
// restores INCLUDES as nodes too — they are transclusions of real content,
|
|
@@ -81,7 +105,7 @@ export function dogsbayMdToTree(source, options) {
|
|
|
81
105
|
nodesFor: preserveMode ? "all" : "include",
|
|
82
106
|
});
|
|
83
107
|
}
|
|
84
|
-
return { tree, frontmatter };
|
|
108
|
+
return { tree, frontmatter, ...(diagnostics ? { diagnostics } : {}) };
|
|
85
109
|
}
|
|
86
110
|
// #384 R3: roles whose caption + following list fold into a semantic `related`
|
|
87
111
|
// node (maps to DITA <related-links>). Mirrors CONTAINER_ROLES in
|
|
@@ -180,6 +204,7 @@ const YAML_BODY_COMPONENTS = new Set([
|
|
|
180
204
|
function preprocessYamlBodies(source) {
|
|
181
205
|
const lines = source.split("\n");
|
|
182
206
|
const out = [];
|
|
207
|
+
const lineMap = [];
|
|
183
208
|
let i = 0;
|
|
184
209
|
while (i < lines.length) {
|
|
185
210
|
const line = lines[i];
|
|
@@ -210,14 +235,16 @@ function preprocessYamlBodies(source) {
|
|
|
210
235
|
newAttrs = `{__yaml__="${encoded}"}`;
|
|
211
236
|
}
|
|
212
237
|
out.push(`${fence}${name}${newAttrs}`);
|
|
238
|
+
lineMap.push(i);
|
|
213
239
|
i = j + 1; // skip past the closing `---`
|
|
214
240
|
continue;
|
|
215
241
|
}
|
|
216
242
|
}
|
|
217
243
|
out.push(line);
|
|
244
|
+
lineMap.push(i);
|
|
218
245
|
i++;
|
|
219
246
|
}
|
|
220
|
-
return out.join("\n");
|
|
247
|
+
return { source: out.join("\n"), lineMap };
|
|
221
248
|
}
|
|
222
249
|
/**
|
|
223
250
|
* Parse a single inline string to InlineNode[] (useful for testing).
|
|
@@ -279,26 +306,108 @@ function createParser() {
|
|
|
279
306
|
});
|
|
280
307
|
return md;
|
|
281
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
|
+
}
|
|
282
391
|
/**
|
|
283
392
|
* markdown-it-container runs a block with no closing marker to the end of the document. For a
|
|
284
393
|
* generic (unknown-name) container that would turn any prose line starting `:::word` into a block
|
|
285
394
|
* holding the rest of the page, so such an opener is marked and parsed back as text.
|
|
286
395
|
*/
|
|
287
396
|
function markUnclosedGenericContainers(tokens, src) {
|
|
288
|
-
|
|
397
|
+
// markdown-it's lines: it splits at \r\n, \r and \n
|
|
398
|
+
const lines = src.split(/\r\n|\r|\n/);
|
|
289
399
|
const open = `container_${GENERIC_CONTAINER}_open`;
|
|
290
400
|
for (const tok of tokens) {
|
|
291
401
|
const map = tok.map;
|
|
292
402
|
if (tok.type !== open || !map)
|
|
293
403
|
continue;
|
|
294
|
-
|
|
295
|
-
if (close === undefined || !/^\s*:{3,}\s*$/.test(close))
|
|
404
|
+
if (!closesContainer(lines[map[1]], (tok.markup ?? ":::").length))
|
|
296
405
|
tok.meta = { ...(tok.meta ?? {}), unclosed: true };
|
|
297
406
|
}
|
|
298
407
|
}
|
|
299
|
-
function tokensToTree(tokens) {
|
|
408
|
+
function tokensToTree(tokens, lineOf) {
|
|
300
409
|
const nodes = [];
|
|
301
|
-
const ctx = { tokens, i: 0 };
|
|
410
|
+
const ctx = { tokens, i: 0, ...(lineOf ? { lineOf } : {}) };
|
|
302
411
|
while (ctx.i < ctx.tokens.length) {
|
|
303
412
|
const node = consumeBlock(ctx);
|
|
304
413
|
if (node) {
|
|
@@ -316,6 +425,20 @@ function tokensToTree(tokens) {
|
|
|
316
425
|
return nodes;
|
|
317
426
|
}
|
|
318
427
|
function consumeBlock(ctx) {
|
|
428
|
+
const tok = ctx.tokens[ctx.i];
|
|
429
|
+
const result = consumeBlockAt(ctx);
|
|
430
|
+
// Source lines: every node the block became starts at its first token's line
|
|
431
|
+
// (nested blocks came through here first and keep their own)
|
|
432
|
+
const line = ctx.lineOf && tok?.map ? ctx.lineOf(tok.map[0]) : undefined;
|
|
433
|
+
if (line !== undefined && result) {
|
|
434
|
+
for (const node of Array.isArray(result) ? result : [result]) {
|
|
435
|
+
if (node.meta?.line === undefined)
|
|
436
|
+
node.meta = { ...node.meta, line };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return result;
|
|
440
|
+
}
|
|
441
|
+
function consumeBlockAt(ctx) {
|
|
319
442
|
const tok = ctx.tokens[ctx.i];
|
|
320
443
|
if (!tok)
|
|
321
444
|
return null;
|
|
@@ -581,6 +704,14 @@ function consumeList(ctx, ordered) {
|
|
|
581
704
|
return node;
|
|
582
705
|
}
|
|
583
706
|
function consumeListItem(ctx) {
|
|
707
|
+
const open = ctx.tokens[ctx.i];
|
|
708
|
+
const item = consumeListItemAt(ctx);
|
|
709
|
+
const line = ctx.lineOf && open?.map ? ctx.lineOf(open.map[0]) : undefined;
|
|
710
|
+
if (line !== undefined && item.meta?.line === undefined)
|
|
711
|
+
item.meta = { ...item.meta, line };
|
|
712
|
+
return item;
|
|
713
|
+
}
|
|
714
|
+
function consumeListItemAt(ctx) {
|
|
584
715
|
ctx.i++; // list_item_open
|
|
585
716
|
const children = [];
|
|
586
717
|
let depth = 1;
|
|
@@ -909,9 +1040,12 @@ function consumeContainer(ctx) {
|
|
|
909
1040
|
ctx.i++;
|
|
910
1041
|
const children = [];
|
|
911
1042
|
const closeType = `container_${registered}_close`;
|
|
1043
|
+
// The closer's end-tag label, `::: <!-- end note -->` (container-rule.ts): kept so it is written back
|
|
1044
|
+
let closer = "";
|
|
912
1045
|
while (ctx.i < ctx.tokens.length) {
|
|
913
1046
|
const tok = ctx.tokens[ctx.i];
|
|
914
1047
|
if (tok.type === closeType) {
|
|
1048
|
+
closer = tok.info ?? "";
|
|
915
1049
|
ctx.i++;
|
|
916
1050
|
break;
|
|
917
1051
|
}
|
|
@@ -941,10 +1075,11 @@ function consumeContainer(ctx) {
|
|
|
941
1075
|
type: "container",
|
|
942
1076
|
props: attrs ? attrsToProps(attrs) : {},
|
|
943
1077
|
children,
|
|
944
|
-
meta: { container: true, name, ...(title ? { title } : {}) },
|
|
1078
|
+
meta: { container: true, name, ...(title ? { title } : {}), ...(closer ? { closer } : {}) },
|
|
945
1079
|
};
|
|
946
1080
|
}
|
|
947
|
-
|
|
1081
|
+
const node = mapContainerToTreeNode(name, attrs, children);
|
|
1082
|
+
return closer && !Array.isArray(node) ? { ...node, meta: { ...(node.meta ?? {}), closer } } : node;
|
|
948
1083
|
}
|
|
949
1084
|
/**
|
|
950
1085
|
* Convert a container + children into the appropriate TreeNode(s).
|
|
@@ -1582,6 +1717,9 @@ function tokensToInline(tokens, inheritedFmt = []) {
|
|
|
1582
1717
|
const name = tok.tag;
|
|
1583
1718
|
const attrs = tok.info ? JSON.parse(tok.info) : null;
|
|
1584
1719
|
const node = inlineDirectiveToNode(name, "", [], attrs);
|
|
1720
|
+
// An unknown leaf stays as written (`::name{…}`), not in the text form `:name[]{…}`, which reads back as something else
|
|
1721
|
+
if (node?.type === "html-inline" && node.html.startsWith(`:${name}[]`))
|
|
1722
|
+
node.html = `::${name}${node.html.slice(name.length + 3)}`;
|
|
1585
1723
|
if (node)
|
|
1586
1724
|
result.push(node);
|
|
1587
1725
|
break;
|
|
@@ -1,7 +1,8 @@
|
|
|
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
|
+
* Each directive name is
|
|
5
6
|
* registered separately; within same colon count, different-name directives can
|
|
6
7
|
* nest. Same-name nesting requires more colons (::::).
|
|
7
8
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { containerRule } from "./container-rule.js";
|
|
2
2
|
import { parseAttrs } from "./parse-attrs.js";
|
|
3
3
|
export const KNOWN_BLOCK_DIRECTIVES = [
|
|
4
4
|
"tabs",
|
|
@@ -72,7 +72,7 @@ export const KNOWN_BLOCK_DIRECTIVES = [
|
|
|
72
72
|
export const GENERIC_CONTAINER = "dogsbay_generic";
|
|
73
73
|
export function containersPlugin(md) {
|
|
74
74
|
for (const name of KNOWN_BLOCK_DIRECTIVES) {
|
|
75
|
-
md
|
|
75
|
+
containerRule(md, name, {
|
|
76
76
|
validate: (params) => {
|
|
77
77
|
const trimmed = params.trim();
|
|
78
78
|
if (!trimmed.startsWith(name))
|
|
@@ -103,7 +103,7 @@ export function containersPlugin(md) {
|
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
105
|
const known = new Set(KNOWN_BLOCK_DIRECTIVES);
|
|
106
|
-
md
|
|
106
|
+
containerRule(md, GENERIC_CONTAINER, {
|
|
107
107
|
validate: (params) => {
|
|
108
108
|
const m = /^([A-Za-z][\w-]*)(?=$|[\s{[])/.exec(params.trim());
|
|
109
109
|
return m !== null && !known.has(m[1]);
|
|
@@ -14,4 +14,6 @@
|
|
|
14
14
|
* The inline directive body contains nested inline tokens parsed recursively.
|
|
15
15
|
*/
|
|
16
16
|
import type MarkdownIt from "markdown-it";
|
|
17
|
+
/** A character a directive cannot follow directly (see inlineDirectiveRule). */
|
|
18
|
+
export declare const PRECEDES_TEXT: RegExp;
|
|
17
19
|
export declare function inlineDirectivesPlugin(md: MarkdownIt): void;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { parseAttrs } from "./parse-attrs.js";
|
|
2
|
+
/** A character a directive cannot follow directly (see inlineDirectiveRule). */
|
|
3
|
+
export const PRECEDES_TEXT = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}0-9:]/u;
|
|
2
4
|
export function inlineDirectivesPlugin(md) {
|
|
3
5
|
md.inline.ruler.before("emphasis", "dogsbay_inline_directive", inlineDirectiveRule);
|
|
4
6
|
}
|
|
@@ -7,6 +9,11 @@ function inlineDirectiveRule(state, silent) {
|
|
|
7
9
|
const src = state.src;
|
|
8
10
|
if (src.charCodeAt(start) !== 0x3a /* : */)
|
|
9
11
|
return false;
|
|
12
|
+
// Not straight after a word in a spaced script, a digit or a colon: `std::vector`, `x:Type[]`
|
|
13
|
+
// and a Tailwind `md:grid-cols-[…]` in an attribute block are text, not directives. CJK text
|
|
14
|
+
// has no spaces, so a directive may follow it directly (`按:kbd[Ctrl]键`)
|
|
15
|
+
if (start > 0 && PRECEDES_TEXT.test(src[start - 1]))
|
|
16
|
+
return false;
|
|
10
17
|
// Determine leaf (::) vs text (:) directive
|
|
11
18
|
const isLeaf = src.charCodeAt(start + 1) === 0x3a;
|
|
12
19
|
const nameStart = isLeaf ? start + 2 : start + 1;
|
package/dist/serialize.js
CHANGED
|
@@ -29,6 +29,12 @@ export function nodeToDogsbayMd(node, options) {
|
|
|
29
29
|
return renderNode(node, resolveOptions(options));
|
|
30
30
|
}
|
|
31
31
|
function renderNode(node, ctx) {
|
|
32
|
+
const out = renderNodeBody(node, ctx);
|
|
33
|
+
// A closer's end-tag label (`::: <!-- end note -->`), as the author wrote it
|
|
34
|
+
const closer = node.meta?.closer;
|
|
35
|
+
return typeof closer === "string" && /^<!--(?:(?!-->)[\s\S])*-->$/.test(closer) && /\n:{3,}$/.test(out) ? `${out} ${closer}` : out;
|
|
36
|
+
}
|
|
37
|
+
function renderNodeBody(node, ctx) {
|
|
32
38
|
switch (node.type) {
|
|
33
39
|
case "heading":
|
|
34
40
|
return renderHeading(node);
|
|
@@ -423,7 +429,9 @@ function renderCallout(node, ctx) {
|
|
|
423
429
|
// panel.
|
|
424
430
|
const props = node.props ?? {};
|
|
425
431
|
const hasOnlyTypeProp = Object.keys(props).every((k) => k === "type");
|
|
426
|
-
|
|
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.126",
|
|
4
4
|
"description": "Dogsbay Markdown dialect — canonical format for TreeNode[] with directives, YAML bodies, and intelligent fallbacks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,13 +36,12 @@
|
|
|
36
36
|
"js-yaml": "^5.2.1",
|
|
37
37
|
"markdown-it": "^14.3.0",
|
|
38
38
|
"markdown-it-attrs": "^5.0.0",
|
|
39
|
-
"markdown-it-container": "^4.0.0",
|
|
40
39
|
"markdown-it-deflist": "^3.0.1",
|
|
41
40
|
"markdown-it-footnote": "^4.0.0",
|
|
42
41
|
"markdown-it-front-matter": "^0.2.4",
|
|
43
|
-
"@dogsbay/minja": "0.2.0-beta.
|
|
44
|
-
"@dogsbay/serialize-core": "0.2.0-beta.
|
|
45
|
-
"@dogsbay/types": "0.2.0-beta.
|
|
42
|
+
"@dogsbay/minja": "0.2.0-beta.126",
|
|
43
|
+
"@dogsbay/serialize-core": "0.2.0-beta.126",
|
|
44
|
+
"@dogsbay/types": "0.2.0-beta.126"
|
|
46
45
|
},
|
|
47
46
|
"license": "MIT",
|
|
48
47
|
"repository": {
|