diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2270bb1c13..ad7d06b977 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,9 +8,11 @@
- Feat: 新しいゲームを追加
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
- Enhance: チャンネルノートのピン留めをノートのメニューからできるように
+- Enhance: 管理者の場合はAPI tokenの発行画面で管理機能に関する権限を付与できるように
- Fix: ネイティブモードの絵文字がモノクロにならないように
- Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正
- Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正
+- Fix: v2023.12.1で追加された`$[clickable ...]`および`onClickEv`が正しく機能していないのを修正
### Server
- Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 28b3a94444..fd7876b947 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -648,6 +648,7 @@ export interface Locale {
"small": string;
"generateAccessToken": string;
"permission": string;
+ "adminPermission": string;
"enableAll": string;
"disableAll": string;
"tokenRequested": string;
@@ -1238,6 +1239,9 @@ export interface Locale {
"bubbleGame": string;
"sfx": string;
"soundWillBePlayed": string;
+ "showReplay": string;
+ "replay": string;
+ "replaying": string;
"_announcement": {
"forExistingUsers": string;
"forExistingUsersDescription": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index e6f9dc6b96..d337adb0ca 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -645,6 +645,7 @@ medium: "中"
small: "小"
generateAccessToken: "アクセストークンの発行"
permission: "権限"
+adminPermission: "管理者権限"
enableAll: "全て有効にする"
disableAll: "全て無効にする"
tokenRequested: "アカウントへのアクセス許可"
@@ -1235,6 +1236,9 @@ enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する"
bubbleGame: "バブルゲーム"
sfx: "効果音"
soundWillBePlayed: "サウンドが再生されます"
+showReplay: "リプレイを見る"
+replay: "リプレイ"
+replaying: "リプレイ中"
_announcement:
forExistingUsers: "既存ユーザーのみ"
diff --git a/packages/frontend/assets/drop-and-fusion/gameover.mp3 b/packages/frontend/assets/drop-and-fusion/gameover.mp3
new file mode 100644
index 0000000000..23b41c5699
Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/gameover.mp3 differ
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 86add62fca..4b66703554 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -59,6 +59,7 @@
"rollup": "4.9.1",
"sanitize-html": "2.11.0",
"sass": "1.69.5",
+ "seedrandom": "^3.0.5",
"shiki": "0.14.7",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue
index d024e1e593..a42767e1b6 100644
--- a/packages/frontend/src/components/MkTokenGenerateWindow.vue
+++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue
@@ -33,7 +33,13 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.enableAll }}
- {{ i18n.t(`_permissions.${kind}`) }}
+ {{ i18n.t(`_permissions.${kind}`) }}
+
+
+
{{ i18n.ts.adminPermission }}
+
+ {{ i18n.t(`_permissions.${kind}`) }}
+
@@ -49,6 +55,7 @@ import MkButton from './MkButton.vue';
import MkInfo from './MkInfo.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
+import { iAmAdmin } from '@/account.js';
const props = withDefaults(defineProps<{
title?: string | null;
@@ -68,37 +75,76 @@ const emit = defineEmits<{
}>();
const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin'));
+const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin'));
+
const dialog = shallowRef>();
const name = ref(props.initialName);
-const permissions = ref(>{});
+const permissionSwitches = ref(>{});
+const permissionSwitchesForAdmin = ref(>{});
if (props.initialPermissions) {
for (const kind of props.initialPermissions) {
- permissions.value[kind] = true;
+ permissionSwitches.value[kind] = true;
}
} else {
for (const kind of defaultPermissions) {
- permissions.value[kind] = false;
+ permissionSwitches.value[kind] = false;
+ }
+
+ if (iAmAdmin) {
+ for (const kind of adminPermissions) {
+ permissionSwitchesForAdmin.value[kind] = false;
+ }
}
}
function ok(): void {
emit('done', {
name: name.value,
- permissions: Object.keys(permissions.value).filter(p => permissions.value[p]),
+ permissions: [
+ ...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]),
+ ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []),
+ ],
});
dialog.value?.close();
}
function disableAll(): void {
- for (const p in permissions.value) {
- permissions.value[p] = false;
+ for (const p in permissionSwitches.value) {
+ permissionSwitches.value[p] = false;
+ }
+ if (iAmAdmin) {
+ for (const p in permissionSwitchesForAdmin.value) {
+ permissionSwitchesForAdmin.value[p] = false;
+ }
}
}
function enableAll(): void {
- for (const p in permissions.value) {
- permissions.value[p] = true;
+ for (const p in permissionSwitches.value) {
+ permissionSwitches.value[p] = true;
+ }
+ if (iAmAdmin) {
+ for (const p in permissionSwitchesForAdmin.value) {
+ permissionSwitchesForAdmin.value[p] = true;
+ }
}
}
+
+
diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index b8d3d8bf04..974daf35e4 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
@@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
{{ comboPrev }} Chain!
-
+
-
+
SCORE:
MAX CHAIN:
-
- Restart
- Share
-
+
+
+
{{ i18n.ts.replaying }}
+
+
+
+
+
+ {{ i18n.ts.done }}
+ {{ i18n.ts.showReplay }}
+ {{ i18n.ts.share }}
+ Copy replay data
@@ -139,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -168,6 +182,7 @@ import { DropAndFusionGame, Mono } from '@/scripts/drop-and-fusion-engine.js';
import * as sound from '@/scripts/sound.js';
import MkRange from '@/components/MkRange.vue';
import MkSwitch from '@/components/MkSwitch.vue';
+import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const NORMAL_BASE_SIZE = 30;
const NORAML_MONOS: Mono[] = [{
@@ -401,6 +416,8 @@ const GAME_HEIGHT = 600;
let viewScale = 1;
let game: DropAndFusionGame;
let containerElRect: DOMRect | null = null;
+let seed: string;
+let logs: ReturnType
| null = null;
const containerEl = shallowRef();
const canvasEl = shallowRef();
@@ -414,22 +431,25 @@ const comboPrev = ref(0);
const maxCombo = ref(0);
const dropReady = ref(true);
const gameMode = ref<'normal' | 'square'>('normal');
-const gameOver = ref(false);
+const isGameOver = ref(false);
const gameStarted = ref(false);
const highScore = ref(null);
const showConfig = ref(false);
+const replaying = ref(false);
const mute = ref(false);
const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
function onClick(ev: MouseEvent) {
if (!containerElRect) return;
+ if (replaying.value) return;
const x = (ev.clientX - containerElRect.left) / viewScale;
game.drop(x);
}
function onTouchend(ev: TouchEvent) {
if (!containerElRect) return;
+ if (replaying.value) return;
const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale;
game.drop(x);
}
@@ -454,9 +474,18 @@ function hold() {
game.hold();
}
-function restart() {
+async function surrender() {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.areYouSure,
+ });
+ if (canceled) return;
+ game.surrender();
+}
+
+function end() {
game.dispose();
- gameOver.value = false;
+ isGameOver.value = false;
currentPick.value = null;
dropReady.value = true;
stock.value = [];
@@ -467,6 +496,45 @@ function restart() {
gameStarted.value = false;
}
+function replay() {
+ replaying.value = true;
+ game.dispose();
+ game = new DropAndFusionGame({
+ width: GAME_WIDTH,
+ height: GAME_HEIGHT,
+ canvas: canvasEl.value!,
+ seed: seed,
+ sfxVolume: mute.value ? 0 : sfxVolume.value,
+ ...(
+ gameMode.value === 'normal' ? {
+ monoDefinitions: NORAML_MONOS,
+ } : {
+ monoDefinitions: SQUARE_MONOS,
+ }
+ ),
+ });
+ attachGameEvents();
+ os.promiseDialog(game.load(), async () => {
+ game.start(logs!);
+ });
+}
+
+function endReplay() {
+ replaying.value = false;
+ game.dispose();
+}
+
+function exportLog() {
+ if (!logs) return;
+ const data = JSON.stringify({
+ seed: seed,
+ date: new Date().toISOString(),
+ logs: logs,
+ });
+ copyToClipboard(data);
+ os.success();
+}
+
function attachGameEvents() {
game.addListener('changeScore', value => {
score.value = value;
@@ -492,9 +560,11 @@ function attachGameEvents() {
});
game.addListener('dropped', () => {
+ if (replaying.value) return;
+
dropReady.value = false;
window.setTimeout(() => {
- if (!gameOver.value) {
+ if (!isGameOver.value) {
dropReady.value = true;
}
}, game.DROP_INTERVAL);
@@ -511,6 +581,8 @@ function attachGameEvents() {
});
game.addListener('monoAdded', (mono) => {
+ if (replaying.value) return;
+
// 実績関連
if (mono.level === 10) {
claimAchievement('bubbleGameExplodingHead');
@@ -523,9 +595,15 @@ function attachGameEvents() {
});
game.addListener('gameOver', () => {
+ if (replaying.value) {
+ endReplay();
+ return;
+ }
+
+ logs = game.getLogs();
currentPick.value = null;
dropReady.value = false;
- gameOver.value = true;
+ isGameOver.value = true;
if (score.value > (highScore.value ?? 0)) {
highScore.value = score.value;
@@ -551,10 +629,13 @@ async function start() {
highScore.value = null;
}
+ seed = Date.now().toString();
+
game = new DropAndFusionGame({
width: GAME_WIDTH,
height: GAME_HEIGHT,
canvas: canvasEl.value!,
+ seed: seed,
sfxVolume: mute.value ? 0 : sfxVolume.value,
...(
gameMode.value === 'normal' ? {
@@ -690,7 +771,7 @@ useInterval(() => {
}, 1000, { immediate: false, afterMounted: true });
onDeactivated(() => {
- restart();
+ end();
});
definePageMetadata({
@@ -922,6 +1003,28 @@ definePageMetadata({
}
}
+.replayIndicator {
+ position: absolute;
+ z-index: 10;
+ left: 10px;
+ bottom: 10px;
+ padding: 6px 8px;
+ color: #f00;
+ background: #0008;
+ border-radius: 6px;
+ pointer-events: none;
+}
+
+.replayIndicatorText {
+ animation: replayIndicator-blink 2s infinite;
+}
+
+@keyframes replayIndicator-blink {
+ 0% { opacity: 1; }
+ 50% { opacity: 0; }
+ 100% { opacity: 1; }
+}
+
@keyframes currentMonoArrow {
0% { transform: translateY(0); }
25% { transform: translateY(-8px); }
diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
index f71f3a668e..16fe87d97a 100644
--- a/packages/frontend/src/scripts/drop-and-fusion-engine.ts
+++ b/packages/frontend/src/scripts/drop-and-fusion-engine.ts
@@ -5,6 +5,7 @@
import { EventEmitter } from 'eventemitter3';
import * as Matter from 'matter-js';
+import seedrandom from 'seedrandom';
import * as sound from '@/scripts/sound.js';
export type Mono = {
@@ -20,6 +21,18 @@ export type Mono = {
spriteScale: number;
};
+type Log = {
+ frame: number;
+ operation: 'drop';
+ x: number;
+} | {
+ frame: number;
+ operation: 'hold';
+} | {
+ frame: number;
+ operation: 'surrender';
+};
+
export class DropAndFusionGame extends EventEmitter<{
changeScore: (newScore: number) => void;
changeCombo: (newCombo: number) => void;
@@ -35,18 +48,23 @@ export class DropAndFusionGame extends EventEmitter<{
public readonly DROP_INTERVAL = 500;
public readonly PLAYAREA_MARGIN = 25;
private STOCK_MAX = 4;
+ private TICK_DELTA = 1000 / 60; // 60fps
private loaded = false;
+ private frame = 0;
private engine: Matter.Engine;
private render: Matter.Render;
- private runner: Matter.Runner;
+ private tickRaf: ReturnType | null = null;
+ private tickCallbackQueue: { frame: number; callback: () => void; }[] = [];
private overflowCollider: Matter.Body;
private isGameOver = false;
-
private gameWidth: number;
private gameHeight: number;
private monoDefinitions: Mono[] = [];
private monoTextures: Record = {};
private monoTextureUrls: Record = {};
+ private rng: () => number;
+ private logs: Log[] = [];
+ private replaying = false;
private sfxVolume = 1;
@@ -87,13 +105,17 @@ export class DropAndFusionGame extends EventEmitter<{
width: number;
height: number;
monoDefinitions: Mono[];
+ seed: string;
sfxVolume?: number;
}) {
super();
+ this.tick = this.tick.bind(this);
+
this.gameWidth = opts.width;
this.gameHeight = opts.height;
this.monoDefinitions = opts.monoDefinitions;
+ this.rng = seedrandom(opts.seed);
if (opts.sfxVolume) {
this.sfxVolume = opts.sfxVolume;
@@ -129,9 +151,6 @@ export class DropAndFusionGame extends EventEmitter<{
Matter.Render.run(this.render);
- this.runner = Matter.Runner.create();
- Matter.Runner.run(this.runner, this.engine);
-
this.engine.world.bodies = [];
//#region walls
@@ -223,9 +242,12 @@ export class DropAndFusionGame extends EventEmitter<{
Matter.Composite.add(this.engine.world, body);
// 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする
- window.setTimeout(() => {
- this.activeBodyIds.push(body.id);
- }, 100);
+ this.tickCallbackQueue.push({
+ frame: this.frame + 6,
+ callback: () => {
+ this.activeBodyIds.push(body.id);
+ },
+ });
const comboBonus = 1 + ((this.combo - 1) / 5);
const additionalScore = Math.round(currentMono.score * comboBonus);
@@ -244,7 +266,7 @@ export class DropAndFusionGame extends EventEmitter<{
} else {
//const VELOCITY = 30;
//for (let i = 0; i < 10; i++) {
- // const body = createBody(FRUITS.find(x => x.level === (1 + Math.floor(Math.random() * 3)))!, x + ((Math.random() * VELOCITY) - (VELOCITY / 2)), y + ((Math.random() * VELOCITY) - (VELOCITY / 2)));
+ // const body = createBody(FRUITS.find(x => x.level === (1 + Math.floor(this.rng() * 3)))!, x + ((this.rng() * VELOCITY) - (VELOCITY / 2)), y + ((this.rng() * VELOCITY) - (VELOCITY / 2)));
// Matter.Composite.add(world, body);
// bodies.push(body);
//}
@@ -255,10 +277,25 @@ export class DropAndFusionGame extends EventEmitter<{
}
}
+ public surrender() {
+ this.logs.push({
+ frame: this.frame,
+ operation: 'surrender',
+ });
+
+ this.gameOver();
+ }
+
private gameOver() {
this.isGameOver = true;
- Matter.Runner.stop(this.runner);
+ if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf);
+ this.tickRaf = null;
this.emit('gameOver');
+
+ // TODO: 効果音再生はコンポーネント側の責務なので移動する
+ sound.playUrl('/client-assets/drop-and-fusion/gameover.mp3', {
+ volume: this.sfxVolume,
+ });
}
/** テクスチャをすべてキャッシュする */
@@ -292,13 +329,14 @@ export class DropAndFusionGame extends EventEmitter<{
return Promise.all(this.monoDefinitions.map(x => loadSingleMonoTexture(x, this)));
}
- public start() {
+ public start(logs?: Log[]) {
if (!this.loaded) throw new Error('game is not loaded yet');
+ if (logs) this.replaying = true;
for (let i = 0; i < this.STOCK_MAX; i++) {
this.stock.push({
- id: Math.random().toString(),
- mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
+ id: this.rng().toString(),
+ mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
});
}
this.emit('changeStock', this.stock);
@@ -327,10 +365,13 @@ export class DropAndFusionGame extends EventEmitter<{
this.fusion(bodyA, bodyB);
} else {
fusionReservedPairs.push({ bodyA, bodyB });
- window.setTimeout(() => {
- fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
- this.fusion(bodyA, bodyB);
- }, 100);
+ this.tickCallbackQueue.push({
+ frame: this.frame + 6,
+ callback: () => {
+ fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id);
+ this.fusion(bodyA, bodyB);
+ },
+ });
}
} else {
const energy = pairs.collision.depth;
@@ -354,6 +395,69 @@ export class DropAndFusionGame extends EventEmitter<{
this.combo = 0;
}
}, 500);
+
+ if (logs) {
+ const playTick = () => {
+ this.frame++;
+ const log = logs.find(x => x.frame === this.frame - 1);
+ if (log) {
+ switch (log.operation) {
+ case 'drop': {
+ this.drop(log.x);
+ break;
+ }
+ case 'hold': {
+ this.hold();
+ break;
+ }
+ case 'surrender': {
+ this.surrender();
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
+ if (x.frame === this.frame) {
+ x.callback();
+ return false;
+ } else {
+ return true;
+ }
+ });
+
+ Matter.Engine.update(this.engine, this.TICK_DELTA);
+
+ if (!this.isGameOver) {
+ this.tickRaf = window.requestAnimationFrame(playTick);
+ }
+ };
+
+ playTick();
+ } else {
+ this.tick();
+ }
+ }
+
+ public getLogs() {
+ return this.logs;
+ }
+
+ private tick() {
+ this.frame++;
+ this.tickCallbackQueue = this.tickCallbackQueue.filter(x => {
+ if (x.frame === this.frame) {
+ x.callback();
+ return false;
+ } else {
+ return true;
+ }
+ });
+ Matter.Engine.update(this.engine, this.TICK_DELTA);
+ if (!this.isGameOver) {
+ this.tickRaf = window.requestAnimationFrame(this.tick);
+ }
}
public async load() {
@@ -387,17 +491,23 @@ export class DropAndFusionGame extends EventEmitter<{
public drop(_x: number) {
if (this.isGameOver) return;
- if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) return;
+ if (!this.replaying && (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL)) return;
const head = this.stock.shift()!;
this.stock.push({
- id: Math.random().toString(),
- mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
+ id: this.rng().toString(),
+ mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
});
this.emit('changeStock', this.stock);
- const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (head.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.size / 2), _x));
+ const inputX = Math.round(_x);
+ const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (head.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (head.mono.size / 2), inputX));
const body = this.createBody(head.mono, x, 50 + head.mono.size / 2);
+ this.logs.push({
+ frame: this.frame,
+ operation: 'drop',
+ x: inputX,
+ });
Matter.Composite.add(this.engine.world, body);
this.activeBodyIds.push(body.id);
this.latestDroppedBodyId = body.id;
@@ -416,6 +526,11 @@ export class DropAndFusionGame extends EventEmitter<{
public hold() {
if (this.isGameOver) return;
+ this.logs.push({
+ frame: this.frame,
+ operation: 'hold',
+ });
+
if (this.holding) {
const head = this.stock.shift()!;
this.stock.unshift(this.holding);
@@ -426,8 +541,8 @@ export class DropAndFusionGame extends EventEmitter<{
const head = this.stock.shift()!;
this.holding = head;
this.stock.push({
- id: Math.random().toString(),
- mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
+ id: this.rng().toString(),
+ mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
});
this.emit('changeHolding', this.holding);
this.emit('changeStock', this.stock);
@@ -440,8 +555,9 @@ export class DropAndFusionGame extends EventEmitter<{
public dispose() {
if (this.comboIntervalId) window.clearInterval(this.comboIntervalId);
+ if (this.tickRaf) window.cancelAnimationFrame(this.tickRaf);
+ this.tickRaf = null;
Matter.Render.stop(this.render);
- Matter.Runner.stop(this.runner);
Matter.World.clear(this.engine.world, false);
Matter.Engine.clear(this.engine);
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f6445d3818..513322de67 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -795,6 +795,9 @@ importers:
sass:
specifier: 1.69.5
version: 1.69.5
+ seedrandom:
+ specifier: ^3.0.5
+ version: 3.0.5
shiki:
specifier: 0.14.7
version: 0.14.7