@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.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @pretextbook/markdown-style-pretext
2
+
3
+ Editor intelligence for **Markdown-style PreTeXt** — CommonMark + GFM plus
4
+ [remark-directive](https://github.com/remarkjs/remark-directive) extensions that convert to
5
+ PreTeXt via [`@pretextbook/remark-pretext`](../remark-pretext).
6
+
7
+ This package implements the **same** `PretextFlavorLanguage` interface as
8
+ [`@pretextbook/latex-style-pretext`](../latex-style-pretext), so the VS Code LSP server and the
9
+ Monaco editor in `pretext-plus-editor` register it alongside the LaTeX flavor with no adapter
10
+ changes. It returns LSP-shaped values (`vscode-languageserver-types`) and ships raw TypeScript
11
+ with no build step, following the `@pretextbook/completions` precedent.
12
+
13
+ ## What's here
14
+
15
+ | Module | Purpose |
16
+ | -------------- | ----------------------------------------------------------------------------------------------------- |
17
+ | `data/` | Curated table of container directives (mirrors the converter); math re-exported from the LaTeX flavor |
18
+ | `scan/` | Lightweight linear scanner (math regions, directive stack, fenced code, comments, frontmatter, ids) |
19
+ | `completions/` | Completion engine (container/leaf directives, cross-references, KaTeX math) |
20
+ | `lint/` | Diagnostics engine (unmatched fences, unknown directives, unknown math macros) |
21
+
22
+ ## The directive forms
23
+
24
+ - **Container fence** (`:::name` … `:::`) — the workhorse; a **fixed** vocabulary (the curated
25
+ table), so an unknown name is flagged by lint. A fence's open and close must use the **same
26
+ number of colons** to pair (mirrors `directive-normalizer.ts`); a mismatched close is an error.
27
+ - **Python-style** (`Name:` with an indented body) — opens a directive whose body is the
28
+ deeper-indented block and closes implicitly on dedent (mirrors `indentation-normalizer.ts`).
29
+ This dialect disables indented code blocks, so indentation is always directive structure.
30
+ Recognized only for supported directive names, so it needs no unknown-directive lint.
31
+ - **Leaf** (`::name{…}`) — the PreTeXt Plus **include** syntax; any name becomes `<plus:name/>`,
32
+ so leaf names are offered as completions but never validated.
33
+ - **Text** (`:name[…]`) — inline; the converter has no handler yet (they become `<TODO>`
34
+ placeholders), so this flavor neither completes nor lints them.
35
+
36
+ A drift-guard test (`data/directives.drift.spec.ts`) runs every curated container directive
37
+ through `@pretextbook/remark-pretext` and asserts it still converts without an "unsupported"
38
+ warning, keeping the table honest against the in-repo converter.
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@pretextbook/markdown-style-pretext",
3
+ "version": "0.0.1",
4
+ "description": "Editor intelligence (completions + validation) for Markdown-style PreTeXt documents",
5
+ "author": "Oscar Levin",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/PreTeXtBook/pretext-tools.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/PreTeXtBook/pretext-tools/issues"
13
+ },
14
+ "homepage": "https://github.com/PreTeXtBook/pretext-tools/tree/main/packages/markdown-style-pretext",
15
+ "private": false,
16
+ "type": "module",
17
+ "main": "./src/index.ts",
18
+ "module": "./src/index.ts",
19
+ "types": "./src/index.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/index.ts",
23
+ "import": "./src/index.ts",
24
+ "default": "./src/index.ts"
25
+ }
26
+ },
27
+ "files": [
28
+ "src/**/*",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc --build tsconfig.lib.json",
33
+ "typecheck": "tsc --build --emitDeclarationOnly",
34
+ "test": "vitest run"
35
+ },
36
+ "dependencies": {
37
+ "@pretextbook/latex-style-pretext": "^0.0.1",
38
+ "vscode-languageserver-types": "^3.17.5"
39
+ },
40
+ "devDependencies": {
41
+ "@pretextbook/remark-pretext": "^0.0.11"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getMarkdownCompletions } from "./get-completions";
3
+ import type { CompletionItem } from "vscode-languageserver-types";
4
+
5
+ /** Run completions at the "|" marker in a fixture. */
6
+ function complete(fixture: string): CompletionItem[] {
7
+ const offset = fixture.indexOf("|");
8
+ if (offset === -1) throw new Error("fixture needs a | cursor marker");
9
+ return getMarkdownCompletions({ text: fixture.replace("|", ""), offset });
10
+ }
11
+
12
+ const labels = (items: CompletionItem[]) => items.map((i) => i.label);
13
+ const byLabel = (items: CompletionItem[], label: string) =>
14
+ items.find((i) => i.label === label);
15
+
16
+ describe("container directive completions", () => {
17
+ it("offers container directives after `:::`", () => {
18
+ expect(labels(complete(":::theo|"))).toContain("theorem");
19
+ });
20
+
21
+ it("expands to a full :::name … ::: skeleton", () => {
22
+ const item = byLabel(complete(":::theo|"), "theorem");
23
+ expect(item?.textEdit?.newText).toBe("theorem\n\t$0\n:::");
24
+ });
25
+
26
+ it("seeds a nested :::task for task-bearing directives", () => {
27
+ const item = byLabel(complete(":::exerc|"), "exercise");
28
+ expect(item?.textEdit?.newText).toContain(":::task");
29
+ });
30
+
31
+ it("boosts child directives of the enclosing container", () => {
32
+ const items = complete(":::theorem\ntext\n:::pro|\n:::\n");
33
+ const proof = byLabel(items, "proof");
34
+ expect(proof?.sortText?.startsWith("0")).toBe(true);
35
+ });
36
+
37
+ it("filters by the typed prefix", () => {
38
+ const items = complete(":::lem|");
39
+ expect(labels(items)).toContain("lemma");
40
+ expect(labels(items)).not.toContain("theorem");
41
+ });
42
+ });
43
+
44
+ describe("python-style directive completions", () => {
45
+ it("completes a bare line-start word into a `Name:` marker", () => {
46
+ const item = byLabel(complete("Theo|"), "theorem:");
47
+ expect(item?.textEdit?.newText).toBe("theorem:\n\t$0");
48
+ });
49
+
50
+ it("is case-insensitive and offers indented directives", () => {
51
+ expect(labels(complete("exerc|"))).toContain("exercise:");
52
+ const item = byLabel(complete("exerc|"), "exercise:");
53
+ expect(item?.textEdit?.newText).toContain("task:");
54
+ });
55
+
56
+ it("boosts child directives inside a python-style parent body", () => {
57
+ const items = complete("Theorem:\n Statement.\n\n Proo|\n");
58
+ const proof = byLabel(items, "proof:");
59
+ expect(proof?.sortText?.startsWith("0")).toBe(true);
60
+ });
61
+
62
+ it("stays quiet for prose that is not a directive prefix", () => {
63
+ // "Let" prefixes no directive name, so no popup appears mid-sentence.
64
+ expect(complete("Let|")).toEqual([]);
65
+ });
66
+ });
67
+
68
+ describe("leaf directive completions", () => {
69
+ it("offers include directives after `::`", () => {
70
+ const items = complete("::sec|");
71
+ const section = byLabel(items, "section");
72
+ expect(section?.textEdit?.newText).toBe('section{ref="$1"}');
73
+ });
74
+
75
+ it("does not offer container directives after only `::`", () => {
76
+ // `proof` is a container directive; it must not appear in leaf context.
77
+ expect(labels(complete("::pro|"))).not.toContain("proof");
78
+ });
79
+ });
80
+
81
+ describe("math completions", () => {
82
+ it("switches to KaTeX macros inside math mode", () => {
83
+ const items = complete("$ x = \\fra| $");
84
+ expect(labels(items)).toContain("frac");
85
+ // Directive names must not leak into math completions.
86
+ expect(labels(items)).not.toContain("theorem");
87
+ });
88
+ });
89
+
90
+ describe("cross-reference completions", () => {
91
+ it("completes `](#…` against harvested ids", () => {
92
+ const items = complete(":::theorem{#thm:main}\nHi\n:::\n\nSee [it](#thm|");
93
+ expect(labels(items)).toContain("thm:main");
94
+ });
95
+ });
96
+
97
+ describe("suppression", () => {
98
+ it("suppresses completions inside fenced code", () => {
99
+ expect(complete("```\n:::theo|\n```\n")).toEqual([]);
100
+ });
101
+
102
+ it("suppresses completions inside HTML comments", () => {
103
+ expect(complete("<!-- :::theo| -->")).toEqual([]);
104
+ });
105
+
106
+ it("suppresses completions inside frontmatter", () => {
107
+ expect(complete("---\ndivi|\n---\n")).toEqual([]);
108
+ });
109
+ });
@@ -0,0 +1,283 @@
1
+ import {
2
+ CompletionItem,
3
+ CompletionItemKind,
4
+ InsertTextFormat,
5
+ MarkupKind,
6
+ } from "vscode-languageserver-types";
7
+ import type { GetCompletionsParams } from "../types";
8
+ import { scanDocument, contextAt } from "../scan/scan-document";
9
+ import { CONTAINER_DIRECTIVES, LEAF_DIRECTIVES } from "../data/directives";
10
+ import { KATEX_MACROS, EXTRA_MATH_MACROS } from "../data/math";
11
+ import { rangeFromOffsets } from "../util/position";
12
+ import {
13
+ containerInsertText,
14
+ leafInsertText,
15
+ pythonInsertText,
16
+ } from "./snippets";
17
+
18
+ // A container fence line up to the cursor: leading space, `:::`+, optional name.
19
+ const CONTAINER_CONTEXT_RE = /^[ \t]*:{3,}[ \t]*([A-Za-z][\w-]*)?$/;
20
+ // A leaf directive line up to the cursor: exactly two colons, optional name.
21
+ const LEAF_CONTEXT_RE = /^[ \t]*::(?!:)([A-Za-z][\w-]*)?$/;
22
+ // A python-style marker being typed: a bare word at the start of the line.
23
+ const PYTHON_CONTEXT_RE = /^[ \t]*([A-Za-z][A-Za-z0-9_-]*)$/;
24
+ // A markdown cross-reference link target: `](#id`.
25
+ const XREF_CONTEXT_RE = /\]\(#([\w:.-]*)$/;
26
+ // A math macro being typed: `\name`.
27
+ const MATH_MACRO_RE = /\\([A-Za-z]*)$/;
28
+
29
+ interface CursorEdit {
30
+ start: number;
31
+ end: number;
32
+ }
33
+
34
+ /**
35
+ * Main entry point: given the document text and a cursor offset, return the
36
+ * completions appropriate to the cursor's syntactic context.
37
+ */
38
+ export function getMarkdownCompletions(
39
+ params: GetCompletionsParams,
40
+ ): CompletionItem[] {
41
+ const { text, offset } = params;
42
+ const scan = scanDocument(text);
43
+ const ctx = contextAt(scan, offset);
44
+
45
+ // No completions inside fenced code, HTML comments, or YAML frontmatter.
46
+ if (ctx.inCode || ctx.inComment || ctx.inFrontmatter) return [];
47
+
48
+ const before = text.slice(0, offset);
49
+ const lineStart = before.lastIndexOf("\n") + 1;
50
+ const currentLine = before.slice(lineStart);
51
+
52
+ // `:::` at the start of a line — container directive names.
53
+ const container = CONTAINER_CONTEXT_RE.exec(currentLine);
54
+ if (container && ctx.mode !== "math") {
55
+ const prefix = container[1] ?? "";
56
+ const edit: CursorEdit = { start: offset - prefix.length, end: offset };
57
+ return containerItems(text, edit, prefix, ctx.currentDirective);
58
+ }
59
+
60
+ // `::` at the start of a line — leaf (include) directive names.
61
+ const leaf = LEAF_CONTEXT_RE.exec(currentLine);
62
+ if (leaf && ctx.mode !== "math") {
63
+ const prefix = leaf[1] ?? "";
64
+ const edit: CursorEdit = { start: offset - prefix.length, end: offset };
65
+ return leafItems(text, edit, prefix);
66
+ }
67
+
68
+ // A bare word at the start of a line — python-style directive markers. Only
69
+ // words that prefix a real directive name produce items, so ordinary prose
70
+ // (which almost never begins with a directive name) stays quiet.
71
+ const python = PYTHON_CONTEXT_RE.exec(currentLine);
72
+ if (python && ctx.mode !== "math") {
73
+ const prefix = python[1];
74
+ const edit: CursorEdit = { start: offset - prefix.length, end: offset };
75
+ const items = pythonItems(text, edit, prefix, ctx.currentDirective);
76
+ if (items.length > 0) return items;
77
+ }
78
+
79
+ // `](#…` — cross-reference against harvested ids.
80
+ const xref = XREF_CONTEXT_RE.exec(before);
81
+ if (xref) {
82
+ const prefix = xref[1];
83
+ const closer = text[offset] === ")" ? offset + 1 : offset;
84
+ const edit: CursorEdit = { start: offset - prefix.length, end: closer };
85
+ return xrefItems(text, edit, scan.labels);
86
+ }
87
+
88
+ // `\macro` inside math — KaTeX macros.
89
+ if (ctx.mode === "math") {
90
+ const macro = MATH_MACRO_RE.exec(before);
91
+ if (macro) {
92
+ const prefix = macro[1];
93
+ const edit: CursorEdit = { start: offset - prefix.length, end: offset };
94
+ return mathMacroItems(text, edit, prefix);
95
+ }
96
+ }
97
+
98
+ return [];
99
+ }
100
+
101
+ // --- context-specific item builders ---------------------------------------
102
+
103
+ function containerItems(
104
+ text: string,
105
+ edit: CursorEdit,
106
+ prefix: string,
107
+ currentDirective: string | undefined,
108
+ ): CompletionItem[] {
109
+ const boosted = new Set(
110
+ (currentDirective &&
111
+ CONTAINER_DIRECTIVES.find((d) => d.name === currentDirective)
112
+ ?.childDirectives) ||
113
+ [],
114
+ );
115
+
116
+ const items: CompletionItem[] = [];
117
+ for (const spec of CONTAINER_DIRECTIVES) {
118
+ if (prefix && !spec.name.startsWith(prefix)) continue;
119
+ const detailBits: string[] = [spec.category];
120
+ if (spec.requiresStatement) detailBits.push("statement");
121
+ const boost = boosted.has(spec.name);
122
+ items.push(
123
+ makeItem({
124
+ text,
125
+ edit,
126
+ label: spec.name,
127
+ kind: CompletionItemKind.Class,
128
+ detail: `directive · ${detailBits.join(", ")}`,
129
+ documentation: spec.documentation,
130
+ insert: containerInsertText(spec),
131
+ // Boosted children sort first, then everything alphabetically.
132
+ sortText: `${boost ? "0" : "1"}${spec.name}`,
133
+ }),
134
+ );
135
+ }
136
+ return items;
137
+ }
138
+
139
+ function pythonItems(
140
+ text: string,
141
+ edit: CursorEdit,
142
+ prefix: string,
143
+ currentDirective: string | undefined,
144
+ ): CompletionItem[] {
145
+ // Case-insensitive: authors write `Theorem:`, the converter lowercases it.
146
+ const lower = prefix.toLowerCase();
147
+ const boosted = new Set(
148
+ (currentDirective &&
149
+ CONTAINER_DIRECTIVES.find((d) => d.name === currentDirective)
150
+ ?.childDirectives) ||
151
+ [],
152
+ );
153
+
154
+ const items: CompletionItem[] = [];
155
+ for (const spec of CONTAINER_DIRECTIVES) {
156
+ if (!spec.name.startsWith(lower)) continue;
157
+ const boost = boosted.has(spec.name);
158
+ items.push(
159
+ makeItem({
160
+ text,
161
+ edit,
162
+ label: `${spec.name}:`,
163
+ kind: CompletionItemKind.Class,
164
+ detail: `directive (indented) · ${spec.category}`,
165
+ documentation: spec.documentation,
166
+ insert: pythonInsertText(spec),
167
+ sortText: `${boost ? "0" : "1"}${spec.name}`,
168
+ }),
169
+ );
170
+ }
171
+ return items;
172
+ }
173
+
174
+ function leafItems(
175
+ text: string,
176
+ edit: CursorEdit,
177
+ prefix: string,
178
+ ): CompletionItem[] {
179
+ const items: CompletionItem[] = [];
180
+ for (const spec of LEAF_DIRECTIVES) {
181
+ if (prefix && !spec.name.startsWith(prefix)) continue;
182
+ items.push(
183
+ makeItem({
184
+ text,
185
+ edit,
186
+ label: spec.name,
187
+ kind: CompletionItemKind.Module,
188
+ detail: "include · plus:" + spec.name,
189
+ documentation: spec.documentation,
190
+ insert: leafInsertText(spec),
191
+ }),
192
+ );
193
+ }
194
+ return items;
195
+ }
196
+
197
+ function xrefItems(
198
+ text: string,
199
+ edit: CursorEdit,
200
+ labels: { name: string }[],
201
+ ): CompletionItem[] {
202
+ const seen = new Set<string>();
203
+ const items: CompletionItem[] = [];
204
+ for (const { name } of labels) {
205
+ if (seen.has(name)) continue;
206
+ seen.add(name);
207
+ items.push(
208
+ makeItem({
209
+ text,
210
+ edit,
211
+ label: name,
212
+ kind: CompletionItemKind.Reference,
213
+ detail: "cross-reference target",
214
+ insert: name,
215
+ plainText: true,
216
+ }),
217
+ );
218
+ }
219
+ return items;
220
+ }
221
+
222
+ function mathMacroItems(
223
+ text: string,
224
+ edit: CursorEdit,
225
+ prefix: string,
226
+ ): CompletionItem[] {
227
+ const items: CompletionItem[] = [];
228
+ const push = (name: string, detail: string) => {
229
+ if (prefix && !name.startsWith(prefix)) return;
230
+ // KaTeX's list includes single-symbol control words (e.g. `\,`); only offer
231
+ // letter-named macros through the `\word` completion path.
232
+ if (!/^[a-zA-Z]/.test(name)) return;
233
+ items.push(
234
+ makeItem({
235
+ text,
236
+ edit,
237
+ label: name,
238
+ kind: CompletionItemKind.Function,
239
+ detail,
240
+ insert: name,
241
+ plainText: true,
242
+ }),
243
+ );
244
+ };
245
+ for (const name of KATEX_MACROS) push(name, "math macro (KaTeX)");
246
+ for (const name of EXTRA_MATH_MACROS) push(name, "math macro");
247
+ return items;
248
+ }
249
+
250
+ // --- item construction -----------------------------------------------------
251
+
252
+ function makeItem(args: {
253
+ text: string;
254
+ edit: CursorEdit;
255
+ label: string;
256
+ kind: CompletionItemKind;
257
+ detail?: string;
258
+ documentation?: string;
259
+ insert: string;
260
+ sortText?: string;
261
+ plainText?: boolean;
262
+ }): CompletionItem {
263
+ const item: CompletionItem = {
264
+ label: args.label,
265
+ kind: args.kind,
266
+ insertTextFormat: args.plainText
267
+ ? InsertTextFormat.PlainText
268
+ : InsertTextFormat.Snippet,
269
+ textEdit: {
270
+ range: rangeFromOffsets(args.text, args.edit.start, args.edit.end),
271
+ newText: args.insert,
272
+ },
273
+ };
274
+ if (args.detail) item.detail = args.detail;
275
+ if (args.sortText) item.sortText = args.sortText;
276
+ if (args.documentation) {
277
+ item.documentation = {
278
+ kind: MarkupKind.Markdown,
279
+ value: args.documentation,
280
+ };
281
+ }
282
+ return item;
283
+ }
@@ -0,0 +1,47 @@
1
+ import type { DirectiveSpec } from "../types";
2
+
3
+ /**
4
+ * The text inserted when completing a container directive, assuming the caller
5
+ * has already typed the opening `:::` (the completion range covers the partial
6
+ * name after it). Produces the name line, a body tab stop, and the closing
7
+ * `:::` fence — the framing is always supplied here so an open can never be
8
+ * generated without its matching close.
9
+ *
10
+ * A spec may override the body via `snippet` (body only). Directives with
11
+ * nested tasks seed a first `:::task` child so the structure is discoverable.
12
+ */
13
+ export function containerInsertText(spec: DirectiveSpec): string {
14
+ if (spec.snippet) return `${spec.name}\n${spec.snippet}\n:::`;
15
+ if (spec.hasNestedTasks) {
16
+ return `${spec.name}\n:::task\n\t$0\n:::\n:::`;
17
+ }
18
+ return `${spec.name}\n\t$0\n:::`;
19
+ }
20
+
21
+ /** The bare closing fence, offered when a container directive is open. */
22
+ export function closeInsertText(): string {
23
+ return ":::";
24
+ }
25
+
26
+ /**
27
+ * The text inserted when completing a python-style directive, assuming the
28
+ * caller has typed a bare line-start word (the completion range covers it).
29
+ * Produces `Name:` and an indented body tab stop; the editor re-indents the
30
+ * body relative to the marker line, so a single `\t` nests one level deeper.
31
+ * Task-bearing directives seed a nested `task:` child.
32
+ */
33
+ export function pythonInsertText(spec: DirectiveSpec): string {
34
+ if (spec.hasNestedTasks) {
35
+ return `${spec.name}:\n\ttask:\n\t\t$0`;
36
+ }
37
+ return `${spec.name}:\n\t$0`;
38
+ }
39
+
40
+ /**
41
+ * The text inserted when completing a leaf directive, assuming the caller has
42
+ * already typed the opening `::`. Leaf directives are PreTeXt Plus includes, so
43
+ * they reference a target by `ref`.
44
+ */
45
+ export function leafInsertText(spec: DirectiveSpec): string {
46
+ return `${spec.name}{ref="$1"}`;
47
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ CONTAINER_DIRECTIVES,
4
+ CONTAINER_BY_NAME,
5
+ LEAF_DIRECTIVES,
6
+ isKnownContainerDirective,
7
+ } from "./directives";
8
+ import { isKnownMathMacro, isKnownMathEnvironment } from "./math";
9
+
10
+ describe("container directive table", () => {
11
+ it("resolves canonical directive names", () => {
12
+ expect(isKnownContainerDirective("theorem")).toBe(true);
13
+ expect(isKnownContainerDirective("bogus")).toBe(false);
14
+ });
15
+
16
+ it("marks theorem/definition-like directives as requiring a statement", () => {
17
+ expect(CONTAINER_BY_NAME.get("theorem")?.requiresStatement).toBe(true);
18
+ expect(CONTAINER_BY_NAME.get("definition")?.requiresStatement).toBe(true);
19
+ expect(CONTAINER_BY_NAME.get("remark")?.requiresStatement).toBe(false);
20
+ });
21
+
22
+ it("offers proof inside theorems and hint/answer/solution inside exercises", () => {
23
+ expect(CONTAINER_BY_NAME.get("theorem")?.childDirectives).toContain(
24
+ "proof",
25
+ );
26
+ expect(CONTAINER_BY_NAME.get("exercise")?.childDirectives).toEqual([
27
+ "task",
28
+ "hint",
29
+ "answer",
30
+ "solution",
31
+ ]);
32
+ });
33
+
34
+ it("flags exercise/project/task as accepting nested tasks", () => {
35
+ expect(CONTAINER_BY_NAME.get("exercise")?.hasNestedTasks).toBe(true);
36
+ expect(CONTAINER_BY_NAME.get("project")?.hasNestedTasks).toBe(true);
37
+ expect(CONTAINER_BY_NAME.get("theorem")?.hasNestedTasks).toBeUndefined();
38
+ });
39
+
40
+ it("has no duplicate directive names", () => {
41
+ const seen = new Set<string>();
42
+ for (const spec of CONTAINER_DIRECTIVES) {
43
+ expect(seen.has(spec.name), `duplicate: ${spec.name}`).toBe(false);
44
+ seen.add(spec.name);
45
+ }
46
+ });
47
+ });
48
+
49
+ describe("leaf (include) directives", () => {
50
+ it("lists division and asset includes but never validates them", () => {
51
+ const names = LEAF_DIRECTIVES.map((d) => d.name);
52
+ expect(names).toContain("section");
53
+ expect(names).toContain("image");
54
+ // Leaf names are includes: they are not container directives.
55
+ expect(isKnownContainerDirective("image")).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe("shared math support", () => {
60
+ it("recognizes KaTeX macros and environments via the LaTeX package", () => {
61
+ expect(isKnownMathMacro("frac")).toBe(true);
62
+ expect(isKnownMathMacro("systeme")).toBe(true);
63
+ expect(isKnownMathEnvironment("align")).toBe(true);
64
+ });
65
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { markdownToPretext } from "@pretextbook/remark-pretext";
3
+ import { CONTAINER_DIRECTIVES } from "./directives";
4
+
5
+ // Drift-guard: every curated container directive must still be recognized by
6
+ // the real converter. If someone renames or removes a directive in
7
+ // `@pretextbook/remark-pretext`, converting our curated name produces an
8
+ // `unknown directive` TODO placeholder, and this test fails — pointing at the
9
+ // exact entry to reconcile.
10
+
11
+ describe("curated directives match the converter", () => {
12
+ it.each(CONTAINER_DIRECTIVES.map((d) => d.name))(
13
+ "%s converts without an unsupported warning",
14
+ (name) => {
15
+ const xml = markdownToPretext(`:::${name}\nSome content.\n:::\n`);
16
+ expect(xml).not.toContain(`unknown directive "${name}"`);
17
+ expect(xml).not.toContain("unknown-directive");
18
+ },
19
+ );
20
+ });