Skip to content

Commit bcbc827

Browse files
committed
fix(showcase/shell-docs): snippet highlight error logging + fallback class + range clamping
- hljs try/catch now logs key/file/language + original error instead of swallowing silently, so authors can spot bad snippets. - When highlighting fails, drop the `hljs language-*` class so the escaped plain-text fallback isn't styled as though it were highlighted. - Defense-in-depth non-string hljs return guard. - `regionFromFile` normalizes by stripping a single trailing newline once, then splits — trailing-newline shape is consistent whether or not `lines=` is supplied, so CopyButton text is predictable. - Clamp `end` via `effectiveEnd = Math.min(end, allLines.length)` and warn when an explicit range drifts past the file. - Move the `WarningMessage` interface up next to the other type decls.
1 parent 6a29215 commit bcbc827

1 file changed

Lines changed: 60 additions & 13 deletions

File tree

showcase/shell-docs/src/components/snippet.tsx

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ interface DemoRecord {
6262
files?: DemoFile[];
6363
}
6464

65+
interface WarningMessage {
66+
warning: string;
67+
}
68+
6569
const demos: Record<string, DemoRecord> = (
6670
demoContent as { demos: Record<string, DemoRecord> }
6771
).demos;
@@ -135,10 +139,25 @@ function resolveHljsLanguage(lang: string): string | null {
135139
/**
136140
* Parse a `lines` prop like `"10-20"` or `"5"` into `[start, end]` (1-indexed,
137141
* inclusive on both ends). Returns null on invalid input.
142+
*
143+
* Special forms:
144+
* - `"A-"` (trailing dash, no end) — treated as "start to end-of-file"; we
145+
* return `[start, Number.POSITIVE_INFINITY]` and the caller clamps to the
146+
* actual file length.
147+
* - empty string / whitespace — treated as "no range" (equivalent to absent
148+
* `lines`), returning null so the caller renders the full file.
138149
*/
139150
function parseLineRange(input: string | undefined): [number, number] | null {
140151
if (!input) return null;
141152
const trimmed = input.trim();
153+
if (trimmed === "") return null;
154+
// `A-` → start through end-of-file (caller clamps).
155+
const openEnded = trimmed.match(/^(\d+)\s*[-\u2013]\s*$/);
156+
if (openEnded) {
157+
const start = parseInt(openEnded[1], 10);
158+
if (start > 0) return [start, Number.POSITIVE_INFINITY];
159+
return null;
160+
}
142161
const dash = trimmed.match(/^(\d+)\s*[-\u2013]\s*(\d+)$/);
143162
if (dash) {
144163
const start = parseInt(dash[1], 10);
@@ -162,20 +181,23 @@ function regionFromFile(
162181
file: DemoFile,
163182
lines?: string,
164183
): Region | WarningMessage {
165-
const allLines = file.content.split("\n");
166-
if (!lines) {
184+
// Strip a single trailing newline so line counts and copied text are
185+
// consistent regardless of whether a `lines=` range is supplied.
186+
const normalized = file.content.replace(/\n$/, "");
187+
const allLines = normalized.split("\n");
188+
if (!lines || lines.trim() === "") {
167189
return {
168190
file: file.filename,
169191
startLine: 1,
170192
endLine: allLines.length,
171-
code: file.content.replace(/\n$/, ""),
193+
code: normalized,
172194
language: file.language,
173195
};
174196
}
175197
const range = parseLineRange(lines);
176198
if (!range) {
177199
return {
178-
warning: `Invalid lines="${lines}" — expected "A-B" or single line "A".`,
200+
warning: `Invalid lines="${lines}" — expected "A-B", "A-" (start to end), or single line "A".`,
179201
};
180202
}
181203
const [start, end] = range;
@@ -184,20 +206,24 @@ function regionFromFile(
184206
warning: `lines="${lines}" is out of range (file has ${allLines.length} lines).`,
185207
};
186208
}
187-
const slice = allLines.slice(start - 1, end).join("\n");
209+
// Clamp `end` once up front; also warn when the author's explicit range
210+
// drifted past the file so they notice the source changed under them.
211+
const effectiveEnd = Math.min(end, allLines.length);
212+
if (Number.isFinite(end) && end > allLines.length) {
213+
console.warn(
214+
`[snippet] lines="${lines}" end (${end}) exceeds file length (${allLines.length}) for ${file.filename} — clamping.`,
215+
);
216+
}
217+
const slice = allLines.slice(start - 1, effectiveEnd).join("\n");
188218
return {
189219
file: file.filename,
190220
startLine: start,
191-
endLine: Math.min(end, allLines.length),
221+
endLine: effectiveEnd,
192222
code: slice,
193223
language: file.language,
194224
};
195225
}
196226

197-
interface WarningMessage {
198-
warning: string;
199-
}
200-
201227
function isWarning(v: Region | WarningMessage): v is WarningMessage {
202228
return (v as WarningMessage).warning !== undefined;
203229
}
@@ -297,18 +323,39 @@ export function Snippet({
297323

298324
const hljsLang = resolveHljsLanguage(reg.language);
299325
let html: string;
326+
let highlightFailed = false;
300327
try {
301328
html = hljsLang
302329
? hljs.highlight(reg.code, { language: hljsLang, ignoreIllegals: true })
303330
.value
304331
: hljs.highlightAuto(reg.code).value;
305-
} catch {
332+
} catch (err) {
306333
// highlight.js should never throw with ignoreIllegals, but defensively
307-
// fall back to unhighlighted text rather than crashing the render.
334+
// fall back to unhighlighted text rather than crashing the render. Log
335+
// enough context that authors can find the offending snippet.
336+
console.warn(
337+
`[snippet] highlight failed for ${key} ${reg.file} (language=${reg.language})`,
338+
err,
339+
);
340+
html = escapeHtml(reg.code);
341+
highlightFailed = true;
342+
}
343+
// Defense-in-depth: if hljs ever returns a non-string (unknown edge case),
344+
// fall back to escaped plain text so `dangerouslySetInnerHTML` can't
345+
// receive garbage.
346+
if (typeof html !== "string") {
308347
html = escapeHtml(reg.code);
348+
highlightFailed = true;
309349
}
310350

311351
const caption = title ?? reg.file;
352+
// When highlighting failed we render escaped plain text; drop the `hljs`
353+
// class so the output doesn't get styled as though it were highlighted.
354+
const codeClassName = highlightFailed
355+
? undefined
356+
: hljsLang
357+
? `hljs language-${hljsLang}`
358+
: "hljs";
312359

313360
return (
314361
<figure className="my-5 rounded-lg border border-[var(--border)] overflow-hidden bg-[var(--bg-surface)]">
@@ -327,7 +374,7 @@ export function Snippet({
327374
)}
328375
<pre className="text-[12.5px] leading-[1.55] overflow-x-auto p-4 m-0">
329376
<code
330-
className={hljsLang ? `hljs language-${hljsLang}` : "hljs"}
377+
className={codeClassName}
331378
dangerouslySetInnerHTML={{ __html: html }}
332379
/>
333380
</pre>

0 commit comments

Comments
 (0)