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
+
+
+
+
+
+
+
+
+ {#if scan_result}
+
+
+
+ {#if scan_result.replacements.length > 0}
+
+ {:else}
+
+

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