starlight-cannoli-plugins 1.1.0 → 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
@@ -3,13 +3,13 @@ import { resolve } from "path";
3
3
 
4
4
  // src/plugins/remark-latex-compile/compile.ts
5
5
  import { createHash } from "crypto";
6
- import { execSync } from "child_process";
6
+ import { spawnSync } from "child_process";
7
7
  import {
8
8
  existsSync,
9
9
  mkdirSync,
10
- copyFileSync,
11
10
  writeFileSync,
12
- rmSync
11
+ rmSync,
12
+ mkdtempSync
13
13
  } from "fs";
14
14
  import { join } from "path";
15
15
  import { tmpdir } from "os";
@@ -17,116 +17,151 @@ import { tmpdir } from "os";
17
17
  // src/plugins/remark-latex-compile/error-parser.ts
18
18
  function parseLatexError(latexOutput) {
19
19
  const lines = latexOutput.split("\n");
20
- const errorLines = [];
21
- let mainError = "";
22
- let errorLineNum;
20
+ const errors = [];
21
+ const seenMessages = /* @__PURE__ */ new Set();
22
+ let hasFatal = false;
23
23
  for (let i = 0; i < lines.length; i++) {
24
24
  const line = lines[i];
25
- if (line.includes("Undefined control sequence")) {
26
- mainError = "Undefined control sequence";
27
- errorLines.push(line);
28
- for (let j = Math.max(0, i - 3); j <= Math.min(i + 3, lines.length - 1); j++) {
29
- if (lines[j].startsWith("l.")) {
30
- const match = lines[j].match(/^l\.(\d+)\s*(.*)/);
31
- if (match) {
32
- errorLineNum = parseInt(match[1], 10);
33
- const sourceLine = match[2].trim();
34
- if (sourceLine) {
35
- mainError = `Undefined control sequence: ${sourceLine}`;
36
- }
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);
37
40
  }
38
- errorLines.push(lines[j]);
39
41
  break;
40
42
  }
41
43
  }
42
- break;
44
+ errors.push({
45
+ message,
46
+ line: lineNum,
47
+ context: context.slice(0, 3),
48
+ severity: "error"
49
+ });
43
50
  }
44
51
  }
45
- if (!mainError) {
46
- for (let i = 0; i < lines.length; i++) {
47
- const line = lines[i];
48
- if (line.startsWith("!")) {
49
- mainError = line.substring(1).trim();
50
- errorLines.push(line);
51
- for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
52
- const contextLine = lines[j];
53
- if (contextLine.trim()) {
54
- errorLines.push(contextLine);
55
- }
56
- if (contextLine.startsWith("l.")) {
57
- break;
58
- }
59
- }
60
- for (let j = i; j < Math.min(i + 10, lines.length); j++) {
61
- const lineMatch = lines[j].match(/^l\.(\d+)/);
62
- if (lineMatch) {
63
- errorLineNum = parseInt(lineMatch[1], 10);
64
- break;
65
- }
66
- }
67
- break;
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
+ });
68
63
  }
69
64
  }
70
65
  }
71
- if (!mainError) {
72
- for (let i = 0; i < lines.length; i++) {
73
- const line = lines[i];
74
- if (line.toLowerCase().includes("emergency stop") || line.toLowerCase().includes("fatal error")) {
75
- for (let j = Math.max(0, i - 5); j < i; j++) {
76
- if (lines[j].startsWith("!")) {
77
- mainError = lines[j].substring(1).trim();
78
- errorLines.push(lines[j]);
79
- break;
80
- }
81
- }
82
- if (!mainError) {
83
- mainError = line.trim();
84
- }
85
- break;
86
- }
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;
87
70
  }
88
71
  }
89
- if (!mainError && latexOutput.length > 0) {
72
+ if (errors.length === 0 && latexOutput.length > 0) {
90
73
  for (const line of lines) {
91
74
  if (line.includes("error") || line.includes("Error") || line.includes("Misplaced") || line.includes("Missing")) {
92
- mainError = line.trim();
93
- errorLines.push(mainError);
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
+ }
94
84
  break;
95
85
  }
96
86
  }
97
87
  }
98
- const message = mainError || "Unknown LaTeX compilation error";
99
- return {
100
- message,
101
- line: errorLineNum,
102
- context: errorLines.slice(0, 6)
103
- };
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 };
104
96
  }
105
- function formatLatexError(error) {
106
- let output = `LaTeX Error: ${error.message}`;
107
- if (error.line) {
108
- output += ` (line ${error.line})`;
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}`;
109
110
  }
110
- if (error.context.length > 0) {
111
- output += "\n\nContext:";
112
- for (const contextLine of error.context) {
113
- output += `
114
- ${contextLine}`;
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
+ `;
115
128
  }
116
129
  }
130
+ for (const warn of errorsByType["warning"] || []) {
131
+ output += `${YELLOW}Warning${RESET}: ${warn.message}
132
+ `;
133
+ }
117
134
  return output;
118
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
+ }
119
154
  function createCompilationErrorMessage(latexSource, rawError) {
120
155
  const parsed = parseLatexError(rawError);
121
156
  const formatted = formatLatexError(parsed);
122
- const RED = "\x1B[31m";
123
- const RESET = "\x1B[0m";
124
- return `${RED}[remark-latex-compile] LaTeX compilation failed${RESET}
125
-
126
- ${formatted}
127
-
157
+ const formattedSource = formatLatexSourceWithLineNumbers(
158
+ latexSource,
159
+ parsed.errors
160
+ );
161
+ return `${formatted}
128
162
  LaTeX source:
129
- ${latexSource}`;
163
+ ${formattedSource}
164
+ `;
130
165
  }
131
166
 
132
167
  // src/plugins/remark-latex-compile/compile.ts
@@ -135,21 +170,20 @@ function hashLatexCode(code) {
135
170
  return createHash("md5").update(normalized).digest("hex").slice(0, 16);
136
171
  }
137
172
  function buildLatexSource(latexCode) {
138
- const packageRegex = /\\usepackage\{[^}]+\}|\\usetikzlibrary\{[^}]+\}/g;
139
- const packages = latexCode.match(packageRegex) || [];
140
- const codeWithoutPackages = latexCode.replace(packageRegex, "").trim();
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
+ }
141
181
  return [
142
- "\\documentclass[border=0.5in]{standalone}",
143
- ...packages,
144
- "\\usepackage{xcolor}",
145
- "\\pagecolor{white}",
182
+ "\\documentclass[border=4pt]{standalone}",
183
+ preamble,
146
184
  "\\begin{document}",
147
185
  "\\Large",
148
- "\\fboxsep=2pt\\relax",
149
- "\\fboxrule=0.5pt\\relax",
150
- "\\fbox{",
151
- codeWithoutPackages,
152
- "}",
186
+ content,
153
187
  "\\end{document}"
154
188
  ].join("\n");
155
189
  }
@@ -160,43 +194,40 @@ function compileLatexToSvg(latexCode, svgOutputDir) {
160
194
  return { hash, svgPath, wasCompiled: false };
161
195
  }
162
196
  mkdirSync(svgOutputDir, { recursive: true });
163
- const workDir = join(tmpdir(), `tikz-compile-${hash}`);
164
- mkdirSync(workDir, { recursive: true });
165
- const texFile = join(workDir, `${hash}.tex`);
166
- const dviFile = join(workDir, `${hash}.dvi`);
167
- const svgTempFile = join(workDir, `${hash}.svg`);
197
+ const workDir = mkdtempSync(join(tmpdir(), "latex-compile-"));
198
+ const texFile = join(workDir, "diagram.tex");
199
+ const pdfFile = join(workDir, "diagram.pdf");
168
200
  const latexSource = buildLatexSource(latexCode);
169
201
  try {
170
202
  writeFileSync(texFile, latexSource, "utf-8");
171
- try {
172
- execSync(
173
- `latex -interaction=nonstopmode -output-directory "${workDir}" "${texFile}"`,
174
- { stdio: "pipe", cwd: workDir, encoding: "utf-8" }
175
- );
176
- } catch (latexErr) {
177
- const errorOutput = latexErr.stdout || latexErr.stderr || latexErr.message || "";
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() || "";
178
211
  const userMessage = createCompilationErrorMessage(
179
212
  latexSource,
180
213
  errorOutput
181
214
  );
182
215
  throw new Error(userMessage);
183
216
  }
184
- try {
185
- execSync(`dvisvgm --bbox=min "${dviFile}" -o "${svgTempFile}"`, {
186
- stdio: "pipe",
187
- cwd: workDir,
188
- encoding: "utf-8"
189
- });
190
- } catch (dvisvgmErr) {
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() || "";
191
226
  throw new Error(
192
- `[remark-latex-compile] DVI to SVG conversion failed (hash: ${hash}).
193
- Error: ${dvisvgmErr.message}
194
- Stderr: ${dvisvgmErr.stderr ?? ""}Stdout: ${dvisvgmErr.stdout ?? ""}`
227
+ `[remark-latex-compile] PDF to SVG conversion failed (hash: ${hash}).
228
+ Error: ${errorOutput}`
195
229
  );
196
230
  }
197
- copyFileSync(svgTempFile, svgPath);
198
- } catch (err) {
199
- throw err;
200
231
  } finally {
201
232
  try {
202
233
  rmSync(workDir, { recursive: true, force: true });
@@ -207,37 +238,53 @@ Stderr: ${dvisvgmErr.stderr ?? ""}Stdout: ${dvisvgmErr.stdout ?? ""}`
207
238
  }
208
239
 
209
240
  // src/plugins/remark-latex-compile/index.ts
210
- function traverseTree(node, svgOutputDir, depth = 0) {
241
+ function traverseTree(node, svgOutputDir, filePath, depth = 0) {
211
242
  if (!node) return;
212
- if (Array.isArray(node.children)) {
213
- for (let i = 0; i < node.children.length; i++) {
214
- const child = node.children[i];
215
- if (child.type === "code" && (child.lang === "tex" || child.lang === "latex") && child.meta?.includes("compile")) {
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")) {
216
248
  try {
217
- const result = compileLatexToSvg(child.value, svgOutputDir);
218
- node.children[i] = {
249
+ const result = compileLatexToSvg(String(child.value), svgOutputDir);
250
+ children[i] = {
219
251
  type: "paragraph",
220
252
  children: [
221
253
  {
222
254
  type: "image",
223
255
  url: `/static/tex-svgs/${result.hash}.svg`,
224
- alt: "LaTeX diagram"
256
+ alt: "LaTeX diagram",
257
+ data: {
258
+ hProperties: {
259
+ className: ["tex-compiled"]
260
+ }
261
+ }
225
262
  }
226
263
  ]
227
264
  };
228
265
  } catch (err) {
229
- console.error(`[remarkLatexCompile] Failed to compile LaTeX code block:`, 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
+ }
230
275
  }
231
276
  } else {
232
- traverseTree(child, svgOutputDir, depth + 1);
277
+ traverseTree(child, svgOutputDir, filePath, depth + 1);
233
278
  }
234
279
  }
235
280
  }
236
281
  }
237
282
  function remarkLatexCompile(options) {
238
- const svgOutputDir = options?.svgOutputDir ? resolve(options.svgOutputDir) : resolve("public/static/tex-svgs");
239
- return (tree) => {
240
- traverseTree(tree, svgOutputDir, 0);
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);
241
288
  };
242
289
  }
243
290
 
@@ -250,7 +297,7 @@ function hashLatexCode2(code) {
250
297
  return createHash2("md5").update(normalized).digest("hex").slice(0, 16);
251
298
  }
252
299
  function createAstroLatexIntegration(options) {
253
- const svgOutputDir = options?.svgOutputDir ? resolve2(options.svgOutputDir) : resolve2("public/static/tex-svgs");
300
+ const svgOutputDir = resolve2(options.svgOutputDir);
254
301
  const contentDir = options?.contentDir ? resolve2(options.contentDir) : resolve2("src/content/docs");
255
302
  return {
256
303
  name: "astro-latex-compile",
@@ -259,14 +306,7 @@ function createAstroLatexIntegration(options) {
259
306
  console.log(
260
307
  "[astro-latex-compile] Build start, scanning for tex/latex compile blocks"
261
308
  );
262
- try {
263
- await scanAndCompileLatex(contentDir, svgOutputDir);
264
- } catch (err) {
265
- console.error(
266
- "[astro-latex-compile] Error during LaTeX compilation:",
267
- err
268
- );
269
- }
309
+ await scanAndCompileLatex(contentDir, svgOutputDir);
270
310
  },
271
311
  "astro:build:done": async ({ dir }) => {
272
312
  console.log(
@@ -315,10 +355,10 @@ async function processMarkdownFile(filePath, svgOutputDir) {
315
355
  `[astro-latex-compile] ${filePath}:${lineNumber}: ${status} ${result.hash}.svg`
316
356
  );
317
357
  } catch (err) {
318
- console.error(
319
- `[astro-latex-compile] Failed to compile LaTeX in ${filePath}:${lineNumber}:`,
320
- err
321
- );
358
+ const error = err instanceof Error ? err : new Error(String(err));
359
+ error.message = `${filePath}:${lineNumber}
360
+ ${error.message}`;
361
+ throw error;
322
362
  }
323
363
  }
324
364
  }
@@ -331,7 +371,7 @@ async function updateHtmlReferences(buildDir, contentDir, svgOutputDir) {
331
371
  await scanMarkdownForHashes(fullPath, latexHashes);
332
372
  }
333
373
  }
334
- await updateHtmlDirWithHashes(buildDir, latexHashes);
374
+ await updateHtmlDirWithHashes(buildDir, latexHashes, svgOutputDir);
335
375
  }
336
376
  async function scanMarkdownForHashes(dir, hashes) {
337
377
  const entries = await readdir(dir, { withFileTypes: true });
@@ -351,26 +391,26 @@ async function scanMarkdownForHashes(dir, hashes) {
351
391
  }
352
392
  }
353
393
  }
354
- async function updateHtmlDirWithHashes(dir, hashes) {
394
+ async function updateHtmlDirWithHashes(dir, hashes, svgOutputDir) {
355
395
  let hashIndex = 0;
356
396
  const entries = await readdir(dir, { withFileTypes: true });
357
397
  for (const entry of entries) {
358
398
  const fullPath = join2(dir, entry.name);
359
399
  if (entry.isDirectory()) {
360
- await updateHtmlDirWithHashes(fullPath, hashes);
400
+ await updateHtmlDirWithHashes(fullPath, hashes, svgOutputDir);
361
401
  } else if (entry.isFile() && entry.name.endsWith(".html")) {
362
402
  let content = await readFile(fullPath, "utf-8");
363
403
  let modified = false;
364
- content = content.replace(
365
- /src="\/static\/tex-svgs\/[a-f0-9]+\.svg"/g,
366
- () => {
367
- if (hashIndex < hashes.length) {
368
- modified = true;
369
- return `src="/static/tex-svgs/${hashes[hashIndex++]}.svg"`;
370
- }
371
- return arguments[0];
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"`;
372
411
  }
373
- );
412
+ return match;
413
+ });
374
414
  if (modified) {
375
415
  await writeFile(fullPath, content, "utf-8");
376
416
  console.log(`[astro-latex-compile] Updated ${fullPath}`);
@@ -392,7 +432,7 @@ function starlightLatexCompile(options) {
392
432
  const existingPlugins = (Array.isArray(config.markdown?.remarkPlugins) ? config.markdown.remarkPlugins : []).filter((p) => p !== void 0 && p !== null);
393
433
  updateConfig({
394
434
  markdown: {
395
- remarkPlugins: [...existingPlugins, [remarkLatexCompile, options ?? {}]]
435
+ remarkPlugins: [...existingPlugins, [remarkLatexCompile, options]]
396
436
  }
397
437
  });
398
438
  }
@@ -400,7 +440,7 @@ function starlightLatexCompile(options) {
400
440
  });
401
441
  hook.addIntegration(
402
442
  createAstroLatexIntegration({
403
- svgOutputDir: options?.svgOutputDir
443
+ svgOutputDir: options.svgOutputDir
404
444
  })
405
445
  );
406
446
  }
@@ -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();
@@ -23,7 +23,7 @@ declare function compileLatexToSvg(latexCode: string, svgOutputDir: string): Com
23
23
  */
24
24
 
25
25
  type StarlightLatexCompileOptions = RemarkLatexCompileOptions;
26
- declare function starlightLatexCompile(options?: StarlightLatexCompileOptions): {
26
+ declare function starlightLatexCompile(options: StarlightLatexCompileOptions): {
27
27
  name: string;
28
28
  hooks: {
29
29
  "config:setup": (hook: HookParameters<"config:setup">) => void;
@@ -33,10 +33,9 @@ declare function starlightLatexCompile(options?: StarlightLatexCompileOptions):
33
33
  interface RemarkLatexCompileOptions {
34
34
  /**
35
35
  * Directory where SVG files should be written.
36
- * @default "public/static/tex-svgs"
37
36
  */
38
- svgOutputDir?: string;
37
+ svgOutputDir: string;
39
38
  }
40
- declare function remarkLatexCompile(options?: RemarkLatexCompileOptions): (tree: any) => void;
39
+ declare function remarkLatexCompile(options: RemarkLatexCompileOptions): (tree: Record<string, unknown>, file: unknown) => void;
41
40
 
42
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,7 +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-DapkTQmZ.js';
4
+ export { r as remarkLatexCompile, s as starlightLatexCompile } from './index-B9CyKYB4.js';
5
5
  import '@astrojs/starlight/types';
6
6
  import 'hast';
7
7
  import 'vfile';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  import {
8
8
  remarkLatexCompile,
9
9
  starlightLatexCompile
10
- } from "./chunk-IRJEKSXQ.js";
10
+ } from "./chunk-T4UKGKU6.js";
11
11
 
12
12
  // src/plugins/astro-normalize-paths.ts
13
13
  import { readFileSync, writeFileSync, existsSync } from "fs";
@@ -1,2 +1,2 @@
1
- export { R as RemarkLatexCompileOptions, c as compileLatexToSvg, r as default, s as starlightLatexCompile } from '../index-DapkTQmZ.js';
1
+ export { R as RemarkLatexCompileOptions, c as compileLatexToSvg, r as default, s as starlightLatexCompile } from '../index-B9CyKYB4.js';
2
2
  import '@astrojs/starlight/types';
@@ -2,7 +2,7 @@ import {
2
2
  compileLatexToSvg,
3
3
  remarkLatexCompile,
4
4
  starlightLatexCompile
5
- } from "../chunk-IRJEKSXQ.js";
5
+ } from "../chunk-T4UKGKU6.js";
6
6
  export {
7
7
  compileLatexToSvg,
8
8
  remarkLatexCompile as default,
@@ -1,2 +1,2 @@
1
1
  import '@astrojs/starlight/types';
2
- export { S as StarlightLatexCompileOptions, s as default, s as starlightLatexCompile } from '../index-DapkTQmZ.js';
2
+ export { S as StarlightLatexCompileOptions, s as default, s as starlightLatexCompile } from '../index-B9CyKYB4.js';
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  starlightLatexCompile,
3
3
  starlight_plugin_default
4
- } from "../chunk-IRJEKSXQ.js";
4
+ } from "../chunk-T4UKGKU6.js";
5
5
  export {
6
6
  starlight_plugin_default as default,
7
7
  starlightLatexCompile
File without changes
@@ -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.1.0",
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",
@@ -63,7 +66,7 @@
63
66
  "clean:empty-dirs": "find src/content/docs -type d -empty -delete"
64
67
  },
65
68
  "dependencies": {
66
- "eslint-cannoli-plugins": "^1.0.12",
69
+ "eslint-cannoli-plugins": "^1.0.13",
67
70
  "glob": "^13.0.6",
68
71
  "minimatch": "^10.2.4",
69
72
  "unist-util-visit": "^5.0.0",