@dogsbay/format-dogsbay-md 0.2.0-beta.13 → 0.2.0-beta.130

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.
Files changed (97) hide show
  1. package/dist/attributes.d.ts +16 -2
  2. package/dist/attributes.js +14 -3
  3. package/dist/browser.d.ts +24 -0
  4. package/dist/browser.js +24 -0
  5. package/dist/capabilities.d.ts +25 -0
  6. package/dist/capabilities.js +91 -0
  7. package/dist/cli.d.ts +18 -1
  8. package/dist/cli.js +271 -30
  9. package/dist/code-regions.d.ts +88 -0
  10. package/dist/code-regions.js +214 -0
  11. package/dist/container-rule.d.ts +83 -0
  12. package/dist/container-rule.js +280 -0
  13. package/dist/diagnostics.d.ts +51 -0
  14. package/dist/diagnostics.js +335 -0
  15. package/dist/directive-guard.d.ts +10 -0
  16. package/dist/directive-guard.js +15 -0
  17. package/dist/directive-preserve.d.ts +71 -0
  18. package/dist/directive-preserve.js +426 -0
  19. package/dist/directives.d.ts +0 -1
  20. package/dist/directives.js +23 -2
  21. package/dist/escape.d.ts +26 -8
  22. package/dist/escape.js +101 -31
  23. package/dist/frontmatter.d.ts +11 -0
  24. package/dist/frontmatter.js +66 -0
  25. package/dist/headings.d.ts +43 -0
  26. package/dist/headings.js +156 -0
  27. package/dist/index.d.ts +13 -3
  28. package/dist/index.js +13 -3
  29. package/dist/inline.d.ts +0 -4
  30. package/dist/inline.js +242 -10
  31. package/dist/minja-mask.d.ts +63 -0
  32. package/dist/minja-mask.js +127 -0
  33. package/dist/nav-file.d.ts +26 -4
  34. package/dist/nav-file.js +91 -15
  35. package/dist/nav.d.ts +7 -1
  36. package/dist/nav.js +23 -5
  37. package/dist/parse-attrs.d.ts +9 -1
  38. package/dist/parse-attrs.js +28 -1
  39. package/dist/parse.d.ts +37 -2
  40. package/dist/parse.js +811 -50
  41. package/dist/plugin-block-leaf.d.ts +0 -1
  42. package/dist/plugin-block-leaf.js +3 -16
  43. package/dist/plugin-containers.d.ts +23 -6
  44. package/dist/plugin-containers.js +69 -4
  45. package/dist/plugin-inline-directives.d.ts +2 -1
  46. package/dist/plugin-inline-directives.js +10 -16
  47. package/dist/plugin-mark.d.ts +12 -0
  48. package/dist/plugin-mark.js +74 -0
  49. package/dist/plugin-math.d.ts +22 -0
  50. package/dist/plugin-math.js +90 -0
  51. package/dist/renderers/diagrams.d.ts +28 -0
  52. package/dist/renderers/diagrams.js +92 -0
  53. package/dist/renderers/mermaid.d.ts +23 -0
  54. package/dist/renderers/mermaid.js +151 -0
  55. package/dist/renderers/pele.d.ts +13 -0
  56. package/dist/renderers/pele.js +42 -0
  57. package/dist/serialize.d.ts +1 -2
  58. package/dist/serialize.js +520 -71
  59. package/dist/template-pages.d.ts +68 -0
  60. package/dist/template-pages.js +248 -0
  61. package/dist/types.d.ts +0 -1
  62. package/dist/types.js +18 -2
  63. package/dist/yaml.d.ts +1 -2
  64. package/dist/yaml.js +78 -3
  65. package/package.json +27 -12
  66. package/dist/attributes.d.ts.map +0 -1
  67. package/dist/attributes.js.map +0 -1
  68. package/dist/cli.d.ts.map +0 -1
  69. package/dist/cli.js.map +0 -1
  70. package/dist/directives.d.ts.map +0 -1
  71. package/dist/directives.js.map +0 -1
  72. package/dist/escape.d.ts.map +0 -1
  73. package/dist/escape.js.map +0 -1
  74. package/dist/index.d.ts.map +0 -1
  75. package/dist/index.js.map +0 -1
  76. package/dist/inline.d.ts.map +0 -1
  77. package/dist/inline.js.map +0 -1
  78. package/dist/nav-file.d.ts.map +0 -1
  79. package/dist/nav-file.js.map +0 -1
  80. package/dist/nav.d.ts.map +0 -1
  81. package/dist/nav.js.map +0 -1
  82. package/dist/parse-attrs.d.ts.map +0 -1
  83. package/dist/parse-attrs.js.map +0 -1
  84. package/dist/parse.d.ts.map +0 -1
  85. package/dist/parse.js.map +0 -1
  86. package/dist/plugin-block-leaf.d.ts.map +0 -1
  87. package/dist/plugin-block-leaf.js.map +0 -1
  88. package/dist/plugin-containers.d.ts.map +0 -1
  89. package/dist/plugin-containers.js.map +0 -1
  90. package/dist/plugin-inline-directives.d.ts.map +0 -1
  91. package/dist/plugin-inline-directives.js.map +0 -1
  92. package/dist/serialize.d.ts.map +0 -1
  93. package/dist/serialize.js.map +0 -1
  94. package/dist/types.d.ts.map +0 -1
  95. package/dist/types.js.map +0 -1
  96. package/dist/yaml.d.ts.map +0 -1
  97. package/dist/yaml.js.map +0 -1
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Code-region walker — THE fence grammar shared by every tool that must
3
+ * treat code specially: the build preprocessor masks Minja openers
4
+ * inside code so template examples never evaluate, the workspace
5
+ * indexer blanks code so `{% include %}` examples never count as real
6
+ * includes, and the refactor commands edit only prose so code samples
7
+ * are never rewritten. One implementation, so none of them can drift.
8
+ *
9
+ * Fence rules (CommonMark): a fence opens with ``` or ~~~ (3+ chars,
10
+ * info string allowed) and closes only on a line of the SAME character
11
+ * with AT LEAST the opener's length and nothing but whitespace after —
12
+ * the serializer emits escalated fences (```` around content containing
13
+ * ```), so honoring the length rule is load-bearing. Fence-looking
14
+ * lines inside an open fence that don't close it are code content.
15
+ */
16
+ type LineKind = "prose" | "fence" | "code";
17
+ /** Context passed to a code transform: which fence the line belongs to. */
18
+ export interface CodeRegionContext {
19
+ /**
20
+ * Fence info string (e.g. `yaml {minja}`). For inline code spans:
21
+ * the markdown-it-attrs group immediately following the closing
22
+ * backticks when present (e.g. `{minja}` for `` `x`{minja} ``),
23
+ * empty otherwise — so `isMinjaProcessedFence` grades fences and
24
+ * marked inline spans with the same predicate.
25
+ */
26
+ fenceInfo: string;
27
+ }
28
+ /**
29
+ * Transform CODE content: every line inside a fence, and the inner
30
+ * content of inline code spans on prose lines (backticks kept). Fence
31
+ * marker lines themselves are untouched.
32
+ *
33
+ * `transformInlineAttrs` (optional) rewrites the attr group of an
34
+ * inline span at re-emission — return `""` to drop it. Only inline
35
+ * spans see it; fence info strings are never routed through it.
36
+ */
37
+ /**
38
+ * Line-by-line fence tracker on THE shared grammar — for tools that must
39
+ * walk a document themselves (the remote-include inliner) instead of
40
+ * transforming regions. A locally re-implemented open/close regex pair
41
+ * drifted immediately: it missed the deflist-hosted `: ```` opener the
42
+ * shared grammar just gained, took the indented closer for an opener,
43
+ * and re-created the exact phantom-fence inversion it was fixing
44
+ * (issue #039 review).
45
+ *
46
+ * step(line) returns what the line IS: "fence" (an opener or closer),
47
+ * "code" (content of an open fence), or "prose". open() reports the
48
+ * state AFTER the last step, so an opener steps "fence" with
49
+ * open()===true and its closer steps "fence" with open()===false.
50
+ */
51
+ export declare function createFenceTracker(): {
52
+ step(line: string): LineKind;
53
+ open(): boolean;
54
+ };
55
+ /**
56
+ * Transform only fence OPENING lines' info strings, on the same shared
57
+ * grammar — a fence-looking line inside an open fence is code content
58
+ * and is never touched. Used to strip the spent `{minja}` marker after
59
+ * the terminal Minja render (a fence marker that survives into the
60
+ * mirror leaks into serializers that round-trip fence info verbatim —
61
+ * the Obsidian vault showed literal ```` ```yaml {minja} ````).
62
+ */
63
+ export declare function transformFenceInfo(source: string, transform: (info: string) => string): string;
64
+ export declare function transformCodeRegions(source: string, transform: (codeText: string, ctx: CodeRegionContext) => string, transformInlineAttrs?: (attrs: string) => string): string;
65
+ /**
66
+ * The inverse walk: transform PROSE lines (outside fences), leaving
67
+ * fenced content and the fence lines untouched. Used by refactor
68
+ * commands that rewrite markdown structure line-wise (heading shifts,
69
+ * section splits) — a `# heading` inside a code example must never be
70
+ * treated as structure.
71
+ */
72
+ export declare function transformProseLines(source: string, transform: (line: string, lineIndex: number) => string): string;
73
+ /**
74
+ * Transform PROSE TEXT SEGMENTS: prose lines with their inline code
75
+ * spans protected (the transform sees only the non-code stretches of
76
+ * each prose line). This is the write-side counterpart of
77
+ * transformCodeRegions — refactor commands rewrite references through
78
+ * it so `[x](old.md)` or `{{ key }}` inside examples survive verbatim.
79
+ */
80
+ export declare function transformProseSegments(source: string, transform: (proseText: string) => string): string;
81
+ /**
82
+ * Fence state BEFORE each line: `openBefore[i]` is true when line i
83
+ * starts inside an unclosed fence. One extra trailing entry gives the
84
+ * state after the last line. Lets line-range refactors check "does
85
+ * this range start or end mid-fence" with the same grammar.
86
+ */
87
+ export declare function fenceOpenBefore(source: string): boolean[];
88
+ export {};
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Code-region walker — THE fence grammar shared by every tool that must
3
+ * treat code specially: the build preprocessor masks Minja openers
4
+ * inside code so template examples never evaluate, the workspace
5
+ * indexer blanks code so `{% include %}` examples never count as real
6
+ * includes, and the refactor commands edit only prose so code samples
7
+ * are never rewritten. One implementation, so none of them can drift.
8
+ *
9
+ * Fence rules (CommonMark): a fence opens with ``` or ~~~ (3+ chars,
10
+ * info string allowed) and closes only on a line of the SAME character
11
+ * with AT LEAST the opener's length and nothing but whitespace after —
12
+ * the serializer emits escalated fences (```` around content containing
13
+ * ```), so honoring the length rule is load-bearing. Fence-looking
14
+ * lines inside an open fence that don't close it are code content.
15
+ */
16
+ // A fence may open as DEFLIST DEFINITION content with the definition
17
+ // marker glued to the opener (`: ```` — the adoc engine emits this when
18
+ // a dlist definition's first block is a listing; markdown-it-deflist also
19
+ // accepts `~` as the marker). markdown-it opens a fence there, so this
20
+ // grammar must too: without the prefix the walker took the CLOSER for an
21
+ // opener and inverted — prose after the dlist was masked as code and its
22
+ // `{{ attr }}` refs never resolved (virt-cluster-resource-requirements,
23
+ // the #030 residual class). The marker takes 1–3 spaces: at 4+ markdown-it
24
+ // reads an indented code block, not a fence. The info capture excludes a
25
+ // trailing `\r` so CRLF text grades identically (`.` can't match `\r`,
26
+ // which silently disabled the whole grammar on CRLF files).
27
+ //
28
+ // KNOWN bounded divergence: markdown-it only forms a deflist when a term
29
+ // paragraph precedes; at document start or right after a heading/list a
30
+ // `: ```` line is plain text. The walker opens (and, via the dedent
31
+ // rule, promptly closes) a fence there — a line-local grammar cannot see
32
+ // the term context. The divergence is bounded to the pseudo-fence's own
33
+ // region; the tail re-aligns at the first dedent.
34
+ const OPEN_RE = /^\s*(?:[:~][ \t]{1,3})?(`{3,}|~{3,})(.*?)\r?$/;
35
+ const CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/;
36
+ const stepFence = (state, line) => {
37
+ if (state.open) {
38
+ // A deflist-hosted fence ends when a non-blank line dedents out of
39
+ // the definition (indent < 4) — markdown-it terminates the fence
40
+ // BEFORE reading that line, so reprocess it in closed state: it may
41
+ // itself open a REAL fence (the col-0 ``` after a dedented deflist
42
+ // fence swallowed the document tail when taken as a closer — review
43
+ // finding).
44
+ if (state.hosted && /^ {0,3}\S/.test(line)) {
45
+ state.open = false;
46
+ state.info = "";
47
+ state.hosted = false;
48
+ // fall through to closed-state handling for this same line
49
+ }
50
+ else {
51
+ const close = line.match(CLOSE_RE);
52
+ if (close && close[1][0] === state.marker && close[1].length >= state.length) {
53
+ state.open = false;
54
+ state.info = "";
55
+ state.hosted = false;
56
+ return "fence";
57
+ }
58
+ return "code";
59
+ }
60
+ }
61
+ const open = line.match(OPEN_RE);
62
+ if (open) {
63
+ state.open = true;
64
+ state.marker = open[1][0];
65
+ state.length = open[1].length;
66
+ state.info = open[2].trim();
67
+ state.hosted = /^\s*[:~][ \t]/.test(line);
68
+ return "fence";
69
+ }
70
+ return "prose";
71
+ };
72
+ const newState = () => ({ open: false, marker: "", length: 0, info: "", hosted: false });
73
+ /**
74
+ * Transform CODE content: every line inside a fence, and the inner
75
+ * content of inline code spans on prose lines (backticks kept). Fence
76
+ * marker lines themselves are untouched.
77
+ *
78
+ * `transformInlineAttrs` (optional) rewrites the attr group of an
79
+ * inline span at re-emission — return `""` to drop it. Only inline
80
+ * spans see it; fence info strings are never routed through it.
81
+ */
82
+ /**
83
+ * Line-by-line fence tracker on THE shared grammar — for tools that must
84
+ * walk a document themselves (the remote-include inliner) instead of
85
+ * transforming regions. A locally re-implemented open/close regex pair
86
+ * drifted immediately: it missed the deflist-hosted `: ```` opener the
87
+ * shared grammar just gained, took the indented closer for an opener,
88
+ * and re-created the exact phantom-fence inversion it was fixing
89
+ * (issue #039 review).
90
+ *
91
+ * step(line) returns what the line IS: "fence" (an opener or closer),
92
+ * "code" (content of an open fence), or "prose". open() reports the
93
+ * state AFTER the last step, so an opener steps "fence" with
94
+ * open()===true and its closer steps "fence" with open()===false.
95
+ */
96
+ export function createFenceTracker() {
97
+ const state = newState();
98
+ return {
99
+ step: (line) => stepFence(state, line),
100
+ open: () => state.open,
101
+ };
102
+ }
103
+ /**
104
+ * Transform only fence OPENING lines' info strings, on the same shared
105
+ * grammar — a fence-looking line inside an open fence is code content
106
+ * and is never touched. Used to strip the spent `{minja}` marker after
107
+ * the terminal Minja render (a fence marker that survives into the
108
+ * mirror leaks into serializers that round-trip fence info verbatim —
109
+ * the Obsidian vault showed literal ```` ```yaml {minja} ````).
110
+ */
111
+ export function transformFenceInfo(source, transform) {
112
+ const state = newState();
113
+ return source
114
+ .split("\n")
115
+ .map((line) => {
116
+ const wasOpen = state.open;
117
+ if (stepFence(state, line) === "fence" && !wasOpen) {
118
+ // Opening fence: rewrite the info string in place. Untouched info
119
+ // returns the line BYTE-IDENTICAL (incl. any trailing \r) — this
120
+ // is a write path on the shared grammar, and rewriting lines the
121
+ // transform didn't change broke mirror byte-stability (review).
122
+ const m = line.match(OPEN_RE);
123
+ if (m) {
124
+ const runEnd = line.indexOf(m[1]) + m[1].length;
125
+ const hadCR = line.endsWith("\r");
126
+ const info = line.substring(runEnd, hadCR ? line.length - 1 : line.length);
127
+ const newInfo = transform(info);
128
+ return newInfo === info ? line : line.substring(0, runEnd) + newInfo + (hadCR ? "\r" : "");
129
+ }
130
+ }
131
+ return line;
132
+ })
133
+ .join("\n");
134
+ }
135
+ export function transformCodeRegions(source, transform, transformInlineAttrs) {
136
+ const state = newState();
137
+ return source
138
+ .split("\n")
139
+ .map((line) => {
140
+ switch (stepFence(state, line)) {
141
+ case "fence":
142
+ return line;
143
+ case "code":
144
+ return transform(line, { fenceInfo: state.info });
145
+ default:
146
+ // An attr group directly after the closing ticks travels as
147
+ // the region's info string: `` `x`{minja} `` opts the span
148
+ // into Minja processing exactly like a `{minja}` fence.
149
+ // Backticks are excluded from the group so a brace group can
150
+ // never swallow a span opener and shift pairing vs markdown-it.
151
+ return line.replace(/(`+)([^`]*?)\1(\{[^{}`\n]*\})?/g, (_m, ticks, inner, attrs) => ticks + transform(inner, { fenceInfo: attrs ?? "" }) + ticks +
152
+ (attrs === undefined ? "" : transformInlineAttrs ? transformInlineAttrs(attrs) : attrs));
153
+ }
154
+ })
155
+ .join("\n");
156
+ }
157
+ /**
158
+ * The inverse walk: transform PROSE lines (outside fences), leaving
159
+ * fenced content and the fence lines untouched. Used by refactor
160
+ * commands that rewrite markdown structure line-wise (heading shifts,
161
+ * section splits) — a `# heading` inside a code example must never be
162
+ * treated as structure.
163
+ */
164
+ export function transformProseLines(source, transform) {
165
+ const state = newState();
166
+ return source
167
+ .split("\n")
168
+ .map((line, index) => (stepFence(state, line) === "prose" ? transform(line, index) : line))
169
+ .join("\n");
170
+ }
171
+ /**
172
+ * Transform PROSE TEXT SEGMENTS: prose lines with their inline code
173
+ * spans protected (the transform sees only the non-code stretches of
174
+ * each prose line). This is the write-side counterpart of
175
+ * transformCodeRegions — refactor commands rewrite references through
176
+ * it so `[x](old.md)` or `{{ key }}` inside examples survive verbatim.
177
+ */
178
+ export function transformProseSegments(source, transform) {
179
+ const state = newState();
180
+ return source
181
+ .split("\n")
182
+ .map((line) => {
183
+ if (stepFence(state, line) !== "prose")
184
+ return line;
185
+ // Split the line into code spans and prose stretches; transform
186
+ // only the prose stretches
187
+ const parts = [];
188
+ let last = 0;
189
+ for (const match of line.matchAll(/(`+)[^`]*?\1/g)) {
190
+ parts.push(transform(line.slice(last, match.index)));
191
+ parts.push(match[0]);
192
+ last = match.index + match[0].length;
193
+ }
194
+ parts.push(transform(line.slice(last)));
195
+ return parts.join("");
196
+ })
197
+ .join("\n");
198
+ }
199
+ /**
200
+ * Fence state BEFORE each line: `openBefore[i]` is true when line i
201
+ * starts inside an unclosed fence. One extra trailing entry gives the
202
+ * state after the last line. Lets line-range refactors check "does
203
+ * this range start or end mid-fence" with the same grammar.
204
+ */
205
+ export function fenceOpenBefore(source) {
206
+ const state = newState();
207
+ const states = [];
208
+ for (const line of source.split("\n")) {
209
+ states.push(state.open);
210
+ stepFence(state, line);
211
+ }
212
+ states.push(state.open);
213
+ return states;
214
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The `:::name` container block rule: markdown-it-container 4.0.0 (MIT,
3
+ * Vitaly Puzrin, Alex Kocharin), adapted. Three changes, all in how the
4
+ * closing line is found:
5
+ *
6
+ * 1. A closer may carry an HTML comment, `::: <!-- end note -->`, the way an
7
+ * author labels an XML end tag. The comment changes nothing about which
8
+ * container closes; it rides on the close token (`info`) so the parser can
9
+ * keep it and diagnostics can check the name.
10
+ * 2. A `:::` line inside a code block does not close the container.
11
+ * markdown-it-container scanned raw lines, so a code example showing a
12
+ * directive closed the container around it and the rest of the page became
13
+ * one code block, with no message. Which lines are code comes from one
14
+ * block-level pass over the source with containers off (codeLines).
15
+ *
16
+ * 3. Blocks nest by a stack, not by colon count (NESTING below).
17
+ *
18
+ * Everything else (indentation, auto-close at the end of the parent) is
19
+ * markdown-it-container's.
20
+ *
21
+ * markdown-it-container's licence:
22
+ *
23
+ * Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
24
+ *
25
+ * Permission is hereby granted, free of charge, to any person
26
+ * obtaining a copy of this software and associated documentation
27
+ * files (the "Software"), to deal in the Software without
28
+ * restriction, including without limitation the rights to use,
29
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
30
+ * copies of the Software, and to permit persons to whom the
31
+ * Software is furnished to do so, subject to the following
32
+ * conditions:
33
+ *
34
+ * The above copyright notice and this permission notice shall be
35
+ * included in all copies or substantial portions of the Software.
36
+ *
37
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
38
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
39
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
40
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
41
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
42
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
43
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
44
+ * OTHER DEALINGS IN THE SOFTWARE.
45
+ */
46
+ import type MarkdownIt from "markdown-it";
47
+ /**
48
+ * NESTING. A closer with N colons closes the innermost open block opened with exactly N colons,
49
+ * else the innermost opened with fewer, and any block still open inside it. So every block can close with plain `:::`
50
+ * (`:::steps` … `:::tabs` … `:::` … `:::`), and today's scaled nesting (`::::steps` … `:::tabs`
51
+ * … `:::` … `::::`) and the inverse (inner blocks with more colons) read the same. Pandoc's fenced
52
+ * divs nest the same way. Before the stack, a closer ended the OUTERMOST block it was long
53
+ * enough for, so a nested block needed more colons on the outer one, and one `:::` could close
54
+ * several blocks (that case now leaves the outer one open, and the diagnostic says why). "Exactly
55
+ * N first" keeps the old reading where the author scaled: `::::note` … `:::tip` … `::::` closes the
56
+ * note (and the tip with it) as before, rather than the tip.
57
+ *
58
+ * Only a known directive name opens a block in the scan. An unknown `:::word` is a block only if
59
+ * it has a closer of its own and is otherwise text (markUnclosedGenericContainers), and the scan
60
+ * cannot know which, so it does not push one: an unknown block nested at the same colon count
61
+ * as its parent still needs more colons on the parent. `:::` 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
+ */
77
+ export interface ContainerOptions {
78
+ validate: (params: string, markup: string) => boolean;
79
+ render?: (tokens: unknown[], idx: number) => string;
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;
83
+ export declare function containerRule(md: MarkdownIt, name: string, options: ContainerOptions): void;
@@ -0,0 +1,280 @@
1
+ /**
2
+ * What may follow a closer's colons: nothing, or spaces and one HTML comment, then spaces. The
3
+ * comment is not optional inside the group: `[ \t]*(…)?[ \t]*` backtracks quadratically on a long
4
+ * run of spaces followed by anything else.
5
+ */
6
+ const CLOSER_TAIL = /^(?:[ \t]*|[ \t]*(<!--(?:(?!-->)[\s\S])*-->)[ \t]*)$/;
7
+ /** state.env keys: the pre-pass that finds the code, and its result. */
8
+ const NO_CONTAINERS = "__dogsbayNoContainers";
9
+ const CODE_LINES = "__dogsbayCodeLines";
10
+ /** state.env keys for VERIFY: the parse's retry budget, and each block's learned openers to skip. */
11
+ const RETRY_BUDGET = "__dogsbayContainerRetries";
12
+ const LEARNED = "__dogsbayContainerNotOpeners";
13
+ /**
14
+ * The source lines inside a fenced code block, from one block-level pass over the whole source
15
+ * with containers switched off, made the first time a container needs it and kept in the parse's
16
+ * env. A `:::` line cannot change where a fence starts or ends (a fence ends only at its own
17
+ * closing fence or the end of its list item or quote), so the pass agrees with the real parse,
18
+ * and markdown-it reads fences in quotes, list items and definitions as a line scan does not.
19
+ * One pass per parse, so the cost is linear however many containers there are or how deep.
20
+ *
21
+ * Only a fence that CLOSES counts. With no container to end it, an unclosed fence in the pass runs
22
+ * to the end of the page, and every closer after it would be code: a ````` ```` ````` fence closed
23
+ * with three backticks would take the rest of the page into its container. Left out, it ends at
24
+ * the container's closer, as with markdown-it-container. (Still misread, and no worse than
25
+ * markdown-it-container: a `:::name` straight after a list item, lazily continuing it in the pass,
26
+ * so a fence indented four in the container is read in the item.)
27
+ */
28
+ function codeLines(state) {
29
+ const env = state.env;
30
+ const cached = env?.[CODE_LINES];
31
+ if (cached && cached.src === state.src && cached.md === state.md)
32
+ return cached.lines;
33
+ const tokens = [];
34
+ state.md.block.parse(state.src, state.md, { [NO_CONTAINERS]: true }, tokens);
35
+ const source = state.src.split(/\r\n|\r|\n/);
36
+ const lines = new Set();
37
+ for (const t of tokens) {
38
+ if (t.type !== "fence" || !t.map || !closesFence(source[t.map[1] - 1], t.markup, t.map[1] - 1 > t.map[0]))
39
+ continue;
40
+ // A fence's opening line is not inside it; the lines after it, to its closing fence, are
41
+ for (let l = t.map[0] + 1; l < t.map[1]; l++)
42
+ lines.add(l);
43
+ }
44
+ if (env)
45
+ env[CODE_LINES] = { src: state.src, md: state.md, lines };
46
+ return lines;
47
+ }
48
+ /** Whether `line` (as written, with any `>` and indentation) is the closing fence of a fence opened with `markup`. */
49
+ function closesFence(line, markup, after) {
50
+ if (!after || line === undefined)
51
+ return false;
52
+ const m = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/.exec(line);
53
+ return m !== null && m[1][0] === markup[0] && m[1].length >= markup.length;
54
+ }
55
+ /** The directive names the scan treats as openers (plugin-containers registers them). */
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
+ }
92
+ export function containerRule(md, name, options) {
93
+ const MARKER = ":".charCodeAt(0);
94
+ function container(state, startLine, endLine, silent) {
95
+ const start = state.bMarks[startLine] + state.tShift[startLine];
96
+ const max = state.eMarks[startLine];
97
+ if (state.src.charCodeAt(start) !== MARKER)
98
+ return false;
99
+ if (state.env?.[NO_CONTAINERS])
100
+ return false;
101
+ let pos = start + 1;
102
+ while (pos <= max && state.src.charCodeAt(pos) === MARKER)
103
+ pos++;
104
+ const markerCount = pos - start;
105
+ if (markerCount < 3)
106
+ return false;
107
+ const markup = state.src.slice(start, pos);
108
+ const params = state.src.slice(pos, max);
109
+ if (!options.validate(params, markup))
110
+ return false;
111
+ if (silent)
112
+ return true;
113
+ const oldParent = state.parentType;
114
+ const oldLineMax = state.lineMax;
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) {
188
+ let code;
189
+ let nextLine = startLine;
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
+ };
201
+ for (;;) {
202
+ nextLine++;
203
+ // unclosed: closed by the end of the document or of the parent block
204
+ if (nextLine >= endLine)
205
+ break;
206
+ const start = state.bMarks[nextLine] + state.tShift[nextLine];
207
+ const max = state.eMarks[nextLine];
208
+ if (start >= max)
209
+ continue;
210
+ // a non-empty line indented less than the block ends it (a list item's container)
211
+ if (state.sCount[nextLine] < state.blkIndent)
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);
216
+ if (state.src.charCodeAt(start) !== MARKER)
217
+ continue;
218
+ // openers and closers are indented less than 4 spaces past the block
219
+ if (state.sCount[nextLine] - state.blkIndent >= 4)
220
+ continue;
221
+ let pos = start + 1;
222
+ while (pos < max && state.src.charCodeAt(pos) === MARKER)
223
+ pos++;
224
+ const colons = pos - start;
225
+ if (colons < 3)
226
+ continue;
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)
231
+ continue;
232
+ // a `:::` in a code example is code
233
+ if ((code ??= codeLines(state)).has(nextLine)) {
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);
245
+ continue;
246
+ }
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] ?? "";
271
+ break;
272
+ }
273
+ scan.nextLine = nextLine;
274
+ return scan;
275
+ }
276
+ md.block.ruler.before("fence", `container_${name}`, container, { alt: ["paragraph", "reference", "blockquote", "list"] });
277
+ const render = options.render ?? (() => "");
278
+ md.renderer.rules[`container_${name}_open`] = render;
279
+ md.renderer.rules[`container_${name}_close`] = render;
280
+ }