starlight-cannoli-plugins 2.12.1 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,7 +121,6 @@ Automatically compiles fenced `tex compile` and `latex compile` code blocks to S
121
121
  - Compiles LaTeX/TikZ code blocks to SVG automatically
122
122
  - Caches compiled SVGs by content hash (no recompilation if unchanged)
123
123
  - Comprehensive error reporting with line numbers and formatted LaTeX source
124
- - Supports custom preamble via `% ===` separator in code blocks
125
124
  - Works with Starlight and plain Astro projects
126
125
  - Requires `svgOutputDir` configuration (no defaults)
127
126
 
@@ -178,46 +177,22 @@ Use either ` ```tex compile ` or ` ```latex compile ` — both work identically:
178
177
 
179
178
  ````markdown
180
179
  ```tex compile
180
+ \documentclass[border=5pt]{standalone}
181
+ \usepackage{tikz}
182
+ \begin{document}
183
+ \Large
181
184
  \begin{tikzpicture}
182
185
  \node (A) at (0,0) {A};
183
186
  \node (B) at (2,0) {B};
184
187
  \draw (A) -- (B);
185
188
  \end{tikzpicture}
186
- ```
187
- ````
188
-
189
- **Minimal approach:**
190
-
191
- Use `% ===` to separate an optional custom preamble from your diagram content. The plugin wraps everything in the following document structure:
192
-
193
- ```latex
194
- \documentclass[border=5pt]{standalone}
195
- {your preamble}
196
- \begin{document}
197
- \Large
198
- {your content}
199
189
  \end{document}
200
190
  ```
201
-
202
- Example **minimal** tex code block:
203
-
204
- ````markdown
205
- ```tex compile
206
- \usepackage{tikz-3dplot}
207
-
208
- % ===
209
-
210
- \begin{tikzpicture}
211
- % diagram code here
212
- \end{tikzpicture}
213
- ```
214
191
  ````
215
192
 
216
- If no `% ===` separator is present, the entire block is treated as content and wrapped in the same default document structure (with an empty preamble).
193
+ **Document structure:**
217
194
 
218
- **Complete Document Control:**
219
-
220
- If your code block contains both `\documentclass` and `\begin{document}`, the plugin treats it as a complete, self-contained LaTeX document and uses it as-is without checking for a `% ===` separator for a preamble:
195
+ Each code block must be a complete, self-contained LaTeX document:
221
196
 
222
197
  ````markdown
223
198
  ```tex compile
@@ -244,9 +219,14 @@ The following attributes can be added to the opening fence:
244
219
 
245
220
  ````markdown
246
221
  ```tex compile class="bg-white rounded-1" alt="A commutative diagram"
222
+ \documentclass[border=5pt]{standalone}
223
+ \usepackage{tikz}
224
+ \begin{document}
225
+ \Large
247
226
  \begin{tikzpicture}
248
227
  \node {Custom styled diagram};
249
228
  \end{tikzpicture}
229
+ \end{document}
250
230
  ```
251
231
  ````
252
232
 
@@ -425,6 +405,50 @@ export default defineConfig({
425
405
  });
426
406
  ```
427
407
 
408
+ ### Expressive Code Emphasis
409
+
410
+ An Expressive Code plugin that highlights specific terms inside code blocks using the `emph` meta attribute. Matched terms are wrapped in a `<span class="fw-supreme">` for custom styling.
411
+
412
+ **Features:**
413
+
414
+ - Comma-separated list of terms to emphasize per code block
415
+ - Exact-word matching: terms surrounded by word boundaries or whitespace/string edges are matched; partial substrings are not
416
+ - Supports non-word characters (e.g. `(`, `]`, `;`) as well as identifiers
417
+
418
+ **Usage:**
419
+
420
+ Register the plugin with Expressive Code:
421
+
422
+ ```ts
423
+ // astro.config.mjs
424
+ import { defineConfig } from "astro/config";
425
+ import starlight from "@astrojs/starlight";
426
+ import { expressiveCodeEmphasis } from "starlight-cannoli-plugins";
427
+
428
+ export default defineConfig({
429
+ integrations: [
430
+ starlight({
431
+ title: "My Docs",
432
+ expressiveCode: {
433
+ plugins: [expressiveCodeEmphasis()],
434
+ },
435
+ }),
436
+ ],
437
+ });
438
+ ```
439
+
440
+ Then use the `emph` meta attribute on any code block:
441
+
442
+ ````markdown
443
+ ```js emph="foo,bar,()"
444
+ function foo() {
445
+ return bar() || ();
446
+ }
447
+ ```
448
+ ````
449
+
450
+ Multiple terms are separated by commas. Each term is matched as a whole unit — it must be surrounded by whitespace, start/end of line, or (for terms that start/end with word characters) word boundaries.
451
+
428
452
  ## CLI Utilities
429
453
 
430
454
  ### cannoli-latex-cleanup
@@ -1,6 +1,11 @@
1
+ import {
2
+ parseFrontmatter
3
+ } from "./chunk-C2VXRQOK.js";
4
+
1
5
  // src/plugins/astro-latex-compile/index.ts
6
+ import { readFileSync as readFileSync2 } from "fs";
2
7
  import { rm } from "fs/promises";
3
- import { join as join2, resolve as resolve2 } from "path";
8
+ import { join as join2, relative as relative2, resolve as resolve2 } from "path";
4
9
  import { visit, SKIP } from "unist-util-visit";
5
10
  import { MetaOptions } from "@expressive-code/core";
6
11
 
@@ -9,11 +14,13 @@ import { createHash } from "crypto";
9
14
  import {
10
15
  existsSync,
11
16
  mkdirSync,
17
+ readdirSync,
18
+ readFileSync,
12
19
  writeFileSync,
13
20
  rmSync,
14
21
  mkdtempSync
15
22
  } from "fs";
16
- import { basename, dirname, join, resolve } from "path";
23
+ import { basename, dirname, join, relative, resolve } from "path";
17
24
  import { tmpdir } from "os";
18
25
  import sharp from "sharp";
19
26
 
@@ -187,6 +194,61 @@ function execProcess(command, args, options) {
187
194
  });
188
195
  }
189
196
 
197
+ // src/plugins/utils/html-builder.ts
198
+ import { JSDOM } from "jsdom";
199
+ var { document } = new JSDOM("").window;
200
+ var HtmlSanitizer = {
201
+ tempElement: document.createElement("div"),
202
+ sanitize(htmlString) {
203
+ this.tempElement.textContent = htmlString;
204
+ return this.tempElement.innerHTML;
205
+ }
206
+ };
207
+ var HtmlString = class extends String {
208
+ element = null;
209
+ constructor(value) {
210
+ super(value);
211
+ }
212
+ asElement() {
213
+ if (this.element !== null) {
214
+ return this.element;
215
+ }
216
+ const temp = document.createElement("div");
217
+ temp.innerHTML = this.valueOf();
218
+ if (temp.childElementCount > 1) {
219
+ throw new Error("html template does not accept more than 1 element");
220
+ }
221
+ const child = temp.firstElementChild;
222
+ if (child === null) {
223
+ throw new Error("html template produced no elements");
224
+ }
225
+ this.element = child;
226
+ return this.element;
227
+ }
228
+ };
229
+ function html(literalValues, ...interpolatedValues) {
230
+ let result = "";
231
+ interpolatedValues.forEach((currentInterpolatedVal, idx) => {
232
+ const literalVal = literalValues[idx];
233
+ let interpolatedVal = "";
234
+ if (Array.isArray(currentInterpolatedVal)) {
235
+ interpolatedVal = currentInterpolatedVal.join("\n");
236
+ } else if (typeof currentInterpolatedVal !== "boolean") {
237
+ interpolatedVal = currentInterpolatedVal.toString();
238
+ }
239
+ const isSanitize = !literalVal.endsWith("$");
240
+ if (isSanitize) {
241
+ result += literalVal;
242
+ result += HtmlSanitizer.sanitize(interpolatedVal);
243
+ } else {
244
+ result += literalVal.slice(0, -1);
245
+ result += interpolatedVal;
246
+ }
247
+ });
248
+ result += literalValues.slice(-1);
249
+ return new HtmlString(result);
250
+ }
251
+
190
252
  // src/plugins/astro-latex-compile/utils.ts
191
253
  var CONTENT_ROOT = "src/content/docs/";
192
254
  function computeJpgPath(tempOutputDir, filePath, blockId) {
@@ -201,7 +263,7 @@ function computeJpgPath(tempOutputDir, filePath, blockId) {
201
263
  }
202
264
  async function writeJpgFromSvg(svgPath, jpgPath) {
203
265
  mkdirSync(dirname(jpgPath), { recursive: true });
204
- await sharp(svgPath).flatten({ background: { r: 255, g: 255, b: 255 } }).jpeg({ quality: 90 }).toFile(jpgPath);
266
+ await sharp(svgPath, { density: 300 }).flatten({ background: { r: 255, g: 255, b: 255 } }).jpeg({ quality: 90 }).toFile(jpgPath);
205
267
  }
206
268
  function stripAnsi(text) {
207
269
  return text.replace(/\x1b\[[0-9;]*m/g, "");
@@ -254,33 +316,107 @@ async function writeJpgError(jpgPath, header, errorText) {
254
316
  mkdirSync(dirname(jpgPath), { recursive: true });
255
317
  await sharp(Buffer.from(svg)).flatten({ background: { r: 255, g: 255, b: 255 } }).jpeg({ quality: 90 }).toFile(jpgPath);
256
318
  }
257
- function hashLatexCode(code) {
319
+ function computeLineOffset(_latexCode) {
320
+ return 0;
321
+ }
322
+ function buildErrorHtml(header, errorMsg, latexCode) {
323
+ const clean = stripAnsi(errorMsg);
324
+ const sourceIdx = clean.indexOf("LaTeX source:");
325
+ const summary = (sourceIdx !== -1 ? clean.slice(0, sourceIdx) : clean).trimEnd();
326
+ const compiledLineNums = [];
327
+ const linePattern = /Error \(line (\d+)\)/g;
328
+ let m;
329
+ while ((m = linePattern.exec(summary)) !== null) {
330
+ compiledLineNums.push(parseInt(m[1], 10));
331
+ }
332
+ const offset = computeLineOffset(latexCode);
333
+ const errorLineSet = new Set(
334
+ compiledLineNums.map((n) => n - offset).filter((n) => n > 0)
335
+ );
336
+ const codeLines = latexCode.split("\n");
337
+ const lineWidth = String(codeLines.length).length;
338
+ const summaryLines = summary.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => html`<div>${line}</div>`);
339
+ const codeBlock = new HtmlString(
340
+ codeLines.map((line, i) => {
341
+ const lineNum = i + 1;
342
+ const isError = errorLineSet.has(lineNum);
343
+ const gutter = String(lineNum).padStart(lineWidth);
344
+ const bg = isError ? "background:var(--cannoli-error-low);" : "";
345
+ return html`<span style="${bg}display:block;padding:0 14px"
346
+ ><span style="color:var(--sl-color-gray-3);user-select:none"
347
+ >${gutter} │ </span
348
+ >${line}</span
349
+ >`.toString();
350
+ }).join("")
351
+ );
352
+ return html`<div
353
+ class="not-content"
354
+ style="border:1px solid var(--cannoli-error-low);border-radius:6px;overflow:hidden;margin:1.5em 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:1.5;display:flex;flex-direction:column"
355
+ >
356
+ <div
357
+ style="background:var(--cannoli-error);color:var(--sl-color-black);padding:8px 14px;font-weight:700"
358
+ >
359
+ &#9888;&nbsp;LaTeX Compilation Error &mdash; ${header}
360
+ </div>
361
+ <div
362
+ style="background:var(--cannoli-error-low);color:var(--cannoli-error-high);padding:10px 14px;border-bottom:1px solid var(--cannoli-error)"
363
+ >
364
+ $${summaryLines}
365
+ </div>
366
+ <pre
367
+ style="margin:0;background:var(--sl-color-gray-6);color:var(--sl-color-text);overflow-x:auto"
368
+ ><code style="display:block;padding:8px 0;font-size:12px">$${codeBlock}</code></pre>
369
+ </div>`.toString();
370
+ }
371
+ function collectTexInputFiles(dir, recursive) {
372
+ const files = [];
373
+ try {
374
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
375
+ const full = join(dir, entry.name);
376
+ if (entry.isDirectory() && recursive) {
377
+ files.push(...collectTexInputFiles(full, recursive));
378
+ } else if (entry.isFile()) {
379
+ files.push(full);
380
+ }
381
+ }
382
+ } catch {
383
+ }
384
+ return files.sort();
385
+ }
386
+ function computeTexInputDirsSalt(texInputDirs) {
387
+ if (texInputDirs.length === 0) return "";
388
+ const hash = createHash("md5");
389
+ let hasAnyFile = false;
390
+ for (const dir of texInputDirs) {
391
+ const recursive = dir.endsWith("//");
392
+ const resolved = resolve(dir.endsWith("/") ? dir.slice(0, -1) : dir);
393
+ for (const filePath of collectTexInputFiles(resolved, recursive)) {
394
+ try {
395
+ hash.update(relative(process.cwd(), filePath));
396
+ hash.update(readFileSync(filePath));
397
+ hasAnyFile = true;
398
+ } catch {
399
+ }
400
+ }
401
+ }
402
+ return hasAnyFile ? hash.digest("hex").slice(0, 16) : "";
403
+ }
404
+ function hashLatexCode(code, salt = "") {
258
405
  const normalized = code.split("\n").map((line) => line.trim()).filter((line) => !line.startsWith("%")).filter(Boolean).join("\n").trim();
259
- return createHash("md5").update(normalized).digest("hex").slice(0, 16);
406
+ const h = createHash("md5").update(normalized);
407
+ if (salt) h.update(salt);
408
+ return h.digest("hex").slice(0, 16);
260
409
  }
261
410
  function buildLatexSource(latexCode) {
262
411
  if (latexCode.includes("\\documentclass") && latexCode.includes("\\begin{document}")) {
263
412
  return latexCode.trim();
264
413
  }
265
- const separatorRegex = /%[ \t]*===/;
266
- const parts = latexCode.split(separatorRegex);
267
- let preamble = "";
268
- let content = latexCode.trim();
269
- if (parts.length === 2) {
270
- preamble = parts[0].trim();
271
- content = parts[1].trim();
272
- }
273
- return [
274
- "\\documentclass[border=5pt]{standalone}",
275
- preamble,
276
- "\\begin{document}",
277
- "\\Large",
278
- content,
279
- "\\end{document}"
280
- ].join("\n");
414
+ throw new Error(
415
+ `[remark-latex-compile] Code block is not a complete LaTeX document. Blocks must contain both \\documentclass and \\begin{document}.`
416
+ );
281
417
  }
282
- async function compileLatexToSvg(latexCode, svgOutputDir, texInputDirs = []) {
283
- const hash = hashLatexCode(latexCode);
418
+ async function compileLatexToSvg(latexCode, svgOutputDir, texInputDirs = [], inputsSalt = "") {
419
+ const hash = hashLatexCode(latexCode, inputsSalt);
284
420
  const svgPath = join(svgOutputDir, `${hash}.svg`);
285
421
  if (existsSync(svgPath)) {
286
422
  return { hash, svgPath, wasCompiled: false };
@@ -351,6 +487,16 @@ Error: ${errorOutput}`
351
487
  }
352
488
 
353
489
  // src/plugins/astro-latex-compile/index.ts
490
+ function getFrontmatterOffset(absoluteFilePath) {
491
+ try {
492
+ const original = readFileSync2(absoluteFilePath, "utf-8");
493
+ const { rawFrontmatter } = parseFrontmatter(original);
494
+ if (!rawFrontmatter) return 0;
495
+ return rawFrontmatter.split("\n").length - 1 + 2;
496
+ } catch {
497
+ return 0;
498
+ }
499
+ }
354
500
  function remarkLatexCompile(options) {
355
501
  const svgOutputDir = resolve2(options.svgOutputDir);
356
502
  return async function transformer(tree, file) {
@@ -363,27 +509,31 @@ function remarkLatexCompile(options) {
363
509
  });
364
510
  if (nodes.length === 0) return;
365
511
  const filePath = file.path || "unknown";
512
+ const relFilePath = filePath !== "unknown" ? relative2(process.cwd(), filePath) : "unknown";
513
+ const frontmatterOffset = filePath !== "unknown" ? getFrontmatterOffset(filePath) : 0;
514
+ const inputsSalt = computeTexInputDirsSalt(options.texInputDirs ?? []);
366
515
  const results = await Promise.all(
367
516
  nodes.map(async ({ node, index, parent }) => {
368
- const lineNumberStr = node.position?.start.line ?? "?";
517
+ const lineNumberStr = node.position?.start.line !== void 0 ? String(node.position.start.line + frontmatterOffset) : "?";
369
518
  const blockId = new MetaOptions(node.meta ?? "").getInteger("blockid");
370
519
  const jpgPath = options.tempOutputDir && blockId !== void 0 ? computeJpgPath(options.tempOutputDir, filePath, blockId) : null;
371
520
  try {
372
521
  const result = await compileLatexToSvg(
373
522
  node.value,
374
523
  svgOutputDir,
375
- options.texInputDirs ?? []
524
+ options.texInputDirs ?? [],
525
+ inputsSalt
376
526
  );
377
527
  if (result.wasCompiled) {
378
528
  console.log(
379
- `[remark-latex-compile] ${filePath}:${lineNumberStr}: compiled ${result.hash}.svg`
529
+ `[remark-latex-compile] ${relFilePath}:${lineNumberStr}: compiled ${result.hash}.svg`
380
530
  );
381
531
  }
382
532
  options._referencedHashes?.add(result.hash);
383
533
  if (jpgPath) {
384
534
  await writeJpgFromSvg(result.svgPath, jpgPath);
385
535
  console.log(
386
- `[remark-latex-compile] ${filePath}:${lineNumberStr}: wrote ${jpgPath}`
536
+ `[remark-latex-compile] ${relFilePath}:${lineNumberStr}: wrote ${jpgPath}`
387
537
  );
388
538
  }
389
539
  return {
@@ -399,13 +549,13 @@ function remarkLatexCompile(options) {
399
549
  const match = errorMsg.match(/\n\n([\s\S]+)/);
400
550
  const details = match ? match[1] : errorMsg;
401
551
  console.error(
402
- `[remark-latex-compile] ${filePath}:${lineNumberStr}
552
+ `[remark-latex-compile] ${relFilePath}:${lineNumberStr}
403
553
  ${details}`
404
554
  );
405
555
  if (jpgPath) {
406
556
  await writeJpgError(
407
557
  jpgPath,
408
- `${filePath}:${lineNumberStr}`,
558
+ `${relFilePath}:${lineNumberStr}`,
409
559
  errorMsg
410
560
  );
411
561
  }
@@ -453,9 +603,22 @@ ${details}`
453
603
  options._fileJpgPathMap.set(filePath, newJpgPaths);
454
604
  }
455
605
  for (let i = results.length - 1; i >= 0; i--) {
456
- const { index, parent, result } = results[i];
606
+ const { index, parent, result, error } = results[i];
457
607
  const { node } = nodes[i];
458
- if (!result) continue;
608
+ if (!result) {
609
+ const lineNumberStr = node.position?.start.line !== void 0 ? String(node.position.start.line + frontmatterOffset) : "?";
610
+ const errorMsg = error instanceof Error ? error.message : String(error);
611
+ const errorNode = {
612
+ type: "html",
613
+ value: buildErrorHtml(
614
+ `${relFilePath}:${lineNumberStr}`,
615
+ errorMsg,
616
+ node.value
617
+ )
618
+ };
619
+ parent.children.splice(index, 1, errorNode);
620
+ continue;
621
+ }
459
622
  const metaOptions = new MetaOptions(node.meta ?? "");
460
623
  const customClasses = metaOptions.getString("class")?.split(/\s+/).filter(Boolean) ?? [];
461
624
  const altText = metaOptions.getString("alt") ?? "LaTeX diagram";