diff --git a/.github/actions/slogx-replay/README.md b/.github/actions/slogx-replay/README.md deleted file mode 100644 index f0892b4..0000000 --- a/.github/actions/slogx-replay/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# slogx replay publisher - -Publishes a slogx CI-mode NDJSON file to a single artifacts branch and comments a -replay link on the pull request. This is intended for public repositories so the -raw GitHub URL is accessible from the browser. - -## Usage - -```yaml -- name: Publish slogx replay - uses: ./.github/actions/slogx-replay - with: - log_path: ./slogx_logs/my-service.ndjson - replay_base_url: https://example.com/replay.html - github_token: ${{ secrets.GITHUB_TOKEN }} -``` - -### Recommended workflow permissions - -```yaml -permissions: - contents: write - pull-requests: write -``` - -## Inputs - -- `log_paths`: Comma or newline-separated list of NDJSON log file paths. -- `replay_base_url`: Base URL to replay.html (default `https://binhonglee.github.io/slogx/replay.html`). -- `github_token`: Token with `contents:write` and `pull-requests:write`. -- `artifact_branch`: Branch used to store logs (default `slogx-artifacts`). -- `artifact_dir`: Directory inside the artifacts branch (default `ci-logs`). -- `artifact_name`: Name used in the stored filename (default `slogx`). -- `max_runs`: Maximum number of run IDs to keep (default `500`). -- `pr_number`: PR number to comment on (optional). -- `comment`: Whether to comment on the PR (default `true`). -- `commit_message`: Commit message for artifact updates. -- `git_user_name`, `git_user_email`: Identity for artifact commits. - -## Outputs - -- `raw_urls`: Raw GitHub URLs for all files (newline-separated). -- `replay_urls`: Replay links for all files (newline-separated). -- `artifact_paths`: Paths for all files within the artifacts branch (newline-separated). diff --git a/.github/workflows/test_slogx_replay.yml b/.github/workflows/test_slogx_replay.yml index de0824f..fe12498 100644 --- a/.github/workflows/test_slogx_replay.yml +++ b/.github/workflows/test_slogx_replay.yml @@ -36,7 +36,7 @@ jobs: head -10 slogx_logs/auth-service.ndjson - name: Test slogx-replay action - uses: ./.github/actions/slogx-replay + uses: ./replay with: log_paths: ./sdk/ts/slogx_logs/auth-service.ndjson github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/web_ui.yml b/.github/workflows/web_ui.yml index e78f315..833ec90 100644 --- a/.github/workflows/web_ui.yml +++ b/.github/workflows/web_ui.yml @@ -57,7 +57,7 @@ jobs: run: npx playwright install --with-deps - name: Run E2E tests - run: SLOGX_PYTHON="$GITHUB_WORKSPACE/.venv/bin/python" npm run test:e2e + run: npm run test:e2e - uses: actions/upload-artifact@v6 if: always() diff --git a/README.md b/README.md index c6f4f6e..e71fa02 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,57 @@ ![](public/assets/full_logo.png) -# slogx — `console.log()` but better for backend debugging +# slogx — good ol' print debugging, but better -slogx is a tool that streams structured logs from your backend to a web UI over WebSockets. It's designed to make it trivial for developers to debug locally. +slogx is a structured logging toolkit for backend developers. One SDK gives you two ways to view logs: stream them live to a browser UI during local development, or write them to a file during CI and replay them later. Same logging calls, different outputs depending on the environment. https://github.com/user-attachments/assets/616ddfb8-20f5-48fe-be58-0dd64e3a0fa3 -**Why use slogx?** -- Fast setup: install the SDK and call `init()` once. No external agents or complicated config. -- Structured logs: captures arguments, stack traces, and source metadata (file/line/function/service). -- Language support: first-class SDK examples for Node, Python, Go, and Rust. -- WebSocket-first: low-latency streaming to the browser for immediate debugging. +## Quickstart -**Quickstart (run UI + demo backend)** +Install the SDK for your language, call `init()` once, and start logging: -- Install dependencies and start the UI: +```js +// npm install @binhonglee/slogx +import { slogx } from '@binhonglee/slogx'; -```bash -git clone https://github.com/binhonglee/slogx.git -cd slogx -npm install -npm run dev +slogx.init({ isDev: true, port: 8080, serviceName: 'api' }); +slogx.info('Server started', { port: 8080 }); ``` -- Start the demo backend (TypeScript demo included): +To view logs locally, run the UI: ```bash -npm run server +git clone https://github.com/binhonglee/slogx.git +cd slogx && npm install && npm run dev ``` -- Open the UI in your browser (Vite usually serves at `http://localhost:3000`). Use the Setup modal to add `ws://localhost:8083` (or the port printed by the demo server) and watch logs stream in real time. +Open `http://localhost:3000/app.html` and connect to `localhost:8080`. + +## SDK Reference -Note: the `server` script uses the TypeScript demo in `sdk/ts/server.ts` which initializes a local log server and emits sample log events. +### init() options -**Minimal integration (copy-paste)** +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `isDev` | boolean | required | Safety flag to prevent accidental production use | +| `port` | number | 8080 | WebSocket server port (live mode) | +| `serviceName` | string | required | Identifies the service in logs | +| `ciMode` | boolean | auto | Force CI mode; auto-detects CI environments if not set | +| `logFilePath` | string | `./slogx_logs/.ndjson` | Output file path for CI mode | +| `maxEntries` | number | 10000 | Max log entries before rolling (CI mode) | -Pick your language and add the SDK snippet below. Each SDK provides: -- `init(isDev, port, serviceName)` — starts a WebSocket server on the given port (default 8080). The `isDev` flag is required to prevent accidental production use. -- logging helpers: `debug`, `info`, `warn`, `error` that accept message strings, objects, Error/Exception values, or multiple arguments. +### Logging methods -- Node (local/dev): +All SDKs provide: `debug`, `info`, `warn`, `error` +Each accepts a message string and optional data (objects, errors, arrays). + +### Language examples + +**Node.js** ```js -// Requires: npm install @binhonglee/slogx -import { slogx } from 'slogx'; +// npm install @binhonglee/slogx +import { slogx } from '@binhonglee/slogx'; slogx.init({ isDev: process.env.NODE_ENV !== 'production', @@ -55,10 +63,9 @@ slogx.info('Server started', { env: process.env.NODE_ENV }); slogx.error('Operation failed', new Error('timeout')); ``` -- Python (local/dev): - +**Python** ```py -# Requires: pip install slogx +# pip install slogx import os from slogx import slogx @@ -70,10 +77,9 @@ slogx.init( slogx.info('Started', {'env': 'dev'}) ``` -- Go (local/dev): - +**Go** ```go -// Requires: go get github.com/binhonglee/slogx +// go get github.com/binhonglee/slogx import ( "os" "github.com/binhonglee/slogx" @@ -89,10 +95,9 @@ func main() { } ``` -- Rust (local/dev): - +**Rust** ```rust -// Requires: cargo add slogx +// cargo add slogx #[tokio::main] async fn main() { let is_dev = std::env::var("ENV").unwrap_or_default() != "production"; @@ -101,19 +106,81 @@ async fn main() { } ``` -If you don't want to run the SDK server inside your app, you can run the demo server from `sdk/ts/server.ts` or adapt the SDK to connect to a central logging service that forwards messages to the UI. +## Viewing Modes + +### Live Mode + +In live mode (the default), the SDK starts a WebSocket server. Connect the slogx UI to see logs as they happen. + +1. Your app calls `slogx.init()` — this starts a WebSocket server +2. Open the slogx UI (`/app.html`) +3. Enter your server's address (e.g., `localhost:8080`) +4. Watch logs stream in real-time + +The UI auto-reconnects if the connection drops. + +### Replay Mode (CI) + +In CI mode, logs are written to an NDJSON file instead of being streamed. You can replay them later in the browser. + +**Enable CI mode:** +```js +slogx.init({ + isDev: true, + serviceName: 'api', + ciMode: true, // Force CI mode + logFilePath: './slogx_logs/api.ndjson' +}); +``` + +**Or let it auto-detect** — the SDK checks for these environment variables: +- `CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `JENKINS_HOME`, `CIRCLECI`, `BUILDKITE`, `TF_BUILD`, `TRAVIS` -**WebSocket & message format** +**View replay logs:** +1. Open the replay UI (`/replay.html`) +2. Drop in an `.ndjson` file or paste a URL +3. Browse logs with the same filtering and search as live mode -slogx streams JSON log entries over WebSockets. The UI will accept either a single JSON object or an array of objects per message. Each log entry follows this schema (fields produced by SDKs in this repo): +## GitHub Action + +Automatically publish CI logs and comment a replay link on PRs: + +```yaml +# .github/workflows/test.yml +- name: Run tests + run: npm test # Your app logs with ciMode: true + +- name: Publish slogx replay + uses: binhonglee/slogx/replay@main + with: + log_paths: ./slogx_logs/*.ndjson + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +This pushes log files to a `slogx-artifacts` branch and comments a replay link on the PR. + +**Action options:** + +| Input | Default | Description | +|-------|---------|-------------| +| `log_paths` | required | Comma-separated paths to NDJSON files | +| `github_token` | required | Token with `contents:write` and `pull-requests:write` | +| `replay_base_url` | `https://binhonglee.github.io/slogx/replay.html` | URL to replay viewer | +| `artifact_branch` | `slogx-artifacts` | Branch for storing log files | +| `max_runs` | `500` | Max CI runs to keep before pruning | +| `comment` | `true` | Whether to comment on the PR | + +## Message Format + +Log entries are JSON objects with this schema: ```json { "id": "", "timestamp": "2025-12-22T12:34:56.789Z", "level": "INFO|DEBUG|WARN|ERROR", - "args": [ /* JSON-serializable values; strings, objects, arrays */ ], - "stacktrace": "optional full stack or call-site frames", + "args": [ /* JSON-serializable values */ ], + "stacktrace": "optional stack trace", "metadata": { "file": "handler.go", "line": 123, @@ -124,11 +191,13 @@ slogx streams JSON log entries over WebSockets. The UI will accept either a sing } ``` -Important implementation notes: -- The frontend `validateWsUrl()` normalizes `http://` -> `ws://` and `https://` -> `wss://`, accepts raw host:port, and supports relative paths (e.g. `/slogx`). -- The UI automatically reconnects if the WebSocket closes. +In live mode, entries are sent over WebSocket (single object or array per message). In CI mode, entries are written as newline-delimited JSON (NDJSON). + +## Testing & Development -**Testing & debugging** -- Unit tests: `npm run test` runs Vitest unit tests. -- E2E tests: `npm run test:e2e` runs Playwright tests. -- Coverage output placed under `coverage/`. +```bash +npm run test # Unit tests (Vitest) +npm run test:e2e # E2E tests (Playwright) +npm run dev # Start dev server +npm run build # Build standalone HTML files +``` diff --git a/app.html b/app.html new file mode 100644 index 0000000..c3b442e --- /dev/null +++ b/app.html @@ -0,0 +1,13 @@ + + + + + + slogx + + + +
+ + + diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 9cbdf06..63d312f 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; test.describe('App Loading', () => { test('loads the application', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); // Check header elements await expect(page.locator('.logo img')).toBeVisible(); @@ -10,14 +10,14 @@ test.describe('App Loading', () => { }); test('shows empty state when no connections', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); await expect(page.getByText('No Active Data Sources')).toBeVisible(); await expect(page.getByText('Connect to a backend service using the input above, or enable Demo to see sample data.')).toBeVisible(); }); test('has filter bar with level buttons', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); await expect(page.getByRole('button', { name: 'DEBUG' })).toBeVisible(); await expect(page.getByRole('button', { name: 'INFO' })).toBeVisible(); @@ -28,7 +28,7 @@ test.describe('App Loading', () => { test.describe('Demo Mode', () => { test('enables demo mode and shows logs', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); // Click demo button await page.getByRole('button', { name: 'Demo' }).click(); @@ -38,7 +38,7 @@ test.describe('Demo Mode', () => { }); test('demo button shows active state when enabled', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); const demoButton = page.getByRole('button', { name: 'Demo' }); await demoButton.click(); @@ -47,7 +47,7 @@ test.describe('Demo Mode', () => { }); test('can disable demo mode', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); const demoButton = page.getByRole('button', { name: 'Demo' }); @@ -63,7 +63,7 @@ test.describe('Demo Mode', () => { test.describe('Log Filtering', () => { test.beforeEach(async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); await page.getByRole('button', { name: 'Demo' }).click(); // Wait for some logs await expect(page.locator('.log-item')).toBeVisible({ timeout: 5000 }); @@ -117,7 +117,7 @@ test.describe('Log Filtering', () => { test.describe('Log Selection', () => { test.beforeEach(async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); await page.getByRole('button', { name: 'Demo' }).click(); await expect(page.locator('.log-item')).toBeVisible({ timeout: 5000 }); }); @@ -163,7 +163,7 @@ test.describe('Log Selection', () => { test.describe('Pause/Resume', () => { test.beforeEach(async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); await page.getByRole('button', { name: 'Demo' }).click(); await expect(page.locator('.log-item')).toBeVisible({ timeout: 5000 }); }); @@ -194,7 +194,7 @@ test.describe('Pause/Resume', () => { test.describe('Clear Logs', () => { test('can clear all logs', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); await page.getByRole('button', { name: 'Demo' }).click(); await expect(page.locator('.log-item')).toBeVisible({ timeout: 5000 }); @@ -214,7 +214,7 @@ test.describe('Clear Logs', () => { test.describe('Setup Modal', () => { test('can open setup modal', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); // Click settings button await page.locator('.header-actions .btn-icon').click(); @@ -224,7 +224,7 @@ test.describe('Setup Modal', () => { }); test('setup modal has language tabs', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); await page.locator('.header-actions .btn-icon').click(); await expect(page.getByRole('button', { name: /Node/ })).toBeVisible(); @@ -233,7 +233,7 @@ test.describe('Setup Modal', () => { }); test('can close setup modal', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); await page.locator('.header-actions .btn-icon').click(); await expect(page.locator('.modal')).toBeVisible(); @@ -245,7 +245,7 @@ test.describe('Setup Modal', () => { }); test('can switch between language tabs', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); await page.locator('.header-actions .btn-icon').click(); await expect(page.locator('.modal')).toBeVisible(); @@ -262,7 +262,7 @@ test.describe('Setup Modal', () => { test.describe('Connection Manager', () => { test('has connection input', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); const input = page.locator('.connection-form input'); await expect(input).toBeVisible(); @@ -270,7 +270,7 @@ test.describe('Connection Manager', () => { }); test('shows validation error for invalid URL', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); const input = page.locator('.connection-form input'); await input.fill('invalid url with spaces'); @@ -283,7 +283,7 @@ test.describe('Connection Manager', () => { }); test('clears validation error when typing', async ({ page }) => { - await page.goto('/'); + await page.goto('/app.html'); const input = page.locator('.connection-form input'); await input.fill('invalid url with spaces'); diff --git a/e2e/sdk-ws.spec.ts b/e2e/sdk-ws.spec.ts index 0cb184b..3c5fe9a 100644 --- a/e2e/sdk-ws.spec.ts +++ b/e2e/sdk-ws.spec.ts @@ -8,7 +8,7 @@ test.describe('SDK WebSocket Integration', () => { const port = await getAvailablePort(0); const proc = await startSdkProcess(`${sdk.name} WS`, sdk.wsCommand(port), 30000); try { - await page.goto('/'); + await page.goto('/app.html'); const input = page.locator('.connection-form input'); await input.fill(`localhost:${port}`); diff --git a/index.html b/index.html index f7e2ca6..5919b34 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,119 @@ - - - - slogx - - - -
- - + + + + slogx - good ol' print debugging, but better + + + + +
+ +
+
+
+ +
+

good ol' print debugging — but better

+

Stream structured logs from your backend to a browser UI. Replay them from CI.

+ +
+
+ + +
+
+
+ + + +
+

Live Mode

+

Connect to your running server via WebSocket. See logs as they happen.

+ Open Live Viewer +
+
+
+ + + +
+

Replay Mode

+

Write logs to NDJSON during test runs. Replay them later with a link in your PR.

+ Open Replay Viewer +
+
+ + +
+

Get started in 2 lines

+
+ + + + +
+
+
// npm install @binhonglee/slogx
+import { slogx } from '@binhonglee/slogx';
+
+slogx.init({ isDev: true, port: 8080, serviceName: 'api' });
+slogx.info('ready', { port: 8080 });
+
# pip install slogx
+from slogx import slogx
+
+slogx.init(is_dev=True, port=8080, service_name='api')
+slogx.info('ready', {'port': 8080})
+
// go get github.com/binhonglee/slogx
+import "github.com/binhonglee/slogx"
+
+slogx.Init(slogx.Config{IsDev: true, Port: 8080, ServiceName: "api"})
+slogx.Info("ready", map[string]any{"port": 8080})
+
// cargo add slogx
+slogx::init(true, 8080, "api").await;
+slogx::info!("ready", { "port": 8080 });
+
+

Works in live mode by default. Add ciMode: true for CI.

+
+ + +
+

Replay logs from CI

+

Run tests, capture logs, get a replay link in your PR.

+
# .github/workflows/test.yml
+- uses: binhonglee/slogx/replay@main
+  with:
+    log_paths: ./slogx_logs/*.ndjson
+    github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ + + +
+ + + diff --git a/index.tsx b/main.tsx similarity index 91% rename from index.tsx rename to main.tsx index 6621fcc..ab38f1e 100644 --- a/index.tsx +++ b/main.tsx @@ -10,4 +10,4 @@ if (!rootElement) { throw new Error("Could not find root element to mount to"); } -render(, rootElement); \ No newline at end of file +render(, rootElement); diff --git a/package.json b/package.json index a58624b..825db9b 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui" + "test:e2e": "SLOGX_PYTHON=\"./.venv/bin/python\" playwright test", + "test:e2e:ui": "SLOGX_PYTHON=\"./.venv/bin/python\" playwright test --ui" }, "dependencies": { "lucide-preact": "0.469.0", diff --git a/replay/README.md b/replay/README.md new file mode 100644 index 0000000..28a6cf2 --- /dev/null +++ b/replay/README.md @@ -0,0 +1,93 @@ +# slogx replay publisher + +Publishes slogx CI-mode NDJSON log files to a branch and comments a replay link on your PR. + +## Usage + +```yaml +- uses: binhonglee/slogx/replay@main + with: + log_paths: ./slogx_logs/*.ndjson + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +## How it works + +1. Copies NDJSON log files to an artifacts branch (`slogx-artifacts` by default) +2. Prunes old logs to stay within `max_runs` limit +3. Comments on the PR with a link to the replay viewer + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `log_paths` | Yes | - | Comma or newline-separated list of NDJSON file paths | +| `github_token` | Yes | - | Token with `contents:write` and `pull-requests:write` | +| `replay_base_url` | No | `https://binhonglee.github.io/slogx/replay.html` | URL to the replay viewer | +| `artifact_branch` | No | `slogx-artifacts` | Branch for storing log files | +| `artifact_dir` | No | `ci-logs` | Directory within the artifacts branch | +| `artifact_name` | No | `slogx` | Name used in the stored filename | +| `max_runs` | No | `500` | Max CI runs to keep before pruning old logs | +| `pr_number` | No | Auto-detected | PR number to comment on | +| `comment` | No | `true` | Whether to comment on the PR | +| `commit_message` | No | `chore(slogx): add CI log` | Commit message for artifact updates | +| `git_user_name` | No | `slogx-bot` | Git user.name for commits | +| `git_user_email` | No | `slogx-bot@users.noreply.github.com` | Git user.email for commits | + +## Outputs + +| Output | Description | +|--------|-------------| +| `raw_urls` | Raw GitHub URLs for the NDJSON files (newline-separated) | +| `replay_urls` | Replay URLs with `?url=` prefilled (newline-separated) | +| `artifact_paths` | Paths to files within the artifacts branch (newline-separated) | + +## Permissions + +Your workflow needs these permissions: + +```yaml +permissions: + contents: write # Push to artifacts branch + pull-requests: write # Comment on PRs +``` + +## Example workflow + +```yaml +name: Test + +on: + pull_request: + +permissions: + contents: write + pull-requests: write + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run tests + run: npm test # Your app logs with ciMode: true + + - name: Publish slogx replay + uses: binhonglee/slogx/replay@main + with: + log_paths: ./slogx_logs/*.ndjson + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Self-hosted replay viewer + +To use your own replay viewer: + +```yaml +- uses: binhonglee/slogx/replay@main + with: + log_paths: ./slogx_logs/*.ndjson + github_token: ${{ secrets.GITHUB_TOKEN }} + replay_base_url: https://your-domain.com/replay.html +``` diff --git a/.github/actions/slogx-replay/action.yml b/replay/action.yml similarity index 99% rename from .github/actions/slogx-replay/action.yml rename to replay/action.yml index 7396d77..36226cc 100644 --- a/.github/actions/slogx-replay/action.yml +++ b/replay/action.yml @@ -1,5 +1,9 @@ name: "slogx replay publisher" description: "Publish slogx CI-mode NDJSON to a branch and comment a replay link" +author: "binhonglee" +branding: + icon: "play-circle" + color: "blue" inputs: log_paths: description: "Comma or newline-separated list of NDJSON log file paths" diff --git a/scripts/build.js b/scripts/build.js index 952cb6d..4375939 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -9,7 +9,7 @@ import { build } from 'vite'; import preact from '@preact/preset-vite'; import path from 'path'; import { fileURLToPath } from 'url'; -import { existsSync, mkdirSync, rmSync } from 'fs'; +import { existsSync, mkdirSync, rmSync, copyFileSync } from 'fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.join(__dirname, '..'); @@ -22,7 +22,7 @@ if (existsSync(distDir)) { mkdirSync(distDir); const entries = [ - { name: 'main', input: 'index.html' }, + { name: 'app', input: 'app.html' }, { name: 'replay', input: 'replay.html' }, ]; @@ -54,6 +54,10 @@ for (const entry of entries) { }); } +// Copy static landing page to dist +console.log('\nCopying landing page...'); +copyFileSync(path.join(rootDir, 'index.html'), path.join(distDir, 'index.html')); + // Run the inline script console.log('\nInlining assets...'); const { execSync } = await import('child_process'); diff --git a/scripts/inline.js b/scripts/inline.js index cd2e1d8..e7054b0 100644 --- a/scripts/inline.js +++ b/scripts/inline.js @@ -1,17 +1,37 @@ #!/usr/bin/env node /** * Inlines all JS, CSS, and images from Vite build into standalone HTML files. - * Run after `vite build` to produce self-contained dist/index.html and dist/replay.html + * Run after `vite build` to produce self-contained dist/app.html, dist/replay.html, and dist/index.html */ import { readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, existsSync } from 'fs'; import { join } from 'path'; -const distDir = join(import.meta.dirname, '..', 'dist'); +const rootDir = join(import.meta.dirname, '..'); +const distDir = join(rootDir, 'dist'); const assetsDir = join(distDir, 'assets'); +// Read source files for landing page +const landingCss = existsSync(join(rootDir, 'styles', 'landing.css')) + ? readFileSync(join(rootDir, 'styles', 'landing.css'), 'utf-8') + : ''; + +// Read source images for landing page +const sourceImages = {}; +const publicAssetsDir = join(rootDir, 'public', 'assets'); +if (existsSync(publicAssetsDir)) { + for (const img of readdirSync(publicAssetsDir).filter(f => f.endsWith('.png') || f.endsWith('.jpg') || f.endsWith('.svg'))) { + const imgData = readFileSync(join(publicAssetsDir, img)); + const ext = img.split('.').pop(); + const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`; + sourceImages[img] = `data:${mimeType};base64,${imgData.toString('base64')}`; + } +} + if (!existsSync(assetsDir)) { - console.log('No assets directory found, skipping inline step'); + // No Vite assets, but still process landing page + processLandingPage(); + console.log('No Vite assets directory found, only processed landing page'); process.exit(0); } @@ -85,8 +105,34 @@ function processHtmlFile(htmlFileName) { return html; } +/** + * Process the landing page: inline CSS and images from source + */ +function processLandingPage() { + const htmlPath = join(distDir, 'index.html'); + if (!existsSync(htmlPath)) { + console.log('Skipping index.html (not found)'); + return null; + } + + let html = readFileSync(htmlPath, 'utf-8'); + + // Replace source image references + for (const [img, dataUrl] of Object.entries(sourceImages)) { + html = html.split(`/assets/${img}`).join(dataUrl); + } + + // Inline landing CSS + html = html.replace( + //, + `` + ); + + return html; +} + // Process HTML files -const htmlFiles = ['index.html', 'replay.html']; +const htmlFiles = ['app.html', 'replay.html']; const results = []; for (const htmlFile of htmlFiles) { @@ -99,6 +145,15 @@ for (const htmlFile of htmlFiles) { } } +// Process landing page +const landingResult = processLandingPage(); +if (landingResult) { + const outputPath = join(distDir, 'index.html'); + writeFileSync(outputPath, landingResult); + results.push({ name: 'index.html', size: landingResult.length }); + console.log(`Created: ${outputPath} (${(landingResult.length / 1024).toFixed(1)} KB)`); +} + // Clean up assets directory for (const file of readdirSync(assetsDir)) { unlinkSync(join(assetsDir, file)); diff --git a/styles/landing.css b/styles/landing.css new file mode 100644 index 0000000..431c128 --- /dev/null +++ b/styles/landing.css @@ -0,0 +1,307 @@ +/* Landing page styles - matches app theme */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + /* Slate palette */ + --slate-50: #f8fafc; + --slate-100: #f1f5f9; + --slate-200: #e2e8f0; + --slate-300: #cbd5e1; + --slate-400: #94a3b8; + --slate-500: #64748b; + --slate-600: #475569; + --slate-700: #334155; + --slate-800: #1e293b; + --slate-900: #0f172a; + --slate-950: #020617; + + /* Accent colors */ + --blue-400: #60a5fa; + --blue-500: #3b82f6; + --blue-600: #2563eb; +} + +html, body { + height: 100%; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background-color: var(--slate-900); + color: var(--slate-200); + line-height: 1.6; +} + +.landing { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +/* Hero */ +.hero { + text-align: center; + padding: 4rem 0 3rem; +} + +.logo-row { + margin-bottom: 1.5rem; +} + +.logo { + height: 64px; + width: auto; +} + +.tagline { + font-size: 1.75rem; + font-weight: 600; + color: var(--slate-100); + margin-bottom: 0.75rem; +} + +.subtitle { + font-size: 1.125rem; + color: var(--slate-400); + margin-bottom: 2rem; + max-width: 500px; + margin-left: auto; + margin-right: auto; +} + +.cta-buttons { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + padding: 0.75rem 1.5rem; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 500; + text-decoration: none; + transition: all 0.15s ease; +} + +.btn-primary { + background: var(--blue-600); + color: white; +} + +.btn-primary:hover { + background: var(--blue-500); +} + +.btn-secondary { + background: var(--slate-700); + color: var(--slate-200); + border: 1px solid var(--slate-600); +} + +.btn-secondary:hover { + background: var(--slate-600); +} + +/* Two Modes */ +.modes { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; + padding: 2rem 0; +} + +@media (max-width: 600px) { + .modes { + grid-template-columns: 1fr; + } +} + +.mode-card { + background: var(--slate-800); + border: 1px solid var(--slate-700); + border-radius: 12px; + padding: 1.5rem; +} + +.mode-icon { + color: var(--blue-400); + margin-bottom: 1rem; +} + +.mode-card h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--slate-100); + margin-bottom: 0.5rem; +} + +.mode-card p { + color: var(--slate-400); + font-size: 0.9375rem; + margin-bottom: 1rem; +} + +.mode-link { + color: var(--blue-400); + font-size: 0.875rem; + text-decoration: none; +} + +.mode-link:hover { + text-decoration: underline; +} + +/* Quickstart */ +.quickstart { + padding: 2rem 0; +} + +.quickstart h2 { + font-size: 1.25rem; + font-weight: 600; + color: var(--slate-100); + margin-bottom: 1rem; +} + +.lang-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.lang-tab { + background: var(--slate-800); + border: 1px solid var(--slate-700); + color: var(--slate-400); + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.lang-tab:hover { + background: var(--slate-700); + color: var(--slate-200); +} + +.lang-tab.active { + background: var(--slate-700); + color: var(--slate-100); + border-color: var(--slate-600); +} + +.code-blocks { + position: relative; +} + +.code-block { + display: none; + background: var(--slate-950); + border: 1px solid var(--slate-800); + border-radius: 8px; + padding: 1.25rem; + overflow-x: auto; + font-family: 'Fira Code', 'SF Mono', Consolas, monospace; + font-size: 0.875rem; + line-height: 1.7; +} + +.code-block.active { + display: block; +} + +.code-block code { + color: var(--slate-200); +} + +.code-block .comment { + color: var(--slate-500); +} + +.code-block .keyword { + color: var(--blue-400); +} + +.code-block .string { + color: #a5d6a7; +} + +.code-block .number { + color: #ffcc80; +} + +.code-note { + margin-top: 0.75rem; + font-size: 0.875rem; + color: var(--slate-500); +} + +.code-note code { + background: var(--slate-800); + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-size: 0.8125rem; +} + +/* GitHub Action */ +.gh-action { + padding: 2rem 0; +} + +.gh-action h2 { + font-size: 1.25rem; + font-weight: 600; + color: var(--slate-100); + margin-bottom: 0.5rem; +} + +.gh-subtitle { + color: var(--slate-400); + margin-bottom: 1rem; +} + +.gh-action .code-block { + display: block; +} + +.code-block.yaml .keyword { + color: #ce93d8; +} + +/* Footer */ +.footer { + padding: 2rem 0; + text-align: center; + border-top: 1px solid var(--slate-800); + margin-top: 2rem; +} + +.footer-links { + display: flex; + gap: 1.5rem; + justify-content: center; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.footer-links a { + color: var(--slate-400); + text-decoration: none; + font-size: 0.9375rem; +} + +.footer-links a:hover { + color: var(--slate-200); +} + +.footer-note { + color: var(--slate-500); + font-size: 0.875rem; +}