Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/rozenite-init-detects-lynx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'rozenite': minor
'@rozenite/tools': minor
---

`rozenite init` now detects rspeedy/Rsbuild (Lynx) projects — via
`lynx.config.ts`/`lynx.config.js`, or `@lynx-js/rspeedy` in `package.json` —
installs `@rozenite/lynx` as a dev dependency, and adds `rozeniteLynxPlugin()`
to the `plugins` array in `lynx.config.ts`, mirroring what it already does for
Metro and Re.Pack. It also reminds you to turn on Lynx DevTool, since that
switch is off by default and Rozenite finds nothing to connect to without it.
127 changes: 126 additions & 1 deletion packages/cli/src/__tests__/config-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,12 @@ describe('wrapConfigFile', () => {

// Helper function to create a config file with given content
const createConfigFile = async (bundlerType: BundlerType, content: string, extension = '.js') => {
const baseName = bundlerType === 'metro' ? 'metro.config' : 'rspack.config';
const baseName =
bundlerType === 'metro'
? 'metro.config'
: bundlerType === 'repack'
? 'rspack.config'
: 'lynx.config';
const filename = baseName + extension;
const configPath = path.join(tempDir, filename);
await fs.writeFile(configPath, content, 'utf8');
Expand Down Expand Up @@ -416,6 +421,126 @@ describe('wrapConfigFile', () => {
});
});

describe('lynx', () => {
it('should add rozeniteLynxPlugin to a basic lynx.config.ts', async () => {
const basicConfig = `import { defineConfig } from '@lynx-js/rspeedy';
import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin';

export default defineConfig({
plugins: [pluginReactLynx()],
});`;

const configPath = await createConfigFile('lynx', basicConfig, '.ts');

await wrapConfigFile(tempDir, 'lynx');

const wrappedContent = await fs.readFile(configPath, 'utf8');

expect(wrappedContent).toContain(
"import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy';",
);
expect(wrappedContent).toContain('pluginReactLynx()');
expect(wrappedContent).toContain('rozeniteLynxPlugin(),');
// The plugin call must land inside the plugins array.
expect(wrappedContent).toMatch(/plugins:\s*\[[\s\S]*rozeniteLynxPlugin\(\)[\s\S]*\]/);
});

it('should add rozeniteLynxPlugin to a real-world create-rspeedy config', async () => {
// Mirrors what `create-rspeedy`'s react template generates.
const realWorldConfig = `import { defineConfig } from '@lynx-js/rspeedy'

import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'
import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'
import { pluginTypeCheck } from '@rsbuild/plugin-type-check'

export default defineConfig({
plugins: [
pluginQRCode({
schema(url) {
// We use \`?fullscreen=true\` to open the page in LynxExplorer in full screen mode
return \`\${url}?fullscreen=true\`
},
}),
pluginReactLynx(),
pluginTypeCheck(),
],
})`;

const configPath = await createConfigFile('lynx', realWorldConfig, '.ts');

await wrapConfigFile(tempDir, 'lynx');

const wrappedContent = await fs.readFile(configPath, 'utf8');

expect(wrappedContent).toContain(
"import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy';",
);
expect(wrappedContent).toContain('rozeniteLynxPlugin(),');
// All the original plugins must be preserved.
expect(wrappedContent).toContain('pluginQRCode({');
expect(wrappedContent).toContain('pluginReactLynx()');
expect(wrappedContent).toContain('pluginTypeCheck()');
// The generated file is otherwise structurally valid JS/TS.
const openBraces = (wrappedContent.match(/\{/g) || []).length;
const closeBraces = (wrappedContent.match(/\}/g) || []).length;
expect(openBraces).toBe(closeBraces);
const openParens = (wrappedContent.match(/\(/g) || []).length;
const closeParens = (wrappedContent.match(/\)/g) || []).length;
expect(openParens).toBe(closeParens);
});

it('should add rozeniteLynxPlugin to an empty plugins array', async () => {
const emptyPluginsConfig = `import { defineConfig } from '@lynx-js/rspeedy';

export default defineConfig({
plugins: [],
});`;

const configPath = await createConfigFile('lynx', emptyPluginsConfig, '.ts');

await wrapConfigFile(tempDir, 'lynx');

const wrappedContent = await fs.readFile(configPath, 'utf8');

expect(wrappedContent).toContain('plugins: [rozeniteLynxPlugin()]');
});

it('should not modify an already configured lynx.config.ts', async () => {
const alreadyWrapped = `import { defineConfig } from '@lynx-js/rspeedy';
import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy';

export default defineConfig({
plugins: [rozeniteLynxPlugin()],
});`;

const configPath = await createConfigFile('lynx', alreadyWrapped, '.ts');

await wrapConfigFile(tempDir, 'lynx');

const wrappedContent = await fs.readFile(configPath, 'utf8');

expect(wrappedContent).toBe(alreadyWrapped);
});

it('should throw when lynx.config.ts does not exist', async () => {
await expect(wrapConfigFile(tempDir, 'lynx')).rejects.toThrow(
'Configuration file lynx.config.{.js,.mjs,.cjs,.ts,.cts,.mts} not found',
);
});

it('should throw when the config has no plugins array', async () => {
const noPluginsConfig = `import { defineConfig } from '@lynx-js/rspeedy';

export default defineConfig({});`;

await createConfigFile('lynx', noPluginsConfig, '.ts');

await expect(wrapConfigFile(tempDir, 'lynx')).rejects.toThrow(
'Could not find a "plugins" array',
);
});
});

describe('import style detection', () => {
it('should use CommonJS style for CommonJS configs', async () => {
const configPath = await createConfigFile('metro', FIXTURES.metro.commonjs.basic);
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/src/__tests__/is-project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { isProject } from '../utils/packages.js';

describe('isProject', () => {
let testDir: string;

beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rozenite-is-project-test-'));
});

afterEach(() => {
fs.rmSync(testDir, { recursive: true, force: true });
});

it('returns false when there is no package.json', () => {
expect(isProject(testDir)).toBe(false);
});

it('returns true for a React Native project', () => {
fs.writeFileSync(
path.join(testDir, 'package.json'),
JSON.stringify({ dependencies: { 'react-native': '^0.76.0' } }),
);

expect(isProject(testDir)).toBe(true);
});

it('returns true for a Lynx (rspeedy) project, which has no react-native dependency', () => {
fs.writeFileSync(
path.join(testDir, 'package.json'),
JSON.stringify({
dependencies: { '@lynx-js/react': '^0.124.0' },
devDependencies: { '@lynx-js/rspeedy': '^0.16.0' },
}),
);

expect(isProject(testDir)).toBe(true);
});

it('returns false for an unrelated project', () => {
fs.writeFileSync(
path.join(testDir, 'package.json'),
JSON.stringify({ dependencies: { react: '^19.0.0' } }),
);

expect(isProject(testDir)).toBe(false);
});
});
32 changes: 28 additions & 4 deletions packages/cli/src/commands/init-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,32 @@ import { spawn } from '../utils/spawn.js';
import { step } from '../utils/steps.js';

const formatBundlerType = (bundlerType: BundlerType): string => {
return bundlerType === 'metro' ? 'Metro' : 'Re.Pack';
switch (bundlerType) {
case 'metro':
return 'Metro';
case 'repack':
return 'Re.Pack';
case 'lynx':
return 'Lynx';
}
};

const getPackageName = (bundlerType: BundlerType): string => {
switch (bundlerType) {
case 'metro':
return '@rozenite/metro';
case 'repack':
return '@rozenite/repack';
case 'lynx':
return '@rozenite/lynx';
}
};

export const initCommand = async (projectRoot: string) => {
intro('Rozenite');

if (!isProject(projectRoot)) {
logger.error("I couldn't find a React Native project in this directory.");
logger.error("I couldn't find a React Native or Lynx project in this directory.");
return;
}

Expand Down Expand Up @@ -54,13 +72,13 @@ export const initCommand = async (projectRoot: string) => {

if (!bundlerTypes.length) {
throw new Error(
'Could not determine bundler type. Please ensure you have a metro.config.js or rspack.config.js file.',
'Could not determine bundler type. Please ensure you have a metro.config.js, rspack.config.js, or lynx.config.ts file.',
);
}

for (const bundlerType of bundlerTypes) {
// Install the appropriate Rozenite package
const packageName = bundlerType === 'metro' ? '@rozenite/metro' : '@rozenite/repack';
const packageName = getPackageName(bundlerType);

await step(
{
Expand All @@ -87,5 +105,11 @@ export const initCommand = async (projectRoot: string) => {
);
}

if (bundlerTypes.includes('lynx')) {
logger.info(
'Lynx DevTool is off by default. Turn it on in LynxExplorer (Settings → Lynx DevTool Switches) or in your app, then relaunch — otherwise Rozenite will find no target to connect to. See https://rozenite.dev/docs/rozenite-for-lynx for details.',
);
}

outro('You are now ready to use Rozenite!');
};
Loading