starlight-cannoli-plugins 1.2.18 → 2.0.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
@@ -48,7 +48,7 @@ export default defineConfig({
48
48
  });
49
49
  ```
50
50
 
51
- ### Starlight LaTeX Compile
51
+ ### LaTeX Compile
52
52
 
53
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
54
 
@@ -58,7 +58,7 @@ Automatically compiles fenced `tex compile` and `latex compile` code blocks to S
58
58
  - Caches compiled SVGs by content hash (no recompilation if unchanged)
59
59
  - Comprehensive error reporting with line numbers and formatted LaTeX source
60
60
  - Supports custom preamble via `% ===` separator in code blocks
61
- - Works seamlessly with Starlight's content pipeline
61
+ - Works with Starlight and plain Astro projects
62
62
  - Requires `svgOutputDir` configuration (no defaults)
63
63
 
64
64
  **System Requirements:**
@@ -75,24 +75,26 @@ pdflatex --version
75
75
  dvisvgm --version
76
76
  ```
77
77
 
78
+ **Options:**
79
+
80
+ - `svgOutputDir` (required): Directory where compiled SVG files are written. Must be inside `public/` so Astro serves them as static assets.
81
+ - `removeOrphanedSvgs` (optional, default: `false`): When `true`, SVG files that are no longer referenced by any `tex compile` block are deleted automatically. In dev mode, stale SVGs are removed immediately when a block is edited. On build, any remaining orphans are swept at the end.
82
+
78
83
  **Usage:**
79
84
 
80
85
  ```ts
81
86
  // astro.config.mjs
82
87
  import { defineConfig } from "astro/config";
83
88
  import starlight from "@astrojs/starlight";
84
- import { starlightLatexCompile } from "cannoli-starlight-plugins";
89
+ import { astroLatexCompile } from "cannoli-starlight-plugins";
85
90
 
86
91
  export default defineConfig({
87
92
  integrations: [
88
- starlight({
89
- title: "My Docs",
90
- plugins: [
91
- starlightLatexCompile({
92
- svgOutputDir: "public/static/tex-svgs",
93
- }),
94
- ],
93
+ astroLatexCompile({
94
+ svgOutputDir: "public/static/tex-svgs",
95
+ removeOrphanedSvgs: true, // optional
95
96
  }),
97
+ starlight({ title: "My Docs" }),
96
98
  ],
97
99
  });
98
100
  ```
@@ -174,16 +176,9 @@ Add custom CSS classes the `tex compile` code block to have them applied to the
174
176
 
175
177
  The img element will have classes: `tex-compiled bg-white rounded-1` (note: the `tex-compiled` class is always included by default).
176
178
 
177
- ### Remark LaTeX Compile
178
-
179
- The underlying remark plugin that powers `starlightLatexCompile`. Use this directly in Astro projects that don't use Starlight.
180
-
181
- **System Requirements:**
179
+ ### Remark LaTeX Compile (low-level)
182
180
 
183
- Same as `starlightLatexCompile`:
184
-
185
- - **`pdflatex`** — LaTeX compiler that produces PDF output
186
- - **`dvisvgm`** — Converts PDF to SVG format
181
+ The underlying remark plugin used by `astroLatexCompile`. Use this directly if you need to wire the transformer into a custom pipeline — most users should use `astroLatexCompile` instead.
187
182
 
188
183
  **Usage:**
189
184
 
@@ -195,18 +190,13 @@ import { remarkLatexCompile } from "cannoli-starlight-plugins/remark-latex-compi
195
190
  export default defineConfig({
196
191
  markdown: {
197
192
  remarkPlugins: [
198
- [
199
- remarkLatexCompile,
200
- {
201
- svgOutputDir: "public/static/tex-svgs",
202
- },
203
- ],
193
+ [remarkLatexCompile, { svgOutputDir: "public/static/tex-svgs" }],
204
194
  ],
205
195
  },
206
196
  });
207
197
  ```
208
198
 
209
- The plugin works identically to `starlightLatexCompile` but is configured directly in the Astro markdown pipeline.
199
+ Note: when used directly (without `astroLatexCompile`), the Starlight content layer cache is not cleared automatically, so SVGs may not recompile on repeat builds in Starlight projects.
210
200
 
211
201
  ### Rehype Validate Links
212
202
 
@@ -288,7 +278,7 @@ export default defineConfig({
288
278
  });
289
279
  ```
290
280
 
291
- ### Starlight Sync Docs to Public
281
+ ### Sync Docs to Public
292
282
 
293
283
  Syncs `src/content/docs/` to `public/` so local files (e.g., PDFs, images) referenced in markdown are served by the dev server and included in builds. In dev mode, it watches for file changes and re-syncs automatically — no restart needed.
294
284
 
@@ -305,18 +295,12 @@ Syncs `src/content/docs/` to `public/` so local files (e.g., PDFs, images) refer
305
295
  // astro.config.mjs
306
296
  import { defineConfig } from "astro/config";
307
297
  import starlight from "@astrojs/starlight";
308
- import { starlightSyncDocsToPublic } from "cannoli-starlight-plugins";
298
+ import { syncDocsToPublic } from "cannoli-starlight-plugins";
309
299
 
310
300
  export default defineConfig({
311
301
  integrations: [
312
- starlight({
313
- title: "My Docs",
314
- plugins: [
315
- starlightSyncDocsToPublic({
316
- preserveDirs: ["static"],
317
- }),
318
- ],
319
- }),
302
+ syncDocsToPublic({ preserveDirs: ["static"] }),
303
+ starlight({ title: "My Docs" }),
320
304
  ],
321
305
  });
322
306
  ```
@@ -327,7 +311,7 @@ export default defineConfig({
327
311
  - `ignorePatterns` (optional): Glob patterns for files to exclude from syncing. Patterns are matched against paths relative to `src/content/docs/`.
328
312
 
329
313
  ```ts
330
- starlightSyncDocsToPublic({
314
+ syncDocsToPublic({
331
315
  preserveDirs: ["static"],
332
316
  ignorePatterns: ["**/*.txt", "**/drafts/**"],
333
317
  });
@@ -337,7 +321,9 @@ starlightSyncDocsToPublic({
337
321
 
338
322
  ### cannoli-latex-cleanup
339
323
 
340
- 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.
324
+ A manual 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.
325
+
326
+ > **Note:** If you use `removeOrphanedSvgs: true` in your `astroLatexCompile` config, this CLI is generally not needed — orphaned SVGs are cleaned up automatically during both dev and build.
341
327
 
342
328
  **Usage:**
343
329
 
@@ -361,7 +347,7 @@ npx cannoli-latex-cleanup --svg-dir public/static/tex-svgs --docs-dir ./src/cont
361
347
 
362
348
  **Options:**
363
349
 
364
- - `--svg-dir` (required): Path to the SVG output directory configured in `starlightLatexCompile`
350
+ - `--svg-dir` (required): Path to the SVG output directory configured in `astroLatexCompile`
365
351
  - `--docs-dir` (optional, default: `src/content/docs`): Path to markdown source directory
366
352
  - `--check`: List orphaned SVGs without deleting
367
353
  - `--delete`: Delete orphaned SVGs
@@ -0,0 +1,143 @@
1
+ import {
2
+ parseFrontmatter
3
+ } from "./chunk-3ATSZG6H.js";
4
+
5
+ // src/plugins/starlight-sync-docs-to-public.ts
6
+ import { cp, mkdir, readdir, readFile, writeFile, rm, stat } from "fs/promises";
7
+ import { resolve, relative } from "path";
8
+ import { minimatch } from "minimatch";
9
+ var DEFAULT_SRC_DIR = "src/content/docs";
10
+ var DEFAULT_PUBLIC_DIR = "public";
11
+ async function fullSync(srcDir, publicDir, preserveDirs, ignorePatterns) {
12
+ await mkdir(publicDir, { recursive: true });
13
+ const items = await readdir(publicDir, { withFileTypes: true });
14
+ await Promise.all(
15
+ items.filter((item) => item.isDirectory() && !preserveDirs.includes(item.name)).map((item) => rm(resolve(publicDir, item.name), { recursive: true, force: true }))
16
+ );
17
+ await copyWithRetry(srcDir, publicDir, ignorePatterns);
18
+ console.log(
19
+ `[starlight-sync-docs-to-public] Synced ${DEFAULT_SRC_DIR}/ \u2192 ${DEFAULT_PUBLIC_DIR}/ (full sync)`
20
+ );
21
+ }
22
+ async function incrementalSync(changedFilePath, srcDir, publicDir, ignorePatterns) {
23
+ const rel = relative(srcDir, changedFilePath);
24
+ if (ignorePatterns.some((pattern) => minimatch(rel, pattern, { dot: true }))) {
25
+ return;
26
+ }
27
+ if (changedFilePath.endsWith(".md") || changedFilePath.endsWith(".mdx")) {
28
+ try {
29
+ const frontmatter = parseFrontmatter(changedFilePath);
30
+ if (frontmatter.draft === true) return;
31
+ } catch {
32
+ return;
33
+ }
34
+ }
35
+ const destPath = resolve(publicDir, rel);
36
+ let srcStat;
37
+ try {
38
+ srcStat = await stat(changedFilePath);
39
+ } catch (err) {
40
+ if (err?.code === "ENOENT") {
41
+ await rm(destPath, { recursive: true, force: true });
42
+ return;
43
+ }
44
+ throw err;
45
+ }
46
+ if (srcStat.isDirectory()) {
47
+ await mkdir(destPath, { recursive: true });
48
+ const files = await readdir(changedFilePath);
49
+ await Promise.all(
50
+ files.map(
51
+ (file) => incrementalSync(resolve(changedFilePath, file), srcDir, publicDir, ignorePatterns)
52
+ )
53
+ );
54
+ } else {
55
+ await mkdir(resolve(destPath, ".."), { recursive: true });
56
+ const content = await readFile(changedFilePath);
57
+ await writeFile(destPath, content);
58
+ }
59
+ console.log(`[starlight-sync-docs-to-public] Synced ${rel}`);
60
+ }
61
+ async function copyWithRetry(srcDir, publicDir, ignorePatterns) {
62
+ for (let attempt = 0; attempt < 3; attempt++) {
63
+ try {
64
+ await cp(srcDir, publicDir, {
65
+ recursive: true,
66
+ force: true,
67
+ filter: (src) => {
68
+ const rel = relative(srcDir, src);
69
+ if (rel === "") return true;
70
+ if (ignorePatterns.some((pattern) => minimatch(rel, pattern, { dot: true }))) {
71
+ return false;
72
+ }
73
+ if (src.endsWith(".md") || src.endsWith(".mdx")) {
74
+ try {
75
+ const frontmatter = parseFrontmatter(src);
76
+ if (frontmatter.draft === true) return false;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+ return true;
82
+ }
83
+ });
84
+ return;
85
+ } catch (err) {
86
+ const code = err?.code;
87
+ if (code !== "EEXIST" && code !== "ENOENT") throw err;
88
+ if (attempt < 2) await new Promise((r) => setTimeout(r, 10 * (attempt + 1)));
89
+ }
90
+ }
91
+ }
92
+ function syncDocsToPublic(options) {
93
+ const srcDir = resolve(DEFAULT_SRC_DIR);
94
+ const publicDir = resolve(DEFAULT_PUBLIC_DIR);
95
+ const { preserveDirs, ignorePatterns = [] } = options;
96
+ return {
97
+ name: "astro-sync-docs-to-public",
98
+ hooks: {
99
+ "astro:build:start": async () => {
100
+ await fullSync(srcDir, publicDir, preserveDirs, ignorePatterns);
101
+ },
102
+ "astro:server:setup": ({ server }) => {
103
+ const pendingPaths = /* @__PURE__ */ new Set();
104
+ let isSyncing = false;
105
+ let debounceTimer = null;
106
+ const performSync = async () => {
107
+ if (isSyncing) return;
108
+ isSyncing = true;
109
+ try {
110
+ while (pendingPaths.size > 0) {
111
+ const paths = [...pendingPaths];
112
+ pendingPaths.clear();
113
+ await Promise.all(
114
+ paths.map(
115
+ (p) => incrementalSync(p, srcDir, publicDir, ignorePatterns).catch((err) => {
116
+ console.error(`[starlight-sync-docs-to-public] Error syncing ${p}:`, err);
117
+ })
118
+ )
119
+ );
120
+ }
121
+ } finally {
122
+ isSyncing = false;
123
+ if (pendingPaths.size > 0) void performSync();
124
+ }
125
+ };
126
+ fullSync(srcDir, publicDir, preserveDirs, ignorePatterns).catch(console.error);
127
+ server.watcher.add(srcDir);
128
+ server.watcher.on("all", (_event, filePath) => {
129
+ if (!filePath.startsWith(srcDir)) return;
130
+ pendingPaths.add(filePath);
131
+ if (debounceTimer) clearTimeout(debounceTimer);
132
+ debounceTimer = setTimeout(performSync, 100);
133
+ });
134
+ }
135
+ }
136
+ };
137
+ }
138
+ var starlightSyncDocsToPublic = syncDocsToPublic;
139
+
140
+ export {
141
+ syncDocsToPublic,
142
+ starlightSyncDocsToPublic
143
+ };
@@ -1,6 +1,6 @@
1
- // src/plugins/remark-latex-compile/compile.ts
1
+ // src/plugins/remark-latex-compile/utils.ts
2
2
  import { createHash } from "crypto";
3
- import { spawnSync } from "child_process";
3
+ import { spawn } from "child_process";
4
4
  import {
5
5
  existsSync,
6
6
  mkdirSync,
@@ -161,7 +161,7 @@ ${formattedSource}
161
161
  `;
162
162
  }
163
163
 
164
- // src/plugins/remark-latex-compile/compile.ts
164
+ // src/plugins/remark-latex-compile/utils.ts
165
165
  var LATEX_BLOCK_REGEX = /```(?:tex|latex)\s+compile[^\r\n]*\r?\n([\s\S]*?)\r?\n```/g;
166
166
  function hashLatexCode(code) {
167
167
  const normalized = code.split("\n").map((line) => line.trim()).filter((line) => !line.startsWith("%")).filter(Boolean).join("\n").trim();
@@ -188,7 +188,26 @@ function buildLatexSource(latexCode) {
188
188
  "\\end{document}"
189
189
  ].join("\n");
190
190
  }
191
- function compileLatexToSvg(latexCode, svgOutputDir) {
191
+ function execProcess(command, args) {
192
+ return new Promise((resolve, reject) => {
193
+ let stdout = "";
194
+ let stderr = "";
195
+ const proc = spawn(command, args);
196
+ proc.stdout.on("data", (data) => {
197
+ stdout += data.toString();
198
+ });
199
+ proc.stderr.on("data", (data) => {
200
+ stderr += data.toString();
201
+ });
202
+ proc.on("error", (err) => {
203
+ reject(err);
204
+ });
205
+ proc.on("close", (code) => {
206
+ resolve({ status: code ?? 1, stdout, stderr });
207
+ });
208
+ });
209
+ }
210
+ async function compileLatexToSvg(latexCode, svgOutputDir) {
192
211
  const hash = hashLatexCode(latexCode);
193
212
  const svgPath = join(svgOutputDir, `${hash}.svg`);
194
213
  if (existsSync(svgPath)) {
@@ -201,41 +220,47 @@ function compileLatexToSvg(latexCode, svgOutputDir) {
201
220
  const latexSource = buildLatexSource(latexCode);
202
221
  try {
203
222
  writeFileSync(texFile, latexSource, "utf-8");
204
- const latexResult = spawnSync("pdflatex", [
205
- "-interaction=nonstopmode",
206
- "-output-directory",
207
- workDir,
208
- texFile
209
- ]);
210
- if (latexResult.error) {
211
- const code = latexResult.error.code;
223
+ let result;
224
+ try {
225
+ result = await execProcess("pdflatex", [
226
+ "-interaction=nonstopmode",
227
+ "-output-directory",
228
+ workDir,
229
+ texFile
230
+ ]);
231
+ } catch (err) {
232
+ const code = err.code;
212
233
  throw new Error(
213
- `[remark-latex-compile] pdflatex not found on PATH (${code}).`
234
+ `[remark-latex-compile] pdflatex not found on PATH (${code}).`,
235
+ { cause: err }
214
236
  );
215
237
  }
216
- if (latexResult.status !== 0) {
217
- const errorOutput = latexResult.stderr?.toString() || latexResult.stdout?.toString() || "";
238
+ if (result.status !== 0) {
239
+ const errorOutput = result.stderr || result.stdout || "";
218
240
  const userMessage = createCompilationErrorMessage(
219
241
  latexSource,
220
242
  errorOutput
221
243
  );
222
244
  throw new Error(userMessage);
223
245
  }
224
- const dvisvgmResult = spawnSync("dvisvgm", [
225
- "--pdf",
226
- "--bbox=dvi",
227
- pdfFile,
228
- "-o",
229
- svgPath
230
- ]);
231
- if (dvisvgmResult.error) {
232
- const code = dvisvgmResult.error.code;
246
+ let svgResult;
247
+ try {
248
+ svgResult = await execProcess("dvisvgm", [
249
+ "--pdf",
250
+ "--bbox=dvi",
251
+ pdfFile,
252
+ "-o",
253
+ svgPath
254
+ ]);
255
+ } catch (err) {
256
+ const code = err.code;
233
257
  throw new Error(
234
- `[remark-latex-compile] dvisvgm not found on PATH (${code}).`
258
+ `[remark-latex-compile] dvisvgm not found on PATH (${code}).`,
259
+ { cause: err }
235
260
  );
236
261
  }
237
- if (dvisvgmResult.status !== 0) {
238
- const errorOutput = dvisvgmResult.stderr?.toString() || dvisvgmResult.stdout?.toString() || "";
262
+ if (svgResult.status !== 0) {
263
+ const errorOutput = svgResult.stderr || svgResult.stdout || "";
239
264
  throw new Error(
240
265
  `[remark-latex-compile] PDF to SVG conversion failed (hash: ${hash}).
241
266
  Error: ${errorOutput}`
@@ -0,0 +1,71 @@
1
+ // src/plugins/astro-normalize-paths.ts
2
+ import { existsSync } from "fs";
3
+ import { readFile, writeFile } from "fs/promises";
4
+ import { glob } from "glob";
5
+ import { dirname, resolve } from "path";
6
+ function astroNormalizePaths() {
7
+ return {
8
+ name: "astro-normalize-paths",
9
+ hooks: {
10
+ "astro:build:done": async ({ dir }) => {
11
+ const htmlFiles = await glob(`${dir.pathname}/**/*.html`);
12
+ await Promise.all(
13
+ htmlFiles.map(async (htmlFile) => {
14
+ let content = await readFile(htmlFile, "utf-8");
15
+ const originalContent = content;
16
+ const imgRegex = /<img([^>]*?)src=["']([^"']+)["']/g;
17
+ let match;
18
+ while ((match = imgRegex.exec(content)) !== null) {
19
+ const attrs = match[1];
20
+ const src = match[2];
21
+ const normalized = normalizeAssetPath(src, htmlFile, dir.pathname);
22
+ if (normalized && src !== normalized) {
23
+ console.log(`[astro-normalize-paths] Img path resolution:`);
24
+ console.log(` Original: ${src}`);
25
+ console.log(` HTML file: ${htmlFile}`);
26
+ console.log(` Normalized: ${normalized}`);
27
+ content = content.replace(`<img${attrs}src="${src}"`, `<img${attrs}src="${normalized}"`);
28
+ }
29
+ }
30
+ const anchorRegex = /<a([^>]*?)href=["']([^"']+)["']/g;
31
+ while ((match = anchorRegex.exec(content)) !== null) {
32
+ const attrs = match[1];
33
+ const href = match[2];
34
+ const normalized = normalizeAssetPath(href, htmlFile, dir.pathname);
35
+ if (normalized && href !== normalized) {
36
+ content = content.replace(`<a${attrs}href="${href}"`, `<a${attrs}href="${normalized}"`);
37
+ }
38
+ }
39
+ if (content !== originalContent) {
40
+ await writeFile(htmlFile, content, "utf-8");
41
+ }
42
+ })
43
+ );
44
+ }
45
+ }
46
+ };
47
+ }
48
+ function normalizeAssetPath(path, htmlFile, siteRootPath) {
49
+ if (path.startsWith("http") || path.startsWith("data:") || path.startsWith("/")) {
50
+ return null;
51
+ }
52
+ const htmlDir = dirname(htmlFile);
53
+ const resolvedPath = resolve(htmlDir, path);
54
+ const siteRoot = resolve(siteRootPath);
55
+ let absolutePath = resolvedPath.slice(siteRoot.length).replace(/\\/g, "/");
56
+ if (!existsSync(resolvedPath)) {
57
+ const parentDir = dirname(htmlDir);
58
+ const alternativePath = resolve(parentDir, path);
59
+ if (existsSync(alternativePath)) {
60
+ absolutePath = alternativePath.slice(siteRoot.length).replace(/\\/g, "/");
61
+ }
62
+ }
63
+ const finalPath = "/" + absolutePath.replace(/^\/+/, "");
64
+ return finalPath;
65
+ }
66
+ var astro_normalize_paths_default = astroNormalizePaths;
67
+
68
+ export {
69
+ astroNormalizePaths,
70
+ astro_normalize_paths_default
71
+ };
@@ -72,24 +72,17 @@ function validateLink(link) {
72
72
  if (project_absolute_href.includes(".{md,mdx}")) {
73
73
  const matches = globSync(project_absolute_href);
74
74
  if (matches.length === 0) {
75
- throw new Error(
76
- `Link validation error: No matching file found for: ${link.original_href} (pattern: ${project_absolute_href})`
77
- );
75
+ return `No matching file found for: ${link.original_href} (pattern: ${project_absolute_href})`;
78
76
  }
79
77
  if (matches.length > 1) {
80
- throw new Error(
81
- `Link validation error: Multiple matching files found: ${matches.join(
82
- ", "
83
- )} (from link: ${link.original_href})`
84
- );
78
+ return `Multiple matching files found: ${matches.join(", ")} (from link: ${link.original_href})`;
85
79
  }
86
80
  } else {
87
81
  if (!existsSync(project_absolute_href)) {
88
- throw new Error(
89
- `Link validation error: File not found: ${project_absolute_href} (from link: ${link.original_href})`
90
- );
82
+ return `File not found: ${project_absolute_href} (from link: ${link.original_href})`;
91
83
  }
92
84
  }
85
+ return null;
93
86
  }
94
87
  function rehypeValidateLinks(options) {
95
88
  return (tree, file) => {
@@ -100,6 +93,7 @@ function rehypeValidateLinks(options) {
100
93
  );
101
94
  return;
102
95
  }
96
+ const errors = [];
103
97
  visit(tree, "element", (node) => {
104
98
  if (node.tagName !== "a") return;
105
99
  const href = node.properties?.href;
@@ -108,9 +102,17 @@ function rehypeValidateLinks(options) {
108
102
  if (!link) return;
109
103
  if (link.skipValidation) return;
110
104
  if (node.properties?.["data-no-link-check"] !== void 0) return;
111
- if (matchesSkipPattern(link.site_absolute_href, options?.skipPatterns)) return;
112
- validateLink(link);
105
+ if (matchesSkipPattern(link.site_absolute_href, options?.skipPatterns))
106
+ return;
107
+ const error = validateLink(link);
108
+ if (error) errors.push(error);
113
109
  });
110
+ if (errors.length > 0) {
111
+ throw new Error(
112
+ `Link validation errors in ${filePath}:
113
+ ` + errors.map((e) => ` - ${e}`).join("\n")
114
+ );
115
+ }
114
116
  };
115
117
  }
116
118
  var rehype_validate_links_default = rehypeValidateLinks;
@@ -0,0 +1,149 @@
1
+ import {
2
+ compileLatexToSvg,
3
+ hashLatexCode
4
+ } from "./chunk-47X5MKFJ.js";
5
+
6
+ // src/plugins/remark-latex-compile/index.ts
7
+ import { rm } from "fs/promises";
8
+ import { join, resolve } from "path";
9
+ import { visit as visit2, SKIP } from "unist-util-visit";
10
+
11
+ // src/plugins/remark-latex-compile/rehype-converter.ts
12
+ import { visit } from "unist-util-visit";
13
+ function rehypeLatexCompile() {
14
+ return (tree, _file) => {
15
+ visit(tree, "element", (node, index, parent) => {
16
+ if (node.tagName !== "pre") return;
17
+ const codeChild = node.children?.[0];
18
+ if (!codeChild || codeChild.tagName !== "code") return;
19
+ const classes = Array.isArray(codeChild.properties?.className) ? codeChild.properties.className : [];
20
+ if (!classes.includes("language-tex") && !classes.includes("language-latex")) {
21
+ return;
22
+ }
23
+ const codeContent = codeChild.children?.map(
24
+ (child) => typeof child === "string" ? child : child.value || ""
25
+ ).join("").trim();
26
+ if (!codeContent) return;
27
+ const dataAttribute = codeChild.properties?.["data-meta"];
28
+ const isCompileBlock = dataAttribute && dataAttribute.includes("compile") || codeContent.includes("compile");
29
+ if (!isCompileBlock) return;
30
+ try {
31
+ const hash = hashLatexCode(codeContent);
32
+ const svgPath = `/static/tex-svgs/${hash}.svg`;
33
+ const imgElement = {
34
+ type: "element",
35
+ tagName: "img",
36
+ properties: {
37
+ src: svgPath,
38
+ alt: "LaTeX diagram",
39
+ className: ["tex-compiled"]
40
+ },
41
+ children: []
42
+ };
43
+ const paragraphElement = {
44
+ type: "element",
45
+ tagName: "p",
46
+ properties: {},
47
+ children: [imgElement]
48
+ };
49
+ if (parent && typeof index === "number") {
50
+ parent.children[index] = paragraphElement;
51
+ }
52
+ } catch (err) {
53
+ console.error(
54
+ `[rehype-latex-compile] Error processing code block:`,
55
+ err
56
+ );
57
+ }
58
+ });
59
+ };
60
+ }
61
+
62
+ // src/plugins/remark-latex-compile/index.ts
63
+ function extractClassesFromMeta(meta) {
64
+ const classMatch = meta.match(/class="([^"]+)"/);
65
+ if (classMatch?.[1]) {
66
+ return classMatch[1].split(/\s+/).filter(Boolean);
67
+ }
68
+ return [];
69
+ }
70
+ function remarkLatexCompile(options) {
71
+ const svgOutputDir = resolve(options.svgOutputDir);
72
+ return async function transformer(tree, file) {
73
+ const nodes = [];
74
+ visit2(tree, "code", (node, index, parent) => {
75
+ if ((node.lang === "tex" || node.lang === "latex") && node.meta?.includes("compile") && parent && index !== void 0) {
76
+ nodes.push({ node, index, parent });
77
+ }
78
+ return SKIP;
79
+ });
80
+ if (nodes.length === 0) return;
81
+ const filePath = file.path || "unknown";
82
+ const results = await Promise.all(
83
+ nodes.map(async ({ node, index, parent }) => {
84
+ const lineNumber = node.position?.start.line ?? "?";
85
+ try {
86
+ const result = await compileLatexToSvg(node.value, svgOutputDir);
87
+ if (result.wasCompiled) {
88
+ console.log(
89
+ `[remark-latex-compile] ${filePath}:${lineNumber}: compiled ${result.hash}.svg`
90
+ );
91
+ }
92
+ options._referencedHashes?.add(result.hash);
93
+ return { index, parent, result, error: null, hash: result.hash };
94
+ } catch (err) {
95
+ const errorMsg = err instanceof Error ? err.message : String(err);
96
+ const match = errorMsg.match(/\n\n([\s\S]+)/);
97
+ const details = match ? match[1] : errorMsg;
98
+ console.error(
99
+ `[remark-latex-compile] ${filePath}:${lineNumber}
100
+ ${details}`
101
+ );
102
+ return { index, parent, result: null, error: err, hash: null };
103
+ }
104
+ })
105
+ );
106
+ if (options._fileHashMap) {
107
+ const newHashes = new Set(
108
+ results.map((r) => r.hash).filter(Boolean)
109
+ );
110
+ const oldHashes = options._fileHashMap.get(filePath) ?? /* @__PURE__ */ new Set();
111
+ const staleHashes = [...oldHashes].filter((h) => !newHashes.has(h));
112
+ for (const staleHash of staleHashes) {
113
+ console.warn(
114
+ `[remark-latex-compile] Removing orphaned svg: ${staleHash}.svg`
115
+ );
116
+ }
117
+ await Promise.all(
118
+ staleHashes.map(
119
+ (h) => rm(join(svgOutputDir, `${h}.svg`), { force: true })
120
+ )
121
+ );
122
+ options._fileHashMap.set(filePath, newHashes);
123
+ }
124
+ for (let i = results.length - 1; i >= 0; i--) {
125
+ const { index, parent, result } = results[i];
126
+ const { node } = nodes[i];
127
+ if (!result) continue;
128
+ const customClasses = extractClassesFromMeta(node.meta ?? "");
129
+ const imageNode = {
130
+ type: "image",
131
+ url: `/static/tex-svgs/${result.hash}.svg`,
132
+ alt: "LaTeX diagram",
133
+ data: {
134
+ hProperties: { className: ["tex-compiled", ...customClasses] }
135
+ }
136
+ };
137
+ const paragraph = {
138
+ type: "paragraph",
139
+ children: [imageNode]
140
+ };
141
+ parent.children.splice(index, 1, paragraph);
142
+ }
143
+ };
144
+ }
145
+
146
+ export {
147
+ rehypeLatexCompile,
148
+ remarkLatexCompile
149
+ };