diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 62ff6db1..d9a84aec 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -51,9 +51,15 @@ jobs: sudo apt-get update sudo apt-get install -y love squashfs-tools - - name: Install squashfs-tools (macOS) + - name: Install LÖVE and squashfs-tools (macOS) if: runner.os == 'macOS' - run: brew install squashfs + run: | + curl -L https://github.com/love2d/love/releases/download/11.5/love-11.5-macos.zip -o love-macos.zip + ditto -x -k love-macos.zip love-macos + sudo cp -R love-macos/love.app /Applications/love.app + sudo ln -sf /Applications/love.app/Contents/MacOS/love /usr/local/bin/love + love --version + brew install squashfs - name: Install dependencies run: npm ci diff --git a/.gitignore b/.gitignore index c655af86..d8351015 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ lerna-debug.log* node_modules dist +dist-showcase +.showcase-vendor dist-ssr *.local diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 00000000..4d5a02ac --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +set -eu + +if [ "${SKIP_PRE_PUSH_TESTS:-}" = "1" ]; then + echo "[feather] SKIP_PRE_PUSH_TESTS=1 set; skipping pre-push tests." + exit 0 +fi + +npm run test:cli:e2e +npm run test:lua:e2e +npm run test:app:e2e +npm run test:showcase:e2e diff --git a/CHANGELOG.md b/CHANGELOG.md index da4fd9bf..d2e537e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CLI Fastlane draft support - Lua plugin unit tests - Lua callback bus with priority overrides +- Standalone Showcase build for trying Shader Graph and Particle System Playground in the browser without connecting a game. +- Static love.js preview bridge for standalone shader and particle previews, including showcase build/preview scripts and a publishing workflow. +- Shader Graph custom function, texture input, texture uniform color, texture upload, and preview texture support. +- Expanded Shader Graph node library with complex math, quaternion operations, symmetry, random, pixel-perfect primitives, patterns, halftone, Lab color helpers, composite, Truchet tiles, improved SDF nodes, and broader vertex shader authoring. +- Added Shader Graph vector math nodes for component-wise `vec2`/`vec3`/`vec4` add, subtract, multiply, divide, and scalar scaling. +- Added Shader Graph right-click node insertion, preset insertion, dirty preset replacement confirmation, graph-local subgraph instances, and deterministic link suggestions. +- Shader Graph preset and UI improvements for graph authoring, node inspection, code preview, and standalone preview controls. +- Session Replay checkpoints, seeking, baseline restore, sparse state deltas, and Lua E2E coverage for replay flows. +- Pre-push hook that runs CLI E2E, Lua E2E, app Playwright E2E, and showcase Playwright E2E before pushing. ### Changed @@ -29,6 +38,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved CLI Linux vendoring - Updated roadmap - Shim hooks on load +- Improved standalone Shader Graph and Particle System Playground ergonomics, including local in-browser state for showcase mode. +- Improved Shader Graph WebGL preview adaptation for LÖVE shader syntax and precision-qualified uniforms. +- Improved Particle System Playground preview payload handling and emitter list behavior. +- Improved Lua E2E reliability in headless CI by disabling audio for E2E runs and making the Lua E2E timeout configurable. +- Improved audio-debug behavior when `love.audio` is unavailable, allowing headless smoke tests to run. +- Improved doctor reporting for dangerous installed plugins even when bundled catalog data is unavailable or stale. +- Moved standalone showcase deployment toward Railway hosting so love.js previews can be served with COOP/COEP/CSP headers. ### Fixed @@ -36,6 +52,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed optimistic plugin updates - Fixed input replay - Fixed stack overflow on LÖVE hooks +- Fixed Shader Graph `u_time` precision mismatch between vertex and fragment shaders in love.js/WebGL. +- Fixed Shader Graph rounded functions and generated GLSL/WebGL preview handling. +- Fixed standalone showcase Playwright coverage so the showcase test runs only in the dedicated showcase config and uses current preview iframe selectors. +- Fixed CLI Linux AppImage vendor extraction fallback to use `unsquashfs -offset` when direct AppImage execution fails or is non-native. +- Fixed CI-only Lua E2E ALSA/audio startup failures in xvfb environments. +- Fixed doctor E2E fixture setup for dangerous bundled plugin trust checks. + +### Tests + +- Added standalone showcase Playwright smoke test and dedicated showcase Playwright config. +- Added Lua plugin E2E smoke coverage, including session replay flows. +- Added CLI doctor trust coverage for unknown and dangerous installed plugins. +- Added CLI Linux AppImage fallback tests for `unsquashfs` offset detection and actionable extraction errors. +- Added pre-push local verification for CLI, Lua, app, and showcase E2E lanes. ## [v1.2.0] - 2026-05-19 - The one with the shader and particles playground diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ccfa8b30..4914dec6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -183,6 +183,17 @@ Focused guidance: If `love`, `luacheck`, Android SDK, Xcode, Fastlane, or other local tooling is missing, document that in the PR or final handoff. Do not pretend the check passed. +The pre-push hook runs the heavier local test lanes before pushing: + +```sh +npm run test:cli:e2e +npm run test:lua:e2e +npm run test:app:e2e +npm run test:showcase:e2e +``` + +When you need to push from a machine that cannot run those tools, use `SKIP_PRE_PUSH_TESTS=1 git push` and call out the skipped checks. + ## Generated Files Some files are generated and must stay in sync: diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 8b61f31a..451828ef 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -257,7 +257,8 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) add(checks, 'Project', 'feather.config.lua', 'warn', 'missing', 'Run `feather init` to create a shared config.'); } - const managedMode = configSource ? parseManagedValue(configSource, 'mode') : null; + const configManagedMode = typeof config?.managed === 'string' ? config.managed : null; + const managedMode = (configSource ? parseManagedValue(configSource, 'mode') : null) ?? configManagedMode; const managedInstallDir = configSource ? parseManagedValue(configSource, 'installDir') : null; const effectiveInstallDir = normalizeInstallDir(opts.installDir ?? managedInstallDir ?? installDir); const mode = typeof config?.mode === 'string' ? config.mode : 'socket'; @@ -386,7 +387,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } for (const dir of pluginDirs) { const manifest = readPluginManifest(dir); - if (!manifest?.id || !knownPluginIds.has(manifest.id)) continue; + if (!manifest?.id) continue; const trust = classifyPluginTrust(manifest, pluginCatalogById.get(manifest.id)); if (dangerousPluginIds.has(manifest.id)) { add( @@ -398,6 +399,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) `Remove before shipping with \`feather plugin remove ${manifest.id} --dir ${projectDirArg} --install-dir ${installDirArg} --yes\`.`, ); } else { + if (!knownPluginIds.has(manifest.id)) continue; add(checks, 'Plugins', `Plugin ${manifest.id} trust`, trust === 'bundled-opt-in' ? 'info' : 'pass', pluginTrustLabel(trust)); } } diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts index ab22c31a..75319234 100644 --- a/cli/src/lib/build/vendor.ts +++ b/cli/src/lib/build/vendor.ts @@ -445,18 +445,27 @@ async function installDesktopRuntime(target: 'windows' | 'macos' | 'linux' | 'st const result = spawnSync(loveAppImage, ['--appimage-extract'], { cwd: targetPath, encoding: 'utf8' }); if (!result.error) { if (result.status === 0 && existsSync(join(squashfsRoot, 'bin', 'love'))) return; - throw new Error((result.stderr || result.stdout || 'Failed to extract LÖVE AppImage.').trim()); + const offset = findSquashFsOffset(loveAppImage); + if (offset < 0) { + throw new Error((result.stderr || result.stdout || 'Failed to extract LÖVE AppImage.').trim()); + } + tryExtractAppImageWithUnsquashfs(loveAppImage, squashfsRoot, targetPath, offset); + return; } const code = (result.error as NodeJS.ErrnoException).code; - if (code !== 'ENOEXEC' && code !== 'ENOENT' && code !== 'EACCES') { + const offset = findSquashFsOffset(loveAppImage); + if (code !== 'ENOEXEC' && code !== 'ENOENT' && code !== 'EACCES' && offset < 0) { throw new Error(`Failed to extract LÖVE AppImage: ${result.error.message}`); } // Not on Linux — try unsquashfs with explicit SquashFS offset (squashfs-tools >= 4.4). // The macOS port doesn't auto-detect the offset, so we scan for the magic bytes ourselves. + tryExtractAppImageWithUnsquashfs(loveAppImage, squashfsRoot, targetPath, offset); +} + +function tryExtractAppImageWithUnsquashfs(loveAppImage: string, squashfsRoot: string, targetPath: string, offset: number): void { if (existsSync(squashfsRoot)) rmSync(squashfsRoot, { recursive: true }); - const offset = findSquashFsOffset(loveAppImage); const unsquashArgs = offset >= 0 ? ['-offset', String(offset), '-d', squashfsRoot, loveAppImage] : ['-d', squashfsRoot, loveAppImage]; diff --git a/cli/test/commands/doctor.test.mjs b/cli/test/commands/doctor.test.mjs index 43e250e2..86e45221 100644 --- a/cli/test/commands/doctor.test.mjs +++ b/cli/test/commands/doctor.test.mjs @@ -46,8 +46,7 @@ test('doctor --json reports dangerous bundled plugin trust', () => { writeGame(dir); writeMinimalRuntime(dir); writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); - const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); - assert.equal(result.exitCode, 0, outputOf(result)); + writeLocalPluginSource(join(dir, 'feather'), 'console', { name: 'Console' }); const parsed = parseDoctorJson(dir); const labels = new Map(parsed.checks.map((check) => [check.label, check])); @@ -105,6 +104,28 @@ return { assert.equal(labels.has('Plugin shader-graph'), false); }); +test('doctor --json treats managed = cli as bundled without legacy metadata comments', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + appId = "feather-app-test-1234567890", + managed = "cli", + include = { "console", "shader-graph" }, +} +`, + ); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Embedded Feather runtime').severity, 'info'); + assert.equal(labels.get('CLI-managed plugins').severity, 'pass'); + assert.equal(labels.get('CLI-managed plugins').detail, '2 included from bundled runtime'); + assert.equal(labels.has('Plugin console'), false); + assert.equal(labels.has('Plugin shader-graph'), false); +}); + test('doctor --json reports malformed plugin manifests with recovery text', () => { const dir = makeTmp(); writeGame(dir); diff --git a/docs/standalone-showcase.md b/docs/standalone-showcase.md new file mode 100644 index 00000000..996344ba --- /dev/null +++ b/docs/standalone-showcase.md @@ -0,0 +1,12 @@ +# Standalone Showcase + +The standalone showcase is a browser-hosted version of Feather's visual authoring +tools. It lets you try the Shader Graph and Particle System Playground without +starting the desktop app or connecting a local LÖVE project. + +[Open the standalone showcase](https://feather-showcase.up.railway.app/) + +Use it to experiment with shader nodes, preview generated GLSL, tune particle +emitters, and see how the tools feel before wiring them into a running game. The +showcase runs separately from the documentation site so its LÖVE preview can use +the browser isolation headers required by love.js. diff --git a/e2e/showcase.spec.ts b/e2e/showcase.spec.ts new file mode 100644 index 00000000..cbf8cfce --- /dev/null +++ b/e2e/showcase.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; + +test('standalone showcase loads the landing page and tools', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: /browser-native authoring tools/i })).toBeVisible(); + + await page.locator('header').getByRole('button', { name: /^shader graph$/i }).click(); + await expect(page.getByRole('heading', { name: 'Shader Graph' })).toBeVisible(); + await expect(page.getByText('GLSL Output')).toBeVisible(); + await expect(page.frameLocator('iframe[title="Shader Preview"]').locator('canvas')).toBeVisible(); + + const nodesBeforeInsert = await page.locator('.react-flow__node').count(); + await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 300, y: 220 } }); + await expect(page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes')).toBeVisible(); + await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('time'); + await page.getByTestId('shader-node-picker').getByRole('button', { name: /^time input$/i }).click(); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 1); + await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 420, y: 220 } }); + await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('float'); + await page.getByTestId('shader-node-picker').getByRole('button', { name: /^float input$/i }).click(); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 2); + await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 260, y: 320 } }); + await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('float parameter'); + await page.getByTestId('shader-node-picker').getByRole('button', { name: /^float parameter input$/i }).click(); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 3); + await expect(page.getByText(/extern number u_param_/i)).toBeVisible(); + await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 380, y: 320 } }); + await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('color parameter'); + await page.getByTestId('shader-node-picker').getByRole('button', { name: /^color parameter input$/i }).click(); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 4); + await expect(page.getByText(/extern vec4 u_param_/i)).toBeVisible(); + await page.keyboard.press('Control+C'); + await page.keyboard.press('Control+V'); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 5); + await expect(page.getByRole('button', { name: /unlink linked node/i })).toHaveCount(1); + await page.keyboard.press('Control+D'); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 6); + await expect(page.getByRole('button', { name: /unlink linked node/i })).toHaveCount(2); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toContain('Unlink this node?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: /unlink linked node/i }).first().click(); + await expect(page.getByRole('button', { name: /unlink linked node/i })).toHaveCount(1); + + const nodesBeforePreset = await page.locator('.react-flow__node').count(); + await page.getByText('Insert preset').click(); + await page.getByRole('option', { name: /texture pass/i }).click(); + await expect.poll(() => page.locator('.react-flow__node').count()).toBeGreaterThan(nodesBeforePreset); + + await page.locator('header').getByRole('button', { name: /particle playground/i }).click(); + await expect(page.getByRole('heading', { name: 'Particles Playground' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Emitter Properties' })).toBeVisible(); + await expect(page.getByText('Rate')).toBeVisible(); + await expect(page.frameLocator('iframe[title="Particle Preview"]').locator('canvas')).toBeVisible(); +}); diff --git a/mkdocs.yml b/mkdocs.yml index 241a102d..c1655d47 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,7 @@ nav: - Assets: assets.md - Particle System Playground: particle-system-playground.md - Shader Graph: shader-graph.md + - Standalone Showcase: standalone-showcase.md - Time Travel: time-travel.md - Session Replay: session-replay.md - Recommendations: recommendations.md diff --git a/package-lock.json b/package-lock.json index 8e65ea81..d2a239d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,7 +84,7 @@ }, "cli": { "name": "@kyonru/feather", - "version": "1.2.0", + "version": "1.3.0", "license": "EPL-2.0", "dependencies": { "chalk": "5.3.0", @@ -13003,7 +13003,7 @@ }, "vscode-extension": { "name": "feather-vscode", - "version": "1.2.0", + "version": "1.3.0", "license": "EPL-2.0", "devDependencies": { "@types/node": "22.15.0", diff --git a/package.json b/package.json index 2bf38c40..282e7469 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:cli:e2e": "npm run cli:build && node --test cli/test/commands/*.test.mjs", "test:lua:e2e": "node scripts/lua-e2e.mjs", "test:app:e2e": "playwright test", + "test:showcase:e2e": "playwright test -c playwright.showcase.config.ts", "test:tauri:e2e": "cargo test --manifest-path src-tauri/Cargo.toml ws_server", "extension:prepare": "npm run cli:build && npm run bundle:lua --workspace=cli && node vscode-extension/scripts/prepare.mjs", "extension:build": "npm run extension:prepare && tsc -p vscode-extension/tsconfig.json", @@ -29,6 +30,11 @@ "lua": "sh ./scripts/watch-lua.sh", "web": "vite", "build": "tsc && vite build", + "showcase:dev": "vite --config vite.showcase.config.ts", + "showcase:build": "tsc --noEmit -p tsconfig.json --composite false && vite build --config vite.showcase.config.ts && node scripts/prepare-showcase-lovejs.mjs && node scripts/build-showcase-love.mjs", + "showcase:prepare-lovejs": "node scripts/prepare-showcase-lovejs.mjs", + "showcase:preview": "vite preview --config vite.showcase.config.ts", + "showcase:start": "node scripts/serve-showcase.mjs", "preview": "vite preview", "typecheck:web": "tsc --noEmit -p tsconfig.json --composite false", "typecheck:lua": "luacheck src-lua", diff --git a/playwright.config.ts b/playwright.config.ts index 0455e794..70434348 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', + testIgnore: /showcase\.spec\.ts/, timeout: 30_000, expect: { timeout: 5_000, diff --git a/playwright.showcase.config.ts b/playwright.showcase.config.ts new file mode 100644 index 00000000..a0fb49fd --- /dev/null +++ b/playwright.showcase.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: /showcase\.spec\.ts/, + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: true, + // @ts-expect-error process is only defined in Node.js environment, and this config file is not bundled + reporter: process?.env.CI ? [['github'], ['list']] : 'list', + use: { + baseURL: 'http://127.0.0.1:4174', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + webServer: { + command: 'npm run showcase:build && npm run showcase:preview -- --host 127.0.0.1', + url: 'http://127.0.0.1:4174', + // @ts-expect-error process is only defined in Node.js environment, and this config file is not bundled + reuseExistingServer: !process?.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/public/showcase-lovejs/index.html b/public/showcase-lovejs/index.html new file mode 100644 index 00000000..268c62f5 --- /dev/null +++ b/public/showcase-lovejs/index.html @@ -0,0 +1,12 @@ + + +
+ + +`); + } + await writeFile(indexPath, next); +} diff --git a/scripts/serve-showcase.mjs b/scripts/serve-showcase.mjs new file mode 100644 index 00000000..41ce989d --- /dev/null +++ b/scripts/serve-showcase.mjs @@ -0,0 +1,100 @@ +/* eslint-disable no-undef */ +import { createReadStream, existsSync } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const publicDir = path.join(root, 'dist-showcase'); +const port = Number(process.env.PORT || 3000); +const host = process.env.HOST || '0.0.0.0'; + +const contentTypes = new Map([ + ['.css', 'text/css; charset=utf-8'], + ['.gif', 'image/gif'], + ['.html', 'text/html; charset=utf-8'], + ['.ico', 'image/x-icon'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.js', 'text/javascript; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.love', 'application/octet-stream'], + ['.map', 'application/json; charset=utf-8'], + ['.png', 'image/png'], + ['.svg', 'image/svg+xml; charset=utf-8'], + ['.wasm', 'application/wasm'], + ['.webp', 'image/webp'], + ['.worker.js', 'text/javascript; charset=utf-8'], +]); + +const sharedHeaders = { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Content-Security-Policy': + "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' blob: data:; worker-src 'self' blob:; child-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'", +}; + +if (!existsSync(publicDir)) { + console.error('[showcase] dist-showcase does not exist. Run `npm run showcase:build` before starting the server.'); + process.exit(1); +} + +const server = createServer(async (request, response) => { + try { + const filePath = await resolvePath(request.url || '/'); + const extension = path.extname(filePath); + const type = filePath.endsWith('.worker.js') + ? contentTypes.get('.worker.js') + : contentTypes.get(extension) || 'application/octet-stream'; + + response.writeHead(200, { + ...sharedHeaders, + 'Content-Type': type, + 'Cache-Control': extension === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable', + }); + createReadStream(filePath).pipe(response); + } catch (error) { + const status = error?.code === 'ENOENT' ? 404 : 500; + response.writeHead(status, { + ...sharedHeaders, + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-cache', + }); + response.end(status === 404 ? 'Not found\n' : 'Internal server error\n'); + } +}); + +server.listen(port, host, () => { + console.log(`[showcase] Serving ${publicDir} on http://${host}:${port}`); +}); + +async function resolvePath(rawUrl) { + const pathname = new URL(rawUrl, 'http://localhost').pathname; + const decoded = decodeURIComponent(pathname); + const requested = decoded.endsWith('/') ? `${decoded}index.html` : decoded; + const candidate = path.resolve(publicDir, `.${requested}`); + + if (!candidate.startsWith(`${publicDir}${path.sep}`) && candidate !== publicDir) { + const error = new Error('Path escapes public directory'); + error.code = 'ENOENT'; + throw error; + } + + try { + const info = await stat(candidate); + if (info.isFile()) return candidate; + } catch { + // Fall through to SPA fallback. + } + + if (!path.extname(candidate)) { + const fallback = path.join(publicDir, 'index.html'); + if (existsSync(fallback)) return fallback; + } + + const error = new Error('File not found'); + error.code = 'ENOENT'; + throw error; +} diff --git a/showcase.html b/showcase.html new file mode 100644 index 00000000..eeed532c --- /dev/null +++ b/showcase.html @@ -0,0 +1,12 @@ + + +
+ + +
+ +
+