@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.
- package/CHANGELOG.md +106 -40
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/dist/astro.d.ts +0 -0
- package/dist/astro.d.ts.map +1 -1
- package/dist/astro.js +13 -4
- package/dist/astro.js.map +1 -1
- package/dist/copy-script.d.ts +2 -1
- package/dist/copy-script.d.ts.map +1 -1
- package/dist/copy-script.js +16 -2
- package/dist/copy-script.js.map +1 -1
- package/dist/index.d.ts +0 -0
- package/dist/index.d.ts.map +0 -0
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/meta.d.ts +0 -0
- package/dist/meta.d.ts.map +1 -1
- package/dist/meta.js +35 -2
- package/dist/meta.js.map +1 -1
- package/dist/remark.d.ts +0 -0
- package/dist/remark.d.ts.map +0 -0
- package/dist/remark.js +0 -0
- package/dist/remark.js.map +0 -0
- package/dist/shiki.d.ts +0 -0
- package/dist/shiki.d.ts.map +1 -1
- package/dist/shiki.js +301 -33
- package/dist/shiki.js.map +1 -1
- package/dist/styles.css +0 -0
- package/dist/transformer.d.ts +0 -0
- package/dist/transformer.d.ts.map +1 -1
- package/dist/transformer.js +230 -16
- package/dist/transformer.js.map +1 -1
- package/dist/types.d.ts +109 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -0
- package/dist/types.js.map +0 -0
- package/package.json +2 -2
- package/src/astro.ts +14 -4
- package/src/copy-script.ts +16 -2
- package/src/index.ts +15 -1
- package/src/meta.ts +35 -2
- package/src/remark.ts +0 -0
- package/src/shiki.ts +306 -34
- package/src/styles.css +0 -0
- package/src/transformer.ts +243 -17
- package/src/types.ts +105 -4
- 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
|
-
|
|
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 =
|
|
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
|
|
108
|
-
bundled.add(
|
|
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
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
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 (
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
(
|
|
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
|