diff --git a/src/hooks/useMarkdown.hook.spec.ts b/src/hooks/useMarkdown.hook.spec.ts
new file mode 100644
index 0000000..a5cf12a
--- /dev/null
+++ b/src/hooks/useMarkdown.hook.spec.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it, vi } from "vitest"
+
+vi.mock("@/data/data", () => ({
+ data: {},
+ generateId: (type: string, id: string) => `${type}-${id}`
+}))
+
+import { renderCodeFile } from "@/hooks/useMarkdown.hook"
+
+describe("renderCodeFile", () => {
+ it("wraps highlighted files in a code-file container with a sized gutter", async () => {
+ const html = await renderCodeFile({
+ rawContent: "const a = 1\nconst b = 2\n",
+ lang: "typescript",
+ filename: "example.ts"
+ })
+
+ expect(html).toContain('
{
+ const html = await renderCodeFile({
+ rawContent: "line one\nline
\n",
+ lang: null
+ })
+
+ expect(html).toContain('line one')
+ expect(html).toContain('line <two>')
+ expect(html.match(/class="line"/g)).toHaveLength(2)
+ })
+
+ it("sizes the gutter for files with many lines", async () => {
+ const html = await renderCodeFile({
+ rawContent: Array.from({ length: 120 }, (_, i) => `line ${i}`).join("\n"),
+ lang: null
+ })
+
+ expect(html).toContain("--line-number-width:3ch")
+ })
+})
diff --git a/src/hooks/useMarkdown.hook.ts b/src/hooks/useMarkdown.hook.ts
index 5fa6562..32251ae 100644
--- a/src/hooks/useMarkdown.hook.ts
+++ b/src/hooks/useMarkdown.hook.ts
@@ -421,10 +421,22 @@ export const renderCodeFile = async ({
}): Promise => {
await useShikiji()
const heading = filename ? `# ${filename}\n\n` : ""
+ // Drop the trailing newline so the final empty line isn't numbered.
+ const lines = rawContent.replace(/\n$/, "").split("\n")
+ // Sized gutter so line numbers stay right-aligned past 3 digits.
+ const wrapper = ``
if (lang !== null) {
- return renderMarkdown(`${heading}\`\`\`\`${lang}\n${rawContent}\n\`\`\`\``)
+ const rendered = renderMarkdown(
+ `${heading}\`\`\`\`${lang}\n${lines.join("\n")}\n\`\`\`\``
+ )
+ // Shikiji always appends an empty line span; drop it so it isn't numbered.
+ return `${wrapper}${rendered.replace('\n', "")}
`
}
- return `${renderMarkdown(heading)}${md.utils.escapeHtml(rawContent)}
`
+ // Mirror Shikiji's per-line spans so the line-number CSS applies uniformly.
+ const code = lines
+ .map((line) => `${md.utils.escapeHtml(line)}`)
+ .join("\n")
+ return `${wrapper}${renderMarkdown(heading)}${code}
`
}
export const markdownBuilder = (defaultPrefix?: Ref | string) => {
diff --git a/src/styles/app.css b/src/styles/app.css
index e075667..6b62733 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -186,6 +186,24 @@ pre {
overflow-wrap: anywhere;
}
+/* Line numbers for whole-file code views (non-markdown files).
+ Scoped to .code-file so fenced blocks inside notes stay untouched. */
+.code-file pre code {
+ counter-reset: line;
+}
+
+.code-file pre code .line::before {
+ counter-increment: line;
+ content: counter(line);
+ display: inline-block;
+ width: var(--line-number-width, 3ch);
+ margin-right: 1.5ch;
+ text-align: right;
+ color: var(--color-base-content);
+ opacity: 0.35;
+ user-select: none;
+}
+
pre.mermaid {
display: flex;
justify-content: center;