Initial commit
This commit is contained in:
16
packages/dev-utils/README.md
Normal file
16
packages/dev-utils/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Shared Package
|
||||
|
||||
This package contains code which could helps you to develop.
|
||||
To use the code in the package, you need to add the following to the package.json file.
|
||||
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
"@extension/dev-utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Information
|
||||
|
||||
`lib/shared-types.ts` contains the types from [type-fest](https://github.com/sindresorhus/type-fest?tab=readme-ov-file#install).
|
||||
1
packages/dev-utils/index.mts
Normal file
1
packages/dev-utils/index.mts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/index.js';
|
||||
2
packages/dev-utils/lib/index.ts
Normal file
2
packages/dev-utils/lib/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './stream-file-to-zip.js';
|
||||
export * from './manifest-parser/index.js';
|
||||
39
packages/dev-utils/lib/manifest-parser/impl.ts
Normal file
39
packages/dev-utils/lib/manifest-parser/impl.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { IManifestParser } from './types.js';
|
||||
import type { ManifestType } from '@extension/shared';
|
||||
|
||||
const convertToFirefoxCompatibleManifest = (manifest: ManifestType) => {
|
||||
const manifestCopy = {
|
||||
...manifest,
|
||||
} as { [key: string]: unknown };
|
||||
|
||||
if (manifest.background?.service_worker) {
|
||||
manifestCopy.background = {
|
||||
scripts: [manifest.background.service_worker],
|
||||
type: 'module',
|
||||
};
|
||||
}
|
||||
if (manifest.options_page) {
|
||||
manifestCopy.options_ui = {
|
||||
page: manifest.options_page,
|
||||
browser_style: false,
|
||||
};
|
||||
}
|
||||
manifestCopy.content_security_policy = {
|
||||
extension_pages: "script-src 'self'; object-src 'self'",
|
||||
};
|
||||
manifestCopy.permissions = (manifestCopy.permissions as string[]).filter(value => value !== 'sidePanel');
|
||||
|
||||
delete manifestCopy.options_page;
|
||||
delete manifestCopy.side_panel;
|
||||
return manifestCopy as ManifestType;
|
||||
};
|
||||
|
||||
export const ManifestParserImpl: IManifestParser = {
|
||||
convertManifestToString: (manifest, isFirefox) => {
|
||||
if (isFirefox) {
|
||||
manifest = convertToFirefoxCompatibleManifest(manifest);
|
||||
}
|
||||
|
||||
return JSON.stringify(manifest, null, 2);
|
||||
},
|
||||
};
|
||||
4
packages/dev-utils/lib/manifest-parser/index.ts
Normal file
4
packages/dev-utils/lib/manifest-parser/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ManifestParserImpl } from './impl.js';
|
||||
|
||||
export type * from './types.js';
|
||||
export const ManifestParser = ManifestParserImpl;
|
||||
5
packages/dev-utils/lib/manifest-parser/types.ts
Normal file
5
packages/dev-utils/lib/manifest-parser/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ManifestType } from '@extension/shared';
|
||||
|
||||
export interface IManifestParser {
|
||||
convertManifestToString: (manifest: ManifestType, isFirefox: boolean) => string;
|
||||
}
|
||||
24
packages/dev-utils/lib/stream-file-to-zip.ts
Normal file
24
packages/dev-utils/lib/stream-file-to-zip.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AsyncZipDeflate } from 'fflate';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import type { Zip } from 'fflate';
|
||||
|
||||
export const streamFileToZip = (
|
||||
absPath: string,
|
||||
relPath: string,
|
||||
zip: Zip,
|
||||
onAbort: () => void,
|
||||
onError: (error: Error) => void,
|
||||
): void => {
|
||||
const data = new AsyncZipDeflate(relPath, { level: 1 });
|
||||
void zip.add(data);
|
||||
|
||||
createReadStream(absPath)
|
||||
.on('data', (chunk: string | Buffer) =>
|
||||
typeof chunk === 'string' ? data.push(Buffer.from(chunk), false) : data.push(chunk, false),
|
||||
)
|
||||
.on('end', () => data.push(new Uint8Array(0), true))
|
||||
.on('error', error => {
|
||||
onAbort();
|
||||
onError(error);
|
||||
});
|
||||
};
|
||||
29
packages/dev-utils/package.json
Normal file
29
packages/dev-utils/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@extension/dev-utils",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - dev utils",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"types": "index.mts",
|
||||
"main": "dist/index.mjs",
|
||||
"scripts": {
|
||||
"clean:bundle": "rimraf dist",
|
||||
"clean:node_modules": "pnpx rimraf node_modules",
|
||||
"clean:turbo": "rimraf .turbo",
|
||||
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
|
||||
"ready": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"format": "prettier . --write --ignore-path ../../.prettierignore",
|
||||
"type-check": "tsc --noEmit",
|
||||
"postinstall": "pnpm ready"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@extension/tsconfig": "workspace:*",
|
||||
"@extension/shared": "workspace:*"
|
||||
}
|
||||
}
|
||||
8
packages/dev-utils/tsconfig.json
Normal file
8
packages/dev-utils/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@extension/tsconfig/module",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["index.mts", "lib"]
|
||||
}
|
||||
31
packages/env/README.md
vendored
Normal file
31
packages/env/README.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Environment Package
|
||||
|
||||
This package contains code which creates env values.
|
||||
To use the code in the package, you need to follow those steps:
|
||||
|
||||
1. Add a new record to `.env` (NEED TO CONTAIN `CEB_` PREFIX),
|
||||
|
||||
- If you want via cli:
|
||||
- Add it as argument like: `pnpm set-global-env CLI_CEB_NEXT_VALUE=new_data ...` (NEED TO CONTAIN `CLI_CEB_` PREFIX)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `CLI_CEB_DEV` and `CLI_CEB_FIREFOX` are `false` by default \
|
||||
> All CLI values are overwriting in each call, that's mean you'll have access to values from current script run only.
|
||||
|
||||
- If you want dynamic variables go to `lib/index.ts` and edit `dynamicEnvValues` object.
|
||||
|
||||
2. Use it, for example:
|
||||
```ts
|
||||
console.log(process.env['CEB_EXAMPLE']);
|
||||
```
|
||||
or
|
||||
```ts
|
||||
console.log(process.env.CEB_EXAMPLE);
|
||||
```
|
||||
but with first solution, autofill should work for IDE:
|
||||

|
||||
3. You are also able to import const like `IS_DEV` from `@extension/env` like:
|
||||
```ts
|
||||
import { IS_DEV } from '@extension/env';
|
||||
```
|
||||
For more look [ENV CONST](lib/const.ts)
|
||||
9
packages/env/index.mts
vendored
Normal file
9
packages/env/index.mts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { baseEnv, dynamicEnvValues } from './lib/index.js';
|
||||
import type { EnvType } from './lib/types.js';
|
||||
|
||||
export * from './lib/index.js';
|
||||
|
||||
export default {
|
||||
...baseEnv,
|
||||
...dynamicEnvValues,
|
||||
} as EnvType;
|
||||
10
packages/env/lib/config.ts
vendored
Normal file
10
packages/env/lib/config.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import { config } from '@dotenvx/dotenvx';
|
||||
|
||||
export const baseEnv =
|
||||
config({
|
||||
path: `${import.meta.dirname}/../../../../.env`,
|
||||
}).parsed ?? {};
|
||||
|
||||
export const dynamicEnvValues = {
|
||||
CEB_NODE_ENV: baseEnv.CEB_DEV === 'true' ? 'development' : 'production',
|
||||
} as const;
|
||||
4
packages/env/lib/const.ts
vendored
Normal file
4
packages/env/lib/const.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export const IS_DEV = process.env['CLI_CEB_DEV'] === 'true';
|
||||
export const IS_PROD = !IS_DEV;
|
||||
export const IS_FIREFOX = process.env['CLI_CEB_FIREFOX'] === 'true';
|
||||
export const IS_CI = process.env['CEB_CI'] === 'true';
|
||||
2
packages/env/lib/index.ts
vendored
Normal file
2
packages/env/lib/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './config.js';
|
||||
export * from './const.js';
|
||||
13
packages/env/lib/types.ts
vendored
Normal file
13
packages/env/lib/types.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { dynamicEnvValues } from './index.js';
|
||||
|
||||
interface ICebEnv {
|
||||
readonly CEB_EXAMPLE: string;
|
||||
readonly CEB_DEV_LOCALE: string;
|
||||
}
|
||||
|
||||
interface ICebCliEnv {
|
||||
readonly CLI_CEB_DEV: string;
|
||||
readonly CLI_CEB_FIREFOX: string;
|
||||
}
|
||||
|
||||
export type EnvType = ICebEnv & ICebCliEnv & typeof dynamicEnvValues;
|
||||
30
packages/env/package.json
vendored
Normal file
30
packages/env/package.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@extension/env",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - environment variables",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"types": "index.mts",
|
||||
"main": "dist/index.mjs",
|
||||
"scripts": {
|
||||
"clean:bundle": "rimraf dist",
|
||||
"clean:node_modules": "pnpx rimraf node_modules",
|
||||
"clean:turbo": "rimraf .turbo",
|
||||
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
|
||||
"ready": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"format": "prettier . --write --ignore-path ../../.prettierignore",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.44.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@extension/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
||||
8
packages/env/tsconfig.json
vendored
Normal file
8
packages/env/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@extension/tsconfig/module",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["index.mts", "lib"]
|
||||
}
|
||||
BIN
packages/env/use-env-example.png
vendored
Normal file
BIN
packages/env/use-env-example.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
1
packages/hmr/index.mts
Normal file
1
packages/hmr/index.mts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/plugins/index.js';
|
||||
6
packages/hmr/lib/consts.ts
Normal file
6
packages/hmr/lib/consts.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const LOCAL_RELOAD_SOCKET_PORT = 8081;
|
||||
export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`;
|
||||
|
||||
export const DO_UPDATE = 'do_update';
|
||||
export const DONE_UPDATE = 'done_update';
|
||||
export const BUILD_COMPLETE = 'build_complete';
|
||||
17
packages/hmr/lib/initializers/init-client.ts
Normal file
17
packages/hmr/lib/initializers/init-client.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { DO_UPDATE, DONE_UPDATE, LOCAL_RELOAD_SOCKET_URL } from '../consts.js';
|
||||
import MessageInterpreter from '../interpreter/index.js';
|
||||
|
||||
export default ({ id, onUpdate }: { id: string; onUpdate: () => void }) => {
|
||||
const ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.addEventListener('message', event => {
|
||||
const message = MessageInterpreter.receive(String(event.data));
|
||||
|
||||
if (message.type === DO_UPDATE && message.id === id) {
|
||||
onUpdate();
|
||||
ws.send(MessageInterpreter.send({ type: DONE_UPDATE }));
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
53
packages/hmr/lib/initializers/init-reload-server.ts
Normal file
53
packages/hmr/lib/initializers/init-reload-server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
BUILD_COMPLETE,
|
||||
DO_UPDATE,
|
||||
DONE_UPDATE,
|
||||
LOCAL_RELOAD_SOCKET_PORT,
|
||||
LOCAL_RELOAD_SOCKET_URL,
|
||||
} from '../consts.js';
|
||||
import MessageInterpreter from '../interpreter/index.js';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
const clientsThatNeedToUpdate: Set<WebSocket> = new Set();
|
||||
|
||||
(() => {
|
||||
const wss = new WebSocketServer({ port: LOCAL_RELOAD_SOCKET_PORT });
|
||||
|
||||
wss.on('listening', () => {
|
||||
console.log(`[HMR] Server listening at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
});
|
||||
|
||||
wss.on('connection', ws => {
|
||||
clientsThatNeedToUpdate.add(ws);
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
clientsThatNeedToUpdate.delete(ws);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', event => {
|
||||
if (typeof event.data !== 'string') return;
|
||||
|
||||
const message = MessageInterpreter.receive(event.data);
|
||||
|
||||
if (message.type === DONE_UPDATE) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
if (message.type === BUILD_COMPLETE) {
|
||||
clientsThatNeedToUpdate.forEach((ws: WebSocket) =>
|
||||
ws.send(MessageInterpreter.send({ type: DO_UPDATE, id: message.id })),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('error', (error: Error & { code: string }) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.info(`[HMR] Server already running at ${LOCAL_RELOAD_SOCKET_URL}, skipping reload server initialization`);
|
||||
} else {
|
||||
console.error(`[HMR] Failed to start server at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
})();
|
||||
33
packages/hmr/lib/injections/refresh.ts
Normal file
33
packages/hmr/lib/injections/refresh.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import initClient from '../initializers/init-client.js';
|
||||
|
||||
(() => {
|
||||
let pendingReload = false;
|
||||
|
||||
initClient({
|
||||
// @ts-expect-error That's because of the dynamic code loading
|
||||
id: __HMR_ID,
|
||||
onUpdate: () => {
|
||||
// disable reload when tab is hidden
|
||||
if (document.hidden) {
|
||||
pendingReload = true;
|
||||
return;
|
||||
}
|
||||
reload();
|
||||
},
|
||||
});
|
||||
|
||||
// reload
|
||||
const reload = (): void => {
|
||||
pendingReload = false;
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// reload when tab is visible
|
||||
const reloadWhenTabIsVisible = (): void => {
|
||||
if (!document.hidden && pendingReload) {
|
||||
reload();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', reloadWhenTabIsVisible);
|
||||
})();
|
||||
13
packages/hmr/lib/injections/reload.ts
Normal file
13
packages/hmr/lib/injections/reload.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import initClient from '../initializers/init-client.js';
|
||||
|
||||
(() => {
|
||||
const reload = () => {
|
||||
chrome.runtime.reload();
|
||||
};
|
||||
|
||||
initClient({
|
||||
// @ts-expect-error That's because of the dynamic code loading
|
||||
id: __HMR_ID,
|
||||
onUpdate: reload,
|
||||
});
|
||||
})();
|
||||
6
packages/hmr/lib/interpreter/index.ts
Normal file
6
packages/hmr/lib/interpreter/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { WebSocketMessageType } from '../types.js';
|
||||
|
||||
export default {
|
||||
send: (message: WebSocketMessageType): string => JSON.stringify(message),
|
||||
receive: (serializedMessage: string): WebSocketMessageType => JSON.parse(serializedMessage),
|
||||
};
|
||||
3
packages/hmr/lib/plugins/index.ts
Normal file
3
packages/hmr/lib/plugins/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './watch-rebuild-plugin.js';
|
||||
export * from './watch-public-plugin.js';
|
||||
export * from './make-entry-point-plugin.js';
|
||||
73
packages/hmr/lib/plugins/make-entry-point-plugin.ts
Normal file
73
packages/hmr/lib/plugins/make-entry-point-plugin.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { IS_FIREFOX } from '@extension/env';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { basename, resolve, sep } from 'node:path';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
/**
|
||||
* Extract content directory from output directory for Firefox
|
||||
* @param outputDir
|
||||
*/
|
||||
const extractContentDir = (outputDir: string) => {
|
||||
const parts = outputDir.split(sep);
|
||||
const distIndex = parts.indexOf('dist');
|
||||
|
||||
if (distIndex !== -1 && distIndex < parts.length - 1) {
|
||||
return parts.slice(distIndex + 1);
|
||||
}
|
||||
|
||||
throw new Error('Output directory does not contain "dist"');
|
||||
};
|
||||
|
||||
const safeWriteFileSync = (path: string, data: string) => {
|
||||
const folder = path.split(sep).slice(0, -1).join(sep);
|
||||
|
||||
if (!existsSync(folder)) {
|
||||
mkdirSync(folder, { recursive: true });
|
||||
}
|
||||
writeFileSync(path, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Make an entry point file for content script cache busting
|
||||
*/
|
||||
export const makeEntryPointPlugin = (): PluginOption => ({
|
||||
name: 'make-entry-point-plugin',
|
||||
generateBundle(options, bundle) {
|
||||
const outputDir = options.dir;
|
||||
|
||||
if (!outputDir) {
|
||||
throw new Error('Output directory not found');
|
||||
}
|
||||
|
||||
for (const module of Object.values(bundle)) {
|
||||
const fileName = module.fileName;
|
||||
const newFileName = fileName.replace('.js', '_dev.js');
|
||||
|
||||
switch (module.type) {
|
||||
case 'asset':
|
||||
if (fileName.endsWith('.map')) {
|
||||
const originalFileName = fileName.replace('.map', '');
|
||||
const replacedSource = String(module.source).replaceAll(originalFileName, newFileName);
|
||||
|
||||
module.source = '';
|
||||
safeWriteFileSync(resolve(outputDir, newFileName), replacedSource);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chunk': {
|
||||
safeWriteFileSync(resolve(outputDir, newFileName), module.code);
|
||||
const newFileNameBase = basename(newFileName);
|
||||
|
||||
if (IS_FIREFOX) {
|
||||
const contentDirectory = extractContentDir(outputDir);
|
||||
module.code = `import(browser.runtime.getURL("${contentDirectory}/${newFileNameBase}"));`;
|
||||
} else {
|
||||
module.code = `import('./${newFileNameBase}');`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
13
packages/hmr/lib/plugins/watch-public-plugin.ts
Normal file
13
packages/hmr/lib/plugins/watch-public-plugin.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import fg from 'fast-glob';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
export const watchPublicPlugin = (): PluginOption => ({
|
||||
name: 'watch-public-plugin',
|
||||
async buildStart() {
|
||||
const files = await fg(['public/**/*']);
|
||||
|
||||
for (const file of files) {
|
||||
this.addWatchFile(file);
|
||||
}
|
||||
},
|
||||
});
|
||||
68
packages/hmr/lib/plugins/watch-rebuild-plugin.ts
Normal file
68
packages/hmr/lib/plugins/watch-rebuild-plugin.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { BUILD_COMPLETE, LOCAL_RELOAD_SOCKET_URL } from '../consts.js';
|
||||
import MessageInterpreter from '../interpreter/index.js';
|
||||
import { WebSocket } from 'ws';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { PluginConfigType } from '../types.js';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
const injectionsPath = resolve(import.meta.dirname, '..', 'injections');
|
||||
|
||||
const refreshCode = readFileSync(resolve(injectionsPath, 'refresh.js'), 'utf-8');
|
||||
const reloadCode = readFileSync(resolve(injectionsPath, 'reload.js'), 'utf-8');
|
||||
|
||||
export const watchRebuildPlugin = (config: PluginConfigType): PluginOption => {
|
||||
const { refresh, reload, id: _id, onStart } = config;
|
||||
const hmrCode = (refresh ? refreshCode : '') + (reload ? reloadCode : '');
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
const id = _id ?? Math.random().toString(36);
|
||||
let reconnectTries = 0;
|
||||
|
||||
const initializeWebSocket = () => {
|
||||
ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`[HMR] Connected to dev-server at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
console.error(`[HMR] Failed to connect server at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
console.warn('Retrying in 3 seconds...');
|
||||
ws = null;
|
||||
|
||||
if (reconnectTries <= 2) {
|
||||
setTimeout(() => {
|
||||
reconnectTries++;
|
||||
initializeWebSocket();
|
||||
}, 3_000);
|
||||
} else {
|
||||
console.error(`[HMR] Cannot establish connection to server at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'watch-rebuild',
|
||||
closeBundle() {
|
||||
onStart?.();
|
||||
if (!ws) {
|
||||
initializeWebSocket();
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* When the build is complete, send a message to the reload server.
|
||||
* The reload server will send a message to the client to reload or refresh the extension.
|
||||
*/
|
||||
ws.send(MessageInterpreter.send({ type: BUILD_COMPLETE, id }));
|
||||
},
|
||||
generateBundle(_options, bundle) {
|
||||
for (const module of Object.values(bundle)) {
|
||||
if (module.type === 'chunk') {
|
||||
module.code = `(function() {let __HMR_ID = "${id}";\n` + hmrCode + '\n' + '})();' + '\n' + module.code;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
18
packages/hmr/lib/types.ts
Normal file
18
packages/hmr/lib/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { BUILD_COMPLETE, DO_UPDATE, DONE_UPDATE } from './consts.js';
|
||||
|
||||
type UpdateRequestMessageType = {
|
||||
type: typeof DO_UPDATE;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type UpdateCompleteMessageType = { type: typeof DONE_UPDATE };
|
||||
type BuildCompletionMessageType = { type: typeof BUILD_COMPLETE; id: string };
|
||||
|
||||
export type WebSocketMessageType = UpdateCompleteMessageType | UpdateRequestMessageType | BuildCompletionMessageType;
|
||||
|
||||
export type PluginConfigType = {
|
||||
onStart?: () => void;
|
||||
reload?: boolean;
|
||||
refresh?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
36
packages/hmr/package.json
Normal file
36
packages/hmr/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@extension/hmr",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - hot module reload/refresh",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": true,
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"types": "index.mts",
|
||||
"main": "dist/index.mjs",
|
||||
"scripts": {
|
||||
"clean:bundle": "rimraf dist && pnpx rimraf build",
|
||||
"clean:node_modules": "pnpx rimraf node_modules",
|
||||
"clean:turbo": "rimraf .turbo",
|
||||
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
|
||||
"ready": "tsc -b && rollup --config dist/rollup.config.js",
|
||||
"dev": "tsx lib/initializers/init-reload-server.ts",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"format": "prettier . --write --ignore-path ../../.prettierignore",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@extension/env": "workspace:*",
|
||||
"@extension/tsconfig": "workspace:*",
|
||||
"@rollup/plugin-sucrase": "^5.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.25.4",
|
||||
"esm": "^3.2.25",
|
||||
"rollup": "^4.41.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"ws": "^8.18.2"
|
||||
}
|
||||
}
|
||||
29
packages/hmr/rollup.config.ts
Normal file
29
packages/hmr/rollup.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import sucrase from '@rollup/plugin-sucrase';
|
||||
import type { Plugin, RollupOptions } from 'rollup';
|
||||
|
||||
const plugins = [
|
||||
// @ts-expect-error Because of the lack of calling signature
|
||||
sucrase({
|
||||
exclude: ['node_modules/**'],
|
||||
transforms: ['typescript'],
|
||||
}),
|
||||
] satisfies Plugin[];
|
||||
|
||||
export default [
|
||||
{
|
||||
plugins,
|
||||
input: 'lib/injections/reload.ts',
|
||||
output: {
|
||||
format: 'esm',
|
||||
file: 'dist/lib/injections/reload.js',
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins,
|
||||
input: 'lib/injections/refresh.ts',
|
||||
output: {
|
||||
format: 'esm',
|
||||
file: 'dist/lib/injections/refresh.js',
|
||||
},
|
||||
},
|
||||
] satisfies RollupOptions[];
|
||||
9
packages/hmr/tsconfig.json
Normal file
9
packages/hmr/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@extension/tsconfig/module",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"types": ["chrome", "node"]
|
||||
},
|
||||
"include": ["index.mts", "rollup.config.ts", "lib"]
|
||||
}
|
||||
1
packages/i18n/.gitignore
vendored
Normal file
1
packages/i18n/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
lib/i18n.ts
|
||||
173
packages/i18n/README.md
Normal file
173
packages/i18n/README.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# I18n Package
|
||||
|
||||
This package provides a set of tools to help you internationalize your Chrome Extension.
|
||||
|
||||
https://developer.chrome.com/docs/extensions/reference/api/i18n
|
||||
|
||||
## Installation
|
||||
|
||||
If you want to use the i18n translation function in each pages, you need to add the following to the package.json file.
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@extension/i18n": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run the following command to install the package.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Manage translations
|
||||
|
||||
You can manage translations in the `locales` directory.
|
||||
|
||||
`locales/en/messages.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"helloWorld": {
|
||||
"message": "Hello, World!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`locales/ko/messages.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"helloWorld": {
|
||||
"message": "안녕하세요, 여러분!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Add a new language
|
||||
|
||||
Create folder inside `locales` with name from [languages](https://developer.chrome.com/docs/extensions/reference/api/i18n?hl=pl#support_multiple_languages), which need include `message.json` file.
|
||||
|
||||
## Usage
|
||||
|
||||
### Translation function
|
||||
|
||||
Just import the `t` function and use it to translate the key.
|
||||
|
||||
```typescript
|
||||
import { t } from '@extension/i18n';
|
||||
|
||||
console.log(t('loading')); // Loading...
|
||||
```
|
||||
|
||||
```typescript jsx
|
||||
import { t } from '@extension/i18n';
|
||||
|
||||
const Component = () => {
|
||||
return (
|
||||
<button>
|
||||
{t('toggleTheme')} // Toggle Theme
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Placeholders
|
||||
|
||||
If you want to use placeholders, you can use the following format.
|
||||
|
||||
> For more information, see the [Message Placeholders](https://developer.chrome.com/docs/extensions/how-to/ui/localization-message-formats#placeholders) section.
|
||||
|
||||
`locales/en/messages.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"greeting": {
|
||||
"description": "Greeting message",
|
||||
"message": "Hello, My name is $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hello": {
|
||||
"description": "Placeholder example",
|
||||
"message": "Hello $1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`locales/ko/messages.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"greeting": {
|
||||
"description": "인사 메시지",
|
||||
"message": "안녕하세요, 제 이름은 $NAME$입니다.",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "서종학"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hello": {
|
||||
"description": "Placeholder 예시",
|
||||
"message": "안녕 $1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you want to replace the placeholder, you can pass the value as the second argument.
|
||||
|
||||
Function `t` has exactly the same interface as the `chrome.i18n.getMessage` function.
|
||||
|
||||
```typescript
|
||||
import { t } from '@extension/i18n';
|
||||
|
||||
console.log(t('greeting', 'John Doe')); // Hello, My name is John Doe
|
||||
console.log(t('greeting', ['John Doe'])); // Hello, My name is John Doe
|
||||
|
||||
console.log(t('hello')); // Hello
|
||||
console.log(t('hello', 'World')); // Hello World
|
||||
console.log(t('hello', ['World'])); // Hello World
|
||||
```
|
||||
|
||||
### Locale setting on development
|
||||
|
||||
If you want to enforce displaying specific language, you need to set `CEB_DEV_LOCALE` in `.env` file (work only for development).
|
||||
|
||||
### Type Safety
|
||||
|
||||
When you forget to add a key to all language's `messages.json` files, you will get a Typescript error.
|
||||
|
||||
`locales/en/messages.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"hello": {
|
||||
"message": "Hello World!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`locales/ko/messages.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"helloWorld": {
|
||||
"message": "안녕하세요, 여러분!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { t } from '@extension/i18n';
|
||||
|
||||
// Error: TS2345: Argument of type "hello" is not assignable to parameter of type
|
||||
console.log(t('hello'));
|
||||
```
|
||||
1
packages/i18n/index.mts
Normal file
1
packages/i18n/index.mts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/index.js';
|
||||
62
packages/i18n/lib/consts.ts
Normal file
62
packages/i18n/lib/consts.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @url https://developer.chrome.com/docs/extensions/reference/api/i18n#support_multiple_languages
|
||||
*/
|
||||
export const SUPPORTED_LANGUAGES = {
|
||||
ar: 'Arabic',
|
||||
am: 'Amharic',
|
||||
bg: 'Bulgarian',
|
||||
bn: 'Bengali',
|
||||
ca: 'Catalan',
|
||||
cs: 'Czech',
|
||||
da: 'Danish',
|
||||
de: 'German',
|
||||
el: 'Greek',
|
||||
en: 'English',
|
||||
en_AU: 'English (Australia)',
|
||||
en_GB: 'English (Great Britain)',
|
||||
en_US: 'English (USA)',
|
||||
es: 'Spanish',
|
||||
es_419: 'Spanish (Latin America and Caribbean)',
|
||||
et: 'Estonian',
|
||||
fa: 'Persian',
|
||||
fi: 'Finnish',
|
||||
fil: 'Filipino',
|
||||
fr: 'French',
|
||||
gu: 'Gujarati',
|
||||
he: 'Hebrew',
|
||||
hi: 'Hindi',
|
||||
hr: 'Croatian',
|
||||
hu: 'Hungarian',
|
||||
id: 'Indonesian',
|
||||
it: 'Italian',
|
||||
ja: 'Japanese',
|
||||
kn: 'Kannada',
|
||||
ko: 'Korean',
|
||||
lt: 'Lithuanian',
|
||||
lv: 'Latvian',
|
||||
ml: 'Malayalam',
|
||||
mr: 'Marathi',
|
||||
ms: 'Malay',
|
||||
nl: 'Dutch',
|
||||
no: 'Norwegian',
|
||||
pl: 'Polish',
|
||||
pt_BR: 'Portuguese (Brazil)',
|
||||
pt_PT: 'Portuguese (Portugal)',
|
||||
ro: 'Romanian',
|
||||
ru: 'Russian',
|
||||
sk: 'Slovak',
|
||||
sl: 'Slovenian',
|
||||
sr: 'Serbian',
|
||||
sv: 'Swedish',
|
||||
sw: 'Swahili',
|
||||
ta: 'Tamil',
|
||||
te: 'Telugu',
|
||||
th: 'Thai',
|
||||
tr: 'Turkish',
|
||||
uk: 'Ukrainian',
|
||||
vi: 'Vietnamese',
|
||||
zh_CN: 'Chinese (China)',
|
||||
zh_TW: 'Chinese (Taiwan)',
|
||||
} as const;
|
||||
|
||||
export const I18N_FILE_PATH = './lib/i18n.ts';
|
||||
34
packages/i18n/lib/i18n-dev.ts
Normal file
34
packages/i18n/lib/i18n-dev.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// IT WILL BE ADJUSTED TO YOUR LANGUAGE DURING BUILD TIME, DON'T MOVE BELOW IMPORT TO OTHER LINE
|
||||
import localeJSON from '../locales/en/messages.json' with { type: 'json' };
|
||||
import type { I18nValueType, LocalesJSONType } from './types.js';
|
||||
|
||||
const translate = (key: keyof LocalesJSONType, substitutions?: string | string[]) => {
|
||||
const localeValues = localeJSON[key] as I18nValueType;
|
||||
let message = localeValues.message;
|
||||
/**
|
||||
* This is a placeholder replacement logic. But it's not perfect.
|
||||
* It just imitates the behavior of the Chrome extension i18n API.
|
||||
* Please check the official document for more information And double-check the behavior on production build.
|
||||
*
|
||||
* @url https://developer.chrome.com/docs/extensions/how-to/ui/localization-message-formats#placeholders
|
||||
*/
|
||||
if (localeValues.placeholders) {
|
||||
Object.entries(localeValues.placeholders).forEach(([key, { content }]) => {
|
||||
if (content) {
|
||||
message = message.replace(new RegExp(`\\$${key}\\$`, 'gi'), content);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!substitutions) {
|
||||
return message;
|
||||
} else if (Array.isArray(substitutions)) {
|
||||
return substitutions.reduce((acc, cur, idx) => acc.replace(`$${idx++}`, cur), message);
|
||||
}
|
||||
|
||||
return message.replace(/\$(\d+)/, substitutions);
|
||||
};
|
||||
|
||||
const removePlaceholder = (message: string) => message.replace(/\$\d+/g, '');
|
||||
|
||||
export const t = (...args: Parameters<typeof translate>) => removePlaceholder(translate(...args));
|
||||
3
packages/i18n/lib/i18n-prod.ts
Normal file
3
packages/i18n/lib/i18n-prod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { MessageKeyType } from './types.js';
|
||||
|
||||
export const t = (key: MessageKeyType, substitutions?: string | string[]) => chrome.i18n.getMessage(key, substitutions);
|
||||
4
packages/i18n/lib/index.ts
Normal file
4
packages/i18n/lib/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { t as t_dev_or_prod } from './i18n.js';
|
||||
import type { t as t_dev } from './i18n-dev.js';
|
||||
|
||||
export const t = t_dev_or_prod as unknown as typeof t_dev;
|
||||
22
packages/i18n/lib/prepare-build.ts
Normal file
22
packages/i18n/lib/prepare-build.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import setRelatedLocaleImports from './set-related-locale-import.js';
|
||||
import { IS_DEV } from '@extension/env';
|
||||
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
(() => {
|
||||
const i18nPath = IS_DEV ? 'lib/i18n-dev.ts' : 'lib/i18n-prod.ts';
|
||||
cpSync(i18nPath, resolve('lib', 'i18n.ts'));
|
||||
|
||||
const outDir = resolve(import.meta.dirname, '..', '..', '..', '..', 'dist');
|
||||
if (!existsSync(outDir)) {
|
||||
mkdirSync(outDir);
|
||||
}
|
||||
|
||||
const localePath = resolve(outDir, '_locales');
|
||||
cpSync(resolve('locales'), localePath, { recursive: true });
|
||||
|
||||
if (IS_DEV) {
|
||||
setRelatedLocaleImports();
|
||||
}
|
||||
console.log('I18n build complete');
|
||||
})();
|
||||
38
packages/i18n/lib/set-related-locale-import.ts
Normal file
38
packages/i18n/lib/set-related-locale-import.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { I18N_FILE_PATH } from './consts.js';
|
||||
import { lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { SupportedLanguagesKeysType, SupportedLanguagesWithoutRegionKeysType } from './types.js';
|
||||
|
||||
export default () => {
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale.replace('-', '_') as SupportedLanguagesKeysType;
|
||||
const localeWithoutRegion = locale.split('_')[0] as SupportedLanguagesWithoutRegionKeysType;
|
||||
|
||||
const localesDir = resolve(import.meta.dirname, '..', '..', 'locales');
|
||||
const readLocalesFolder = readdirSync(localesDir);
|
||||
|
||||
const implementedLocales = readLocalesFolder.map(innerDir => {
|
||||
if (lstatSync(resolve(localesDir, innerDir)).isDirectory()) {
|
||||
return innerDir;
|
||||
}
|
||||
return;
|
||||
});
|
||||
|
||||
const i18nFileSplitContent = readFileSync(I18N_FILE_PATH, 'utf-8').split('\n');
|
||||
|
||||
if (process.env['CEB_DEV_LOCALE']) {
|
||||
i18nFileSplitContent[1] = `import localeJSON from '../locales/${process.env['CEB_DEV_LOCALE']}/messages.json' with { type: 'json' };`;
|
||||
} else {
|
||||
if (implementedLocales.includes(locale)) {
|
||||
i18nFileSplitContent[1] = `import localeJSON from '../locales/${locale}/messages.json' with { type: 'json' };`;
|
||||
} else if (implementedLocales.includes(localeWithoutRegion)) {
|
||||
i18nFileSplitContent[1] = `import localeJSON from '../locales/${localeWithoutRegion}/messages.json' with { type: 'json' };`;
|
||||
} else {
|
||||
i18nFileSplitContent[1] = `import localeJSON from '../locales/en/messages.json' with { type: 'json' };`;
|
||||
}
|
||||
}
|
||||
|
||||
// Join lines back together
|
||||
const updatedI18nFile = i18nFileSplitContent.join('\n');
|
||||
|
||||
writeFileSync(I18N_FILE_PATH, updatedI18nFile, 'utf-8');
|
||||
};
|
||||
12
packages/i18n/lib/types.ts
Normal file
12
packages/i18n/lib/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import enMessage from '../locales/en/messages.json' with { type: 'json' };
|
||||
import type { SUPPORTED_LANGUAGES } from './consts.js';
|
||||
|
||||
export type SupportedLanguagesKeysType = keyof typeof SUPPORTED_LANGUAGES;
|
||||
export type SupportedLanguagesWithoutRegionKeysType = Exclude<SupportedLanguagesKeysType, `${string}_${string}`>;
|
||||
export type I18nValueType = {
|
||||
message: string;
|
||||
placeholders?: Record<string, { content?: string; example?: string }>;
|
||||
};
|
||||
|
||||
export type MessageKeyType = keyof typeof enMessage;
|
||||
export type LocalesJSONType = typeof enMessage;
|
||||
45
packages/i18n/locales/en/messages.json
Normal file
45
packages/i18n/locales/en/messages.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extensionDescription": {
|
||||
"description": "Extension description",
|
||||
"message": "Chrome extension boilerplate developed with Vite, React and Typescript"
|
||||
},
|
||||
"extensionName": {
|
||||
"description": "Extension name",
|
||||
"message": "Chrome extension boilerplate"
|
||||
},
|
||||
"toggleTheme": {
|
||||
"message": "Toggle theme"
|
||||
},
|
||||
"injectButton": {
|
||||
"message": "Click to inject Content Scripts"
|
||||
},
|
||||
"greeting": {
|
||||
"description": "Greeting message",
|
||||
"message": "Hello, My name is $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hello": {
|
||||
"description": "Placeholder example",
|
||||
"message": "Hello $1"
|
||||
},
|
||||
"displayErrorInfo": {
|
||||
"message": "Error occur"
|
||||
},
|
||||
"displayErrorDescription": {
|
||||
"message": "Sorry, something went wrong while loading the page."
|
||||
},
|
||||
"displayErrorDetailsInfo": {
|
||||
"message": "Error details:"
|
||||
},
|
||||
"displayErrorUnknownErrorInfo": {
|
||||
"message": "Unknown error"
|
||||
},
|
||||
"displayErrorReset": {
|
||||
"message": "Run Again"
|
||||
}
|
||||
}
|
||||
45
packages/i18n/locales/ko/messages.json
Normal file
45
packages/i18n/locales/ko/messages.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extensionDescription": {
|
||||
"description": "Extension description",
|
||||
"message": "React, Typescript, Vite를 사용한 크롬 익스텐션 보일러플레이트입니다."
|
||||
},
|
||||
"extensionName": {
|
||||
"description": "Extension name",
|
||||
"message": "크롬 익스텐션 보일러플레이트"
|
||||
},
|
||||
"toggleTheme": {
|
||||
"message": "테마 변경"
|
||||
},
|
||||
"injectButton": {
|
||||
"message": "콘텐츠 스크립트를 삽입하려면 클릭하세요."
|
||||
},
|
||||
"greeting": {
|
||||
"description": "인사 메시지",
|
||||
"message": "안녕하세요, 제 이름은 $NAME$입니다.",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "홍길동"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hello": {
|
||||
"description": "Placeholder 예시",
|
||||
"message": "안녕하세요, $1님."
|
||||
},
|
||||
"displayErrorInfo": {
|
||||
"message": "오류 발생"
|
||||
},
|
||||
"displayErrorDescription": {
|
||||
"message": "죄송합니다. 페이지를 로드하는 중 문제가 발생했습니다."
|
||||
},
|
||||
"displayErrorDetailsInfo": {
|
||||
"message": "오류 세부 정보:"
|
||||
},
|
||||
"displayErrorUnknownErrorInfo": {
|
||||
"message": "알 수 없는 오류"
|
||||
},
|
||||
"displayErrorReset": {
|
||||
"message": "다시 실행합니다."
|
||||
}
|
||||
}
|
||||
30
packages/i18n/package.json
Normal file
30
packages/i18n/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@extension/i18n",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - internationalization",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"main": "dist/index.mjs",
|
||||
"types": "index.mts",
|
||||
"scripts": {
|
||||
"clean:bundle": "rimraf dist",
|
||||
"clean:node_modules": "pnpx rimraf node_modules",
|
||||
"clean:turbo": "rimraf .turbo",
|
||||
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
|
||||
"ready": "tsc -b prepare-build.tsconfig.json && node --env-file=../../.env dist/lib/prepare-build.js && tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"format": "prettier . --write --ignore-path ../../.prettierignore",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@extension/env": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@extension/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
||||
4
packages/i18n/prepare-build.tsconfig.json
Normal file
4
packages/i18n/prepare-build.tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["lib/prepare-build.ts"]
|
||||
}
|
||||
8
packages/i18n/tsconfig.json
Normal file
8
packages/i18n/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@extension/tsconfig/module",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["index.mts", "lib"]
|
||||
}
|
||||
64
packages/module-manager/README.md
Normal file
64
packages/module-manager/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Module manager
|
||||
|
||||
Module manager is a tool to manage modules in a project. It can be used to delete or recover some pages.
|
||||
|
||||
## Usage
|
||||
|
||||
### On Root
|
||||
|
||||
```bash
|
||||
$ pnpm module-manager
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```bash
|
||||
pnpm module-manager -d popup
|
||||
```
|
||||
|
||||
### On Module
|
||||
|
||||
```bash
|
||||
$ pnpm start
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```bash
|
||||
pnpm start -d popup
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For complete info about CLI support run:
|
||||
> `pnpm module-manager --help`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you want to remove all tests, and something else with one command, you need to set `tests` as first argument like:
|
||||
> `pnpm module-manager -d tests popup ...`
|
||||
|
||||
### Choose a tool
|
||||
|
||||
```
|
||||
? Choose a tool (Use arrow keys)
|
||||
❯ Delete Feature
|
||||
Recover Feature
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
### Delete Feature
|
||||
|
||||
When you select an unused module, Module Manager compresses the contents of that folder, takes a snapshot of it, and removes it.
|
||||
|
||||
It also automatically removes anything that needs to be cleared from the manifest.
|
||||
|
||||
### Recover Feature
|
||||
|
||||
When you select a module that has been deleted, The Module Manager will recover that module from the snapshot and add it back to the manifest.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> SAVE ALL FILES FROM /ARCHIVE, WITHOUT THESE FILES' RECOVERING PROCESS WON'T WORK
|
||||
|
||||
> [!IMPORTANT]
|
||||
> IF YOU DECIDE TO REMOVE ONE OF CONTENT SCRIPTs AFTER EDIT, E.G YOU REMOVE OR ADD ANY MATCHES,
|
||||
> REMEMBER TO EDIT ALSO [MODULE_CONFIG](./lib/const.ts) TO BE ABLE TO RECOVER IT EASILY IN THE FUTURE.
|
||||
3
packages/module-manager/index.mts
Normal file
3
packages/module-manager/index.mts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { startManager } from './lib/index.js';
|
||||
|
||||
startManager();
|
||||
51
packages/module-manager/lib/base/cli-args-processor.ts
Normal file
51
packages/module-manager/lib/base/cli-args-processor.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CLI_OPTIONS, DEFAULT_CHOICES_VALUES, HELP_EXAMPLES } from '../const.js';
|
||||
import { checkCliArgsIsValid } from '../helpers/utils.js';
|
||||
import { excludeValuesFromBaseArray } from '@extension/shared';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import type { ICLIOptions, ModuleNameType } from '../types.js';
|
||||
import type { WritableDeep } from '@extension/shared';
|
||||
|
||||
export const processCLIArgs = (): ICLIOptions | null => {
|
||||
const argv = yargs(hideBin(process.argv))
|
||||
.option('delete', CLI_OPTIONS[0])
|
||||
.option('recover', CLI_OPTIONS[1])
|
||||
.option('delete-exclude', CLI_OPTIONS[2])
|
||||
.option('recover-exclude', CLI_OPTIONS[3])
|
||||
.check(argv => checkCliArgsIsValid(argv))
|
||||
.strict()
|
||||
.example(HELP_EXAMPLES as WritableDeep<typeof HELP_EXAMPLES>)
|
||||
.help()
|
||||
.showHelpOnFail(true)
|
||||
.parseSync();
|
||||
|
||||
if (argv.delete) {
|
||||
return {
|
||||
action: 'delete',
|
||||
targets: argv.delete as ModuleNameType[],
|
||||
};
|
||||
}
|
||||
|
||||
if (argv.recover) {
|
||||
return {
|
||||
action: 'recover',
|
||||
targets: argv.recover as ModuleNameType[],
|
||||
};
|
||||
}
|
||||
|
||||
if (argv.deleteExclude) {
|
||||
return {
|
||||
action: 'delete',
|
||||
targets: excludeValuesFromBaseArray(DEFAULT_CHOICES_VALUES, argv.deleteExclude),
|
||||
};
|
||||
}
|
||||
|
||||
if (argv.recoverExclude) {
|
||||
return {
|
||||
action: 'recover',
|
||||
targets: excludeValuesFromBaseArray(DEFAULT_CHOICES_VALUES, argv.recoverExclude),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
2
packages/module-manager/lib/base/index.ts
Normal file
2
packages/module-manager/lib/base/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './cli-args-processor.js';
|
||||
export * from './run-module-manager.js';
|
||||
49
packages/module-manager/lib/base/run-module-manager.ts
Normal file
49
packages/module-manager/lib/base/run-module-manager.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import manifest from '../../../../chrome-extension/manifest.js';
|
||||
import { MANAGER_ACTION_PROMPT_CONFIG } from '../const.js';
|
||||
import { promptSelection } from '../helpers/utils.js';
|
||||
import { deleteFeature, recoverFeature } from '../processing/index.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { CliActionType, ModuleNameType } from '../types.js';
|
||||
import type { ManifestType } from '@extension/shared';
|
||||
|
||||
const manifestPath = resolve(import.meta.dirname, '..', '..', '..', '..', 'chrome-extension', 'manifest.ts');
|
||||
|
||||
const manifestObject = JSON.parse(JSON.stringify(manifest)) as ManifestType;
|
||||
const manifestString = readFileSync(manifestPath, 'utf-8');
|
||||
|
||||
export const runModuleManager = async (moduleName?: ModuleNameType, action?: CliActionType, isLastLap = true) => {
|
||||
if (!action) {
|
||||
action = (await promptSelection(MANAGER_ACTION_PROMPT_CONFIG)) as Awaited<CliActionType>;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
await deleteFeature(manifestObject, moduleName);
|
||||
break;
|
||||
case 'recover':
|
||||
await recoverFeature(manifestObject, moduleName);
|
||||
}
|
||||
|
||||
const updatedManifest = manifestString
|
||||
.replace(
|
||||
/const manifest = {[\s\S]*?} satisfies/,
|
||||
'const manifest = ' + JSON.stringify(manifestObject, null, 2) + ' satisfies',
|
||||
)
|
||||
.replace(/ {2}"version": "[\s\S]*?",/, ' version: packageJson.version,');
|
||||
|
||||
writeFileSync(manifestPath, updatedManifest);
|
||||
|
||||
if (isLastLap) {
|
||||
execSync('pnpm i', {
|
||||
stdio: 'inherit',
|
||||
cwd: resolve('..', '..'),
|
||||
});
|
||||
|
||||
execSync('pnpm -F chrome-extension lint:fix', {
|
||||
stdio: 'inherit',
|
||||
cwd: resolve('..', '..'),
|
||||
});
|
||||
}
|
||||
};
|
||||
98
packages/module-manager/lib/const.ts
Normal file
98
packages/module-manager/lib/const.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
const CHOICE_QUESTION = 'Choose feature to';
|
||||
export const RECOVER_CHOICE_QUESTION = `${CHOICE_QUESTION} recover`;
|
||||
export const DELETE_CHOICE_QUESTION = `${CHOICE_QUESTION} delete`;
|
||||
|
||||
export const DEFAULT_CHOICES = [
|
||||
{ name: 'Content Script (Execute JS on Web Page)', value: 'content' },
|
||||
{ name: 'Content Script UI (Render Custom React Component on Web Page)', value: 'content-ui' },
|
||||
{ name: 'Content Script Runtime (Inject JS on Specific Actions like Popup Click)', value: 'content-runtime' },
|
||||
{ name: 'Background Script', value: 'background' },
|
||||
{ name: 'New Tab Override', value: 'new-tab' },
|
||||
{ name: 'Popup (On Extension Icon Click)', value: 'popup' },
|
||||
{ name: 'DevTools (Include DevTools Panel)', value: 'devtools' },
|
||||
{ name: 'Side Panel', value: 'side-panel' },
|
||||
{ name: 'Options Page', value: 'options' },
|
||||
{ name: 'All tests', value: 'tests' },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_CHOICES_VALUES = DEFAULT_CHOICES.map(item => item.value);
|
||||
|
||||
export const HELP_EXAMPLES = [
|
||||
['-d content-ui content-runtime', 'Delete content-ui and content-runtime'],
|
||||
['--de content devtools', 'Delete everything exclude content and devtools'],
|
||||
['-r options side-panel', 'Recover options and side-panel'],
|
||||
['--re popup new-tab', 'Recover everything exclude popup and new-tab'],
|
||||
] as const;
|
||||
|
||||
export const CLI_OPTIONS = [
|
||||
{ alias: 'd', type: 'array', description: 'Delete specified features' },
|
||||
{ alias: 'r', type: 'array', description: 'Recover specified features' },
|
||||
{ alias: 'de', type: 'array', description: 'Delete all features except specified' },
|
||||
{ alias: 're', type: 'array', description: 'Recover all features except specified' },
|
||||
] as const;
|
||||
|
||||
export const MANAGER_ACTION_PROMPT_CONFIG = {
|
||||
message: 'Choose a tool',
|
||||
choices: [
|
||||
{ name: 'Delete Feature', value: 'delete' },
|
||||
{ name: 'Recover Feature', value: 'recover' },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const MODULE_CONFIG = {
|
||||
content: {
|
||||
content_scripts: [
|
||||
{
|
||||
matches: ['http://*/*', 'https://*/*', '<all_urls>'],
|
||||
js: ['content/all.iife.js'],
|
||||
},
|
||||
{
|
||||
matches: ['https://example.com/*'],
|
||||
js: ['content/example.iife.js'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'content-ui': {
|
||||
content_scripts: [
|
||||
{
|
||||
matches: ['http://*/*', 'https://*/*', '<all_urls>'],
|
||||
js: ['content-ui/all.iife.js'],
|
||||
},
|
||||
{
|
||||
matches: ['https://example.com/*'],
|
||||
js: ['content-ui/example.iife.js'],
|
||||
},
|
||||
],
|
||||
},
|
||||
background: {
|
||||
background: {
|
||||
service_worker: 'background.js',
|
||||
type: 'module',
|
||||
},
|
||||
},
|
||||
'new-tab': {
|
||||
chrome_url_overrides: {
|
||||
newtab: 'new-tab/index.html',
|
||||
},
|
||||
},
|
||||
popup: {
|
||||
action: {
|
||||
default_popup: 'popup/index.html',
|
||||
default_icon: 'icon-34.png',
|
||||
},
|
||||
},
|
||||
devtools: {
|
||||
devtools_page: 'devtools/index.html',
|
||||
},
|
||||
'side-panel': {
|
||||
side_panel: {
|
||||
default_path: 'side-panel/index.html',
|
||||
},
|
||||
permissions: ['sidePanel'],
|
||||
},
|
||||
options: {
|
||||
options_page: 'options/index.html',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const EXIT_PROMPT_ERROR = 'ExitPromptError';
|
||||
2
packages/module-manager/lib/helpers/index.ts
Normal file
2
packages/module-manager/lib/helpers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './utils.js';
|
||||
export * from './zip-utils.js';
|
||||
113
packages/module-manager/lib/helpers/utils.ts
Normal file
113
packages/module-manager/lib/helpers/utils.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { DEFAULT_CHOICES_VALUES, EXIT_PROMPT_ERROR, MODULE_CONFIG } from '../const.js';
|
||||
import { colorfulLog } from '@extension/shared';
|
||||
import { select } from '@inquirer/prompts';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import type { DELETE_CHOICE_QUESTION, RECOVER_CHOICE_QUESTION } from '../const.js';
|
||||
import type {
|
||||
ChoicesType,
|
||||
CliEntriesType,
|
||||
InputConfigType,
|
||||
ModuleNameType,
|
||||
WritableModuleConfigValuesType,
|
||||
} from '../types.js';
|
||||
import type { ConditionalPickDeep, Entries, ManifestType } from '@extension/shared';
|
||||
import type { Arguments } from 'yargs';
|
||||
|
||||
export const isFolderEmpty = (path: string) => !readdirSync(path).length;
|
||||
|
||||
export const promptSelection = async (inputConfig: InputConfigType) =>
|
||||
select(inputConfig).catch(err => {
|
||||
if (err.name === EXIT_PROMPT_ERROR) {
|
||||
process.exit(0);
|
||||
} else {
|
||||
colorfulLog(err.message, 'error');
|
||||
}
|
||||
}) as Promise<string>;
|
||||
|
||||
export const processModuleConfig = (
|
||||
manifestObject: ManifestType,
|
||||
moduleName: ModuleNameType,
|
||||
isRecovering?: boolean,
|
||||
) => {
|
||||
if (moduleName === 'content-runtime' || moduleName === 'devtools-panel' || moduleName === 'tests') {
|
||||
return;
|
||||
}
|
||||
|
||||
const moduleConfigValues = MODULE_CONFIG[moduleName];
|
||||
const moduleConfigEntriesOfKeys = Object.entries(moduleConfigValues) as Entries<typeof moduleConfigValues>;
|
||||
|
||||
if (moduleName === 'content' || moduleName === 'content-ui') {
|
||||
if (isRecovering) {
|
||||
(moduleConfigValues as WritableModuleConfigValuesType<typeof moduleName>).content_scripts.map(script =>
|
||||
manifestObject.content_scripts?.push(script),
|
||||
);
|
||||
} else {
|
||||
const outputFileName = new RegExp(`${moduleName}/+`);
|
||||
|
||||
manifestObject.content_scripts = manifestObject.content_scripts?.filter(
|
||||
script => !outputFileName.test(script.js ? script.js[0] : ''),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
moduleConfigEntriesOfKeys.forEach(([key, value]) => {
|
||||
const manifestValue = manifestObject[key];
|
||||
|
||||
if (manifestValue) {
|
||||
if (manifestValue instanceof Array) {
|
||||
const arrayValues = Object.values(
|
||||
moduleConfigValues[key as ConditionalPickDeep<keyof typeof moduleConfigValues, typeof moduleName>],
|
||||
);
|
||||
|
||||
if (isRecovering) {
|
||||
manifestObject[key] = manifestValue.concat(arrayValues);
|
||||
} else {
|
||||
manifestObject[key] = manifestValue.filter((value: string) => !arrayValues.includes(value));
|
||||
}
|
||||
} else {
|
||||
delete manifestObject[key];
|
||||
}
|
||||
} else if (isRecovering) {
|
||||
Object.assign(manifestObject, { [key]: value });
|
||||
} else {
|
||||
throw new Error(`Key ${key} not found in manifest.ts`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const checkCliArgsIsValid = <T extends Arguments>(argv: T) => {
|
||||
const [key, values] = Object.entries(argv)[1] as CliEntriesType;
|
||||
|
||||
if (Array.isArray(values)) {
|
||||
for (const value of values) {
|
||||
if (!DEFAULT_CHOICES_VALUES.some(moduleName => value === moduleName)) {
|
||||
throw new Error(`All values after --${key} must be names of pages`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const processSelection = async (
|
||||
choices: ChoicesType,
|
||||
question: typeof RECOVER_CHOICE_QUESTION | typeof DELETE_CHOICE_QUESTION,
|
||||
moduleName?: ModuleNameType,
|
||||
) => {
|
||||
if (!choices.length) {
|
||||
colorfulLog('No options available', 'warning');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!moduleName) {
|
||||
const inputConfig = {
|
||||
message: question,
|
||||
choices,
|
||||
} as const;
|
||||
|
||||
return (await promptSelection(inputConfig)) as Awaited<ModuleNameType>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
97
packages/module-manager/lib/helpers/zip-utils.ts
Normal file
97
packages/module-manager/lib/helpers/zip-utils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { streamFileToZip } from '@extension/dev-utils';
|
||||
import fg from 'fast-glob';
|
||||
import { unzipSync, Zip } from 'fflate';
|
||||
import { rimraf } from 'rimraf';
|
||||
import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import type { ModuleNameType } from '../types.js';
|
||||
|
||||
const zipAndDeleteModuleTest = async (moduleName: ModuleNameType, archivePath: string, testsPath: string) => {
|
||||
const moduleTestName = `page-${moduleName}.test.ts`;
|
||||
const moduleTestsPath = resolve(testsPath, moduleTestName);
|
||||
|
||||
await zipFolder(testsPath, resolve(archivePath, `${moduleName}.test.zip`), [moduleTestName]);
|
||||
void rimraf([moduleTestsPath]);
|
||||
};
|
||||
|
||||
export const zipAndDeleteModule = async (
|
||||
moduleName: ModuleNameType,
|
||||
pagesPath: string,
|
||||
archivePath: string,
|
||||
testsPath?: string,
|
||||
) => {
|
||||
const modulePath = resolve(pagesPath, moduleName);
|
||||
|
||||
await zipFolder(resolve(pagesPath, moduleName), resolve(archivePath, `${moduleName}.zip`));
|
||||
void rimraf([modulePath]);
|
||||
|
||||
if (testsPath && existsSync(testsPath)) {
|
||||
await zipAndDeleteModuleTest(moduleName, archivePath, testsPath);
|
||||
}
|
||||
};
|
||||
|
||||
export const zipAndDeleteTests = async (testsPath: string, archivePath: string) => {
|
||||
await zipFolder(testsPath, resolve(archivePath, `tests.zip`));
|
||||
void rimraf(testsPath);
|
||||
};
|
||||
|
||||
export const unZipAndDeleteModule = (zipFilePath: string, destPath: string) => {
|
||||
const unzipped = unzipSync(readFileSync(zipFilePath));
|
||||
|
||||
mkdirSync(destPath, { recursive: true });
|
||||
|
||||
for (const [filePath, fileData] of Object.entries(unzipped)) {
|
||||
const dir = dirname(filePath);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, fileData);
|
||||
}
|
||||
|
||||
unlinkSync(zipFilePath);
|
||||
};
|
||||
|
||||
export const zipFolder = async (folderPath: string, out: string, filesToInclude?: string[]) => {
|
||||
const fileList = await fg(
|
||||
['!node_modules', '!dist', '!**/*/node_modules', '!**/*/dist'].concat(
|
||||
filesToInclude?.length ? filesToInclude : ['**/*'],
|
||||
),
|
||||
{
|
||||
cwd: folderPath,
|
||||
onlyFiles: true,
|
||||
},
|
||||
);
|
||||
const output = createWriteStream(out);
|
||||
|
||||
return new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
const zip = new Zip((err, data, final) => {
|
||||
if (err) {
|
||||
rejectPromise(err);
|
||||
} else {
|
||||
output.write(data);
|
||||
if (final) {
|
||||
output.end();
|
||||
resolvePromise();
|
||||
console.log(`Archive created at: ${out} for recovery`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const file of fileList) {
|
||||
const absPath = resolve(folderPath, file);
|
||||
|
||||
console.log(`Archiving file: ${absPath}`);
|
||||
streamFileToZip(
|
||||
absPath,
|
||||
absPath,
|
||||
zip,
|
||||
() => {
|
||||
output.end();
|
||||
rejectPromise(new Error('Aborted'));
|
||||
},
|
||||
rejectPromise,
|
||||
);
|
||||
}
|
||||
|
||||
zip.end();
|
||||
});
|
||||
};
|
||||
21
packages/module-manager/lib/index.ts
Normal file
21
packages/module-manager/lib/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { processCLIArgs, runModuleManager } from './base/index.js';
|
||||
import { colorfulLog, sleep } from '@extension/shared';
|
||||
|
||||
const cliOptions = processCLIArgs();
|
||||
|
||||
export const startManager = () => {
|
||||
if (cliOptions) {
|
||||
const targets = cliOptions.targets;
|
||||
|
||||
(async () => {
|
||||
for (const moduleName of targets) {
|
||||
colorfulLog(`Processing module: ${moduleName}`, 'info');
|
||||
const isLastIndex = targets.at(-1) === moduleName;
|
||||
await runModuleManager(moduleName, cliOptions.action, isLastIndex);
|
||||
await sleep(500);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
void runModuleManager();
|
||||
}
|
||||
};
|
||||
6
packages/module-manager/lib/paths.ts
Normal file
6
packages/module-manager/lib/paths.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export const pagesPath = resolve(import.meta.dirname, '..', '..', '..', 'pages');
|
||||
export const testsPath = resolve(pagesPath, '..', 'tests');
|
||||
export const specsPath = resolve(testsPath, 'e2e', 'specs');
|
||||
export const archivePath = resolve(import.meta.dirname, '..', 'archive');
|
||||
34
packages/module-manager/lib/processing/delete-feature.ts
Normal file
34
packages/module-manager/lib/processing/delete-feature.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { deleteModule } from './modules-handler.js';
|
||||
import { DEFAULT_CHOICES, DELETE_CHOICE_QUESTION } from '../const.js';
|
||||
import { processSelection } from '../helpers/utils.js';
|
||||
import { pagesPath, testsPath } from '../paths.js';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import type { ChoicesType, ModuleNameType } from '../types.ts';
|
||||
import type { ManifestType } from '@extension/shared';
|
||||
|
||||
export const deleteFeature = async (manifestObject: ManifestType, moduleName?: ModuleNameType) => {
|
||||
const pageFolders = readdirSync(pagesPath);
|
||||
|
||||
const choices: ChoicesType = DEFAULT_CHOICES.filter(choice => {
|
||||
if (choice.value === 'background') {
|
||||
return !!manifestObject.background;
|
||||
} else if (choice.value === 'tests') {
|
||||
return existsSync(testsPath);
|
||||
}
|
||||
|
||||
return pageFolders.includes(choice.value);
|
||||
});
|
||||
|
||||
const processResult = await processSelection(choices, DELETE_CHOICE_QUESTION, moduleName);
|
||||
|
||||
if (processResult) {
|
||||
moduleName = processResult;
|
||||
}
|
||||
|
||||
if (moduleName === 'devtools') {
|
||||
await deleteModule(manifestObject, moduleName as ModuleNameType);
|
||||
await deleteModule(manifestObject, 'devtools-panel');
|
||||
} else {
|
||||
await deleteModule(manifestObject, moduleName as ModuleNameType);
|
||||
}
|
||||
};
|
||||
3
packages/module-manager/lib/processing/index.ts
Normal file
3
packages/module-manager/lib/processing/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './delete-feature.js';
|
||||
export * from './recover-feature.js';
|
||||
export * from './modules-handler.js';
|
||||
48
packages/module-manager/lib/processing/modules-handler.ts
Normal file
48
packages/module-manager/lib/processing/modules-handler.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { unZipAndDeleteModule, zipAndDeleteModule, zipAndDeleteTests } from '../helpers/index.js';
|
||||
import { isFolderEmpty, processModuleConfig } from '../helpers/utils.js';
|
||||
import { archivePath, pagesPath, specsPath, testsPath } from '../paths.js';
|
||||
import { colorfulLog } from '@extension/shared';
|
||||
import { existsSync, mkdirSync, rmdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { ModuleNameType } from '../types.ts';
|
||||
import type { ManifestType } from '@extension/shared';
|
||||
|
||||
export const recoverModule = (manifestObject: ManifestType, moduleName: ModuleNameType) => {
|
||||
const zipFilePath = resolve(archivePath, `${moduleName}.zip`);
|
||||
const zipTestFilePath = resolve(archivePath, `${moduleName}.test.zip`);
|
||||
|
||||
if (!existsSync(zipFilePath)) {
|
||||
colorfulLog(`No archive found for ${moduleName}`, 'info');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
processModuleConfig(manifestObject, moduleName, true);
|
||||
|
||||
unZipAndDeleteModule(zipFilePath, pagesPath);
|
||||
|
||||
if (existsSync(zipTestFilePath)) {
|
||||
unZipAndDeleteModule(zipTestFilePath, specsPath);
|
||||
}
|
||||
|
||||
colorfulLog(`Recovered: ${moduleName}`, 'info');
|
||||
|
||||
if (isFolderEmpty(archivePath)) {
|
||||
rmdirSync(archivePath);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteModule = async (manifestObject: ManifestType, moduleName: ModuleNameType) => {
|
||||
processModuleConfig(manifestObject, moduleName);
|
||||
|
||||
if (!existsSync(archivePath)) {
|
||||
mkdirSync(archivePath, { recursive: true });
|
||||
}
|
||||
|
||||
if (moduleName === 'tests') {
|
||||
await zipAndDeleteTests(testsPath, archivePath);
|
||||
} else {
|
||||
await zipAndDeleteModule(moduleName, pagesPath, archivePath, specsPath);
|
||||
}
|
||||
|
||||
colorfulLog(`Deleted: ${moduleName}`, 'info');
|
||||
};
|
||||
41
packages/module-manager/lib/processing/recover-feature.ts
Normal file
41
packages/module-manager/lib/processing/recover-feature.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { recoverModule } from './modules-handler.js';
|
||||
import { DEFAULT_CHOICES, RECOVER_CHOICE_QUESTION } from '../const.js';
|
||||
import { processSelection } from '../helpers/utils.js';
|
||||
import { archivePath } from 'lib/paths.js';
|
||||
import { rimraf } from 'rimraf';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { ChoicesType, ModuleNameType } from '../types.ts';
|
||||
import type { ManifestType } from '@extension/shared';
|
||||
|
||||
export const recoverFeature = async (manifestObject: ManifestType, moduleName?: ModuleNameType) => {
|
||||
const archiveFiles = existsSync(archivePath) ? readdirSync(archivePath) : [];
|
||||
|
||||
const choices: ChoicesType = DEFAULT_CHOICES.filter(choice => {
|
||||
if (choice.value === 'background') {
|
||||
return !manifestObject.background;
|
||||
}
|
||||
|
||||
return archiveFiles.includes(`${choice.value}.zip`);
|
||||
});
|
||||
|
||||
const processResult = await processSelection(choices, RECOVER_CHOICE_QUESTION, moduleName);
|
||||
|
||||
if (processResult) {
|
||||
moduleName = processResult;
|
||||
}
|
||||
|
||||
if (moduleName === 'tests') {
|
||||
recoverModule(manifestObject, moduleName as ModuleNameType);
|
||||
const testZipFilePath = resolve(archivePath, `${moduleName}.test.zip`);
|
||||
|
||||
if (existsSync(testZipFilePath)) {
|
||||
await rimraf(testZipFilePath);
|
||||
}
|
||||
} else if (moduleName === 'devtools') {
|
||||
recoverModule(manifestObject, moduleName as ModuleNameType);
|
||||
recoverModule(manifestObject, 'devtools-panel');
|
||||
} else {
|
||||
recoverModule(manifestObject, moduleName as ModuleNameType);
|
||||
}
|
||||
};
|
||||
19
packages/module-manager/lib/types.ts
Normal file
19
packages/module-manager/lib/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { DEFAULT_CHOICES, MODULE_CONFIG } from './const.js';
|
||||
import type { WritableDeep } from '@extension/shared';
|
||||
import type { select } from '@inquirer/prompts';
|
||||
|
||||
type ModuleConfigType = typeof MODULE_CONFIG;
|
||||
|
||||
export type ChoiceType = (typeof DEFAULT_CHOICES)[number];
|
||||
export type ChoicesType = ChoiceType[];
|
||||
export type ModuleNameType = ChoiceType['value'] | 'devtools-panel';
|
||||
export type InputConfigType = Parameters<typeof select>[0];
|
||||
export type WritableModuleConfigValuesType<T extends keyof ModuleConfigType> = WritableDeep<ModuleConfigType[T]>;
|
||||
|
||||
export interface ICLIOptions {
|
||||
action: 'delete' | 'recover';
|
||||
targets: ModuleNameType[];
|
||||
}
|
||||
|
||||
export type CliEntriesType = [string, (string | number)[]][];
|
||||
export type CliActionType = 'delete' | 'recover';
|
||||
23
packages/module-manager/package.json
Normal file
23
packages/module-manager/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@extension/module-manager",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - module manager",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": true,
|
||||
"scripts": {
|
||||
"start": "tsx index.mts",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"format": "prettier . --write --ignore-path ../../.prettierignore"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@extension/dev-utils": "workspace:*",
|
||||
"@extension/shared": "workspace:*",
|
||||
"@extension/tsconfig": "workspace:*",
|
||||
"@inquirer/prompts": "^7.5.1",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"tsx": "^4.19.4",
|
||||
"yargs": "^17.7.2"
|
||||
}
|
||||
}
|
||||
8
packages/module-manager/tsconfig.json
Normal file
8
packages/module-manager/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@extension/tsconfig/module",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["lib", "index.mts"]
|
||||
}
|
||||
12
packages/shared/README.md
Normal file
12
packages/shared/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Shared Package
|
||||
|
||||
This package contains code shared with other packages.
|
||||
To use the code in the package, you need to add the following to the package.json file.
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@extension/shared": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
3
packages/shared/const.ts
Normal file
3
packages/shared/const.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const PROJECT_URL_OBJECT = {
|
||||
url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite',
|
||||
} as const;
|
||||
4
packages/shared/index.mts
Normal file
4
packages/shared/index.mts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './lib/hooks/index.js';
|
||||
export * from './lib/hoc/index.js';
|
||||
export * from './lib/utils/index.js';
|
||||
export * from './const.js';
|
||||
2
packages/shared/lib/hoc/index.ts
Normal file
2
packages/shared/lib/hoc/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { withSuspense } from './with-suspense.js';
|
||||
export { withErrorBoundary } from './with-error-boundary.js';
|
||||
15
packages/shared/lib/hoc/with-error-boundary.tsx
Normal file
15
packages/shared/lib/hoc/with-error-boundary.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { FallbackProps } from 'react-error-boundary';
|
||||
|
||||
export const withErrorBoundary = <T extends Record<string, unknown>>(
|
||||
Component: ComponentType<T>,
|
||||
FallbackComponent: ComponentType<FallbackProps>,
|
||||
) =>
|
||||
function WithErrorBoundary(props: T) {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={FallbackComponent}>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
10
packages/shared/lib/hoc/with-suspense.tsx
Normal file
10
packages/shared/lib/hoc/with-suspense.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from 'react';
|
||||
import type { ComponentType, ReactElement } from 'react';
|
||||
|
||||
export const withSuspense =
|
||||
<T extends Record<string, unknown>>(Component: ComponentType<T>, SuspenseComponent: ReactElement) =>
|
||||
(props: T) => (
|
||||
<Suspense fallback={SuspenseComponent}>
|
||||
<Component {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
1
packages/shared/lib/hooks/index.ts
Normal file
1
packages/shared/lib/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './use-storage.js';
|
||||
56
packages/shared/lib/hooks/use-storage.tsx
Normal file
56
packages/shared/lib/hooks/use-storage.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useRef, useSyncExternalStore } from 'react';
|
||||
import type { BaseStorageType } from '@extension/storage';
|
||||
|
||||
type WrappedPromise = ReturnType<typeof wrapPromise>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const storageMap: Map<BaseStorageType<any>, WrappedPromise> = new Map();
|
||||
|
||||
const wrapPromise = <R,>(promise: Promise<R>) => {
|
||||
let status = 'pending';
|
||||
let result: R;
|
||||
|
||||
const suspender = promise.then(
|
||||
r => {
|
||||
status = 'success';
|
||||
result = r;
|
||||
},
|
||||
e => {
|
||||
status = 'error';
|
||||
result = e;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
read() {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
throw suspender;
|
||||
case 'error':
|
||||
throw result;
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useStorage = <
|
||||
Storage extends BaseStorageType<Data>,
|
||||
Data = Storage extends BaseStorageType<infer Data> ? Data : unknown,
|
||||
>(
|
||||
storage: Storage,
|
||||
) => {
|
||||
const initializedRef = useRef(false);
|
||||
const _data = useSyncExternalStore<Data | null>(storage.subscribe, storage.getSnapshot);
|
||||
|
||||
if (!storageMap.has(storage)) {
|
||||
storageMap.set(storage, wrapPromise(storage.get()));
|
||||
}
|
||||
|
||||
if (_data || initializedRef.current) {
|
||||
storageMap.set(storage, { read: () => _data });
|
||||
initializedRef.current = true;
|
||||
}
|
||||
|
||||
return (_data ?? storageMap.get(storage)!.read()) as Exclude<Data, PromiseLike<unknown>>;
|
||||
};
|
||||
27
packages/shared/lib/utils/colorful-logger.ts
Normal file
27
packages/shared/lib/utils/colorful-logger.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { COLORS } from './const.js';
|
||||
import type { ColorType, ValueOf } from './types.js';
|
||||
|
||||
export const colorfulLog = (message: string, type: ColorType) => {
|
||||
let color: ValueOf<typeof COLORS>;
|
||||
|
||||
switch (type) {
|
||||
case 'success':
|
||||
color = COLORS.FgGreen;
|
||||
break;
|
||||
case 'info':
|
||||
color = COLORS.FgBlue;
|
||||
break;
|
||||
case 'error':
|
||||
color = COLORS.FgRed;
|
||||
break;
|
||||
case 'warning':
|
||||
color = COLORS.FgYellow;
|
||||
break;
|
||||
default:
|
||||
color = COLORS[type];
|
||||
break;
|
||||
}
|
||||
|
||||
console.info(color, message);
|
||||
console.info(COLORS['Reset']);
|
||||
};
|
||||
25
packages/shared/lib/utils/const.ts
Normal file
25
packages/shared/lib/utils/const.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const COLORS = {
|
||||
Reset: '\x1b[0m',
|
||||
Bright: '\x1b[1m',
|
||||
Dim: '\x1b[2m',
|
||||
Underscore: '\x1b[4m',
|
||||
Blink: '\x1b[5m',
|
||||
Reverse: '\x1b[7m',
|
||||
Hidden: '\x1b[8m',
|
||||
FgBlack: '\x1b[30m',
|
||||
FgRed: '\x1b[31m',
|
||||
FgGreen: '\x1b[32m',
|
||||
FgYellow: '\x1b[33m',
|
||||
FgBlue: '\x1b[34m',
|
||||
FgMagenta: '\x1b[35m',
|
||||
FgCyan: '\x1b[36m',
|
||||
FgWhite: '\x1b[37m',
|
||||
BgBlack: '\x1b[40m',
|
||||
BgRed: '\x1b[41m',
|
||||
BgGreen: '\x1b[42m',
|
||||
BgYellow: '\x1b[43m',
|
||||
BgBlue: '\x1b[44m',
|
||||
BgMagenta: '\x1b[45m',
|
||||
BgCyan: '\x1b[46m',
|
||||
BgWhite: '\x1b[47m',
|
||||
} as const;
|
||||
8
packages/shared/lib/utils/helpers.ts
Normal file
8
packages/shared/lib/utils/helpers.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ExcludeValuesFromBaseArrayType } from './types.js';
|
||||
|
||||
export const excludeValuesFromBaseArray = <B extends string[], E extends (string | number)[]>(
|
||||
baseArray: B,
|
||||
excludeArray: E,
|
||||
) => baseArray.filter(value => !excludeArray.includes(value)) as ExcludeValuesFromBaseArrayType<B, E>;
|
||||
|
||||
export const sleep = async (time: number) => new Promise(r => setTimeout(r, time));
|
||||
4
packages/shared/lib/utils/index.ts
Normal file
4
packages/shared/lib/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './helpers.js';
|
||||
export * from './colorful-logger.js';
|
||||
export * from './init-app-with-shadow.js';
|
||||
export type * from './types.js';
|
||||
34
packages/shared/lib/utils/init-app-with-shadow.ts
Normal file
34
packages/shared/lib/utils/init-app-with-shadow.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export const initAppWithShadow = ({ id, app, inlineCss }: { id: string; inlineCss: string; app: ReactElement }) => {
|
||||
const root = document.createElement('div');
|
||||
root.id = id;
|
||||
|
||||
document.body.append(root);
|
||||
|
||||
const rootIntoShadow = document.createElement('div');
|
||||
rootIntoShadow.id = `shadow-root-${id}`;
|
||||
|
||||
const shadowRoot = root.attachShadow({ mode: 'open' });
|
||||
|
||||
if (navigator.userAgent.includes('Firefox')) {
|
||||
/**
|
||||
* In the firefox environment, adoptedStyleSheets cannot be used due to the bug
|
||||
* @url https://bugzilla.mozilla.org/show_bug.cgi?id=1770592
|
||||
*
|
||||
* Injecting styles into the document, this may cause style conflicts with the host page
|
||||
*/
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.innerHTML = inlineCss;
|
||||
shadowRoot.appendChild(styleElement);
|
||||
} else {
|
||||
/** Inject styles into shadow dom */
|
||||
const globalStyleSheet = new CSSStyleSheet();
|
||||
globalStyleSheet.replaceSync(inlineCss);
|
||||
shadowRoot.adoptedStyleSheets = [globalStyleSheet];
|
||||
}
|
||||
|
||||
shadowRoot.appendChild(rootIntoShadow);
|
||||
createRoot(rootIntoShadow).render(app);
|
||||
};
|
||||
10
packages/shared/lib/utils/types.ts
Normal file
10
packages/shared/lib/utils/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { COLORS } from './const.js';
|
||||
import type { TupleToUnion } from 'type-fest';
|
||||
|
||||
export type * from 'type-fest';
|
||||
export type ColorType = 'success' | 'info' | 'error' | 'warning' | keyof typeof COLORS;
|
||||
export type ExcludeValuesFromBaseArrayType<B extends string[], E extends (string | number)[]> = Exclude<
|
||||
TupleToUnion<B>,
|
||||
TupleToUnion<E>
|
||||
>[];
|
||||
export type ManifestType = chrome.runtime.ManifestV3;
|
||||
33
packages/shared/package.json
Normal file
33
packages/shared/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@extension/shared",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - shared code",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"types": "index.mts",
|
||||
"main": "dist/index.mjs",
|
||||
"scripts": {
|
||||
"clean:bundle": "rimraf dist",
|
||||
"clean:node_modules": "pnpx rimraf node_modules",
|
||||
"clean:turbo": "rimraf .turbo",
|
||||
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
|
||||
"ready": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"format": "prettier . --write --ignore-path ../../.prettierignore",
|
||||
"type-check": "tsc --noEmit",
|
||||
"postinstall": "pnpm ready"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-error-boundary": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@extension/storage": "workspace:*",
|
||||
"@extension/tsconfig": "workspace:*",
|
||||
"type-fest": "^4.41.0"
|
||||
}
|
||||
}
|
||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@extension/tsconfig/module",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["index.mts", "lib"]
|
||||
}
|
||||
1
packages/storage/index.mts
Normal file
1
packages/storage/index.mts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/index.js';
|
||||
157
packages/storage/lib/base/base.ts
Normal file
157
packages/storage/lib/base/base.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { SessionAccessLevelEnum, StorageEnum } from './enums.js';
|
||||
import type { BaseStorageType, StorageConfigType, ValueOrUpdateType } from './types.js';
|
||||
|
||||
/**
|
||||
* Chrome reference error while running `processTailwindFeatures` in tailwindcss.
|
||||
* To avoid this, we need to check if globalThis.chrome is available and add fallback logic.
|
||||
*/
|
||||
const chrome = globalThis.chrome;
|
||||
|
||||
/**
|
||||
* Sets or updates an arbitrary cache with a new value or the result of an update function.
|
||||
*/
|
||||
const updateCache = async <D>(valueOrUpdate: ValueOrUpdateType<D>, cache: D | null): Promise<D> => {
|
||||
// Type guard to check if our value or update is a function
|
||||
const isFunction = <D>(value: ValueOrUpdateType<D>): value is (prev: D) => D | Promise<D> =>
|
||||
typeof value === 'function';
|
||||
|
||||
// Type guard to check in case of a function if it's a Promise
|
||||
const returnsPromise = <D>(func: (prev: D) => D | Promise<D>): func is (prev: D) => Promise<D> =>
|
||||
// Use ReturnType to infer the return type of the function and check if it's a Promise
|
||||
(func as (prev: D) => Promise<D>) instanceof Promise;
|
||||
if (isFunction(valueOrUpdate)) {
|
||||
// Check if the function returns a Promise
|
||||
if (returnsPromise(valueOrUpdate)) {
|
||||
return valueOrUpdate(cache as D);
|
||||
} else {
|
||||
return valueOrUpdate(cache as D);
|
||||
}
|
||||
} else {
|
||||
return valueOrUpdate;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If one session storage needs access from content scripts, we need to enable it globally.
|
||||
* @default false
|
||||
*/
|
||||
let globalSessionAccessLevelFlag: StorageConfigType['sessionAccessForContentScripts'] = false;
|
||||
|
||||
/**
|
||||
* Checks if the storage permission is granted in the manifest.json.
|
||||
*/
|
||||
const checkStoragePermission = (storageEnum: StorageEnum): void => {
|
||||
if (!chrome) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chrome.storage[storageEnum]) {
|
||||
throw new Error(`"storage" permission in manifest.ts: "storage ${storageEnum}" isn't defined`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a storage area for persisting and exchanging data.
|
||||
*/
|
||||
export const createStorage = <D = string>(
|
||||
key: string,
|
||||
fallback: D,
|
||||
config?: StorageConfigType<D>,
|
||||
): BaseStorageType<D> => {
|
||||
let cache: D | null = null;
|
||||
let initialCache = false;
|
||||
let listeners: Array<() => void> = [];
|
||||
|
||||
const storageEnum = config?.storageEnum ?? StorageEnum.Local;
|
||||
const liveUpdate = config?.liveUpdate ?? false;
|
||||
|
||||
const serialize = config?.serialization?.serialize ?? ((v: D) => v);
|
||||
const deserialize = config?.serialization?.deserialize ?? (v => v as D);
|
||||
|
||||
// Set global session storage access level for StoryType.Session, only when not already done but needed.
|
||||
if (
|
||||
globalSessionAccessLevelFlag === false &&
|
||||
storageEnum === StorageEnum.Session &&
|
||||
config?.sessionAccessForContentScripts === true
|
||||
) {
|
||||
checkStoragePermission(storageEnum);
|
||||
|
||||
chrome?.storage[storageEnum]
|
||||
.setAccessLevel({
|
||||
accessLevel: SessionAccessLevelEnum.ExtensionPagesAndContentScripts,
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
console.error('Please call .setAccessLevel() into different context, like a background script.');
|
||||
});
|
||||
globalSessionAccessLevelFlag = true;
|
||||
}
|
||||
|
||||
// Register life cycle methods
|
||||
const get = async (): Promise<D> => {
|
||||
checkStoragePermission(storageEnum);
|
||||
const value = await chrome?.storage[storageEnum].get([key]);
|
||||
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return deserialize(value[key]) ?? fallback;
|
||||
};
|
||||
|
||||
const set = async (valueOrUpdate: ValueOrUpdateType<D>) => {
|
||||
if (!initialCache) {
|
||||
cache = await get();
|
||||
}
|
||||
cache = await updateCache(valueOrUpdate, cache);
|
||||
|
||||
await chrome?.storage[storageEnum].set({ [key]: serialize(cache) });
|
||||
_emitChange();
|
||||
};
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners = [...listeners, listener];
|
||||
|
||||
return () => {
|
||||
listeners = listeners.filter(l => l !== listener);
|
||||
};
|
||||
};
|
||||
|
||||
const getSnapshot = () => cache;
|
||||
|
||||
const _emitChange = () => {
|
||||
listeners.forEach(listener => listener());
|
||||
};
|
||||
|
||||
// Listener for live updates from the browser
|
||||
const _updateFromStorageOnChanged = async (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
||||
// Check if the key we are listening for is in the changes object
|
||||
if (changes[key] === undefined) return;
|
||||
|
||||
const valueOrUpdate: ValueOrUpdateType<D> = deserialize(changes[key].newValue);
|
||||
|
||||
if (cache === valueOrUpdate) return;
|
||||
|
||||
cache = await updateCache(valueOrUpdate, cache);
|
||||
|
||||
_emitChange();
|
||||
};
|
||||
|
||||
get().then(data => {
|
||||
cache = data;
|
||||
initialCache = true;
|
||||
_emitChange();
|
||||
});
|
||||
|
||||
// Register listener for live updates for our storage area
|
||||
if (liveUpdate) {
|
||||
chrome?.storage[storageEnum].onChanged.addListener(_updateFromStorageOnChanged);
|
||||
}
|
||||
|
||||
return {
|
||||
get,
|
||||
set,
|
||||
getSnapshot,
|
||||
subscribe,
|
||||
};
|
||||
};
|
||||
42
packages/storage/lib/base/enums.ts
Normal file
42
packages/storage/lib/base/enums.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Storage area type for persisting and exchanging data.
|
||||
* @see https://developer.chrome.com/docs/extensions/reference/storage/#overview
|
||||
*/
|
||||
export enum StorageEnum {
|
||||
/**
|
||||
* Persist data locally against browser restarts. Will be deleted by uninstalling the extension.
|
||||
* @default
|
||||
*/
|
||||
Local = 'local',
|
||||
/**
|
||||
* Uploads data to the users account in the cloud and syncs to the users browsers on other devices. Limits apply.
|
||||
*/
|
||||
Sync = 'sync',
|
||||
/**
|
||||
* Requires an [enterprise policy](https://www.chromium.org/administrators/configuring-policy-for-extensions) with a
|
||||
* json schema for company wide config.
|
||||
*/
|
||||
Managed = 'managed',
|
||||
/**
|
||||
* Only persist data until the browser is closed. Recommended for service workers which can shutdown anytime and
|
||||
* therefore need to restore their state. Set {@link SessionAccessLevelEnum} for permitting content scripts access.
|
||||
* @implements Chromes [Session Storage](https://developer.chrome.com/docs/extensions/reference/storage/#property-session)
|
||||
*/
|
||||
Session = 'session',
|
||||
}
|
||||
|
||||
/**
|
||||
* Global access level requirement for the {@link StorageEnum.Session} Storage Area.
|
||||
* @implements Chromes [Session Access Level](https://developer.chrome.com/docs/extensions/reference/storage/#method-StorageArea-setAccessLevel)
|
||||
*/
|
||||
export enum SessionAccessLevelEnum {
|
||||
/**
|
||||
* Storage can only be accessed by Extension pages (not Content scripts).
|
||||
* @default
|
||||
*/
|
||||
ExtensionPagesOnly = 'TRUSTED_CONTEXTS',
|
||||
/**
|
||||
* Storage can be accessed by both Extension pages and Content scripts.
|
||||
*/
|
||||
ExtensionPagesAndContentScripts = 'TRUSTED_AND_UNTRUSTED_CONTEXTS',
|
||||
}
|
||||
3
packages/storage/lib/base/index.ts
Normal file
3
packages/storage/lib/base/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './base.js';
|
||||
export * from './enums.js';
|
||||
export type * from './types.js';
|
||||
54
packages/storage/lib/base/types.ts
Normal file
54
packages/storage/lib/base/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { StorageEnum } from './index.js';
|
||||
|
||||
export type ValueOrUpdateType<D> = D | ((prev: D) => Promise<D> | D);
|
||||
|
||||
export type BaseStorageType<D> = {
|
||||
get: () => Promise<D>;
|
||||
set: (value: ValueOrUpdateType<D>) => Promise<void>;
|
||||
getSnapshot: () => D | null;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
};
|
||||
|
||||
export type StorageConfigType<D = string> = {
|
||||
/**
|
||||
* Assign the {@link StorageEnum} to use.
|
||||
* @default Local
|
||||
*/
|
||||
storageEnum?: StorageEnum;
|
||||
/**
|
||||
* Only for {@link StorageEnum.Session}: Grant Content scripts access to storage area?
|
||||
* @default false
|
||||
*/
|
||||
sessionAccessForContentScripts?: boolean;
|
||||
/**
|
||||
* Keeps state live in sync between all instances of the extension. Like between popup, side panel and content scripts.
|
||||
* To allow chrome background scripts to stay in sync as well, use {@link StorageEnum.Session} storage area with
|
||||
* {@link StorageConfigType.sessionAccessForContentScripts} potentially also set to true.
|
||||
* @see https://stackoverflow.com/a/75637138/2763239
|
||||
* @default false
|
||||
*/
|
||||
liveUpdate?: boolean;
|
||||
/**
|
||||
* An optional props for converting values from storage and into it.
|
||||
* @default undefined
|
||||
*/
|
||||
serialization?: {
|
||||
/**
|
||||
* convert non-native values to string to be saved in storage
|
||||
*/
|
||||
serialize: (value: D) => string;
|
||||
/**
|
||||
* convert string value from storage to non-native values
|
||||
*/
|
||||
deserialize: (text: string) => D;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ThemeStateType {
|
||||
theme: 'light' | 'dark';
|
||||
isLight: boolean;
|
||||
}
|
||||
|
||||
export type ThemeStorageType = BaseStorageType<ThemeStateType> & {
|
||||
toggle: () => Promise<void>;
|
||||
};
|
||||
28
packages/storage/lib/impl/example-theme-storage.ts
Normal file
28
packages/storage/lib/impl/example-theme-storage.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createStorage, StorageEnum } from '../base/index.js';
|
||||
import type { ThemeStateType, ThemeStorageType } from '../base/index.js';
|
||||
|
||||
const storage = createStorage<ThemeStateType>(
|
||||
'theme-storage-key',
|
||||
{
|
||||
theme: 'light',
|
||||
isLight: true,
|
||||
},
|
||||
{
|
||||
storageEnum: StorageEnum.Local,
|
||||
liveUpdate: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const exampleThemeStorage: ThemeStorageType = {
|
||||
...storage,
|
||||
toggle: async () => {
|
||||
await storage.set(currentState => {
|
||||
const newTheme = currentState.theme === 'light' ? 'dark' : 'light';
|
||||
|
||||
return {
|
||||
theme: newTheme,
|
||||
isLight: newTheme === 'light',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
1
packages/storage/lib/impl/index.ts
Normal file
1
packages/storage/lib/impl/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './example-theme-storage.js';
|
||||
2
packages/storage/lib/index.ts
Normal file
2
packages/storage/lib/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type * from './types.js';
|
||||
export * from './impl/index.js';
|
||||
8
packages/storage/lib/types.ts
Normal file
8
packages/storage/lib/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ValueOrUpdateType } from './base/index.js';
|
||||
|
||||
export type BaseStorageType<D> = {
|
||||
get: () => Promise<D>;
|
||||
set: (value: ValueOrUpdateType<D>) => Promise<void>;
|
||||
getSnapshot: () => D | null;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
};
|
||||
27
packages/storage/package.json
Normal file
27
packages/storage/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@extension/storage",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - storage",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"types": "index.mts",
|
||||
"main": "dist/index.mjs",
|
||||
"scripts": {
|
||||
"clean:bundle": "rimraf dist",
|
||||
"clean:node_modules": "pnpx rimraf node_modules",
|
||||
"clean:turbo": "rimraf .turbo",
|
||||
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
|
||||
"ready": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"format": "prettier . --write --ignore-path ../../.prettierignore",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@extension/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
||||
8
packages/storage/tsconfig.json
Normal file
8
packages/storage/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@extension/tsconfig/module",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["index.mts", "lib"]
|
||||
}
|
||||
11
packages/tailwindcss-config/package.json
Normal file
11
packages/tailwindcss-config/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@extension/tailwindcss-config",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - tailwindcss configuration",
|
||||
"main": "tailwind.config.ts",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"devDependencies": {
|
||||
"@extension/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
||||
8
packages/tailwindcss-config/tailwind.config.ts
Normal file
8
packages/tailwindcss-config/tailwind.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
} as Omit<Config, 'content'>;
|
||||
7
packages/tailwindcss-config/tsconfig.json
Normal file
7
packages/tailwindcss-config/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@extension/tsconfig/base",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["tailwind.config.ts"]
|
||||
}
|
||||
5
packages/tsconfig/app.json
Normal file
5
packages/tsconfig/app.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Chrome Extension App",
|
||||
"extends": "./base.json"
|
||||
}
|
||||
25
packages/tsconfig/base.json
Normal file
25
packages/tsconfig/base.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Base",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noImplicitReturns": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"types": ["node", "chrome"]
|
||||
}
|
||||
}
|
||||
10
packages/tsconfig/module.json
Normal file
10
packages/tsconfig/module.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Module",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "NodeNext"
|
||||
}
|
||||
}
|
||||
7
packages/tsconfig/package.json
Normal file
7
packages/tsconfig/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@extension/tsconfig",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - tsconfig",
|
||||
"private": true,
|
||||
"sideEffects": false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user