@pretextbook/markdown-style-pretext 0.0.1

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.
@@ -0,0 +1,168 @@
1
+ // Curated table of container directives supported by the PreTeXt conversion.
2
+ //
3
+ // Source of truth (mirror, not import):
4
+ // packages/remark-pretext/src/lib/directive-map.ts (DIRECTIVE_SPEC_TABLE)
5
+ // Keep in sync with that file; the drift-guard test (directives.drift.spec.ts)
6
+ // asserts every entry here still converts without an "unsupported" warning.
7
+ //
8
+ // A note on the three directive forms:
9
+ // - CONTAINER directives (`:::name` … `:::`) have a *fixed* vocabulary — the
10
+ // table below. An unknown container name will not convert, so lint flags it.
11
+ // - LEAF directives (`::name{…}`) are the PreTeXt Plus *include* syntax; the
12
+ // converter turns any `::name` into `<plus:name/>`, so no leaf name is ever
13
+ // "unknown". `LEAF_DIRECTIVES` below is only a convenience completion list.
14
+ // - TEXT directives (`:name[…]`) have no converter handler yet (they become
15
+ // `<TODO>` placeholders), so this package neither completes nor lints them.
16
+
17
+ import type { DirectiveSpec, DirectiveCategory } from "../types";
18
+
19
+ /** The mirror of `DIRECTIVE_SPEC_TABLE`, minus fields we derive below. */
20
+ type ContainerSeed = {
21
+ category: DirectiveCategory;
22
+ requiresStatement: boolean;
23
+ hasNestedTasks?: boolean;
24
+ documentation?: string;
25
+ };
26
+
27
+ const CONTAINER_SEEDS: Record<string, ContainerSeed> = {
28
+ // theorem-like (body wrapped in <statement>, proof hoisted to a sibling)
29
+ theorem: { category: "theorem-like", requiresStatement: true },
30
+ lemma: { category: "theorem-like", requiresStatement: true },
31
+ corollary: { category: "theorem-like", requiresStatement: true },
32
+ proposition: { category: "theorem-like", requiresStatement: true },
33
+ claim: { category: "theorem-like", requiresStatement: true },
34
+ fact: { category: "theorem-like", requiresStatement: true },
35
+ conjecture: { category: "theorem-like", requiresStatement: true },
36
+ axiom: { category: "theorem-like", requiresStatement: true },
37
+ principle: { category: "theorem-like", requiresStatement: true },
38
+ hypothesis: { category: "theorem-like", requiresStatement: true },
39
+ algorithm: { category: "theorem-like", requiresStatement: true },
40
+
41
+ // definition-like (also statement-wrapped)
42
+ definition: { category: "definition-like", requiresStatement: true },
43
+ notation: { category: "definition-like", requiresStatement: true },
44
+
45
+ // remark-like (no statement wrapping)
46
+ remark: { category: "remark-like", requiresStatement: false },
47
+ note: { category: "remark-like", requiresStatement: false },
48
+ observation: { category: "remark-like", requiresStatement: false },
49
+ warning: { category: "remark-like", requiresStatement: false },
50
+ insight: { category: "remark-like", requiresStatement: false },
51
+ assemblage: { category: "remark-like", requiresStatement: false },
52
+
53
+ // example-like (exercise/project/task accept nested :::task children)
54
+ example: { category: "example-like", requiresStatement: false },
55
+ question: { category: "example-like", requiresStatement: false },
56
+ problem: { category: "example-like", requiresStatement: false },
57
+ exercise: {
58
+ category: "example-like",
59
+ requiresStatement: true,
60
+ hasNestedTasks: true,
61
+ },
62
+ activity: { category: "example-like", requiresStatement: false },
63
+ exploration: { category: "example-like", requiresStatement: false },
64
+ investigation: { category: "example-like", requiresStatement: false },
65
+ project: {
66
+ category: "example-like",
67
+ requiresStatement: true,
68
+ hasNestedTasks: true,
69
+ },
70
+ task: {
71
+ category: "example-like",
72
+ requiresStatement: true,
73
+ hasNestedTasks: true,
74
+ },
75
+
76
+ // proof-like (no statement wrapping)
77
+ proof: { category: "proof-like", requiresStatement: false },
78
+ case: { category: "proof-like", requiresStatement: false },
79
+
80
+ // solution-like (no statement wrapping)
81
+ solution: { category: "solution-like", requiresStatement: false },
82
+ hint: { category: "solution-like", requiresStatement: false },
83
+ answer: { category: "solution-like", requiresStatement: false },
84
+ };
85
+
86
+ /**
87
+ * Exercise-flavored directives whose meaningful children are hint/answer/
88
+ * solution. Mirrors the LaTeX package's `EXERCISE_LIKE` set for consistency.
89
+ */
90
+ const EXERCISE_LIKE = new Set([
91
+ "exercise",
92
+ "problem",
93
+ "question",
94
+ "task",
95
+ "activity",
96
+ "exploration",
97
+ "investigation",
98
+ ]);
99
+
100
+ /** Derive the boosted child directives for a container from its semantics. */
101
+ function childDirectivesFor(name: string, seed: ContainerSeed): string[] {
102
+ const children: string[] = [];
103
+ if (seed.hasNestedTasks) children.push("task");
104
+ if (EXERCISE_LIKE.has(name)) {
105
+ children.push("hint", "answer", "solution");
106
+ } else if (
107
+ seed.category === "theorem-like" ||
108
+ seed.category === "definition-like" ||
109
+ seed.category === "axiom-like"
110
+ ) {
111
+ children.push("proof");
112
+ }
113
+ return children;
114
+ }
115
+
116
+ function seedToSpec(name: string, seed: ContainerSeed): DirectiveSpec {
117
+ const children = childDirectivesFor(name, seed);
118
+ return {
119
+ name,
120
+ type: name,
121
+ category: seed.category,
122
+ kind: "container",
123
+ requiresStatement: seed.requiresStatement,
124
+ hasNestedTasks: seed.hasNestedTasks,
125
+ childDirectives: children.length > 0 ? children : undefined,
126
+ documentation: seed.documentation,
127
+ };
128
+ }
129
+
130
+ /** All supported container directives (`:::name` … `:::`). */
131
+ export const CONTAINER_DIRECTIVES: DirectiveSpec[] = Object.entries(
132
+ CONTAINER_SEEDS,
133
+ ).map(([name, seed]) => seedToSpec(name, seed));
134
+
135
+ /**
136
+ * Convenience completion list for leaf directives (`::name{…}`). These are the
137
+ * PreTeXt Plus *include* syntax — a reference expanded by a later assembly step
138
+ * (`::section{ref="ch-intro"}` → `<plus:section ref="ch-intro"/>`). Any name is
139
+ * valid, so this list is offered as suggestions but never used for validation.
140
+ */
141
+ export const LEAF_DIRECTIVES: DirectiveSpec[] = [
142
+ ["part", "Include a modular part."],
143
+ ["chapter", "Include a modular chapter."],
144
+ ["section", "Include a modular section."],
145
+ ["subsection", "Include a modular subsection."],
146
+ ["subsubsection", "Include a modular subsubsection."],
147
+ ["image", "Include an image asset by `ref`."],
148
+ ["listing", "Include a code listing by `ref`."],
149
+ ["figure", "Include a figure by `ref`."],
150
+ ["table", "Include a table by `ref`."],
151
+ ].map(([name, documentation]) => ({
152
+ name,
153
+ type: name,
154
+ category: "remark-like" as DirectiveCategory,
155
+ kind: "leaf" as const,
156
+ requiresStatement: false,
157
+ documentation,
158
+ }));
159
+
160
+ /** Lookup from a container directive name to its spec. */
161
+ export const CONTAINER_BY_NAME: ReadonlyMap<string, DirectiveSpec> = new Map(
162
+ CONTAINER_DIRECTIVES.map((spec) => [spec.name, spec]),
163
+ );
164
+
165
+ /** True if `name` is a supported container directive. */
166
+ export function isKnownContainerDirective(name: string): boolean {
167
+ return CONTAINER_BY_NAME.has(name.toLowerCase());
168
+ }
@@ -0,0 +1,11 @@
1
+ // Math-mode support is shared with the LaTeX flavor: both flavors accept the
2
+ // same `$…$`, `$$…$$`, `\(…\)`, `\[…\]` delimiters and validate against the
3
+ // same KaTeX list. Re-export from `@pretextbook/latex-style-pretext` so there is
4
+ // exactly one vendored KaTeX support list across the two packages.
5
+ export {
6
+ KATEX_MACROS,
7
+ KATEX_ENVIRONMENTS,
8
+ EXTRA_MATH_MACROS,
9
+ isKnownMathMacro,
10
+ isKnownMathEnvironment,
11
+ } from "@pretextbook/latex-style-pretext";
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./types";
2
+ export * from "./data/directives";
3
+ export * from "./data/math";
4
+ export * from "./scan/scan-document";
5
+ export * from "./util/position";
6
+ export * from "./completions/snippets";
7
+ export * from "./completions/get-completions";
8
+ export * from "./lint/get-diagnostics";
9
+ export * from "./language";
@@ -0,0 +1,27 @@
1
+ import type { CompletionItem, Diagnostic } from "vscode-languageserver-types";
2
+ import type { GetCompletionsParams, PretextFlavorLanguage } from "./types";
3
+ import { getMarkdownCompletions } from "./completions/get-completions";
4
+ import { getMarkdownDiagnostics } from "./lint/get-diagnostics";
5
+
6
+ /** Language id used by VS Code and Monaco for Markdown-style PreTeXt. */
7
+ export const PRETEXT_MARKDOWN_LANGUAGE_ID = "pretext-markdown";
8
+
9
+ /**
10
+ * The concrete `PretextFlavorLanguage` for Markdown-style PreTeXt. It implements
11
+ * the *same* interface as `pretextLatexLanguage`, so both hosts (the VS Code LSP
12
+ * server and the Monaco editor in pretext-plus-editor) register it alongside the
13
+ * LaTeX flavor without any adapter changes.
14
+ *
15
+ * `getDiagnostics` is async to match the interface (a future AST-based linter
16
+ * may load the parser lazily); the current scanner-based implementation is
17
+ * synchronous and resolves immediately.
18
+ */
19
+ export const pretextMarkdownLanguage: PretextFlavorLanguage = {
20
+ languageId: PRETEXT_MARKDOWN_LANGUAGE_ID,
21
+ getCompletions(params: GetCompletionsParams): CompletionItem[] {
22
+ return getMarkdownCompletions(params);
23
+ },
24
+ async getDiagnostics(text: string): Promise<Diagnostic[]> {
25
+ return getMarkdownDiagnostics(text);
26
+ },
27
+ };
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getMarkdownDiagnostics } from "./get-diagnostics";
3
+ import { DiagnosticSeverity } from "vscode-languageserver-types";
4
+
5
+ const messages = (text: string) =>
6
+ getMarkdownDiagnostics(text).map((d) => d.message);
7
+
8
+ describe("directive fence matching", () => {
9
+ it("accepts a well-formed document", () => {
10
+ expect(getMarkdownDiagnostics(":::theorem\nHi\n:::\n")).toEqual([]);
11
+ });
12
+
13
+ it("flags an unclosed container fence", () => {
14
+ const diags = getMarkdownDiagnostics(":::theorem\nHi\n");
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].severity).toBe(DiagnosticSeverity.Error);
17
+ expect(diags[0].message).toContain("never closed");
18
+ });
19
+
20
+ it("flags a stray closing fence", () => {
21
+ const diags = getMarkdownDiagnostics("text\n:::\n");
22
+ expect(diags[0].severity).toBe(DiagnosticSeverity.Error);
23
+ expect(diags[0].message).toContain("no matching open");
24
+ });
25
+
26
+ it("accepts nested containers written with flexible equal colons", () => {
27
+ expect(
28
+ getMarkdownDiagnostics(":::exercise\n:::task\nx\n:::\n:::\n"),
29
+ ).toEqual([]);
30
+ });
31
+
32
+ it("accepts explicitly-leveled nesting where each pair's counts match", () => {
33
+ expect(
34
+ getMarkdownDiagnostics("::::exercise\n:::task\nx\n:::\n::::\n"),
35
+ ).toEqual([]);
36
+ });
37
+
38
+ it("flags a closing fence whose colon count does not match the open", () => {
39
+ const diags = getMarkdownDiagnostics("::::theorem\nHi\n:::\n");
40
+ const mismatch = diags.find((d) => d.message.includes("does not match"));
41
+ expect(mismatch?.severity).toBe(DiagnosticSeverity.Error);
42
+ // The unmatched `:::` leaves the theorem open, so that is flagged too.
43
+ expect(diags.some((d) => d.message.includes("never closed"))).toBe(true);
44
+ });
45
+ });
46
+
47
+ describe("python-style directives", () => {
48
+ it("accepts a well-formed python-style directive", () => {
49
+ expect(
50
+ getMarkdownDiagnostics("Theorem:\n A statement.\n\nAfter.\n"),
51
+ ).toEqual([]);
52
+ });
53
+
54
+ it("does not require an explicit close for python directives", () => {
55
+ // Reaches end of document with the directive open; the dedent-close is
56
+ // implicit, so this is valid (no "never closed" error).
57
+ expect(getMarkdownDiagnostics("Theorem:\n A statement.\n")).toEqual([]);
58
+ });
59
+
60
+ it("does not flag a non-directive `Name:` line", () => {
61
+ expect(getMarkdownDiagnostics("Summary:\n indented text\n")).toEqual([]);
62
+ });
63
+ });
64
+
65
+ describe("unknown directives", () => {
66
+ it("warns on an unsupported container directive", () => {
67
+ const diags = getMarkdownDiagnostics(":::bogus\n:::\n");
68
+ expect(diags[0].severity).toBe(DiagnosticSeverity.Warning);
69
+ expect(diags[0].message).toContain("not a supported PreTeXt directive");
70
+ });
71
+
72
+ it("never flags leaf (include) directives, whatever the name", () => {
73
+ expect(getMarkdownDiagnostics('::anything{ref="x"}\n')).toEqual([]);
74
+ });
75
+ });
76
+
77
+ describe("unknown math macros", () => {
78
+ it("validates math macros against the KaTeX list", () => {
79
+ expect(getMarkdownDiagnostics("Inline $\\frac{1}{2}$ here.")).toEqual([]);
80
+ expect(messages("$ \\notamacro $")[0]).toContain("KaTeX");
81
+ });
82
+
83
+ it("ignores $ and macros inside fenced code and comments", () => {
84
+ expect(getMarkdownDiagnostics("```\n$\\notamacro$\n```\n")).toEqual([]);
85
+ expect(getMarkdownDiagnostics("<!-- $\\notamacro$ -->\n")).toEqual([]);
86
+ });
87
+ });
@@ -0,0 +1,102 @@
1
+ import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver-types";
2
+ import { scanDocument } from "../scan/scan-document";
3
+ import { isKnownContainerDirective } from "../data/directives";
4
+ import { isKnownMathMacro } from "../data/math";
5
+ import { rangeFromOffsets } from "../util/position";
6
+
7
+ const SOURCE = "pretext-markdown";
8
+
9
+ /**
10
+ * Parse-level validation for Markdown-style PreTeXt. Reports colon fences whose
11
+ * open/close counts don't match (or that are unmatched), container directives
12
+ * the conversion does not support, and math macros KaTeX does not know. Nothing
13
+ * is reported inside fenced code, HTML comments, or frontmatter (the scanner
14
+ * already excludes those regions).
15
+ *
16
+ * Python-style markers (`Name:` with an indented body) close implicitly by
17
+ * dedent and only exist for names the converter supports, so they are never a
18
+ * source of fence-matching or unknown-directive diagnostics.
19
+ *
20
+ * There is no "unknown macro" text-mode check: markdown has no loose `\macro`
21
+ * prose to police, and leaf directives (`::name`) accept any name (they become
22
+ * `<plus:name/>` includes), so they are never flagged.
23
+ */
24
+ export function getMarkdownDiagnostics(text: string): Diagnostic[] {
25
+ const scan = scanDocument(text);
26
+ const diagnostics: Diagnostic[] = [];
27
+
28
+ // 1 & 2. Fence matching (colon counts must match) and unknown-directive
29
+ // checks. Only colon fences participate; python markers pop implicitly.
30
+ const stack: { name: string; colons: number; start: number; end: number }[] =
31
+ [];
32
+ for (const occ of scan.directives) {
33
+ if (occ.type === "open") {
34
+ stack.push({
35
+ name: occ.name,
36
+ colons: occ.colons,
37
+ start: occ.start,
38
+ end: occ.end,
39
+ });
40
+ if (occ.style === "fence" && !isKnownContainerDirective(occ.name)) {
41
+ diagnostics.push({
42
+ severity: DiagnosticSeverity.Warning,
43
+ range: rangeFromOffsets(text, occ.start, occ.end),
44
+ source: SOURCE,
45
+ message: `":::${occ.name}" is not a supported PreTeXt directive and will not convert.`,
46
+ });
47
+ }
48
+ } else if (occ.type === "close") {
49
+ const top = stack[stack.length - 1];
50
+ if (occ.style === "python") {
51
+ stack.pop();
52
+ } else if (top && top.colons === occ.colons) {
53
+ stack.pop();
54
+ } else if (top) {
55
+ // A colon fence only pairs when its count matches the innermost open;
56
+ // the converter drops a mismatched close as literal text, leaving the
57
+ // directive open (directive-normalizer.ts:122).
58
+ diagnostics.push({
59
+ severity: DiagnosticSeverity.Error,
60
+ range: rangeFromOffsets(text, occ.start, occ.end),
61
+ source: SOURCE,
62
+ message: `Closing fence "${":".repeat(occ.colons)}" (${occ.colons} colons) does not match the open ":::${top.name}" (${top.colons} colons); an open and its close must use the same number of colons.`,
63
+ });
64
+ } else {
65
+ diagnostics.push({
66
+ severity: DiagnosticSeverity.Error,
67
+ range: rangeFromOffsets(text, occ.start, occ.end),
68
+ source: SOURCE,
69
+ message: `Closing "${":".repeat(occ.colons)}" fence has no matching open directive.`,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ for (const open of stack) {
75
+ diagnostics.push({
76
+ severity: DiagnosticSeverity.Error,
77
+ range: rangeFromOffsets(text, open.start, open.end),
78
+ source: SOURCE,
79
+ message: `":::${open.name}" is never closed with a "${":".repeat(open.colons)}" fence.`,
80
+ });
81
+ }
82
+
83
+ // 3. Unknown math macros inside math regions.
84
+ const macroRe = /\\([a-zA-Z]+)/g;
85
+ for (const region of scan.mathRegions) {
86
+ const body = text.slice(region.start, region.end);
87
+ let m: RegExpExecArray | null;
88
+ while ((m = macroRe.exec(body)) !== null) {
89
+ const name = m[1];
90
+ if (isKnownMathMacro(name)) continue;
91
+ const start = region.start + m.index;
92
+ diagnostics.push({
93
+ severity: DiagnosticSeverity.Information,
94
+ range: rangeFromOffsets(text, start, start + m[0].length),
95
+ source: SOURCE,
96
+ message: `\\${name} is not a KaTeX-supported math macro; it may not render.`,
97
+ });
98
+ }
99
+ }
100
+
101
+ return diagnostics;
102
+ }
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { scanDocument, contextAt } from "./scan-document";
3
+
4
+ /** Offset of the cursor marker "|" in a fixture, and the text with it removed. */
5
+ function cursor(fixture: string): { text: string; offset: number } {
6
+ const offset = fixture.indexOf("|");
7
+ if (offset === -1) throw new Error("fixture must contain a | cursor marker");
8
+ return { text: fixture.replace("|", ""), offset };
9
+ }
10
+
11
+ function contextIn(fixture: string) {
12
+ const { text, offset } = cursor(fixture);
13
+ return contextAt(scanDocument(text), offset);
14
+ }
15
+
16
+ describe("directive scanning", () => {
17
+ it("records container open/close occurrences", () => {
18
+ const { directives } = scanDocument(":::theorem\nHi\n:::\n");
19
+ expect(directives.map((d) => [d.name, d.type])).toEqual([
20
+ ["theorem", "open"],
21
+ ["", "close"],
22
+ ]);
23
+ });
24
+
25
+ it("records leaf directives without opening a container", () => {
26
+ const { directives } = scanDocument('::section{ref="ch-1"}\n');
27
+ expect(directives).toEqual([
28
+ expect.objectContaining({ name: "section", type: "leaf", colons: 2 }),
29
+ ]);
30
+ });
31
+
32
+ it("tracks the open container stack, innermost last", () => {
33
+ const ctx = contextIn(":::exercise\n:::task\nno|w\n:::\n:::\n");
34
+ expect(ctx.directiveStack).toEqual(["exercise", "task"]);
35
+ expect(ctx.currentDirective).toBe("task");
36
+ });
37
+
38
+ it("pops the innermost container on a closing fence", () => {
39
+ const ctx = contextIn(":::exercise\n:::task\n:::\naf|ter\n:::\n");
40
+ expect(ctx.directiveStack).toEqual(["exercise"]);
41
+ });
42
+
43
+ it("does not count a container whose open line straddles the cursor", () => {
44
+ const ctx = contextIn(":::theo|rem\n");
45
+ expect(ctx.directiveStack).toEqual([]);
46
+ });
47
+
48
+ it("pairs a colon fence only when open/close counts match", () => {
49
+ // The `:::` cannot close the `::::theorem`, so it stays open at the cursor.
50
+ const ctx = contextIn("::::theorem\n:::\naf|ter\n");
51
+ expect(ctx.directiveStack).toEqual(["theorem"]);
52
+ });
53
+
54
+ it("closes a deeper-nested fence with its matching count", () => {
55
+ const ctx = contextIn("::::exercise\n:::task\n:::\nno|w\n::::\n");
56
+ expect(ctx.directiveStack).toEqual(["exercise"]);
57
+ });
58
+ });
59
+
60
+ describe("python-style indented directives", () => {
61
+ it("opens a directive from a `Name:` marker with an indented body", () => {
62
+ const ctx = contextIn("Theorem:\n Let $x$ be re|al.\n");
63
+ expect(ctx.currentDirective).toBe("theorem");
64
+ });
65
+
66
+ it("nests by indentation and closes on dedent", () => {
67
+ const doc =
68
+ "Theorem:\n Statement.\n\n Proof:\n deep| inside\n\nPlain text.\n";
69
+ expect(contextIn(doc).directiveStack).toEqual(["theorem", "proof"]);
70
+ });
71
+
72
+ it("closes python directives once content outdents", () => {
73
+ const doc = "Theorem:\n Statement.\n\nPlain te|xt.\n";
74
+ expect(contextIn(doc).directiveStack).toEqual([]);
75
+ });
76
+
77
+ it("ignores a `Name:` line with no indented body", () => {
78
+ // No deeper body, so this is ordinary prose, not a directive.
79
+ const ctx = contextIn("Theorem:\nNext li|ne at same indent.\n");
80
+ expect(ctx.directiveStack).toEqual([]);
81
+ });
82
+
83
+ it("ignores a `Name:` line whose name is not a directive", () => {
84
+ const ctx = contextIn("Summary:\n some indented te|xt\n");
85
+ expect(ctx.directiveStack).toEqual([]);
86
+ });
87
+ });
88
+
89
+ describe("math-mode detection", () => {
90
+ it("is math inside $...$", () => {
91
+ expect(contextIn("text $x + |y$ more").mode).toBe("math");
92
+ });
93
+
94
+ it("is text outside inline math", () => {
95
+ expect(contextIn("text $x$ mo|re").mode).toBe("text");
96
+ });
97
+
98
+ it("handles \\(...\\) and \\[...\\] and multi-line $$", () => {
99
+ expect(contextIn("a \\( x |y \\) b").mode).toBe("math");
100
+ expect(contextIn("$$\nx = |1\n$$\n").mode).toBe("math");
101
+ });
102
+
103
+ it("treats an escaped dollar as literal, not math", () => {
104
+ expect(contextIn("costs \\$5 and is fi|ne").mode).toBe("text");
105
+ });
106
+ });
107
+
108
+ describe("code, comment, and frontmatter suppression", () => {
109
+ it("marks offsets inside a fenced code block", () => {
110
+ const ctx = contextIn("```\n:::theorem still co|de\n```\n");
111
+ expect(ctx.inCode).toBe(true);
112
+ // A `:::` inside code must not open a directive.
113
+ expect(ctx.directiveStack).toEqual([]);
114
+ });
115
+
116
+ it("does not open math from a $ inside fenced code", () => {
117
+ expect(contextIn("```\nprice $5\n```\nplain te|xt").mode).toBe("text");
118
+ });
119
+
120
+ it("flags offsets inside an HTML comment, including its trailing edge", () => {
121
+ expect(contextIn("text <!-- a comment wi|th $x$ -->\n").inComment).toBe(
122
+ true,
123
+ );
124
+ expect(
125
+ contextIn("text <!-- multi\nline com|ment\n--> after").inComment,
126
+ ).toBe(true);
127
+ });
128
+
129
+ it("marks offsets inside YAML frontmatter", () => {
130
+ const ctx = contextIn("---\ntitle: My Bo|ok\n---\n# Chapter\n");
131
+ expect(ctx.inFrontmatter).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe("id harvesting", () => {
136
+ it("harvests ids from directive attributes and headings", () => {
137
+ const { labels } = scanDocument(
138
+ ":::theorem{#thm:main}\nHi\n:::\n\n## Intro {#sec:intro}\n",
139
+ );
140
+ expect(labels.map((l) => l.name)).toEqual(["thm:main", "sec:intro"]);
141
+ });
142
+
143
+ it("harvests xml:id / label attribute forms and frontmatter", () => {
144
+ const { labels } = scanDocument(
145
+ '---\nxmlid: root-id\n---\n::section{ref="x" label=lbl-1}\n',
146
+ );
147
+ expect(labels.map((l) => l.name)).toContain("root-id");
148
+ expect(labels.map((l) => l.name)).toContain("lbl-1");
149
+ });
150
+ });