diff --git a/src/lib/FileInput.svelte b/src/lib/FileInput.svelte new file mode 100644 index 0000000..d05b571 --- /dev/null +++ b/src/lib/FileInput.svelte @@ -0,0 +1,59 @@ + + + + + diff --git a/src/lib/SingleInputSubmitButton.svelte b/src/lib/SingleInputSubmitButton.svelte new file mode 100644 index 0000000..40850b0 --- /dev/null +++ b/src/lib/SingleInputSubmitButton.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c3b80e3..9ad6a5b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,6 +4,7 @@ import { all } from 'module-replacements'; import Autocomplete from '$lib/Autocomplete.svelte'; import ReplacementsTitle from '$lib/ReplacementsTitle.svelte'; + import SingleInputSubmitButton from '$lib/SingleInputSubmitButton.svelte'; import { scopify } from '$lib/utils'; const examples = ['is-number', 'left-pad', 'is-odd', 'object-assign']; @@ -56,7 +57,7 @@ on_select_navigate_to={navigate_to} autofocus /> - +
@@ -134,22 +135,6 @@ border-color: var(--accent); } - .submit-btn { - background: none; - border: none; - color: var(--accent); - font-family: inherit; - font-size: 1.125rem; - padding: 0.625rem 0.75rem; - cursor: pointer; - flex-shrink: 0; - line-height: 1; - } - - .submit-btn:hover { - color: var(--accent-hover); - } - .examples { margin-top: 2rem; } diff --git a/src/routes/package-json/+page.svelte b/src/routes/package-json/+page.svelte new file mode 100644 index 0000000..6556e83 --- /dev/null +++ b/src/routes/package-json/+page.svelte @@ -0,0 +1,395 @@ + + + + Scan package.json - replacements.fyi + + + +
+
+

// package.json

+

Package Directory

+

Scan package.json for replacements

+
+ +
+ + {file_name || 'Choose package.json'} + + {#if scan_error} + + {/if} +
+ + {#if scan_result} +
+
+

// scan complete

+

+ {scan_result.replacements.length > 0 + ? `Found ${scan_result.replacements.length} replacements` + : '🎉 Your dependencies look good!🎉'} +

+

+ Checked {scan_result.checked} packages from package.json +

+
+ + {#if scan_result.replacements.length > 0} + + {:else} +
+ A person celebrating at a computer +
+

scan complete

+

No replaceable dependencies found

+

+ No packages with native replacements or more performant alternatives were found. Nice + work. +

+ Browse all known replacements → +
+
+ {/if} +
+ {/if} +
+ + diff --git a/src/routes/package-json/data.remote.ts b/src/routes/package-json/data.remote.ts new file mode 100644 index 0000000..b8b218c --- /dev/null +++ b/src/routes/package-json/data.remote.ts @@ -0,0 +1,102 @@ +import * as v from 'valibot'; +import { command, form } from '$app/server'; +import { invalid } from '@sveltejs/kit'; +import { all } from 'module-replacements'; +import type { ModuleReplacementMapping } from 'module-replacements'; + +const package_json_schema = v.pipe( + v.file('Please select a package.json file.'), + v.mimeType(['application/json'], 'Only valid JSON files are accepted.'), + v.maxSize(1024 * 1024 * 10, 'Please select a file smaller than 10 MB.') +); + +function is_record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export type PackageJSONScanSuccessResult = { + success: true; + checked: number; + replacements: { + dep: string; + replacement: ModuleReplacementMapping; + }[]; +}; + +type PackageJsonScanResult = { success: false; error: string } | PackageJSONScanSuccessResult; + +function eval_package_json(package_json_string: string): PackageJsonScanResult { + let parsed_json: unknown; + try { + parsed_json = JSON.parse(package_json_string); + } catch { + return { success: false, error: 'File was not valid JSON.' }; + } + + if (!is_record(parsed_json)) { + return { success: false, error: 'File was an invalid format (not an object).' }; + } + + if (!parsed_json['devDependencies'] && !parsed_json['dependencies']) { + return { success: false, error: 'No dependencies or devDependencies found.' }; + } + + const dev_deps = parsed_json['devDependencies'] ?? {}; + const prod_deps = parsed_json['dependencies'] ?? {}; + + if (!is_record(dev_deps) || !is_record(prod_deps)) { + return { success: false, error: 'dependencies and devDependencies must be objects.' }; + } + + const replacements = []; + for (const mapping of Object.keys(all.mappings)) { + const match = dev_deps[mapping] ?? prod_deps[mapping]; + if (match) { + replacements.push({ + dep: mapping, + replacement: all.mappings[mapping] + }); + } + } + + return { + success: true, + checked: Object.keys(dev_deps).length + Object.keys(prod_deps).length, + replacements + }; +} + +export const scan_package_json_file = form( + v.object({ + package_json: package_json_schema + }), + async ({ package_json }, issue) => { + const result = eval_package_json(await package_json.text()); + + if (!result.success) { + invalid(issue.package_json(result.error)); + } + + return result; + } +); + +export const scan_package_json_paste = command( + v.object({ + package_json: v.string() + }), + async ({ package_json }): Promise => { + console.log('Evaluating package.json from paste', { package_json }); + const result = eval_package_json(package_json); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { + success: true, + checked: result.checked, + replacements: result.replacements + }; + } +); diff --git a/static/clean-package.gif b/static/clean-package.gif new file mode 100644 index 0000000..0072955 Binary files /dev/null and b/static/clean-package.gif differ diff --git a/tests/app.e2e.ts b/tests/app.e2e.ts index edc227a..7d05ade 100644 --- a/tests/app.e2e.ts +++ b/tests/app.e2e.ts @@ -70,6 +70,67 @@ test.describe('Package detail page', () => { }); }); +test.describe('Package JSON scanner', () => { + test('loads with package.json form', async ({ page }) => { + await page.goto('/package-json'); + await expect(page.locator('input[name="package_json"]')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Scan package.json' })).toBeVisible(); + }); + + test('finds replacements from package.json dependencies', async ({ page }) => { + await page.goto('/package-json'); + await page.locator('input[name="package_json"]').fill( + JSON.stringify({ + name: 'express', + dependencies: { + 'body-parser': '^2.2.1', + debug: '^4.4.0', + qs: '^6.14.2' + } + }) + ); + await page.getByRole('button', { name: 'Scan package.json' }).click(); + + await expect(page.getByRole('heading', { name: 'Found 3 replacements' })).toBeVisible(); + await expect(page.getByRole('link', { name: /body-parser/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /debug/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /qs/ })).toBeVisible(); + }); + + test('celebrates when package.json has no replacements', async ({ page }) => { + await page.goto('/package-json'); + await page.locator('input[name="package_json"]').fill( + JSON.stringify({ + name: 'clean-package', + dependencies: { + svelte: '^5.0.0' + } + }) + ); + await page.getByRole('button', { name: 'Scan package.json' }).click(); + + await expect( + page.getByRole('heading', { name: '🎉 Your dependencies look good!🎉' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'No replaceable dependencies found ' }) + ).toBeVisible(); + await expect( + page.getByText( + 'No packages with native replacements or more performant alternatives were found.' + ) + ).toBeVisible(); + }); + + test('shows validation for invalid JSON', async ({ page }) => { + await page.goto('/package-json'); + await page.locator('input[name="package_json"]').fill('{'); + await page.getByRole('button', { name: 'Scan package.json' }).click(); + + await expect(page.getByRole('alert')).toContainText('File was not valid JSON'); + }); +}); + test.describe('Runtime switcher', () => { test('defaults to Any and persists selection via cookie', async ({ page }) => { await page.goto('/is-number');