@dr-ishaan/rehype-perfect-code-blocks 1.1.7 → 1.2.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +106 -40
  2. package/LICENSE +0 -0
  3. package/README.md +0 -0
  4. package/dist/astro.d.ts +0 -0
  5. package/dist/astro.d.ts.map +1 -1
  6. package/dist/astro.js +13 -4
  7. package/dist/astro.js.map +1 -1
  8. package/dist/copy-script.d.ts +2 -1
  9. package/dist/copy-script.d.ts.map +1 -1
  10. package/dist/copy-script.js +16 -2
  11. package/dist/copy-script.js.map +1 -1
  12. package/dist/index.d.ts +0 -0
  13. package/dist/index.d.ts.map +0 -0
  14. package/dist/index.js +15 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/meta.d.ts +0 -0
  17. package/dist/meta.d.ts.map +1 -1
  18. package/dist/meta.js +35 -2
  19. package/dist/meta.js.map +1 -1
  20. package/dist/remark.d.ts +0 -0
  21. package/dist/remark.d.ts.map +0 -0
  22. package/dist/remark.js +0 -0
  23. package/dist/remark.js.map +0 -0
  24. package/dist/shiki.d.ts +0 -0
  25. package/dist/shiki.d.ts.map +1 -1
  26. package/dist/shiki.js +301 -33
  27. package/dist/shiki.js.map +1 -1
  28. package/dist/styles.css +0 -0
  29. package/dist/transformer.d.ts +0 -0
  30. package/dist/transformer.d.ts.map +1 -1
  31. package/dist/transformer.js +230 -16
  32. package/dist/transformer.js.map +1 -1
  33. package/dist/types.d.ts +109 -4
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js +0 -0
  36. package/dist/types.js.map +0 -0
  37. package/package.json +2 -2
  38. package/src/astro.ts +14 -4
  39. package/src/copy-script.ts +16 -2
  40. package/src/index.ts +15 -1
  41. package/src/meta.ts +35 -2
  42. package/src/remark.ts +0 -0
  43. package/src/shiki.ts +306 -34
  44. package/src/styles.css +0 -0
  45. package/src/transformer.ts +243 -17
  46. package/src/types.ts +105 -4
  47. package/src/vite-raw.d.ts +0 -0
package/src/shiki.ts CHANGED
@@ -16,7 +16,6 @@
16
16
  import type { Element, Root } from 'hast';
17
17
  import { fromHtml } from 'hast-util-from-html';
18
18
  import { visit } from 'unist-util-visit';
19
- import { createRequire } from 'node:module';
20
19
  import type { PerfectCodeOptions } from './types.js';
21
20
  import {
22
21
  transformerNotationDiff,
@@ -31,7 +30,25 @@ import {
31
30
  transformerRemoveNotationEscape,
32
31
  } from '@shikijs/transformers';
33
32
 
34
- const require = createRequire(import.meta.url);
33
+ // Lazily resolve a `require` function for synchronous Shiki bundle lookups.
34
+ // In Node.js ESM we use `createRequire(import.meta.url)`. In edge runtimes
35
+ // / browsers / Deno, `node:module` may not exist — in that case we fall back
36
+ // to `null` and `filterBundledLangs` returns a permissive filter (all langs
37
+ // pass through; the try/catch around `codeToHast` handles unknown langs).
38
+ let syncRequire: ((id: string) => unknown) | null = null;
39
+ try {
40
+ // `node:module` is a Node.js built-in. The static import would fail at
41
+ // module-load time in non-Node environments, so we use a dynamic import
42
+ // wrapped in try/catch (top-level await is supported in ESM + Node 18+).
43
+ const nodeModuleApi = (await import('node:module').catch(() => null)) as
44
+ | { createRequire?: (url: string) => (id: string) => unknown }
45
+ | null;
46
+ if (nodeModuleApi?.createRequire) {
47
+ syncRequire = nodeModuleApi.createRequire(import.meta.url);
48
+ }
49
+ } catch {
50
+ syncRequire = null;
51
+ }
35
52
 
36
53
  // Use a permissive type for ShikiTransformer to avoid cross-package type
37
54
  // identity issues when @shikijs/transformers and shiki bundle different copies
@@ -91,9 +108,17 @@ async function getHighlighter(
91
108
 
92
109
  /** Filter out languages that aren't bundled with Shiki (avoids sync throws). */
93
110
  function filterBundledLangs(langs: string[]): string[] {
111
+ // Always keep plaintext variants (special — don't require a bundle).
112
+ const alwaysKeep = new Set(['plaintext', 'text', 'txt', 'ansi']);
94
113
  let bundled: Set<string>;
114
+ if (!syncRequire) {
115
+ // Edge runtime / browser — can't read shiki's bundle list synchronously.
116
+ // Pass through everything; the try/catch around codeToHast handles
117
+ // unknown langs by falling back to plaintext.
118
+ return langs;
119
+ }
95
120
  try {
96
- const shiki = require('shiki') as {
121
+ const shiki = syncRequire('shiki') as {
97
122
  bundledLanguages?: Record<string, unknown>;
98
123
  bundledLanguagesAlias?: Record<string, unknown>;
99
124
  };
@@ -102,13 +127,11 @@ function filterBundledLangs(langs: string[]): string[] {
102
127
  ...Object.keys(shiki.bundledLanguagesAlias ?? {}),
103
128
  ]);
104
129
  } catch {
105
- bundled = new Set();
130
+ bundled = new Set(alwaysKeep);
131
+ return langs.filter((l) => bundled.has(l) || bundled.has(l.toLowerCase()));
106
132
  }
107
- // Always keep plaintext variants (special — don't require a bundle).
108
- bundled.add('plaintext');
109
- bundled.add('text');
110
- bundled.add('txt');
111
- bundled.add('ansi');
133
+ // Always keep plaintext variants.
134
+ for (const p of alwaysKeep) bundled.add(p);
112
135
  return langs.filter((l) => bundled.has(l) || bundled.has(l.toLowerCase()));
113
136
  }
114
137
 
@@ -120,6 +143,14 @@ async function buildTransformers(
120
143
  const transformers: unknown[] = [];
121
144
  void metaStr;
122
145
 
146
+ // If user wants full manual control, only push their transformers.
147
+ if (opts.disableAutoTransformers) {
148
+ if (opts.shiki.transformers) {
149
+ transformers.push(...opts.shiki.transformers);
150
+ }
151
+ return transformers;
152
+ }
153
+
123
154
  // Always remove the escape marker `// [\!code xxx]` first so other
124
155
  // notation transformers can read what's left.
125
156
  transformers.push(transformerRemoveNotationEscape());
@@ -129,6 +160,8 @@ async function buildTransformers(
129
160
  transformers.push(
130
161
  transformerMetaHighlight({
131
162
  className: 'pcb__line--hl',
163
+ // Issue #11 from competitor analysis: support zero-indexed line numbers.
164
+ zeroIndexed: opts.zeroIndexed === true,
132
165
  })
133
166
  );
134
167
  }
@@ -217,14 +250,100 @@ async function buildTransformers(
217
250
  }
218
251
  }
219
252
 
220
- // User-provided transformers
221
- if (opts.shiki.transformers) {
222
- transformers.push(...opts.shiki.transformers);
253
+ // Custom notations: map custom // [!code xxx] markers to CSS classes.
254
+ // (Previously this was `void customNotations` — now actually wired up.)
255
+ if (opts.customNotations && Object.keys(opts.customNotations).length > 0) {
256
+ try {
257
+ const { transformerNotationMap } = await import('@shikijs/transformers');
258
+ // Build the classMap in the format transformerNotationMap expects:
259
+ // { markerName: [classList] }
260
+ const classMap: Record<string, string[]> = {};
261
+ for (const [marker, cls] of Object.entries(opts.customNotations)) {
262
+ classMap[marker] = [cls];
263
+ }
264
+ transformers.push(
265
+ (transformerNotationMap as (opts: { classMap: Record<string, string[]>; matchAlgorithm: 'v3' }) => unknown)({
266
+ classMap,
267
+ matchAlgorithm: 'v3',
268
+ })
269
+ );
270
+ } catch {
271
+ // transformerNotationMap not available in this @shikijs/transformers version.
272
+ }
273
+ }
274
+
275
+ // Remove comments from rendered code (// ..., # ..., /* ... */, <!-- ... -->)
276
+ if (opts.removeComments) {
277
+ try {
278
+ const { transformerRemoveComments } = await import('@shikijs/transformers');
279
+ transformers.push(transformerRemoveComments());
280
+ } catch {
281
+ // Module not available — skip silently.
282
+ }
283
+ }
284
+
285
+ // Remove line breaks (joins all lines into one)
286
+ if (opts.removeLineBreaks) {
287
+ try {
288
+ const { transformerRemoveLineBreak } = await import('@shikijs/transformers');
289
+ transformers.push(transformerRemoveLineBreak());
290
+ } catch {
291
+ // Module not available — skip silently.
292
+ }
293
+ }
294
+
295
+ // Programmatic per-line class assignment (transformerCompactLineOptions)
296
+ if (opts.lineOptions && opts.lineOptions.length > 0) {
297
+ try {
298
+ const { transformerCompactLineOptions } = await import('@shikijs/transformers');
299
+ transformers.push(transformerCompactLineOptions(opts.lineOptions));
300
+ } catch {
301
+ // Module not available — skip silently.
302
+ }
303
+ }
304
+
305
+ // ANSI escape sequence stripping for terminal output.
306
+ // We use a custom transformer (not in @shikijs/transformers) that walks
307
+ // all text nodes and removes `\x1b\[[0-9;]*[a-zA-Z]` sequences.
308
+ // Applied only when the lang is 'ansi' (which is in the default terminalLangs).
309
+ // (The actual per-block application happens in runShikiOnRawBlocks based on lang.)
310
+
311
+ // User-provided transformers — 'before' or 'after' (default) our auto-registered ones.
312
+ const userTransformers = opts.shiki.transformers ?? [];
313
+ if (opts.shiki.transformerOrder === 'before') {
314
+ transformers.unshift(...userTransformers);
315
+ } else {
316
+ transformers.push(...userTransformers);
223
317
  }
224
318
 
225
319
  return transformers;
226
320
  }
227
321
 
322
+ /**
323
+ * Custom transformer that strips ANSI escape sequences from text nodes.
324
+ * Used for `lang: 'ansi'` blocks (terminal output with color codes).
325
+ */
326
+ function createAnsiStripTransformer(): unknown {
327
+ return {
328
+ name: 'pcb:ansi-strip',
329
+ code(hast: unknown) {
330
+ // Walk all text nodes and strip \x1b\[[0-9;]*[a-zA-Z] sequences.
331
+ const visit = (node: unknown): void => {
332
+ if (!node || typeof node !== 'object') return;
333
+ const n = node as { type?: string; value?: string; children?: unknown[] };
334
+ if (n.type === 'text' && typeof n.value === 'string') {
335
+ n.value = n.value.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
336
+ }
337
+ if (Array.isArray(n.children)) {
338
+ for (const child of n.children) visit(child);
339
+ }
340
+ };
341
+ visit(hast);
342
+ return hast;
343
+ },
344
+ };
345
+ }
346
+
228
347
  /**
229
348
  * Walk the tree; for every <pre><code> that does NOT yet look Shiki-processed
230
349
  * (i.e. no `astro-code` / `shiki` class), tokenize it via Shiki and replace
@@ -246,17 +365,35 @@ export async function runShikiOnRawBlocks(
246
365
 
247
366
  if (targets.length === 0) return;
248
367
 
249
- // Build theme keys (one or two)
368
+ // Build theme keys — supports single (string), dual ({light,dark}), and
369
+ // multi-theme (Record<string,string> with 3+ entries) for advanced use cases.
250
370
  const themeSpec = opts.shiki.theme;
251
- const themeKeys: string[] =
252
- typeof themeSpec === 'string'
253
- ? [themeSpec]
254
- : themeSpec
255
- ? [themeSpec.dark, themeSpec.light]
256
- : ['github-dark'];
371
+ let themeKeys: string[];
372
+ let isMultiTheme = false;
373
+ if (typeof themeSpec === 'string') {
374
+ themeKeys = [themeSpec];
375
+ } else if (themeSpec && typeof themeSpec === 'object') {
376
+ if ('light' in themeSpec && 'dark' in themeSpec && Object.keys(themeSpec).length === 2) {
377
+ themeKeys = [themeSpec.dark, themeSpec.light];
378
+ } else {
379
+ // Multi-theme: Record<string, string> with 3+ entries.
380
+ themeKeys = Object.values(themeSpec);
381
+ isMultiTheme = true;
382
+ }
383
+ } else {
384
+ themeKeys = ['github-dark'];
385
+ }
257
386
 
258
387
  // Collect all langs needed for these blocks
259
- const langSet = new Set<string>(opts.shiki.langs ?? []);
388
+ // NOTE: language identifiers are normalized to lowercase here so that
389
+ // case-insensitive fence spellings (```JS, ```TypeScript, ```Python)
390
+ // resolve to the same Shiki grammar as their canonical lowercase forms
391
+ // (javascript, typescript, python). This matches Shiki's own case-
392
+ // insensitive behavior in codeToHast/codeToHtml, and matches what every
393
+ // other CommonMark renderer accepts. See issue #12.
394
+ const langSet = new Set<string>(
395
+ (opts.shiki.langs ?? []).map((l) => l.toLowerCase())
396
+ );
260
397
  for (const pre of targets) {
261
398
  const code = pre.children.find(
262
399
  (c): c is Element => c.type === 'element' && c.tagName === 'code'
@@ -265,7 +402,7 @@ export async function runShikiOnRawBlocks(
265
402
  const cls = (code.properties?.className as string[] | undefined) ?? [];
266
403
  for (const c of cls) {
267
404
  const m = c.match(/^language-(.+)$/);
268
- if (m) langSet.add(m[1]);
405
+ if (m) langSet.add(m[1].toLowerCase());
269
406
  }
270
407
  }
271
408
  langSet.add('plaintext');
@@ -287,7 +424,7 @@ export async function runShikiOnRawBlocks(
287
424
  const loaded = new Set(highlighter.getLoadedLanguages());
288
425
  const missing = [...langSet].filter((l) => !loaded.has(l));
289
426
  if (missing.length > 0) {
290
- await Promise.allSettled(
427
+ const results = await Promise.allSettled(
291
428
  missing.map((l) => {
292
429
  try {
293
430
  return Promise.resolve(highlighter.loadLanguage(l));
@@ -296,10 +433,35 @@ export async function runShikiOnRawBlocks(
296
433
  }
297
434
  })
298
435
  );
436
+ // Log failed language loads (competitor analysis: EC does this, improves DX).
437
+ const failed: string[] = [];
438
+ results.forEach((r, i) => {
439
+ if (r.status === 'rejected') failed.push(missing[i]);
440
+ });
441
+ if (failed.length > 0) {
442
+ const logger = opts.logger ?? console;
443
+ logger.warn(
444
+ `[rehype-perfect-code-blocks] Failed to load languages: ${failed.join(', ')}. ` +
445
+ `Falling back to plaintext for these blocks. ` +
446
+ `Check for typos or install the language grammar.`
447
+ );
448
+ }
299
449
  }
300
450
 
301
451
  // Apply language aliases (e.g., { ts: 'typescript' }).
302
- const langAlias = opts.languageAliases ?? {};
452
+ // Build a lowercase-keyed lookup so user config like { TS: 'typescript' }
453
+ // or { ts: 'typescript' } both work regardless of the case used in the
454
+ // fence or in the config. The alias target is preserved as-is (typically
455
+ // already lowercase). See issue #12.
456
+ const rawLangAlias = opts.languageAliases ?? {};
457
+ const langAlias: Record<string, string> = {};
458
+ for (const [k, v] of Object.entries(rawLangAlias)) {
459
+ langAlias[k.toLowerCase()] = v;
460
+ }
461
+ // Resolve the logger once.
462
+ const logger = opts.logger ?? console;
463
+ // Track which langs we've already warned about (avoid duplicate warnings).
464
+ const warnedLangs = new Set<string>();
303
465
 
304
466
  for (const pre of targets) {
305
467
  const code = pre.children.find(
@@ -307,19 +469,58 @@ export async function runShikiOnRawBlocks(
307
469
  );
308
470
  if (!code) continue;
309
471
 
310
- const text = extractText(code);
472
+ // Normalize line endings: \r\n and \r → \n (prevents \r artifacts in output).
473
+ let text = extractText(code).replace(/\r\n?/g, '\n');
474
+
475
+ // tabWidth normalization: replace tabs with N spaces before tokenization.
476
+ if (opts.tabWidth && opts.tabWidth > 0) {
477
+ text = text.replace(/\t/g, ' '.repeat(opts.tabWidth));
478
+ }
479
+
311
480
  const langClass = (code.properties?.className as string[] | undefined)?.[0] ?? '';
312
481
  const rawLang = (langClass.match(/^language-(.+)$/) ?? [])[1] ?? 'plaintext';
313
- const lang = langAlias[rawLang] ?? rawLang;
482
+ // Normalize to lowercase before any Shiki call. Shiki's bundled grammars
483
+ // all use lowercase IDs (javascript, typescript, ...), and its codeToHast
484
+ // is case-insensitive — but the lazy-loader path (loadLanguage) is not,
485
+ // which previously caused `JS`/`TypeScript`/`Python` to throw "Language
486
+ // is not included in this bundle". Lowercasing here fixes that and
487
+ // matches what every other CommonMark renderer does.
488
+ // See issue #12.
489
+ const normalizedRawLang = rawLang.toLowerCase();
490
+ // Apply user-defined languageAliases (e.g. { ts: 'typescript' }). Looked
491
+ // up by lowercase key so users can write either `ts` or `TS` in their
492
+ // config. The alias target is used as-is (typically already lowercase).
493
+ const lang = langAlias[normalizedRawLang] ?? normalizedRawLang;
314
494
  const metaStr =
315
495
  (code.properties?.dataMeta as string | undefined) ??
316
496
  (pre.properties?.dataMeta as string | undefined) ??
317
497
  '';
318
498
 
499
+ // Terminal <placeholder> workaround: Shiki mis-highlights shell snippets
500
+ // containing `<user>@<host>`. Temporarily replace `<...>` with a sentinel,
501
+ // then restore after tokenization.
502
+ const isTerminalLang = opts.terminalLangs.includes(lang);
503
+ let placeholderMap: Map<string, string> | null = null;
504
+ if (isTerminalLang && /<([^>]*[^>\s])>/.test(text)) {
505
+ placeholderMap = new Map();
506
+ let i = 0;
507
+ text = text.replace(/<([^>]*[^>\s])>/g, (match, inner) => {
508
+ const sentinel = `\u0000PCB_PH_${i++}\u0000`;
509
+ placeholderMap!.set(sentinel, `<${inner}>`);
510
+ return sentinel;
511
+ });
512
+ }
513
+
319
514
  const transformers = await buildTransformers(opts, metaStr);
320
515
 
321
- // Build codeToHast/codeToHtml options. Use `themes` (plural) for dual-theme output
322
- // so Shiki emits `--shiki-light` / `--shiki-dark` CSS vars.
516
+ // For 'ansi' lang, add the ANSI escape-sequence stripper transformer.
517
+ if (lang === 'ansi') {
518
+ transformers.push(createAnsiStripTransformer());
519
+ }
520
+
521
+ // Build codeToHast/codeToHtml options. Use `themes` (plural) for dual-theme
522
+ // and multi-theme output so Shiki emits `--shiki-light` / `--shiki-dark` /
523
+ // `--shiki-<name>` CSS vars.
323
524
  const shikiOpts: Record<string, unknown> = {
324
525
  lang,
325
526
  meta: { __raw: metaStr },
@@ -327,6 +528,11 @@ export async function runShikiOnRawBlocks(
327
528
  };
328
529
  if (typeof themeSpec === 'string') {
329
530
  shikiOpts.theme = themeSpec;
531
+ } else if (isMultiTheme) {
532
+ // Multi-theme (3+ themes): pass the full Record as `themes`.
533
+ shikiOpts.themes = themeSpec;
534
+ // Don't inline any single theme — emit all variants as CSS vars.
535
+ shikiOpts.defaultColor = false;
330
536
  } else {
331
537
  shikiOpts.themes = themeSpec;
332
538
  shikiOpts.defaultColor = 'dark'; // tells Shiki which color to inline by default
@@ -344,29 +550,57 @@ export async function runShikiOnRawBlocks(
344
550
  // `className`, `aria-hidden` instead of `ariaHidden`). Normalize them
345
551
  // so the rest of our pipeline (which expects hast property names) works.
346
552
  normalizeHast(hastRoot);
553
+ // Restore terminal <placeholder> sentinels back to original text.
554
+ if (placeholderMap) {
555
+ restorePlaceholders(hastRoot, placeholderMap);
556
+ }
347
557
  newPre = hastRoot.children.find(
348
558
  (c): c is Element => c.type === 'element' && c.tagName === 'pre'
349
559
  ) ?? null;
350
560
  } else {
351
561
  const html = highlighter.codeToHtml(text, shikiOpts);
352
- const fragment = fromHtml(html, { fragment: true });
562
+ let htmlOut = html;
563
+ if (placeholderMap) {
564
+ for (const [sentinel, original] of placeholderMap) {
565
+ htmlOut = htmlOut.split(sentinel).join(original);
566
+ }
567
+ }
568
+ const fragment = fromHtml(htmlOut, { fragment: true });
353
569
  newPre = fragment.children.find(
354
570
  (c): c is Element => c.type === 'element' && c.tagName === 'pre'
355
571
  ) ?? null;
356
572
  }
357
- } catch {
573
+ } catch (err) {
574
+ // Log unknown-language fallbacks (once per lang).
575
+ const langKey = lang;
576
+ if (!warnedLangs.has(langKey) && langKey !== 'plaintext') {
577
+ warnedLangs.add(langKey);
578
+ logger.warn(
579
+ `[rehype-perfect-code-blocks] Failed to tokenize language "${langKey}" ` +
580
+ `(${err instanceof Error ? err.message : String(err)}). Falling back to plaintext.`
581
+ );
582
+ }
358
583
  // Fallback: plaintext
359
584
  try {
360
585
  const fallbackOpts = { ...shikiOpts, lang: 'plaintext' };
361
586
  if (useHast) {
362
587
  const hastRoot = highlighter.codeToHast(text, fallbackOpts) as { type: 'root'; children: Element[] };
363
588
  normalizeHast(hastRoot);
589
+ if (placeholderMap) {
590
+ restorePlaceholders(hastRoot, placeholderMap);
591
+ }
364
592
  newPre = hastRoot.children.find(
365
593
  (c): c is Element => c.type === 'element' && c.tagName === 'pre'
366
594
  ) ?? null;
367
595
  } else {
368
596
  const html = highlighter.codeToHtml(text, fallbackOpts);
369
- const fragment = fromHtml(html, { fragment: true });
597
+ let htmlOut = html;
598
+ if (placeholderMap) {
599
+ for (const [sentinel, original] of placeholderMap) {
600
+ htmlOut = htmlOut.split(sentinel).join(original);
601
+ }
602
+ }
603
+ const fragment = fromHtml(htmlOut, { fragment: true });
370
604
  newPre = fragment.children.find(
371
605
  (c): c is Element => c.type === 'element' && c.tagName === 'pre'
372
606
  ) ?? null;
@@ -388,12 +622,25 @@ export async function runShikiOnRawBlocks(
388
622
  (newCode.properties as Record<string, unknown>).dataMeta = metaStr;
389
623
  }
390
624
  // Re-attach language-X class so the transformer can detect the language.
625
+ // Use the lowercase normalized form so downstream matching (which is
626
+ // case-sensitive in our transformer.ts:extractLanguageFromClass) is
627
+ // consistent with the lowercase lang we passed to Shiki. The original
628
+ // mixed-case class is also added (if different) so user CSS targeting
629
+ // like `.language-JS` continues to work. See issue #12.
391
630
  const existingClasses = (newCode.properties.className as string[] | undefined) ?? [];
392
- const langClass2 = `language-${rawLang}`;
393
- if (!existingClasses.includes(langClass2)) {
394
- (newCode.properties as Record<string, unknown>).className = [...existingClasses, langClass2];
631
+ const langClassLower = `language-${normalizedRawLang}`;
632
+ const langClassOriginal = `language-${rawLang}`;
633
+ const additions: string[] = [];
634
+ if (!existingClasses.includes(langClassLower)) additions.push(langClassLower);
635
+ if (rawLang !== normalizedRawLang && !existingClasses.includes(langClassOriginal) && !additions.includes(langClassOriginal)) {
636
+ additions.push(langClassOriginal);
395
637
  }
396
- (newCode.properties as Record<string, unknown>).dataLanguage = rawLang;
638
+ if (additions.length > 0) {
639
+ (newCode.properties as Record<string, unknown>).className = [...existingClasses, ...additions];
640
+ }
641
+ // dataLanguage uses the lowercase form for consistency with the
642
+ // language-* class and the Shiki lang we actually used.
643
+ (newCode.properties as Record<string, unknown>).dataLanguage = normalizedRawLang;
397
644
  }
398
645
  Object.assign(pre, newPre);
399
646
  }
@@ -465,3 +712,28 @@ function normalizeHast(node: unknown): void {
465
712
  for (const child of n.children) normalizeHast(child);
466
713
  }
467
714
  }
715
+
716
+ /**
717
+ * Restore terminal <placeholder> sentinels back to their original text.
718
+ * Walks all text nodes in the HAST tree and replaces sentinel strings
719
+ * with the original `<...>` content.
720
+ *
721
+ * Used after Shiki tokenization to undo the temporary sentinel substitution
722
+ * we applied to prevent Shiki from mis-highlighting `<user>@<host>` patterns
723
+ * in shell/terminal blocks.
724
+ */
725
+ function restorePlaceholders(node: unknown, map: Map<string, string>): void {
726
+ if (!node || typeof node !== 'object') return;
727
+ const n = node as { type?: string; value?: string; children?: unknown[] };
728
+ if (n.type === 'text' && typeof n.value === 'string') {
729
+ let value = n.value;
730
+ for (const [sentinel, original] of map) {
731
+ // Use split/join to avoid regex special-char issues with sentinels.
732
+ value = value.split(sentinel).join(original);
733
+ }
734
+ n.value = value;
735
+ }
736
+ if (Array.isArray(n.children)) {
737
+ for (const child of n.children) restorePlaceholders(child, map);
738
+ }
739
+ }
package/src/styles.css CHANGED
File without changes