diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..319a830
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,24 @@
+{
+ "name": "sourcemap_tools_devcontainer",
+ "image": "public.ecr.aws/docker/library/node:24",
+ "workspaceMount": "source=${localWorkspaceFolder},target=/sm,type=bind",
+ "workspaceFolder": "/sm",
+ "runArgs": ["--name", "sourcemap_tools_devcontainer", "--tmpfs", "/tmp:exec,mode=01777"],
+ "customizations": {
+ "vscode": {
+ "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"],
+ "settings": {
+ "editor.rulers": [100],
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "typescript.tsdk": "node_modules/typescript/lib",
+ "typescript.enablePromptUseWorkspaceTsdk": true,
+ "npm.packageManager": "npm",
+ "[json]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ }
+ }
+ }
+ },
+ "postCreateCommand": "cd /sm && npm ci",
+ "remoteUser": "node"
+}
diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx
index a96cc90..20f7a43 100644
--- a/src/__tests__/app.test.tsx
+++ b/src/__tests__/app.test.tsx
@@ -267,6 +267,65 @@ describe('source maps', () => {
// Should display the fallback with generated ID
expect(listItem).toHaveTextContent(/NO NAME \(Generated id:/)
})
+
+ test('decodes base64 data URL source map pasted into textarea', async () => {
+ render()
+ const user = userEvent.setup()
+
+ const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })
+
+ const dataUrl = `data:application/json;base64,${btoa(regular.sourcemaps[0].content)}`
+
+ sourcemapTextarea.focus()
+ await user.paste(dataUrl)
+
+ const sourcemapList = await screen.findByRole('list', { name: /sourcemaps list/i })
+ expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('index-d803759c.js')
+ })
+
+ test('decodes base64 data URL with charset parameter', async () => {
+ render()
+ const user = userEvent.setup()
+
+ const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })
+
+ const dataUrl = `data:application/json;charset=utf-8;base64,${btoa(regular.sourcemaps[0].content)}`
+
+ sourcemapTextarea.focus()
+ await user.paste(dataUrl)
+
+ const sourcemapList = await screen.findByRole('list', { name: /sourcemaps list/i })
+ expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('index-d803759c.js')
+ })
+
+ test('shows warning for malformed base64 data URL', async () => {
+ render()
+ const user = userEvent.setup()
+
+ const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })
+
+ sourcemapTextarea.focus()
+ await user.paste('data:application/json;base64,!!!not-valid-base64!!!')
+
+ const warning = await screen.findByText(/provided text is not a source map/i)
+ expect(warning).toBeInTheDocument()
+
+ // The original (undecoded) value should remain in the textarea so the user can fix it.
+ expect(sourcemapTextarea).toHaveValue('data:application/json;base64,!!!not-valid-base64!!!')
+ })
+
+ test('treats plain source map text as non-base64', async () => {
+ render()
+ const user = userEvent.setup()
+
+ const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })
+
+ sourcemapTextarea.focus()
+ await user.paste(regular.sourcemaps[1].content)
+
+ const sourcemapList = await screen.findByRole('list', { name: /sourcemaps list/i })
+ expect(within(sourcemapList).getByRole('listitem')).toHaveTextContent('vendor-221d27ba.js')
+ })
})
describe('theme', () => {
diff --git a/src/app.tsx b/src/app.tsx
index b443a4c..1d7d1e9 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -9,6 +9,9 @@ import { ThemeToggle } from './theme-toggle.tsx'
import { useSourcemapsStore } from './use-sourcemaps-store.ts'
import { setTheme, useTheme } from './use-theme.ts'
+const base64PrefixRegex =
+ /^(?:\/\/# sourceMappingURL=)?data:application\/json;(?:charset=[^;]+;)?base64,/
+
export default function App() {
const [stackTraceInputValue, setStackTraceInputValue] = useState('')
const [sourceMapInputValue, setSourceMapInputValue] = useState('')
@@ -43,7 +46,19 @@ export default function App() {
}
async function handleSourceMapTextAreaChange(event: ChangeEvent) {
- const text = event.target.value
+ let text = event.target.value
+
+ if (base64PrefixRegex.test(text)) {
+ try {
+ const base64Content = text.replace(base64PrefixRegex, '')
+ const bytes = Uint8Array.from(atob(base64Content), c => c.charCodeAt(0))
+ text = new TextDecoder().decode(bytes)
+ } catch {
+ setIsSourceMapInputError(true)
+ setSourceMapInputValue(event.target.value)
+ return
+ }
+ }
setSourceMapInputValue(text)