Initial commit

This commit is contained in:
Aditya Gupta
2026-06-29 19:29:19 +05:30
committed by GitHub
commit aabb8986aa
292 changed files with 17976 additions and 0 deletions

View 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).

View File

@@ -0,0 +1 @@
export * from './lib/index.js';

View File

@@ -0,0 +1,2 @@
export * from './stream-file-to-zip.js';
export * from './manifest-parser/index.js';

View 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);
},
};

View File

@@ -0,0 +1,4 @@
import { ManifestParserImpl } from './impl.js';
export type * from './types.js';
export const ManifestParser = ManifestParserImpl;

View File

@@ -0,0 +1,5 @@
import type { ManifestType } from '@extension/shared';
export interface IManifestParser {
convertManifestToString: (manifest: ManifestType, isFirefox: boolean) => string;
}

View 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);
});
};

View 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:*"
}
}

View 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
View 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:
![img.png](use-env-example.png)
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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
export * from './config.js';
export * from './const.js';

13
packages/env/lib/types.ts vendored Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

1
packages/hmr/index.mts Normal file
View File

@@ -0,0 +1 @@
export * from './lib/plugins/index.js';

View 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';

View 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 }));
}
});
};
};

View 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;
}
});
})();

View 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);
})();

View 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,
});
})();

View 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),
};

View File

@@ -0,0 +1,3 @@
export * from './watch-rebuild-plugin.js';
export * from './watch-public-plugin.js';
export * from './make-entry-point-plugin.js';

View 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;
}
}
}
},
});

View 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);
}
},
});

View 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
View 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
View 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"
}
}

View 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[];

View 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
View File

@@ -0,0 +1 @@
lib/i18n.ts

173
packages/i18n/README.md Normal file
View 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
View File

@@ -0,0 +1 @@
export * from './lib/index.js';

View 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';

View 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));

View File

@@ -0,0 +1,3 @@
import type { MessageKeyType } from './types.js';
export const t = (key: MessageKeyType, substitutions?: string | string[]) => chrome.i18n.getMessage(key, substitutions);

View 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;

View 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');
})();

View 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');
};

View 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;

View 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"
}
}

View 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": "다시 실행합니다."
}
}

View 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:*"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["lib/prepare-build.ts"]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
},
"include": ["index.mts", "lib"]
}

View 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.

View File

@@ -0,0 +1,3 @@
import { startManager } from './lib/index.js';
startManager();

View 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;
};

View File

@@ -0,0 +1,2 @@
export * from './cli-args-processor.js';
export * from './run-module-manager.js';

View 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('..', '..'),
});
}
};

View 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';

View File

@@ -0,0 +1,2 @@
export * from './utils.js';
export * from './zip-utils.js';

View 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;
};

View 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();
});
};

View 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();
}
};

View 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');

View 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);
}
};

View File

@@ -0,0 +1,3 @@
export * from './delete-feature.js';
export * from './recover-feature.js';
export * from './modules-handler.js';

View 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');
};

View 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);
}
};

View 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';

View 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"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
},
"include": ["lib", "index.mts"]
}

12
packages/shared/README.md Normal file
View 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
View File

@@ -0,0 +1,3 @@
export const PROJECT_URL_OBJECT = {
url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite',
} as const;

View 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';

View File

@@ -0,0 +1,2 @@
export { withSuspense } from './with-suspense.js';
export { withErrorBoundary } from './with-error-boundary.js';

View 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>
);
};

View 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>
);

View File

@@ -0,0 +1 @@
export * from './use-storage.js';

View 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>>;
};

View 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']);
};

View 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;

View 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));

View 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';

View 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);
};

View 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;

View 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"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
},
"include": ["index.mts", "lib"]
}

View File

@@ -0,0 +1 @@
export * from './lib/index.js';

View 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,
};
};

View 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',
}

View File

@@ -0,0 +1,3 @@
export * from './base.js';
export * from './enums.js';
export type * from './types.js';

View 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>;
};

View 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',
};
});
},
};

View File

@@ -0,0 +1 @@
export * from './example-theme-storage.js';

View File

@@ -0,0 +1,2 @@
export type * from './types.js';
export * from './impl/index.js';

View 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;
};

View 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:*"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
},
"include": ["index.mts", "lib"]
}

View 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:*"
}
}

View File

@@ -0,0 +1,8 @@
import type { Config } from 'tailwindcss';
export default {
theme: {
extend: {},
},
plugins: [],
} as Omit<Config, 'content'>;

View File

@@ -0,0 +1,7 @@
{
"extends": "@extension/tsconfig/base",
"compilerOptions": {
"baseUrl": "."
},
"include": ["tailwind.config.ts"]
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Chrome Extension App",
"extends": "./base.json"
}

View 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"]
}
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Module",
"extends": "./base.json",
"compilerOptions": {
"noEmit": false,
"moduleResolution": "NodeNext",
"module": "NodeNext"
}
}

View 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