19 Commits

Author SHA1 Message Date
鲁树人
2f9cfaa763 0.2.5 2023-12-25 18:15:56 +01:00
鲁树人
5d7f5b76ef fix: qmcv2 db name matching when musicex was not found 2023-12-25 18:15:51 +01:00
鲁树人
2d50a45ef2 0.2.4 2023-12-24 12:16:08 +01:00
鲁树人
fcc4b14211 feat: support for qmcv2 musicex tail 2023-12-24 12:15:56 +01:00
鲁树人
6c21150fc8 0.2.3 2023-12-23 19:39:46 +00:00
鲁树人
e98470cb70 Merge branch 'docs/android-emu-root' 2023-12-23 19:38:45 +00:00
鲁树人
bb8f69f137 Merge pull request '安卓 root 相关说明' (#63) from docs/android-emu-root into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/63
2023-12-23 16:04:50 +00:00
鲁树人
f194dfd135 build: cache webp images 2023-12-23 15:59:28 +00:00
鲁树人
7e741412a8 docs: typo 2023-12-23 15:54:22 +00:00
鲁树人
fe39ac6604 docs: re-order faq sections 2023-12-23 15:48:51 +00:00
鲁树人
d781767dd0 docs: update in-app-faq about broken android browsers 2023-12-23 15:47:35 +00:00
鲁树人
a7158a75e9 docs: update list of issues with broken android browsers 2023-12-23 15:45:02 +00:00
鲁树人
2bd35f899d docs: format supported format, add qtfm, added warn about android browser 2023-12-23 15:44:45 +00:00
鲁树人
bb37da5066 chore: bump node to v20.10.0 2023-12-23 15:41:13 +00:00
鲁树人
27a91a67bb docs: document broken browsers 2023-12-23 11:20:31 +00:00
鲁树人
067ad6e40b docs: update offline faq with android emu root notes #62 2023-12-23 11:15:32 +00:00
鲁树人
c95bcd7eda docs: update offline md faq with updated notes 2023-12-23 11:13:23 +00:00
鲁树人
caed717755 add notes about android emu for root #62 2023-12-23 11:11:38 +00:00
鲁树人
16bb7cb0fb docs: update faq 2023-12-22 11:02:56 +00:00
23 changed files with 230 additions and 73 deletions

View File

@@ -5,7 +5,7 @@ name: default
steps:
- name: test & build
image: node:20.8.1-bookworm
image: node:20.10.0-bookworm
commands:
# - git config --global --add safe.directory "/drone/src"
- corepack enable
@@ -17,7 +17,7 @@ steps:
npm_config_registry: https://registry.npmmirror.com
- name: publish
image: node:20.8.1-bookworm
image: node:20.10.0-bookworm
environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY:

4
.npmrc
View File

@@ -1,3 +1,3 @@
use-node-version=20.8.1
node-version=20.8.1
use-node-version=20.10.0
node-version=20.10.0
engine-strict=true

View File

@@ -16,21 +16,24 @@
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
## 支持的格式
- [x] QQ 音乐 QMCv1 (..qmc3/.qmcflac 等)
- [x] QQ 音乐 QMCv1 (`.qmc3` / `.qmcflac` 等)
- [x] QQ 音乐 QMCv2
- PC 客户端 (.mflac/.mgg 等) [^qm-key-pc]
- 安卓客户端 (.mflac0/.mgg1/.mggl 等) [^qm-key-android]
- iOS 客户端 (.mgalaxy 等) [^qm-key-ios]
- Mac 客户端 (.mflach 等) [^qm-key-mac]
- [x] 网易云音乐 (.ncm)
- [x] 虾米音乐 (.xm)
- [x] 酷我音乐 (.kwm)
- [x] 酷狗音乐 (.kgm/.vpr)
- [x] 喜马拉雅 Android 端 (.x2m/.x3m)
- [x] 咪咕音乐格式 (.mg3d)
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)~~
- PC 客户端 (`.mflac` / `.mgg` 等) [^qm-key-pc]
- 安卓客户端 (`.mflac0` / `.mgg1` / `.mggl` 等) [^qm-key-android]
- iOS 客户端 (`.mgalaxy` 等) [^qm-key-ios]
- Mac 客户端 (`.mflach` 等) [^qm-key-mac]
- [x] 网易云音乐 (`.ncm`)
- [x] 虾米音乐 (`.xm`)
- [x] 酷我音乐 (`.kwm`)
- [x] 酷狗音乐 (`.kgm` / `.vpr`)
- [x] 喜马拉雅 Android 端 (`.x2m` / `.x3m`)
- [x] 咪咕音乐格式 (`.mg3d`)
- [x] 蜻蜓 FM (`.qta`)
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (`.ofl_en`)~~
[^qm-key-pc]: PC 客户端仅支持 v19.43 或更低版本。
[^qm-key-android]: 需要获取超级管理员权限后提取密钥数据库,并导入后使用。

View File

@@ -10,7 +10,7 @@
#### 2、检查您的平台。
日前,<mark>仅 Windows 客户端</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
日前,<mark>仅 Windows 客户端 v19.43 或以下版本</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
> iOS 用户提取歌曲困难建议换用电脑操作Android 用户提取密钥需要 root也建议用电脑操作。
@@ -34,6 +34,8 @@
日前,<mark>仅手机客户端</mark>下载的歌曲**至臻全景声**及**至臻母带**为新版加密手机平台的其他音质暂时不需要提取密钥PC 平台暂未推出使用新版加密的音质。
※ 已知部分第三方修改版会破坏密钥写出功能,导致无法导入密钥。请使用官方版本。
> Android 用户提取密钥需要 root或者注入文件提供器。
提取密钥教程请访问[新版解锁网站](https://um-react.netlify.app/),前往网站内的设置 →<mark>切换密钥为 KWMv2 密钥</mark>→“添加一条密钥”旁的<mark>**下拉按钮**</mark>→ 从文件导入密钥…→ 选择您对应的平台查看具体教程。
@@ -54,6 +56,35 @@
目前新版没有做歌曲信息匹配与编辑,所以歌曲如果自己没有写入歌曲信息,解出来就是没有的。
### 安卓 root 相关
对安卓设备获取 root 特权通常会破坏系统的完整性并导致部分功能无法使用。
例如部分厂商的安卓设备会在解锁后丧失保修资格,或导致无法使用 NFC 移动支付功能等限制。
如果希望不破坏系统完整性,你可以考虑使用模拟器。
目前常见的带有 root 特权支持的的安卓模拟器方案,分别是雷电模拟器(※ 官方版有内置广告)和微软在 Windows 11 开始支援的适用于 Android™ 的 Windows 子系统 (WSA)。
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
![雷电模拟器 其他设置](../src/faq/assets/ld_settings_misc.webp)
### Via 等浏览器无法正常解密/下载
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
已知有问题的浏览器:
- Via 浏览器
- 夸克浏览器
- UC 浏览器
可能会遇到的问题包括:
- 网页白屏
- 无法下载解密后内容
- 下载的文件名错误
### 新版解锁网站没有批量下载
目前没有做。抱歉。

View File

@@ -1,7 +1,7 @@
{
"name": "um-react",
"private": true,
"version": "0.2.2",
"version": "0.2.5",
"type": "module",
"scripts": {
"start": "vite",
@@ -21,10 +21,9 @@
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@jixun/libparakeet": "0.4.2",
"@jixun/libparakeet": "0.4.3",
"@reduxjs/toolkit": "^2.0.1",
"framer-motion": "^10.16.16",
"immer": "^10.0.3",
"nanoid": "^5.0.4",
"radash": "^11.0.0",
"react": "^18.2.0",

11
pnpm-lock.yaml generated
View File

@@ -33,17 +33,14 @@ dependencies:
specifier: ^11.11.0
version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.45)(react@18.2.0)
'@jixun/libparakeet':
specifier: 0.4.2
version: 0.4.2
specifier: 0.4.3
version: 0.4.3
'@reduxjs/toolkit':
specifier: ^2.0.1
version: 2.0.1(react-redux@9.0.4)(react@18.2.0)
framer-motion:
specifier: ^10.16.16
version: 10.16.16(react-dom@18.2.0)(react@18.2.0)
immer:
specifier: ^10.0.3
version: 10.0.3
nanoid:
specifier: ^5.0.4
version: 5.0.4
@@ -2885,8 +2882,8 @@ packages:
'@sinclair/typebox': 0.27.8
dev: true
/@jixun/libparakeet@0.4.2:
resolution: {integrity: sha512-E6XXrHeOOIexKSyWUgQwHUpZNMh5I1IoC9gbwyVQjJhBLiURy6KU7U2Fgsg62Q3BSkdi7NwbdbPERrygUFmA7Q==}
/@jixun/libparakeet@0.4.3:
resolution: {integrity: sha512-Y+h65ZXbJ604sO1RyXA+2kx1WQoA6xJSlIIFWLcmAJpbj34XY6eCpyRXnltgkzcOLsLaO89jjGqMPS7MBC4XqA==}
dev: false
/@jridgewell/gen-mapping@0.3.3:

View File

@@ -1,5 +1,6 @@
export enum DECRYPTION_WORKER_ACTION_NAME {
DECRYPT = 'DECRYPT',
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
VERSION = 'VERSION',
}

View File

@@ -1,10 +1,9 @@
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
import type { CryptoBase } from '../CryptoBase';
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
import { fetchParakeet } from '@jixun/libparakeet';
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
import { makeQMCv2FooterParser, makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
export class QMC2Crypto implements CryptoBase {
cryptoName = 'QMC/v2';
@@ -12,7 +11,7 @@ export class QMC2Crypto implements CryptoBase {
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
const parakeet = await fetchParakeet();
const footerParser = parakeet.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
const footerParser = makeQMCv2FooterParser(parakeet);
return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), {
parakeet,
cleanup: () => footerParser.delete(),

View File

@@ -10,3 +10,8 @@ export interface DecryptCommandPayload {
blobURI: string;
options: DecryptCommandOptions;
}
export interface FetchMusicExNamePayload {
id: string;
blobURI: string;
}

View File

@@ -2,3 +2,4 @@ import type { Parakeet } from '@jixun/libparakeet';
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from '../crypto/qmc/qmc_v2.key';
export const makeQMCv2KeyCrypto = (p: Parakeet) => p.make.QMCv2KeyCrypto(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
export const makeQMCv2FooterParser = (p: Parakeet) => p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);

View File

@@ -4,9 +4,11 @@ import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
import { getSDKVersion } from '@jixun/libparakeet';
import { workerDecryptHandler } from './worker/handler/decrypt';
import { workerParseMusicExMediaName } from './worker/handler/qmcv2_parser';
const bus = new WorkerServerBus();
onmessage = bus.onmessage;
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getSDKVersion);

View File

@@ -0,0 +1,30 @@
import { fetchParakeet, FooterParserState } from '@jixun/libparakeet';
import type { FetchMusicExNamePayload } from '~/decrypt-worker/types';
import { makeQMCv2FooterParser } from '~/decrypt-worker/util/qmc2KeyCrypto';
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
export const workerParseMusicExMediaName = async ({ id, blobURI }: FetchMusicExNamePayload) => {
const label = `decrypt(${id})`;
return withTimeGroupedLogs(label, async () => {
const parakeet = await timedLogger(`${label}/init`, fetchParakeet);
const blob = await timedLogger(`${label}/fetch-src`, async () =>
fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob()),
);
const buffer = await timedLogger(`${label}/read-src`, async () => {
// Firefox: the range header does not work...?
const blobBuffer = await blob.arrayBuffer();
if (blobBuffer.byteLength > 1024) {
return blobBuffer.slice(-1024);
}
return blobBuffer;
});
const parsed = makeQMCv2FooterParser(parakeet).parse(buffer);
if (parsed.state === FooterParserState.OK) {
return parsed.mediaName;
}
return null;
});
};

View File

@@ -1,5 +1,5 @@
// This is a dummy module for vite/rollup to resolve.
export function createRequire() {
import('immer'); // we need to import something, so vite don't complain on build
import('radash'); // we need to import something, so vite don't complain on build
throw new Error('this is a dummy module. Do not use');
}

View File

@@ -37,7 +37,12 @@ export function KuwoFAQ() {
<AlertIcon />
<Flex flexDir="column">
<Text> root </Text>
<Text>使使</Text>
<Text>
<strong></strong>
</Text>
<Text>
<strong></strong>使使
</Text>
</Flex>
</Alert>
</Container>

View File

@@ -1,7 +1,9 @@
import { Text } from '@chakra-ui/react';
import { Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
import { ExtLink } from '~/components/ExtLink';
import { Header4 } from '~/components/HelpText/Header4';
import { VQuote } from '~/components/HelpText/VQuote';
import { ProjectIssue } from '~/components/ProjectIssue';
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
export function OtherFAQ() {
return (
@@ -9,11 +11,68 @@ export function OtherFAQ() {
<Header4></Header4>
<Text></Text>
<Text>使</Text>
<Header4></Header4>
<Header4></Header4>
<Text>
<ProjectIssue id={34} title="[UI] 全部下载功能" /> {' '}
<ProjectIssue id={43} title="批量下载" />
{'暂时没有实现,不过你可以在 '}
<ProjectIssue id={34} title="[UI] 全部下载功能" />
{' 以及 '}
<ProjectIssue id={43} title="批量下载" />
{' 追踪该问题。'}
</Text>
<Header4>安卓: 浏览器支持说明</Header4>
<Text> 使 Chrome Firefox </Text>
<Text></Text>
<UnorderedList>
<ListItem>Via </ListItem>
<ListItem></ListItem>
<ListItem>UC </ListItem>
</UnorderedList>
<Text></Text>
<UnorderedList>
<ListItem></ListItem>
<ListItem></ListItem>
<ListItem></ListItem>
</UnorderedList>
<Header4>安卓: root </Header4>
<Text>
root 使
使 NFC
</Text>
<Text>使</Text>
<Text>
root 广 Windows 11
<ExtLink href="https://learn.microsoft.com/zh-cn/windows/android/wsa/">
<ruby>
Android Windows (WSA)
<rp> (</rp>
<rt>
<code>Windows Subsystem for Android</code>
</rt>
<rp>)</rp>
</ruby>
</ExtLink>
</Text>
<UnorderedList>
<ListItem>
<Text>
{'WSA 可以参考 '}
<ExtLink href="https://github.com/LSPosed/MagiskOnWSALocal">MagiskOnWSALocal</ExtLink>
{' 的说明操作。'}
</Text>
</ListItem>
<ListItem>
<Text>
<VQuote></VQuote> <VQuote></VQuote> root
</Text>
<Img borderRadius={5} border="1px solid #ccc" src={LdPlayerSettingsScreen}></Img>
</ListItem>
</UnorderedList>
<Header4></Header4>
<Text>
{'欢迎进入 '}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -2,9 +2,9 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '~/store';
import type { DecryptionResult } from '~/decrypt-worker/constants';
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
import { decryptionQueue } from '~/decrypt-worker/client';
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
import type { DecryptCommandOptions, FetchMusicExNamePayload } from '~/decrypt-worker/types';
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector';
@@ -44,7 +44,7 @@ export interface FileListingState {
displayMode: ListingMode;
}
const initialState: FileListingState = {
files: Object.create(null),
files: {},
displayMode: ListingMode.LIST,
};
@@ -64,17 +64,27 @@ export const processFile = createAsyncThunk<
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
};
const fileHeader = await fetch(file.raw, {
headers: {
Range: 'bytes=0-1023',
},
})
const fileHeader = await fetch(file.raw, { headers: { Range: 'bytes=0-1023' } })
.then((r) => r.blob())
.then((r) => r.arrayBuffer());
.then((r) => r.arrayBuffer())
.then((r) => {
if (r.byteLength > 1024) {
return r.slice(0, 1024);
}
return r;
});
const qmcv2MusicExMediaFile = await workerClientBus.request<string, FetchMusicExNamePayload>(
DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME,
{
id: fileId,
blobURI: file.raw,
},
);
const options: DecryptCommandOptions = {
fileName: file.fileName,
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName),
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
qingTingAndroidKey: selectQtfmAndroidKey(state),
};

View File

@@ -61,7 +61,7 @@ export function PanelQMCv2Key() {
alert(`不是支持的 SQLite 数据库文件。`);
return;
}
} else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) {
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer();
const map = parseAndroidQmEKey(new DataView(fileBuffer));
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));

View File

@@ -3,7 +3,8 @@ import { Text } from '@chakra-ui/react';
export function InstructionsPC() {
return (
<>
<Text>使 Windows </Text>
<Text>使 Windows 19.43 </Text>
<Text>使 Windows 19.51 </Text>
</>
);
}

View File

@@ -1,43 +1,44 @@
import { debounce } from 'radash';
import { produce } from 'immer';
import type { AppStore } from '~/store';
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
import { enumObject } from '~/util/objects';
import { getLogger } from '~/util/logUtils';
import { parseKwm2ProductionKey } from './keyFormats';
import { deepClone } from '~/util/deepClone';
const DEFAULT_STORAGE_KEY = 'um-react-settings';
function mergeSettings(settings: ProductionSettings): ProductionSettings {
return produce(settingsSlice.getInitialState().production, (draft) => {
if (settings?.qmc2) {
const { allowFuzzyNameSearch, keys } = settings.qmc2;
for (const [k, v] of enumObject(keys)) {
if (typeof v === 'string') {
draft.qmc2.keys[k] = v;
}
}
if (typeof allowFuzzyNameSearch === 'boolean') {
draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch;
const draft = deepClone(settingsSlice.getInitialState().production);
if (settings?.qmc2) {
const { allowFuzzyNameSearch, keys } = settings.qmc2;
for (const [k, v] of enumObject(keys)) {
if (typeof v === 'string') {
draft.qmc2.keys[k] = v;
}
}
if (settings?.kwm2) {
const { keys } = settings.kwm2;
if (typeof allowFuzzyNameSearch === 'boolean') {
draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch;
}
}
for (const [k, v] of enumObject(keys)) {
if (typeof v === 'string' && parseKwm2ProductionKey(k)) {
draft.kwm2.keys[k] = v;
}
if (settings?.kwm2) {
const { keys } = settings.kwm2;
for (const [k, v] of enumObject(keys)) {
if (typeof v === 'string' && parseKwm2ProductionKey(k)) {
draft.kwm2.keys[k] = v;
}
}
}
if (typeof settings?.qtfm?.android === 'string') {
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
}
});
if (typeof settings?.qtfm?.android === 'string') {
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
}
return draft;
}
export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KEY) {

View File

@@ -31,6 +31,16 @@ export const theme = extendTheme({
color: 'blue.600',
},
},
Text: {
baseStyle: {
mt: 1,
},
},
Header: {
baseStyle: {
mt: 3,
},
},
},
styles: {
global: {

3
src/util/deepClone.ts Normal file
View File

@@ -0,0 +1,3 @@
export function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}

View File

@@ -57,7 +57,7 @@ export default defineConfig({
registerType: 'prompt',
workbox: {
// Cache everything from dist
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm}'],
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm,webp}'],
},
manifest: {
display: 'standalone',
@@ -98,7 +98,7 @@ export default defineConfig({
reacts: ['react', 'react-dom', 'react-dropzone', 'react-promise-suspense', 'react-redux', '@reduxjs/toolkit'],
chakra: ['@chakra-ui/react', '@emotion/react', '@emotion/styled', 'framer-motion'],
icons: ['react-icons', '@chakra-ui/icons'],
utility: ['radash', 'nanoid', 'immer', 'react-syntax-highlighter'],
utility: ['radash', 'nanoid', 'react-syntax-highlighter'],
},
},
},