From 1a8243f1cace06c2eb872177d39536f76c9a8f5d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sun, 29 Oct 2023 14:12:40 +0900
Subject: [PATCH] =?UTF-8?q?MkCode=E3=81=AE=E3=83=91=E3=83=BC=E3=82=B9?=
 =?UTF-8?q?=E3=82=A8=E3=83=B3=E3=82=B8=E3=83=B3=E3=82=92Shiki=E3=81=AB?=
 =?UTF-8?q?=E5=A4=89=E6=9B=B4=20(#12102)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* (swap) prism -> shiki

* fix styles

* (bump) aiscript-vscode to v0.0.5

* refactor

* replace prism-editor (beta)

* Update scratchpad.vue

* (enhance) MkCodeEditor自動インデント改行

* (fix) lint

* (add) scratchpad: MkStickyContainer

* Update CHANGELOG.md

* clean up

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
---
 CHANGELOG.md                                  |   3 +
 packages/frontend/package.json                |   4 +-
 .../frontend/src/components/MkCode.core.vue   |  85 ++++++++-
 packages/frontend/src/components/MkCode.vue   |  21 ++-
 .../frontend/src/components/MkCodeEditor.vue  | 166 ++++++++++++++++++
 packages/frontend/src/pages/flash/flash.vue   |   2 +-
 packages/frontend/src/pages/scratchpad.vue    |  60 +++----
 .../frontend/src/pages/settings/plugin.vue    |   2 +-
 .../frontend/src/scripts/code-highlighter.ts  |  31 ++++
 packages/frontend/src/style.scss              |   4 -
 pnpm-lock.yaml                                |  87 +++++----
 scripts/build-assets.mjs                      |   8 +
 12 files changed, 380 insertions(+), 93 deletions(-)
 create mode 100644 packages/frontend/src/components/MkCodeEditor.vue
 create mode 100644 packages/frontend/src/scripts/code-highlighter.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa65bf8135..b909b26cae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,9 @@
 - Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました
 	- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください
 	  https://misskey-hub.net/docs/advanced/publish-on-your-website.html
+- Enhance: コードのシンタックスハイライトエンジンをShikiに変更
+  - AiScriptのシンタックスハイライトに対応
+  - MFMでAiScriptをハイライトする場合、コードブロックの開始部分を ` ```is ` もしくは ` ```aiscript ` としてください
 - Enhance: データセーバー有効時はアニメーション付きのアバター画像が停止するように
 - Enhance: プラグインを削除した際には、使用されていたアクセストークンも同時に削除されるようになりました
 - Enhance: プラグインで`Plugin:register_note_view_interruptor`を用いてnoteの代わりにnullを返却することでノートを非表示にできるようになりました
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index f8492b3e56..fe35519d27 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -29,6 +29,7 @@
 		"@vue/compiler-sfc": "3.3.7",
 		"astring": "1.8.6",
 		"autosize": "6.0.1",
+		"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.5",
 		"broadcast-channel": "5.5.1",
 		"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
 		"buraha": "0.0.1",
@@ -54,11 +55,11 @@
 		"mfm-js": "0.23.3",
 		"misskey-js": "workspace:*",
 		"photoswipe": "5.4.2",
-		"prismjs": "1.29.0",
 		"punycode": "2.3.0",
 		"querystring": "0.2.1",
 		"rollup": "4.1.4",
 		"sanitize-html": "2.11.0",
+		"shiki": "^0.14.5",
 		"sass": "1.69.5",
 		"strict-event-emitter-types": "2.0.0",
 		"textarea-caret": "3.1.0",
@@ -74,7 +75,6 @@
 		"vanilla-tilt": "1.8.1",
 		"vite": "4.5.0",
 		"vue": "3.3.7",
-		"vue-prism-editor": "2.0.0-alpha.2",
 		"vuedraggable": "next"
 	},
 	"devDependencies": {
diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue
index a1300be1f6..4ec3540419 100644
--- a/packages/frontend/src/components/MkCode.core.vue
+++ b/packages/frontend/src/components/MkCode.core.vue
@@ -5,21 +5,90 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <!-- eslint-disable vue/no-v-html -->
 <template>
-<code v-if="inline" :class="`language-${prismLang}`" style="overflow-wrap: anywhere;" v-html="html"></code>
-<pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre>
+<div :class="['codeBlockRoot', { 'codeEditor': codeEditor }]" v-html="html"></div>
 </template>
 
 <script lang="ts" setup>
-import { computed } from 'vue';
-import Prism from 'prismjs';
-import 'prismjs/themes/prism-okaidia.css';
+import { ref, computed, watch } from 'vue';
+import { BUNDLED_LANGUAGES } from 'shiki';
+import type { Lang as ShikiLang } from 'shiki';
+import { getHighlighter } from '@/scripts/code-highlighter.js';
 
 const props = defineProps<{
 	code: string;
 	lang?: string;
-	inline?: boolean;
+	codeEditor?: boolean;
 }>();
 
-const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js');
-const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value));
+const highlighter = await getHighlighter();
+
+const codeLang = ref<ShikiLang | 'aiscript'>('js');
+const html = computed(() => highlighter.codeToHtml(props.code, {
+	lang: codeLang.value,
+	theme: 'dark-plus',
+}));
+
+async function fetchLanguage(to: string): Promise<void> {
+	const language = to as ShikiLang;
+
+	// Check for the loaded languages, and load the language if it's not loaded yet.
+	if (!highlighter.getLoadedLanguages().includes(language)) {
+		// Check if the language is supported by Shiki
+		const bundles = BUNDLED_LANGUAGES.filter((bundle) => {
+			// Languages are specified by their id, they can also have aliases (i. e. "js" and "javascript")
+			return bundle.id === language || bundle.aliases?.includes(language);
+		});
+		if (bundles.length > 0) {
+			await highlighter.loadLanguage(language);
+			codeLang.value = language;
+		} else {
+			codeLang.value = 'js';
+		}
+	} else {
+		codeLang.value = language;
+	}
+}
+
+watch(() => props.lang, (to) => {
+	if (codeLang.value === to || !to) return;
+	return new Promise((resolve) => {
+		fetchLanguage(to).then(() => resolve);
+	});
+}, { immediate: true, });
 </script>
+
+<style scoped lang="scss">
+.codeBlockRoot :deep(.shiki) {
+	padding: 1em;
+	margin: .5em 0;
+	overflow: auto;
+	border-radius: .3em;
+
+	& pre,
+	& code {
+		font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
+	}
+}
+
+.codeBlockRoot.codeEditor {
+	min-width: 100%;
+	height: 100%;
+
+	& :deep(.shiki) {
+		padding: 12px;
+		margin: 0;
+		border-radius: 6px;
+		min-height: 130px;
+		pointer-events: none;
+		min-width: calc(100% - 24px);
+		height: 100%;
+		display: inline-block;
+		line-height: 1.5em;
+		font-size: 1em;
+		overflow: visible;
+		text-rendering: inherit;
+    text-transform: inherit;
+    white-space: pre;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue
index 8972b1863b..b39e6ff23c 100644
--- a/packages/frontend/src/components/MkCode.vue
+++ b/packages/frontend/src/components/MkCode.vue
@@ -4,11 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<XCode :code="code" :lang="lang" :inline="inline"/>
+	<Suspense>
+		<template #fallback>
+			<MkLoading v-if="!inline ?? true" />
+		</template>
+		<code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code>
+		<XCode v-else :code="code" :lang="lang"/>
+	</Suspense>
 </template>
 
 <script lang="ts" setup>
 import { defineAsyncComponent } from 'vue';
+import MkLoading from '@/components/global/MkLoading.vue';
 
 defineProps<{
 	code: string;
@@ -18,3 +25,15 @@ defineProps<{
 
 const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
 </script>
+
+<style module lang="scss">
+.codeInlineRoot {
+	display: inline-block;
+	font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
+	overflow-wrap: anywhere;
+	color: #D4D4D4;
+	background: #1E1E1E;
+	padding: .1em;
+	border-radius: .3em;
+}
+</style>
diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue
new file mode 100644
index 0000000000..2d56a61963
--- /dev/null
+++ b/packages/frontend/src/components/MkCodeEditor.vue
@@ -0,0 +1,166 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.codeEditorRoot, { [$style.disabled]: disabled, [$style.focused]: focused }]">
+	<div :class="$style.codeEditorScroller">
+		<textarea
+			ref="inputEl"
+			v-model="vModel"
+			:class="[$style.textarea]"
+			:disabled="disabled"
+			:required="required"
+			:readonly="readonly"
+			autocomplete="off"
+			wrap="off"
+			spellcheck="false"
+			@focus="focused = true"
+			@blur="focused = false"
+			@keydown="onKeydown($event)"
+			@input="onInput"
+		></textarea>
+		<XCode :class="$style.codeEditorHighlighter" :codeEditor="true" :code="v" :lang="lang"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch, toRefs, shallowRef, nextTick } from 'vue';
+import XCode from '@/components/MkCode.core.vue';
+
+const props = withDefaults(defineProps<{
+	modelValue: string | null;
+	lang: string;
+	required?: boolean;
+	readonly?: boolean;
+	disabled?: boolean;
+}>(), {
+	lang: 'js',
+});
+
+const emit = defineEmits<{
+	(ev: 'change', _ev: KeyboardEvent): void;
+	(ev: 'keydown', _ev: KeyboardEvent): void;
+	(ev: 'enter'): void;
+	(ev: 'update:modelValue', value: string): void;
+}>();
+
+const { modelValue } = toRefs(props);
+const vModel = ref<string>(modelValue.value ?? '');
+const v = ref<string>(modelValue.value ?? '');
+const focused = ref(false);
+const changed = ref(false);
+const inputEl = shallowRef<HTMLTextAreaElement>();
+
+const onInput = (ev) => {
+	v.value = ev.target?.value ?? v.value;
+	changed.value = true;
+	emit('change', ev);
+};
+
+const onKeydown = (ev: KeyboardEvent) => {
+	if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
+
+	emit('keydown', ev);
+
+	if (ev.code === 'Enter') {
+		const pos = inputEl.value?.selectionStart ?? 0;
+		const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length;
+		if (pos === posEnd) {
+			const lines = vModel.value.slice(0, pos).split('\n');
+			const currentLine = lines[lines.length - 1];
+			const currentLineSpaces = currentLine.match(/^\s+/);
+			const posDelta = currentLineSpaces ? currentLineSpaces[0].length : 0;
+			ev.preventDefault();
+			vModel.value = vModel.value.slice(0, pos) + '\n' + (currentLineSpaces ? currentLineSpaces[0] : '') + vModel.value.slice(pos);
+			v.value = vModel.value;
+			nextTick(() => {
+				inputEl.value?.setSelectionRange(pos + 1 + posDelta, pos + 1 + posDelta);
+			});
+		}
+		emit('enter');
+	}
+
+	if (ev.key === 'Tab') {
+		const pos = inputEl.value?.selectionStart ?? 0;
+		const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length;
+		vModel.value = vModel.value.slice(0, pos) + '\t' + vModel.value.slice(posEnd);
+		v.value = vModel.value;
+		nextTick(() => {
+			inputEl.value?.setSelectionRange(pos + 1, pos + 1);
+		});
+		ev.preventDefault();
+	}
+};
+
+const updated = () => {
+	changed.value = false;
+	emit('update:modelValue', v.value);
+};
+
+watch(modelValue, newValue => {
+	v.value = newValue ?? '';
+});
+
+watch(v, () => {
+	updated();
+});
+</script>
+
+<style lang="scss" module>
+.codeEditorRoot {
+	min-width: 100%;
+	max-width: 100%;
+	overflow-x: auto;
+	overflow-y: hidden;
+	box-sizing: border-box;
+	margin: 0;
+	padding: 0;
+	color: var(--fg);
+	border: solid 1px var(--panel);
+	transition: border-color 0.1s ease-out;
+	font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
+	&:hover {
+		border-color: var(--inputBorderHover) !important;
+	}
+}
+
+.focused.codeEditorRoot {
+	border-color: var(--accent) !important;
+	border-radius: 6px;
+}
+
+.codeEditorScroller {
+	position: relative;
+	display: inline-block;
+	min-width: 100%;
+	height: 100%;
+}
+
+.textarea {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	display: inline-block;
+	appearance: none;
+	resize: none;
+	text-align: left;
+	color: transparent;
+	caret-color: rgb(225, 228, 232);
+	background-color: transparent;
+	border: 0;
+	outline: 0;
+	padding: 12px;
+	line-height: 1.5em;
+	font-size: 1em;
+	font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
+}
+
+.textarea::selection {
+	color: #fff;
+}
+</style>
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
index 32a835831c..ebf117ffbf 100644
--- a/packages/frontend/src/pages/flash/flash.vue
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<template #icon><i class="ti ti-code"></i></template>
 					<template #label>{{ i18n.ts._play.viewSource }}</template>
 
-					<MkCode :code="flash.script" :inline="false" class="_monospace"/>
+					<MkCode :code="flash.script" lang="is" :inline="false" class="_monospace"/>
 				</MkFolder>
 				<div :class="$style.footer">
 					<Mfm :text="`By @${flash.user.username}`"/>
diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue
index 3dfd2d661f..f8d3187bd4 100644
--- a/packages/frontend/src/pages/scratchpad.vue
+++ b/packages/frontend/src/pages/scratchpad.vue
@@ -4,46 +4,46 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkSpacer :contentMax="800">
-	<div :class="$style.root">
-		<div :class="$style.editor" class="_panel">
-			<PrismEditor v-model="code" class="_monospace" :class="$style.code" :highlight="highlighter" :lineNumbers="false"/>
-			<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
-		</div>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
 
-		<MkContainer v-if="root && components.length > 1" :key="uiKey" :foldable="true">
-			<template #header>UI</template>
-			<div :class="$style.ui">
-				<MkAsUi :component="root" :components="components" size="small"/>
+	<MkSpacer :contentMax="800">
+		<div :class="$style.root">
+			<div class="_gaps_s">
+				<div :class="$style.editor" class="_panel">
+					<MkCodeEditor v-model="code" lang="aiscript"/>
+				</div>
+				<MkButton primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
 			</div>
-		</MkContainer>
 
-		<MkContainer :foldable="true" class="">
-			<template #header>{{ i18n.ts.output }}</template>
-			<div :class="$style.logs">
-				<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
+			<MkContainer v-if="root && components.length > 1" :key="uiKey" :foldable="true">
+				<template #header>UI</template>
+				<div :class="$style.ui">
+					<MkAsUi :component="root" :components="components" size="small"/>
+				</div>
+			</MkContainer>
+
+			<MkContainer :foldable="true" class="">
+				<template #header>{{ i18n.ts.output }}</template>
+				<div :class="$style.logs">
+					<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
+				</div>
+			</MkContainer>
+
+			<div class="">
+				{{ i18n.ts.scratchpadDescription }}
 			</div>
-		</MkContainer>
-
-		<div class="">
-			{{ i18n.ts.scratchpadDescription }}
 		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
-import 'prismjs';
-import { highlight, languages } from 'prismjs/components/prism-core';
-import 'prismjs/components/prism-clike';
-import 'prismjs/components/prism-javascript';
-import 'prismjs/themes/prism-okaidia.css';
-import { PrismEditor } from 'vue-prism-editor';
-import 'vue-prism-editor/dist/prismeditor.min.css';
 import { Interpreter, Parser, utils } from '@syuilo/aiscript';
 import MkContainer from '@/components/MkContainer.vue';
 import MkButton from '@/components/MkButton.vue';
+import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import { createAiScriptEnv } from '@/scripts/aiscript/api.js';
 import * as os from '@/os.js';
 import { $i } from '@/account.js';
@@ -152,10 +152,6 @@ async function run() {
 	}
 }
 
-function highlighter(code) {
-	return highlight(code, languages.js, 'javascript');
-}
-
 onDeactivated(() => {
 	if (aiscript) aiscript.abort();
 });
diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue
index d72d8d00f3..5ebd74ef7a 100644
--- a/packages/frontend/src/pages/settings/plugin.vue
+++ b/packages/frontend/src/pages/settings/plugin.vue
@@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 							<MkButton inline @click="copy(plugin)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
 						</div>
 
-						<MkCode :code="plugin.src ?? ''"/>
+						<MkCode :code="plugin.src ?? ''" lang="is"/>
 					</div>
 				</MkFolder>
 			</div>
diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts
new file mode 100644
index 0000000000..957669122e
--- /dev/null
+++ b/packages/frontend/src/scripts/code-highlighter.ts
@@ -0,0 +1,31 @@
+import { setWasm, setCDN, Highlighter, getHighlighter as _getHighlighter } from 'shiki';
+
+setWasm('/assets/shiki/dist/onig.wasm');
+setCDN('/assets/shiki/');
+
+let _highlighter: Highlighter | null = null;
+
+export async function getHighlighter(): Promise<Highlighter> {
+	if (!_highlighter) {
+		return await initHighlighter();
+	}
+	return _highlighter;
+}
+
+export async function initHighlighter() {
+	const highlighter = await _getHighlighter({
+		theme: 'dark-plus',
+		langs: ['js'],
+	});
+
+	await highlighter.loadLanguage({
+		path: 'languages/aiscript.tmLanguage.json',
+		id: 'aiscript',
+		scopeName: 'source.aiscript',
+		aliases: ['is', 'ais'],
+	});
+
+	_highlighter = highlighter;
+
+	return highlighter;
+}
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index c644fc76da..c22879d677 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -400,10 +400,6 @@ hr {
 	font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
 }
 
-.prism-editor__textarea:focus {
-	outline: none;
-}
-
 ._zoom {
 	transition-duration: 0.5s, 0.5s;
 	transition-property: opacity, transform;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4d47134d43..0ffd449494 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -673,6 +673,9 @@ importers:
       '@vue/compiler-sfc':
         specifier: 3.3.7
         version: 3.3.7
+      aiscript-vscode:
+        specifier: github:aiscript-dev/aiscript-vscode#v0.0.5
+        version: github.com/aiscript-dev/aiscript-vscode/a8fa5bb41885391cdb6a6e3165eaa6e4868da86e
       astring:
         specifier: 1.8.6
         version: 1.8.6
@@ -754,9 +757,6 @@ importers:
       photoswipe:
         specifier: 5.4.2
         version: 5.4.2
-      prismjs:
-        specifier: 1.29.0
-        version: 1.29.0
       punycode:
         specifier: 2.3.0
         version: 2.3.0
@@ -772,6 +772,9 @@ importers:
       sass:
         specifier: 1.69.5
         version: 1.69.5
+      shiki:
+        specifier: ^0.14.5
+        version: 0.14.5
       strict-event-emitter-types:
         specifier: 2.0.0
         version: 2.0.0
@@ -814,9 +817,6 @@ importers:
       vue:
         specifier: 3.3.7
         version: 3.3.7(typescript@5.2.2)
-      vue-prism-editor:
-        specifier: 2.0.0-alpha.2
-        version: 2.0.0-alpha.2(vue@3.3.7)
       vuedraggable:
         specifier: next
         version: 4.1.0(vue@3.3.7)
@@ -871,10 +871,10 @@ importers:
         version: 7.5.1
       '@storybook/vue3':
         specifier: 7.5.1
-        version: 7.5.1(@vue/compiler-core@3.3.6)(vue@3.3.7)
+        version: 7.5.1(@vue/compiler-core@3.3.7)(vue@3.3.7)
       '@storybook/vue3-vite':
         specifier: 7.5.1
-        version: 7.5.1(@vue/compiler-core@3.3.6)(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)(vite@4.5.0)(vue@3.3.7)
+        version: 7.5.1(@vue/compiler-core@3.3.7)(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)(vite@4.5.0)(vue@3.3.7)
       '@testing-library/vue':
         specifier: 7.0.0
         version: 7.0.0(@vue/compiler-sfc@3.3.7)(vue@3.3.7)
@@ -6867,7 +6867,7 @@ packages:
       file-system-cache: 2.3.0
     dev: true
 
-  /@storybook/vue3-vite@7.5.1(@vue/compiler-core@3.3.6)(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)(vite@4.5.0)(vue@3.3.7):
+  /@storybook/vue3-vite@7.5.1(@vue/compiler-core@3.3.7)(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)(vite@4.5.0)(vue@3.3.7):
     resolution: {integrity: sha512-5bO5BactTbyOxxeRw8U6t3FqqfTvVLTefzg1NLDkKt2iAL6lGBSsPTKMgpy3dt+cxdiqEis67niQL68ZtW02Zw==}
     engines: {node: ^14.18 || >=16}
     peerDependencies:
@@ -6877,7 +6877,7 @@ packages:
     dependencies:
       '@storybook/builder-vite': 7.5.1(typescript@5.2.2)(vite@4.5.0)
       '@storybook/core-server': 7.5.1
-      '@storybook/vue3': 7.5.1(@vue/compiler-core@3.3.6)(vue@3.3.7)
+      '@storybook/vue3': 7.5.1(@vue/compiler-core@3.3.7)(vue@3.3.7)
       '@vitejs/plugin-vue': 4.4.0(vite@4.5.0)(vue@3.3.7)
       magic-string: 0.30.3
       react: 18.2.0
@@ -6896,7 +6896,7 @@ packages:
       - vue
     dev: true
 
-  /@storybook/vue3@7.5.1(@vue/compiler-core@3.3.6)(vue@3.3.7):
+  /@storybook/vue3@7.5.1(@vue/compiler-core@3.3.7)(vue@3.3.7):
     resolution: {integrity: sha512-9srw2rnSYaU45kkunXT8+bX3QMO2QPV6MCWRayKo7Pl+B0H/euHvxPSZb1X8mRpgLtYgVgSNJFoNbk/2Fn8z8g==}
     engines: {node: '>=16.0.0'}
     peerDependencies:
@@ -6908,7 +6908,7 @@ packages:
       '@storybook/global': 5.0.0
       '@storybook/preview-api': 7.5.1
       '@storybook/types': 7.5.1
-      '@vue/compiler-core': 3.3.6
+      '@vue/compiler-core': 3.3.7
       lodash: 4.17.21
       ts-dedent: 2.2.0
       type-fest: 2.19.0
@@ -8367,15 +8367,6 @@ packages:
       postcss: 8.4.31
       source-map-js: 1.0.2
 
-  /@vue/compiler-ssr@3.3.6:
-    resolution: {integrity: sha512-QTIHAfDCHhjXlYGkUg5KH7YwYtdUM1vcFl/FxFDlD6d0nXAmnjizka3HITp8DGudzHndv2PjKVS44vqqy0vP4w==}
-    requiresBuild: true
-    dependencies:
-      '@vue/compiler-dom': 3.3.6
-      '@vue/shared': 3.3.6
-    dev: true
-    optional: true
-
   /@vue/compiler-ssr@3.3.7:
     resolution: {integrity: sha512-TxOfNVVeH3zgBc82kcUv+emNHo+vKnlRrkv8YvQU5+Y5LJGJwSNzcmLUoxD/dNzv0bhQ/F0s+InlgV0NrApJZg==}
     dependencies:
@@ -8428,17 +8419,6 @@ packages:
       '@vue/shared': 3.3.7
       csstype: 3.1.2
 
-  /@vue/server-renderer@3.3.6(vue@3.3.7):
-    resolution: {integrity: sha512-kgLoN43W4ERdZ6dpyy+gnk2ZHtcOaIr5Uc/WUP5DRwutgvluzu2pudsZGoD2b7AEJHByUVMa9k6Sho5lLRCykw==}
-    peerDependencies:
-      vue: 3.3.6
-    dependencies:
-      '@vue/compiler-ssr': 3.3.6
-      '@vue/shared': 3.3.6
-      vue: 3.3.7(typescript@5.2.2)
-    dev: true
-    optional: true
-
   /@vue/server-renderer@3.3.7(vue@3.3.7):
     resolution: {integrity: sha512-UlpKDInd1hIZiNuVVVvLgxpfnSouxKQOSE2bOfQpBuGwxRV/JqqTCyyjXUWiwtVMyeRaZhOYYqntxElk8FhBhw==}
     peerDependencies:
@@ -8466,8 +8446,8 @@ packages:
       js-beautify: 1.14.6
       vue: 3.3.7(typescript@5.2.2)
     optionalDependencies:
-      '@vue/compiler-dom': 3.3.6
-      '@vue/server-renderer': 3.3.6(vue@3.3.7)
+      '@vue/compiler-dom': 3.3.7
+      '@vue/server-renderer': 3.3.7(vue@3.3.7)
     dev: true
 
   /@webgpu/types@0.1.30:
@@ -8687,6 +8667,10 @@ packages:
     resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==}
     engines: {node: '>=12'}
 
+  /ansi-sequence-parser@1.1.1:
+    resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==}
+    dev: false
+
   /ansi-styles@3.2.1:
     resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
     engines: {node: '>=4'}
@@ -13942,7 +13926,6 @@ packages:
 
   /jsonc-parser@3.2.0:
     resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
-    dev: true
 
   /jsonfile@4.0.0:
     resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
@@ -16251,6 +16234,7 @@ packages:
   /prismjs@1.29.0:
     resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
     engines: {node: '>=6'}
+    dev: true
 
   /private-ip@2.3.3:
     resolution: {integrity: sha512-5zyFfekIVUOTVbL92hc8LJOtE/gyGHeREHkJ2yTyByP8Q2YZVoBqLg3EfYLeF0oVvGqtaEX2t2Qovja0/gStXw==}
@@ -17480,6 +17464,15 @@ packages:
     resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
     engines: {node: '>=8'}
 
+  /shiki@0.14.5:
+    resolution: {integrity: sha512-1gCAYOcmCFONmErGTrS1fjzJLA7MGZmKzrBNX7apqSwhyITJg2O102uFzXUeBxNnEkDA9vHIKLyeKq0V083vIw==}
+    dependencies:
+      ansi-sequence-parser: 1.1.1
+      jsonc-parser: 3.2.0
+      vscode-oniguruma: 1.7.0
+      vscode-textmate: 8.0.0
+    dev: false
+
   /side-channel@1.0.4:
     resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
     dependencies:
@@ -19232,6 +19225,14 @@ packages:
     resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
     engines: {node: '>=0.10.0'}
 
+  /vscode-oniguruma@1.7.0:
+    resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==}
+    dev: false
+
+  /vscode-textmate@8.0.0:
+    resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==}
+    dev: false
+
   /vue-component-type-helpers@1.8.22:
     resolution: {integrity: sha512-LK3wJHs3vJxHG292C8cnsRusgyC5SEZDCzDCD01mdE/AoREFMl2tzLRuzwyuEsOIz13tqgBcnvysN3Lxsa14Fw==}
     dev: true
@@ -19295,15 +19296,6 @@ packages:
       vue: 3.3.7(typescript@5.2.2)
     dev: true
 
-  /vue-prism-editor@2.0.0-alpha.2(vue@3.3.7):
-    resolution: {integrity: sha512-Gu42ba9nosrE+gJpnAEuEkDMqG9zSUysIR8SdXUw8MQKDjBnnNR9lHC18uOr/ICz7yrA/5c7jHJr9lpElODC7w==}
-    engines: {node: '>=10'}
-    peerDependencies:
-      vue: ^3.0.0
-    dependencies:
-      vue: 3.3.7(typescript@5.2.2)
-    dev: false
-
   /vue-template-compiler@2.7.14:
     resolution: {integrity: sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==}
     dependencies:
@@ -19765,6 +19757,13 @@ packages:
       readable-stream: 3.6.0
     dev: false
 
+  github.com/aiscript-dev/aiscript-vscode/a8fa5bb41885391cdb6a6e3165eaa6e4868da86e:
+    resolution: {tarball: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/a8fa5bb41885391cdb6a6e3165eaa6e4868da86e}
+    name: aiscript-vscode
+    version: 0.0.5
+    engines: {vscode: ^1.83.0}
+    dev: false
+
   github.com/misskey-dev/browser-image-resizer/0227e860621e55cbed0aabe6dc601096a7748c4a:
     resolution: {tarball: https://codeload.github.com/misskey-dev/browser-image-resizer/tar.gz/0227e860621e55cbed0aabe6dc601096a7748c4a}
     name: browser-image-resizer
diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs
index a8a2cafa5f..1ffcec8aa3 100644
--- a/scripts/build-assets.mjs
+++ b/scripts/build-assets.mjs
@@ -33,6 +33,13 @@ async function copyFrontendLocales() {
   }
 }
 
+async function copyFrontendShikiAssets() {
+  await fs.cp('./packages/frontend/node_modules/shiki/dist', './built/_frontend_dist_/shiki/dist', { dereference: true, recursive: true });
+  await fs.cp('./packages/frontend/node_modules/shiki/languages', './built/_frontend_dist_/shiki/languages', { dereference: true, recursive: true });
+  await fs.cp('./packages/frontend/node_modules/aiscript-vscode/aiscript/syntaxes', './built/_frontend_dist_/shiki/languages', { dereference: true, recursive: true });
+  await fs.cp('./packages/frontend/node_modules/shiki/themes', './built/_frontend_dist_/shiki/themes', { dereference: true, recursive: true });
+}
+
 async function copyBackendViews() {
   await fs.cp('./packages/backend/src/server/web/views', './packages/backend/built/server/web/views', { recursive: true });
 }
@@ -72,6 +79,7 @@ async function build() {
     copyFrontendFonts(),
     copyFrontendTablerIcons(),
     copyFrontendLocales(),
+    copyFrontendShikiAssets(),
     copyBackendViews(),
     buildBackendScript(),
     buildBackendStyle(),