feat: enhance entity management and reactivity in vue-sync-engine

This commit is contained in:
2026-06-07 03:57:58 +07:00
parent b2d79b97c1
commit aa3148f4e4
17 changed files with 840 additions and 95 deletions
+5 -3
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import code from './assets/snippet?raw';
import ShikiCode from './ShikiCode/ShikiCode.vue';
// Подсветка выполняется на этапе сборки — в браузер уходит готовый HTML,
// без Shiki/грамматик в бандле.
import codeHtml from './assets/snippet.ts?shiki';
import ShikiStatic from './ShikiCode/ShikiStatic.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 />
<ShikiStatic :html="codeHtml" line-numbers />
</div>
</main>
</template>
+2 -30
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import type { ShikiTransformer } from 'shiki/core'
import { useShikiHighlight } from './useShikiHighlight'
import './shiki-host.css'
const props = withDefaults(
defineProps<{
@@ -47,33 +48,4 @@ const gutterStyle = computed(() => {
<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>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import './shiki-host.css'
// HTML приходит из импорта `*?shiki` — подсветка уже сделана на этапе сборки.
// Этот компонент не тянет ни Shiki, ни грамматики в клиентский бандл.
const props = withDefaults(
defineProps<{
html: string
lineNumbers?: boolean
startLine?: number
}>(),
{ lineNumbers: false, startLine: 1 },
);
const gutterStyle = computed(() => {
const lines = props.html.match(/class="line"/g)?.length ?? 1;
const total = props.startLine + lines - 1;
return {
'--shiki-line-start': String(props.startLine),
'--shiki-gutter-width': `${String(total).length}ch`,
};
});
</script>
<template>
<div
class="shiki-host"
:data-line-numbers="lineNumbers ? '' : undefined"
:style="gutterStyle"
v-html="html"
/>
</template>
@@ -0,0 +1,26 @@
.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;
}
@@ -0,0 +1,106 @@
import { readFile } from 'node:fs/promises';
import type { Plugin } from 'vite';
import {
createHighlighterCore,
type HighlighterCore,
type ShikiTransformer,
} from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
const SHIKI_QUERY = 'shiki';
/** Расширение файла -> id грамматики Shiki. */
const EXT_TO_LANG: Record<string, string> = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'tsx',
vue: 'vue', json: 'json', jsonc: 'jsonc', css: 'css', scss: 'scss',
html: 'html', md: 'markdown', py: 'python', rs: 'rust', go: 'go',
sh: 'bash', bash: 'bash', yml: 'yaml', yaml: 'yaml', toml: 'toml', sql: 'sql',
};
export interface ShikiPluginOptions {
/** Одиночная тема (по умолчанию aurora-x). Игнорируется, если задан `themes`. */
theme?: string;
/** Парные темы — Shiki отдаёт HTML с CSS-переменными для light/dark. */
themes?: { light: string; dark: string };
/** Доп. соответствия расширение -> язык поверх дефолтных. */
langAlias?: Record<string, string>;
/** Трансформеры Shiki (номера строк, диффы и т.п.). */
transformers?: ShikiTransformer[];
}
/**
* Импорт `./snippet.ts?shiki` возвращает строку с уже подсвеченным HTML.
* Вся работа Shiki происходит в Node на этапе сборки/дева — в бандл клиента
* не попадает ни движок, ни грамматики. Zero runtime.
*
* Язык берётся из расширения файла, либо из `?shiki&lang=...`.
*/
export function shiki(options: ShikiPluginOptions = {}): Plugin {
const { theme = 'aurora-x', themes, transformers } = options;
const extToLang = { ...EXT_TO_LANG, ...options.langAlias };
let highlighter: Promise<HighlighterCore> | null = null;
const loadedLangs = new Set<string>();
const loadedThemes = new Set<string>();
const getHighlighter = () => {
highlighter ??= createHighlighterCore({
langs: [],
themes: [],
engine: createJavaScriptRegexEngine(),
});
return highlighter;
};
const ensureLang = async (hl: HighlighterCore, lang: string) => {
if (loadedLangs.has(lang)) return;
const mod = await import(`shiki/langs/${lang}.mjs`);
await hl.loadLanguage(mod.default);
loadedLangs.add(lang);
};
const ensureTheme = async (hl: HighlighterCore, name: string) => {
if (loadedThemes.has(name)) return;
const mod = await import(`shiki/themes/${name}.mjs`);
await hl.loadTheme(mod.default);
loadedThemes.add(name);
};
return {
name: 'vite-plugin-shiki',
enforce: 'pre',
async load(id) {
const [filepath, rawQuery] = id.split('?', 2);
if (!rawQuery) return;
const params = new URLSearchParams(rawQuery);
if (!params.has(SHIKI_QUERY)) return;
const ext = filepath.split('.').pop()?.toLowerCase() ?? '';
const lang = params.get('lang') ?? extToLang[ext] ?? ext ?? 'text';
// Перечитываем исходник сами + регистрируем как зависимость для HMR.
const source = await readFile(filepath, 'utf8');
this.addWatchFile(filepath);
const hl = await getHighlighter();
await ensureLang(hl, lang);
if (themes) {
await ensureTheme(hl, themes.light);
await ensureTheme(hl, themes.dark);
} else {
await ensureTheme(hl, theme);
}
const html = hl.codeToHtml(source.replace(/\n$/, ''), {
lang,
...(themes ? { themes } : { theme }),
transformers,
});
return { code: `export default ${JSON.stringify(html)}`, map: null };
},
};
}
+5
View File
@@ -0,0 +1,5 @@
/** Импорт `*?shiki` отдаёт уже подсвеченный HTML (см. vite-plugin-shiki). */
declare module '*?shiki' {
const html: string;
export default html;
}
+6 -1
View File
@@ -1,7 +1,12 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { shiki } from './src/ShikiCode/vite-plugin-shiki';
export default defineConfig({
plugins: [vue(), tailwind()],
plugins: [
vue(),
tailwind(),
shiki({ theme: 'aurora-x' }),
],
});