starlight-cannoli-plugins 1.0.16 → 1.2.0

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 CHANGED
@@ -9,6 +9,7 @@ A collection of powerful plugins for [Astro Starlight](https://starlight.astro.b
9
9
  Automatically generates a nested Starlight sidebar by recursively scanning directories for `index.md`/`index.mdx` files. Only directories with index files appear in the sidebar, creating a clean, minimal navigation structure.
10
10
 
11
11
  **Features:**
12
+
12
13
  - Recursively scans directories for `index.md` or `index.mdx` files
13
14
  - Creates sidebar entries only for pages with index files
14
15
  - Respects frontmatter: `draft: true` and `sidebar.hidden: true` hide entries
@@ -38,7 +39,7 @@ export default defineConfig({
38
39
  plugins: [
39
40
  starlightIndexOnlySidebar({
40
41
  directories: ["guides", "api", "tutorials"],
41
- maxDepthNesting: 2, // optional
42
+ maxDepthNesting: 2, // optional
42
43
  dirnameDeterminesLabels: false, // optional
43
44
  }),
44
45
  ],
@@ -47,11 +48,123 @@ export default defineConfig({
47
48
  });
48
49
  ```
49
50
 
51
+ ### Starlight LaTeX Compile
52
+
53
+ Automatically compiles fenced `tex compile` and `latex compile` code blocks to SVG diagrams during the build process. Uses `pdflatex` and `dvisvgm` for high-quality, cached SVG output.
54
+
55
+ **Features:**
56
+
57
+ - Compiles LaTeX/TikZ code blocks to SVG automatically
58
+ - Caches compiled SVGs by content hash (no recompilation if unchanged)
59
+ - Comprehensive error reporting with line numbers and formatted LaTeX source
60
+ - Supports custom preamble via `%---` separator in code blocks
61
+ - Works seamlessly with Starlight's content pipeline
62
+ - Requires `svgOutputDir` configuration (no defaults)
63
+
64
+ **System Requirements:**
65
+
66
+ This plugin requires the following CLI tools to be installed and available on your system:
67
+
68
+ - **`pdflatex`** — LaTeX compiler that produces PDF output
69
+ - **`dvisvgm`** — Converts PDF to SVG format
70
+
71
+ Verify installation by running:
72
+
73
+ ```bash
74
+ pdflatex --version
75
+ dvisvgm --version
76
+ ```
77
+
78
+ **Usage:**
79
+
80
+ ```ts
81
+ // astro.config.mjs
82
+ import { defineConfig } from "astro/config";
83
+ import starlight from "@astrojs/starlight";
84
+ import { starlightLatexCompile } from "cannoli-starlight-plugins";
85
+
86
+ export default defineConfig({
87
+ integrations: [
88
+ starlight({
89
+ title: "My Docs",
90
+ plugins: [
91
+ starlightLatexCompile({
92
+ svgOutputDir: "public/static/tex-svgs",
93
+ }),
94
+ ],
95
+ }),
96
+ ],
97
+ });
98
+ ```
99
+
100
+ **Markdown Syntax:**
101
+
102
+ ````markdown
103
+ ```tex compile
104
+ \begin{tikzpicture}
105
+ \node (A) at (0,0) {A};
106
+ \node (B) at (2,0) {B};
107
+ \draw (A) -- (B);
108
+ \end{tikzpicture}
109
+ ```
110
+ ````
111
+
112
+ **Custom Preamble:**
113
+
114
+ Use `%---` to separate custom preamble from diagram content:
115
+
116
+ ````markdown
117
+ ```tex compile
118
+ \usepackage{tikz-3dplot}
119
+
120
+ %---
121
+
122
+ \begin{tikzpicture}
123
+ % diagram code here
124
+ \end{tikzpicture}
125
+ ```
126
+ ````
127
+
128
+ ### Remark LaTeX Compile
129
+
130
+ The underlying remark plugin that powers `starlightLatexCompile`. Use this directly in Astro projects that don't use Starlight.
131
+
132
+ **System Requirements:**
133
+
134
+ Same as `starlightLatexCompile`:
135
+
136
+ - **`pdflatex`** — LaTeX compiler that produces PDF output
137
+ - **`dvisvgm`** — Converts PDF to SVG format
138
+
139
+ **Usage:**
140
+
141
+ ```ts
142
+ // astro.config.mjs
143
+ import { defineConfig } from "astro/config";
144
+ import { remarkLatexCompile } from "cannoli-starlight-plugins/remark-latex-compile";
145
+
146
+ export default defineConfig({
147
+ markdown: {
148
+ remarkPlugins: [
149
+ [
150
+ remarkLatexCompile,
151
+ {
152
+ svgOutputDir: "public/static/tex-svgs",
153
+ },
154
+ ],
155
+ ],
156
+ },
157
+ });
158
+ ```
159
+
160
+ The plugin works identically to `starlightLatexCompile` but is configured directly in the Astro markdown pipeline.
161
+
50
162
  ### Rehype Validate Links
51
163
 
52
164
  A rehype plugin that validates all internal links in your Markdown/MDX files at build time. Links without matching files will cause the build to fail.
53
165
 
54
166
  **Features:**
167
+
55
168
  - Validates `<a href>` and `<img src>` attributes
56
169
  - Supports relative paths (`../other`) and absolute paths (`/some/page`)
57
170
  - Auto-expands extensionless links to match `.md` or `.mdx` files
@@ -68,9 +181,7 @@ import { rehypeValidateLinks } from "cannoli-starlight-plugins";
68
181
 
69
182
  export default defineConfig({
70
183
  markdown: {
71
- rehypePlugins: [
72
- rehypeValidateLinks,
73
- ],
184
+ rehypePlugins: [rehypeValidateLinks],
74
185
  },
75
186
  });
76
187
  ```
@@ -100,7 +211,9 @@ Prepend a `?` to the link href to skip validation:
100
211
  Use the `data-no-link-check` attribute on anchor tags:
101
212
 
102
213
  ```mdx
103
- <a href="csci-320-331-obrenic/grade-calculator" data-no-link-check>Grade Calculator</a>
214
+ <a href="csci-320-331-obrenic/grade-calculator" data-no-link-check>
215
+ Grade Calculator
216
+ </a>
104
217
  ```
105
218
 
106
219
  **3. Global Skip Patterns** (Configuration-based)
@@ -112,17 +225,53 @@ Use the `skipPatterns` option to exclude links matching glob patterns:
112
225
  export default defineConfig({
113
226
  markdown: {
114
227
  rehypePlugins: [
115
- [rehypeValidateLinks, {
116
- skipPatterns: [
117
- '/csci-320-331-obrenic/grade-calculator', // exact match
118
- '**/draft-*', // glob pattern
119
- ]
120
- }],
228
+ [
229
+ rehypeValidateLinks,
230
+ {
231
+ skipPatterns: [
232
+ "/csci-320-331-obrenic/grade-calculator", // exact match
233
+ "**/draft-*", // glob pattern
234
+ ],
235
+ },
236
+ ],
121
237
  ],
122
238
  },
123
239
  });
124
240
  ```
125
241
 
242
+ ## CLI Utilities
243
+
244
+ ### cannoli-latex-cleanup
245
+
246
+ A cleanup utility for the LaTeX compile plugin. Scans your markdown source files for all `tex compile` code blocks, hashes them, and identifies orphaned SVG files in the output directory that are no longer referenced by any code block.
247
+
248
+ **Usage:**
249
+
250
+ Check for orphaned SVGs without deleting:
251
+
252
+ ```bash
253
+ npx cannoli-latex-cleanup --svg-dir public/static/tex-svgs --check
254
+ ```
255
+
256
+ Delete orphaned SVGs:
257
+
258
+ ```bash
259
+ npx cannoli-latex-cleanup --svg-dir public/static/tex-svgs --delete
260
+ ```
261
+
262
+ With custom docs directory (defaults to `src/content/docs`):
263
+
264
+ ```bash
265
+ npx cannoli-latex-cleanup --svg-dir public/static/tex-svgs --docs-dir ./src/content/docs --delete
266
+ ```
267
+
268
+ **Options:**
269
+
270
+ - `--svg-dir` (required): Path to the SVG output directory configured in `starlightLatexCompile`
271
+ - `--docs-dir` (optional, default: `src/content/docs`): Path to markdown source directory
272
+ - `--check`: List orphaned SVGs without deleting
273
+ - `--delete`: Delete orphaned SVGs
274
+
126
275
  ## Installation
127
276
 
128
277
  ```bash
@@ -0,0 +1,457 @@
1
+ // src/plugins/remark-latex-compile/index.ts
2
+ import { resolve } from "path";
3
+
4
+ // src/plugins/remark-latex-compile/compile.ts
5
+ import { createHash } from "crypto";
6
+ import { spawnSync } from "child_process";
7
+ import {
8
+ existsSync,
9
+ mkdirSync,
10
+ writeFileSync,
11
+ rmSync,
12
+ mkdtempSync
13
+ } from "fs";
14
+ import { join } from "path";
15
+ import { tmpdir } from "os";
16
+
17
+ // src/plugins/remark-latex-compile/error-parser.ts
18
+ function parseLatexError(latexOutput) {
19
+ const lines = latexOutput.split("\n");
20
+ const errors = [];
21
+ const seenMessages = /* @__PURE__ */ new Set();
22
+ let hasFatal = false;
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const line = lines[i];
25
+ if (line.startsWith("!")) {
26
+ const message = line.substring(1).trim();
27
+ if (seenMessages.has(message)) continue;
28
+ seenMessages.add(message);
29
+ const context = [];
30
+ let lineNum;
31
+ for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
32
+ const contextLine = lines[j];
33
+ if (contextLine.trim()) {
34
+ context.push(contextLine);
35
+ }
36
+ if (contextLine.startsWith("l.")) {
37
+ const lineMatch = contextLine.match(/^l\.(\d+)/);
38
+ if (lineMatch) {
39
+ lineNum = parseInt(lineMatch[1], 10);
40
+ }
41
+ break;
42
+ }
43
+ }
44
+ errors.push({
45
+ message,
46
+ line: lineNum,
47
+ context: context.slice(0, 3),
48
+ severity: "error"
49
+ });
50
+ }
51
+ }
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const line = lines[i];
54
+ if (line.includes("Overfull") || line.includes("Underfull")) {
55
+ const msg = line.trim();
56
+ if (!seenMessages.has(msg)) {
57
+ seenMessages.add(msg);
58
+ errors.push({
59
+ message: msg,
60
+ context: [],
61
+ severity: "warning"
62
+ });
63
+ }
64
+ }
65
+ }
66
+ for (const line of lines) {
67
+ if (line.toLowerCase().includes("emergency stop") || line.toLowerCase().includes("fatal error") || line.toLowerCase().includes("not found")) {
68
+ hasFatal = true;
69
+ break;
70
+ }
71
+ }
72
+ if (errors.length === 0 && latexOutput.length > 0) {
73
+ for (const line of lines) {
74
+ if (line.includes("error") || line.includes("Error") || line.includes("Misplaced") || line.includes("Missing")) {
75
+ const msg = line.trim();
76
+ if (!seenMessages.has(msg)) {
77
+ seenMessages.add(msg);
78
+ errors.push({
79
+ message: msg,
80
+ context: [],
81
+ severity: "error"
82
+ });
83
+ }
84
+ break;
85
+ }
86
+ }
87
+ }
88
+ if (errors.length === 0) {
89
+ errors.push({
90
+ message: "Unknown LaTeX compilation error",
91
+ context: [],
92
+ severity: "error"
93
+ });
94
+ }
95
+ return { errors, hasFatal };
96
+ }
97
+ function formatLatexError(parsed) {
98
+ const RED = "\x1B[31m";
99
+ const YELLOW = "\x1B[33m";
100
+ const RESET = "\x1B[0m";
101
+ const errorCount = parsed.errors.filter((e) => e.severity === "error").length;
102
+ const warningCount = parsed.errors.filter(
103
+ (e) => e.severity === "warning"
104
+ ).length;
105
+ let output = `${RED}[remark-latex-compile] LaTeX compilation failed${RESET}
106
+ `;
107
+ output += `${RED}${errorCount} error${errorCount !== 1 ? "s" : ""}${RESET}`;
108
+ if (warningCount > 0) {
109
+ output += `, ${YELLOW}${warningCount} warning${warningCount !== 1 ? "s" : ""}${RESET}`;
110
+ }
111
+ output += "\n\n";
112
+ const errorsByType = parsed.errors.reduce(
113
+ (acc, e) => {
114
+ if (!acc[e.severity]) acc[e.severity] = [];
115
+ acc[e.severity].push(e);
116
+ return acc;
117
+ },
118
+ {}
119
+ );
120
+ for (const err of errorsByType["error"] || []) {
121
+ output += `${RED}Error${RESET}`;
122
+ if (err.line) output += ` (line ${err.line})`;
123
+ output += `: ${err.message}
124
+ `;
125
+ if (err.context.length > 0) {
126
+ output += ` Context: ${err.context[0]}
127
+ `;
128
+ }
129
+ }
130
+ for (const warn of errorsByType["warning"] || []) {
131
+ output += `${YELLOW}Warning${RESET}: ${warn.message}
132
+ `;
133
+ }
134
+ return output;
135
+ }
136
+ function formatLatexSourceWithLineNumbers(latexSource, errors) {
137
+ const RED = "\x1B[31m";
138
+ const RESET = "\x1B[0m";
139
+ const lines = latexSource.split("\n");
140
+ const maxLineNum = lines.length;
141
+ const lineNumWidth = String(maxLineNum).length;
142
+ const errorLineNumbers = new Set(errors.map((e) => e.line).filter(Boolean));
143
+ const formattedLines = lines.map((line, index) => {
144
+ const lineNum = index + 1;
145
+ const lineNumStr = String(lineNum);
146
+ const padding = lineNumStr.length < lineNumWidth ? " " : "";
147
+ if (errorLineNumbers.has(lineNum)) {
148
+ return `${padding}${RED}[${lineNumStr}]:${RESET} ${line}`;
149
+ }
150
+ return `${padding}[${lineNumStr}]: ${line}`;
151
+ }).join("\n");
152
+ return formattedLines;
153
+ }
154
+ function createCompilationErrorMessage(latexSource, rawError) {
155
+ const parsed = parseLatexError(rawError);
156
+ const formatted = formatLatexError(parsed);
157
+ const formattedSource = formatLatexSourceWithLineNumbers(
158
+ latexSource,
159
+ parsed.errors
160
+ );
161
+ return `${formatted}
162
+ LaTeX source:
163
+ ${formattedSource}
164
+ `;
165
+ }
166
+
167
+ // src/plugins/remark-latex-compile/compile.ts
168
+ function hashLatexCode(code) {
169
+ const normalized = code.split("\n").map((line) => line.trim()).join("\n").trim();
170
+ return createHash("md5").update(normalized).digest("hex").slice(0, 16);
171
+ }
172
+ function buildLatexSource(latexCode) {
173
+ const separatorRegex = /%[ \t]*---/;
174
+ const parts = latexCode.split(separatorRegex);
175
+ let preamble = "";
176
+ let content = latexCode.trim();
177
+ if (parts.length === 2) {
178
+ preamble = parts[0].trim();
179
+ content = parts[1].trim();
180
+ }
181
+ return [
182
+ "\\documentclass[border=4pt]{standalone}",
183
+ preamble,
184
+ "\\begin{document}",
185
+ "\\Large",
186
+ content,
187
+ "\\end{document}"
188
+ ].join("\n");
189
+ }
190
+ function compileLatexToSvg(latexCode, svgOutputDir) {
191
+ const hash = hashLatexCode(latexCode);
192
+ const svgPath = join(svgOutputDir, `${hash}.svg`);
193
+ if (existsSync(svgPath)) {
194
+ return { hash, svgPath, wasCompiled: false };
195
+ }
196
+ mkdirSync(svgOutputDir, { recursive: true });
197
+ const workDir = mkdtempSync(join(tmpdir(), "latex-compile-"));
198
+ const texFile = join(workDir, "diagram.tex");
199
+ const pdfFile = join(workDir, "diagram.pdf");
200
+ const latexSource = buildLatexSource(latexCode);
201
+ try {
202
+ writeFileSync(texFile, latexSource, "utf-8");
203
+ const latexResult = spawnSync("pdflatex", [
204
+ "-interaction=nonstopmode",
205
+ "-output-directory",
206
+ workDir,
207
+ texFile
208
+ ]);
209
+ if (latexResult.status !== 0) {
210
+ const errorOutput = latexResult.stderr?.toString() || latexResult.stdout?.toString() || "";
211
+ const userMessage = createCompilationErrorMessage(
212
+ latexSource,
213
+ errorOutput
214
+ );
215
+ throw new Error(userMessage);
216
+ }
217
+ const dvisvgmResult = spawnSync("dvisvgm", [
218
+ "--pdf",
219
+ "--bbox=dvi",
220
+ pdfFile,
221
+ "-o",
222
+ svgPath
223
+ ]);
224
+ if (dvisvgmResult.status !== 0) {
225
+ const errorOutput = dvisvgmResult.stderr?.toString() || dvisvgmResult.stdout?.toString() || "";
226
+ throw new Error(
227
+ `[remark-latex-compile] PDF to SVG conversion failed (hash: ${hash}).
228
+ Error: ${errorOutput}`
229
+ );
230
+ }
231
+ } finally {
232
+ try {
233
+ rmSync(workDir, { recursive: true, force: true });
234
+ } catch {
235
+ }
236
+ }
237
+ return { hash, svgPath, wasCompiled: true };
238
+ }
239
+
240
+ // src/plugins/remark-latex-compile/index.ts
241
+ function traverseTree(node, svgOutputDir, filePath, depth = 0) {
242
+ if (!node) return;
243
+ const children = node.children;
244
+ if (Array.isArray(children)) {
245
+ for (let i = 0; i < children.length; i++) {
246
+ const child = children[i];
247
+ if (child.type === "code" && (child.lang === "tex" || child.lang === "latex") && String(child.meta || "").includes("compile")) {
248
+ try {
249
+ const result = compileLatexToSvg(String(child.value), svgOutputDir);
250
+ children[i] = {
251
+ type: "paragraph",
252
+ children: [
253
+ {
254
+ type: "image",
255
+ url: `/static/tex-svgs/${result.hash}.svg`,
256
+ alt: "LaTeX diagram",
257
+ data: {
258
+ hProperties: {
259
+ className: ["tex-compiled"]
260
+ }
261
+ }
262
+ }
263
+ ]
264
+ };
265
+ } catch (err) {
266
+ if (process.env.NODE_ENV !== "production") {
267
+ const position = child.position;
268
+ const lineNumber = position?.start?.line || "?";
269
+ const errorMsg = err instanceof Error ? err.message : String(err);
270
+ const match = errorMsg.match(/\n\n([\s\S]+)/);
271
+ const details = match ? match[1] : errorMsg;
272
+ console.error(`${filePath}:${lineNumber}
273
+ ${details}`);
274
+ }
275
+ }
276
+ } else {
277
+ traverseTree(child, svgOutputDir, filePath, depth + 1);
278
+ }
279
+ }
280
+ }
281
+ }
282
+ function remarkLatexCompile(options) {
283
+ const svgOutputDir = resolve(options.svgOutputDir);
284
+ return (tree, file) => {
285
+ const fileObj = file;
286
+ const filePath = String(fileObj?.path || fileObj?.filename || "unknown");
287
+ traverseTree(tree, svgOutputDir, filePath, 0);
288
+ };
289
+ }
290
+
291
+ // src/plugins/remark-latex-compile/astro-integration.ts
292
+ import { readdir, readFile, writeFile } from "fs/promises";
293
+ import { resolve as resolve2, join as join2, extname } from "path";
294
+ import { createHash as createHash2 } from "crypto";
295
+ function hashLatexCode2(code) {
296
+ const normalized = code.split("\n").map((line) => line.trim()).join("\n").trim();
297
+ return createHash2("md5").update(normalized).digest("hex").slice(0, 16);
298
+ }
299
+ function createAstroLatexIntegration(options) {
300
+ const svgOutputDir = resolve2(options.svgOutputDir);
301
+ const contentDir = options?.contentDir ? resolve2(options.contentDir) : resolve2("src/content/docs");
302
+ return {
303
+ name: "astro-latex-compile",
304
+ hooks: {
305
+ "astro:build:start": async () => {
306
+ console.log(
307
+ "[astro-latex-compile] Build start, scanning for tex/latex compile blocks"
308
+ );
309
+ await scanAndCompileLatex(contentDir, svgOutputDir);
310
+ },
311
+ "astro:build:done": async ({ dir }) => {
312
+ console.log(
313
+ "[astro-latex-compile] Build done, updating HTML references"
314
+ );
315
+ try {
316
+ await updateHtmlReferences(dir.pathname, contentDir, svgOutputDir);
317
+ } catch (err) {
318
+ console.error(
319
+ "[astro-latex-compile] Error updating HTML references:",
320
+ err
321
+ );
322
+ }
323
+ }
324
+ }
325
+ };
326
+ }
327
+ async function scanAndCompileLatex(dir, svgOutputDir) {
328
+ const entries = await readdir(dir, { withFileTypes: true });
329
+ for (const entry of entries) {
330
+ const fullPath = join2(dir, entry.name);
331
+ if (entry.isDirectory()) {
332
+ await scanAndCompileLatex(fullPath, svgOutputDir);
333
+ } else if (entry.isFile()) {
334
+ const ext = extname(entry.name);
335
+ if (ext === ".md" || ext === ".mdx") {
336
+ await processMarkdownFile(fullPath, svgOutputDir);
337
+ }
338
+ }
339
+ }
340
+ }
341
+ function getLineNumber(content, position) {
342
+ return content.substring(0, position).split("\n").length;
343
+ }
344
+ async function processMarkdownFile(filePath, svgOutputDir) {
345
+ const content = await readFile(filePath, "utf-8");
346
+ const latexBlockRegex = /```(?:tex|latex)\s+compile\n([\s\S]*?)\n```/g;
347
+ const matches = content.matchAll(latexBlockRegex);
348
+ for (const match of matches) {
349
+ const latexCode = match[1];
350
+ const lineNumber = getLineNumber(content, match.index || 0);
351
+ try {
352
+ const result = compileLatexToSvg(latexCode, svgOutputDir);
353
+ const status = result.wasCompiled ? "compiled" : "used cached";
354
+ console.log(
355
+ `[astro-latex-compile] ${filePath}:${lineNumber}: ${status} ${result.hash}.svg`
356
+ );
357
+ } catch (err) {
358
+ const error = err instanceof Error ? err : new Error(String(err));
359
+ error.message = `${filePath}:${lineNumber}
360
+ ${error.message}`;
361
+ throw error;
362
+ }
363
+ }
364
+ }
365
+ async function updateHtmlReferences(buildDir, contentDir, svgOutputDir) {
366
+ const latexHashes = [];
367
+ const entries = await readdir(contentDir, { withFileTypes: true });
368
+ for (const entry of entries) {
369
+ const fullPath = join2(contentDir, entry.name);
370
+ if (entry.isDirectory()) {
371
+ await scanMarkdownForHashes(fullPath, latexHashes);
372
+ }
373
+ }
374
+ await updateHtmlDirWithHashes(buildDir, latexHashes, svgOutputDir);
375
+ }
376
+ async function scanMarkdownForHashes(dir, hashes) {
377
+ const entries = await readdir(dir, { withFileTypes: true });
378
+ for (const entry of entries) {
379
+ const fullPath = join2(dir, entry.name);
380
+ if (entry.isDirectory()) {
381
+ await scanMarkdownForHashes(fullPath, hashes);
382
+ } else if (entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".mdx"))) {
383
+ const content = await readFile(fullPath, "utf-8");
384
+ const latexBlockRegex = /```(?:tex|latex)\s+compile\n([\s\S]*?)\n```/g;
385
+ const matches = content.matchAll(latexBlockRegex);
386
+ for (const match of matches) {
387
+ const latexCode = match[1];
388
+ const hash = hashLatexCode2(latexCode);
389
+ hashes.push(hash);
390
+ }
391
+ }
392
+ }
393
+ }
394
+ async function updateHtmlDirWithHashes(dir, hashes, svgOutputDir) {
395
+ let hashIndex = 0;
396
+ const entries = await readdir(dir, { withFileTypes: true });
397
+ for (const entry of entries) {
398
+ const fullPath = join2(dir, entry.name);
399
+ if (entry.isDirectory()) {
400
+ await updateHtmlDirWithHashes(fullPath, hashes, svgOutputDir);
401
+ } else if (entry.isFile() && entry.name.endsWith(".html")) {
402
+ let content = await readFile(fullPath, "utf-8");
403
+ let modified = false;
404
+ const pathSegments = svgOutputDir.split("/").slice(-2).join("/");
405
+ const htmlPath = `/${pathSegments}`;
406
+ const svgRegex = new RegExp(`src="${htmlPath}/[a-f0-9]+\\.svg"`, "g");
407
+ content = content.replace(svgRegex, (match) => {
408
+ if (hashIndex < hashes.length) {
409
+ modified = true;
410
+ return `src="${htmlPath}/${hashes[hashIndex++]}.svg"`;
411
+ }
412
+ return match;
413
+ });
414
+ if (modified) {
415
+ await writeFile(fullPath, content, "utf-8");
416
+ console.log(`[astro-latex-compile] Updated ${fullPath}`);
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ // src/plugins/remark-latex-compile/starlight-plugin.ts
423
+ function starlightLatexCompile(options) {
424
+ return {
425
+ name: "starlight-latex-compile",
426
+ hooks: {
427
+ "config:setup": (hook) => {
428
+ hook.addIntegration({
429
+ name: "latex-compile-remark-integration",
430
+ hooks: {
431
+ "astro:config:setup": ({ updateConfig, config }) => {
432
+ const existingPlugins = (Array.isArray(config.markdown?.remarkPlugins) ? config.markdown.remarkPlugins : []).filter((p) => p !== void 0 && p !== null);
433
+ updateConfig({
434
+ markdown: {
435
+ remarkPlugins: [...existingPlugins, [remarkLatexCompile, options]]
436
+ }
437
+ });
438
+ }
439
+ }
440
+ });
441
+ hook.addIntegration(
442
+ createAstroLatexIntegration({
443
+ svgOutputDir: options.svgOutputDir
444
+ })
445
+ );
446
+ }
447
+ }
448
+ };
449
+ }
450
+ var starlight_plugin_default = starlightLatexCompile;
451
+
452
+ export {
453
+ compileLatexToSvg,
454
+ starlightLatexCompile,
455
+ starlight_plugin_default,
456
+ remarkLatexCompile
457
+ };
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+
3
+ // scripts/cli/cannoli-latex-cleanup.ts
4
+ import { readdir, readFile, unlink } from "fs/promises";
5
+ import { resolve, join } from "path";
6
+ import { createHash } from "crypto";
7
+ var args = process.argv.slice(2);
8
+ var svgDir = null;
9
+ var docsDir = "src/content/docs";
10
+ var checkMode = false;
11
+ var deleteMode = false;
12
+ for (let i = 0; i < args.length; i++) {
13
+ if (args[i] === "--svg-dir" && i + 1 < args.length) {
14
+ svgDir = args[++i];
15
+ } else if (args[i] === "--docs-dir" && i + 1 < args.length) {
16
+ docsDir = args[++i];
17
+ } else if (args[i] === "--check") {
18
+ checkMode = true;
19
+ } else if (args[i] === "--delete") {
20
+ deleteMode = true;
21
+ }
22
+ }
23
+ if (!svgDir) {
24
+ console.error("Error: --svg-dir is required");
25
+ process.exit(1);
26
+ }
27
+ if (!checkMode && !deleteMode) {
28
+ console.error("Error: either --check or --delete must be specified");
29
+ process.exit(1);
30
+ }
31
+ var svgDirPath = resolve(svgDir);
32
+ var docsDirPath = resolve(docsDir);
33
+ function hashLatexCode(code) {
34
+ const normalized = code.split("\n").map((line) => line.trim()).join("\n").trim();
35
+ return createHash("md5").update(normalized).digest("hex").slice(0, 16);
36
+ }
37
+ async function scanMarkdownForHashes(dir, hashes) {
38
+ try {
39
+ const entries = await readdir(dir, { withFileTypes: true });
40
+ for (const entry of entries) {
41
+ const fullPath = join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ await scanMarkdownForHashes(fullPath, hashes);
44
+ } else if (entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".mdx"))) {
45
+ const content = await readFile(fullPath, "utf-8");
46
+ const latexBlockRegex = /```(?:tex|latex)\s+compile\n([\s\S]*?)\n```/g;
47
+ const matches = content.matchAll(latexBlockRegex);
48
+ for (const match of matches) {
49
+ const latexCode = match[1];
50
+ const hash = hashLatexCode(latexCode);
51
+ hashes.add(hash);
52
+ }
53
+ }
54
+ }
55
+ } catch (err) {
56
+ console.error(`Error scanning directory ${dir}:`, err);
57
+ throw err;
58
+ }
59
+ }
60
+ async function findOrphanedSvgs(svgPath, usedHashes) {
61
+ const orphaned = [];
62
+ try {
63
+ const entries = await readdir(svgPath, { withFileTypes: true });
64
+ for (const entry of entries) {
65
+ if (entry.isFile() && entry.name.endsWith(".svg")) {
66
+ const hash = entry.name.replace(".svg", "");
67
+ if (!usedHashes.has(hash)) {
68
+ orphaned.push(join(svgPath, entry.name));
69
+ }
70
+ }
71
+ }
72
+ } catch (err) {
73
+ if (err.code === "ENOENT") {
74
+ console.warn(`SVG directory does not exist: ${svgPath}`);
75
+ return [];
76
+ }
77
+ throw err;
78
+ }
79
+ return orphaned;
80
+ }
81
+ async function main() {
82
+ try {
83
+ console.log(`Scanning markdown files in ${docsDirPath}...`);
84
+ const usedHashes = /* @__PURE__ */ new Set();
85
+ await scanMarkdownForHashes(docsDirPath, usedHashes);
86
+ console.log(`Found ${usedHashes.size} unique tex compile blocks
87
+ `);
88
+ console.log(`Scanning SVG directory ${svgDirPath}...`);
89
+ const orphanedSvgs = await findOrphanedSvgs(svgDirPath, usedHashes);
90
+ if (orphanedSvgs.length === 0) {
91
+ console.log("No orphaned SVGs found \u2713");
92
+ process.exit(0);
93
+ }
94
+ console.log(`
95
+ Found ${orphanedSvgs.length} orphaned SVG(s):`);
96
+ orphanedSvgs.forEach((svg) => {
97
+ const filename = svg.split("/").pop();
98
+ console.log(` - ${filename}`);
99
+ });
100
+ if (checkMode) {
101
+ console.log("\n(Use --delete to remove these files)");
102
+ process.exit(0);
103
+ }
104
+ if (deleteMode) {
105
+ console.log("\nDeleting orphaned SVGs...");
106
+ let deleted = 0;
107
+ for (const svg of orphanedSvgs) {
108
+ try {
109
+ await unlink(svg);
110
+ deleted++;
111
+ console.log(` \u2713 Deleted ${svg.split("/").pop()}`);
112
+ } catch (err) {
113
+ console.error(` \u2717 Failed to delete ${svg}:`, err);
114
+ }
115
+ }
116
+ console.log(`
117
+ Deleted ${deleted}/${orphanedSvgs.length} files`);
118
+ process.exit(0);
119
+ }
120
+ } catch (err) {
121
+ console.error("Error:", err);
122
+ process.exit(1);
123
+ }
124
+ }
125
+ main();
@@ -0,0 +1,41 @@
1
+ import { HookParameters } from '@astrojs/starlight/types';
2
+
3
+ interface CompilationResult {
4
+ hash: string;
5
+ svgPath: string;
6
+ wasCompiled: boolean;
7
+ }
8
+ /**
9
+ * Compile LaTeX code to SVG.
10
+ *
11
+ * @param latexCode - The LaTeX code to compile (e.g., TikZ, pgfplots, etc.)
12
+ * @param svgOutputDir - The directory where SVG files should be written
13
+ * @returns Result object with hash, svgPath, and whether compilation occurred
14
+ * @throws Error if compilation fails
15
+ */
16
+ declare function compileLatexToSvg(latexCode: string, svgOutputDir: string): CompilationResult;
17
+
18
+ /**
19
+ * Starlight plugin wrapper for remark-latex-compile.
20
+ *
21
+ * This plugin hooks into Starlight's config:setup to inject the remark-latex-compile
22
+ * plugin and the build-time Astro integration for scanning markdown files.
23
+ */
24
+
25
+ type StarlightLatexCompileOptions = RemarkLatexCompileOptions;
26
+ declare function starlightLatexCompile(options: StarlightLatexCompileOptions): {
27
+ name: string;
28
+ hooks: {
29
+ "config:setup": (hook: HookParameters<"config:setup">) => void;
30
+ };
31
+ };
32
+
33
+ interface RemarkLatexCompileOptions {
34
+ /**
35
+ * Directory where SVG files should be written.
36
+ */
37
+ svgOutputDir: string;
38
+ }
39
+ declare function remarkLatexCompile(options: RemarkLatexCompileOptions): (tree: Record<string, unknown>, file: unknown) => void;
40
+
41
+ export { type RemarkLatexCompileOptions as R, type StarlightLatexCompileOptions as S, compileLatexToSvg as c, remarkLatexCompile as r, starlightLatexCompile as s };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { starlightIndexOnlySidebar } from './plugins/starlight-index-only-sidebar.js';
2
2
  export { default as rehypeValidateLinks } from './plugins/rehype-validate-links.js';
3
3
  import { AstroIntegration } from 'astro';
4
+ export { r as remarkLatexCompile, s as starlightLatexCompile } from './index-B9CyKYB4.js';
4
5
  import '@astrojs/starlight/types';
5
6
  import 'hast';
6
7
  import 'vfile';
package/dist/index.js CHANGED
@@ -4,6 +4,10 @@ import {
4
4
  import {
5
5
  rehypeValidateLinks
6
6
  } from "./chunk-HRUZQZ5L.js";
7
+ import {
8
+ remarkLatexCompile,
9
+ starlightLatexCompile
10
+ } from "./chunk-T4UKGKU6.js";
7
11
 
8
12
  // src/plugins/astro-normalize-paths.ts
9
13
  import { readFileSync, writeFileSync, existsSync } from "fs";
@@ -74,5 +78,7 @@ function normalizeAssetPath(path, htmlFile, siteRootPath) {
74
78
  export {
75
79
  astroNormalizePaths,
76
80
  rehypeValidateLinks,
77
- starlightIndexOnlySidebar
81
+ remarkLatexCompile,
82
+ starlightIndexOnlySidebar,
83
+ starlightLatexCompile
78
84
  };
@@ -0,0 +1,2 @@
1
+ export { R as RemarkLatexCompileOptions, c as compileLatexToSvg, r as default, s as starlightLatexCompile } from '../index-B9CyKYB4.js';
2
+ import '@astrojs/starlight/types';
@@ -0,0 +1,10 @@
1
+ import {
2
+ compileLatexToSvg,
3
+ remarkLatexCompile,
4
+ starlightLatexCompile
5
+ } from "../chunk-T4UKGKU6.js";
6
+ export {
7
+ compileLatexToSvg,
8
+ remarkLatexCompile as default,
9
+ starlightLatexCompile
10
+ };
@@ -0,0 +1,2 @@
1
+ import '@astrojs/starlight/types';
2
+ export { S as StarlightLatexCompileOptions, s as default, s as starlightLatexCompile } from '../index-B9CyKYB4.js';
@@ -0,0 +1,8 @@
1
+ import {
2
+ starlightLatexCompile,
3
+ starlight_plugin_default
4
+ } from "../chunk-T4UKGKU6.js";
5
+ export {
6
+ starlight_plugin_default as default,
7
+ starlightLatexCompile
8
+ };
@@ -179,3 +179,7 @@ img.note-svg {
179
179
  filter: invert(1) hue-rotate(180deg);
180
180
  }
181
181
  }
182
+
183
+ .tex-compiled {
184
+ background-color: white;
185
+ }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "starlight-cannoli-plugins",
3
3
  "type": "module",
4
- "version": "1.0.16",
4
+ "version": "1.2.0",
5
5
  "description": "Starlight plugins for automatic sidebar generation and link validation",
6
6
  "license": "ISC",
7
7
  "main": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "cannoli-latex-cleanup": "./dist/cli/cannoli-latex-cleanup.js"
11
+ },
9
12
  "exports": {
10
13
  ".": {
11
14
  "import": "./dist/index.js",
@@ -23,6 +26,14 @@
23
26
  "import": "./dist/plugins/astro-normalize-paths.js",
24
27
  "types": "./dist/plugins/astro-normalize-paths.d.ts"
25
28
  },
29
+ "./remark-latex-compile": {
30
+ "import": "./dist/plugins/remark-latex-compile.js",
31
+ "types": "./dist/plugins/remark-latex-compile.d.ts"
32
+ },
33
+ "./starlight-latex-compile": {
34
+ "import": "./dist/plugins/starlight-latex-compile.js",
35
+ "types": "./dist/plugins/starlight-latex-compile.d.ts"
36
+ },
26
37
  "./styles": "./dist/styles/",
27
38
  "./styles/*": "./dist/styles/*"
28
39
  },
@@ -55,7 +66,7 @@
55
66
  "clean:empty-dirs": "find src/content/docs -type d -empty -delete"
56
67
  },
57
68
  "dependencies": {
58
- "eslint-cannoli-plugins": "^1.0.12",
69
+ "eslint-cannoli-plugins": "^1.0.13",
59
70
  "glob": "^13.0.6",
60
71
  "minimatch": "^10.2.4",
61
72
  "unist-util-visit": "^5.0.0",