feat(shiki-vue-wrapper): add Shiki code highlighting component with Vue integration

This commit is contained in:
2026-05-20 19:16:55 +00:00
parent d62855853b
commit 6f417ba514
14 changed files with 1662 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>shiki-vue-wrapper</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "shiki-vue-wrapper",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"shiki": "^4.1.0",
"tailwindcss": "^4.3.0",
"vue": "^3.5.34"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.3",
"vite": "^8.0.13",
"vue-tsc": "^3.3.1"
}
}
+1355
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import code from './assets/snippet?raw';
import ShikiCode from './ShikiCode/ShikiCode.vue';
</script>
<template>
<main class="h-full w-full flex">
<div class="m-auto max-w-136 rounded-3xl overflow-clip">
<ShikiCode :code line-numbers />
</div>
</main>
</template>
<style>
@import 'tailwindcss';
#app {
@apply h-svh w-screen antialiased;
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ShikiTransformer } from 'shiki/core'
import { useShikiHighlight } from './useShikiHighlight'
const props = withDefaults(
defineProps<{
code: string
lang?: string
theme?: string
themes?: { light: string; dark: string }
transformers?: ShikiTransformer[]
lineNumbers?: boolean
startLine?: number
}>(),
{ lang: 'javascript', lineNumbers: false, startLine: 1 },
);
const { html, isReady, error } = useShikiHighlight({
code: () => props.code,
lang: () => props.lang,
theme: () => props.theme,
themes: () => props.themes,
transformers: () => props.transformers,
})
const gutterStyle = computed(() => {
const total = props.startLine + props.code.split('\n').length - 1;
return {
'--shiki-line-start': String(props.startLine),
'--shiki-gutter-width': `${String(total).length}ch`,
};
});
</script>
<template>
<slot v-if="error" name="error" :error="error" :code="code">
<pre class="shiki-fallback"><code>{{ code }}</code></pre>
</slot>
<div
v-else-if="isReady"
class="shiki-host"
:data-line-numbers="lineNumbers ? '' : undefined"
:style="gutterStyle"
v-html="html"
/>
<slot v-else name="loading">
<pre class="shiki-fallback"><code>{{ code }}</code></pre>
</slot>
</template>
<style>
.shiki-host .shiki {
padding: 1rem;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--color-zinc-300) var(--color-zinc-950);
}
.shiki-host[data-line-numbers] .shiki code {
counter-reset: shiki-line calc(var(--shiki-line-start, 1) - 1);
}
.shiki-host[data-line-numbers] .shiki code .line::before {
counter-increment: shiki-line;
content: counter(shiki-line);
display: inline-block;
width: var(--shiki-gutter-width, 2ch);
margin-right: 1.25rem;
text-align: right;
color: color-mix(in srgb, currentColor 40%, transparent);
user-select: none;
}
/* shiki иногда оставляет пустую финальную строку — прячем её номер */
.shiki-host[data-line-numbers] .shiki code .line:last-child:empty::before {
content: none;
}
</style>
@@ -0,0 +1,31 @@
import { createHighlighterCore, type HighlighterCore } from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import js from 'shiki/langs/javascript.mjs';
import aurora from 'shiki/themes/aurora-x.mjs';
const createShiki = () =>
createHighlighterCore({
langs: [js],
themes: [aurora],
engine: createJavaScriptRegexEngine(),
});
let instance: Promise<HighlighterCore> | null =
import.meta.hot?.data.shiki ?? null;
export const getShiki = () => {
if (!instance) {
instance = createShiki();
if (import.meta.hot) import.meta.hot.data.shiki = instance;
}
return instance;
}
export const disposeShiki = async () => {
if (!instance) return;
;(await instance).dispose();
instance = null;
if (import.meta.hot) import.meta.hot.data.shiki = undefined;
}
if (import.meta.hot) import.meta.hot.accept();
@@ -0,0 +1,41 @@
import { shallowRef, watchEffect, type MaybeRefOrGetter, toValue } from 'vue';
import type { ShikiTransformer } from 'shiki/core';
export interface UseShikiHighlightOptions {
code: MaybeRefOrGetter<string>;
lang: MaybeRefOrGetter<string>;
theme?: MaybeRefOrGetter<string | undefined>;
themes?: MaybeRefOrGetter<{ light: string; dark: string } | undefined>;
transformers?: MaybeRefOrGetter<ShikiTransformer[] | undefined>;
}
export function useShikiHighlight(options: UseShikiHighlightOptions) {
const html = shallowRef('');
const isReady = shallowRef(false);
const error = shallowRef<Error | null>(null);
watchEffect(async (onCleanup) => {
let cancelled = false;
onCleanup(() => { cancelled = true });
try {
const { getShiki } = await import('./highlighter');
const shiki = await getShiki();
if (cancelled) return;
const themes = toValue(options.themes);
const theme = toValue(options.theme);
html.value = shiki.codeToHtml(toValue(options.code), {
lang: toValue(options.lang),
...(themes ? { themes } : { theme: theme ?? 'aurora-x' }),
transformers: toValue(options.transformers),
});
isReady.value = true;
} catch (e) {
if (!cancelled) error.value = e as Error;
}
})
return { html, isReady, error };
}
+17
View File
@@ -0,0 +1,17 @@
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const uid = '<API_KEY_UID>';
const privateKeyBase64 = '<PRIVATE_KEY_BASE64>';
const privateKeyPem = Buffer.from(privateKeyBase64, 'base64').toString('utf8');
const jwtToken = jwt.sign(
{
exp: Math.floor(Date.now() / 1000) + 60
},
privateKeyPem,
{ algorithm: 'RS256' }
);
console.log(jwtToken);
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"moduleResolution": "bundler",
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["src/assets/*"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
export default defineConfig({
plugins: [vue(), tailwind()],
});