From 92fd3ff0a391f06e472386ae03e3b3d257851b50 Mon Sep 17 00:00:00 2001
From: mizchi
Date: Wed, 18 Feb 2026 16:12:12 +0900
Subject: [PATCH 1/6] feat: bootstrap bithub with mars-first boundary
---
.gitignore | 4 ++++
README.md | 17 ++++++++++++++
docs/mars-sol-boundary.md | 27 +++++++++++++++++++++++
moon.mod.json | 20 +++++++++++++++++
moon.pkg | 1 +
src/adapters/mars_http/moon.pkg | 4 ++++
src/adapters/mars_http/pkg.generated.mbti | 18 +++++++++++++++
src/adapters/mars_http/server.mbt | 8 +++++++
src/cmd/main/main.mbt | 5 +++++
src/cmd/main/moon.pkg | 9 ++++++++
src/cmd/main/pkg.generated.mbti | 13 +++++++++++
src/core/core.mbt | 24 ++++++++++++++++++++
src/core/core_test.mbt | 15 +++++++++++++
src/core/moon.pkg | 1 +
src/core/pkg.generated.mbti | 23 +++++++++++++++++++
15 files changed, 189 insertions(+)
create mode 100644 .gitignore
create mode 100644 docs/mars-sol-boundary.md
create mode 100644 moon.mod.json
create mode 100644 moon.pkg
create mode 100644 src/adapters/mars_http/moon.pkg
create mode 100644 src/adapters/mars_http/pkg.generated.mbti
create mode 100644 src/adapters/mars_http/server.mbt
create mode 100644 src/cmd/main/main.mbt
create mode 100644 src/cmd/main/moon.pkg
create mode 100644 src/cmd/main/pkg.generated.mbti
create mode 100644 src/core/core.mbt
create mode 100644 src/core/core_test.mbt
create mode 100644 src/core/moon.pkg
create mode 100644 src/core/pkg.generated.mbti
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..08977cf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+_build/
+target/
+.mooncakes/
+.DS_Store
diff --git a/README.md b/README.md
index 4526eaa..c51039c 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,19 @@
# bithub
+
+`bit` と連携する GitHub-like UI のための実験リポジトリです。
+
+## Current Direction
+
+- まずは `mars` で実装する
+- `sol` へ引き上げられるように分解点を固定する
+
+分解方針は `/Users/mz/ghq/github.com/bit-vcs/bithub/docs/mars-sol-boundary.md` を参照。
+
+## Run
+
+```bash
+moon check
+moon test
+moon run src/cmd/main
+```
GitHub-like UI interface for bit
diff --git a/docs/mars-sol-boundary.md b/docs/mars-sol-boundary.md
new file mode 100644
index 0000000..d0a1d4a
--- /dev/null
+++ b/docs/mars-sol-boundary.md
@@ -0,0 +1,27 @@
+# Mars/Sol Boundary Notes (bithub)
+
+## Goal
+
+`bithub` は当面 `mars` で機能を作り、`sol` への引き上げ可能性を維持する。
+
+## Design Rule
+
+- `core`:
+ - 画面/機能の契約と純粋ロジック
+ - `mars` / `sol` 依存を入れない
+- `adapters/mars_http`:
+ - `core` を `mars.Server` へ接続する層
+- `cmd/main`:
+ - 起動と設定の組み立てのみ
+
+## Migration Intention
+
+将来 `sol` へ寄せる場合は、`core` を温存したまま `adapters/sol_*` を追加し、`mars_http` との差し替えで移行する。
+
+## Current Minimal Contract
+
+- `core.mars_route_specs()`
+- `core.home_text()`
+- `core.healthz_text()`
+
+`mars_http` はこの契約だけに依存する。
diff --git a/moon.mod.json b/moon.mod.json
new file mode 100644
index 0000000..2753e60
--- /dev/null
+++ b/moon.mod.json
@@ -0,0 +1,20 @@
+{
+ "name": "bit-vcs/bithub",
+ "version": "0.1.0",
+ "deps": {
+ "moonbitlang/async": "0.16.6",
+ "mizchi/mars": "0.3.7"
+ },
+ "readme": "README.md",
+ "repository": "https://github.com/bit-vcs/bithub",
+ "license": "MIT",
+ "keywords": [
+ "bithub",
+ "bit",
+ "mars",
+ "sol"
+ ],
+ "description": "GitHub-like UI backend for bit",
+ "source": "src",
+ "preferred-target": "native"
+}
diff --git a/moon.pkg b/moon.pkg
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/moon.pkg
@@ -0,0 +1 @@
+
diff --git a/src/adapters/mars_http/moon.pkg b/src/adapters/mars_http/moon.pkg
new file mode 100644
index 0000000..bea1bbd
--- /dev/null
+++ b/src/adapters/mars_http/moon.pkg
@@ -0,0 +1,4 @@
+import {
+ "mizchi/mars" @mars,
+ "bit-vcs/bithub/core" @core,
+}
diff --git a/src/adapters/mars_http/pkg.generated.mbti b/src/adapters/mars_http/pkg.generated.mbti
new file mode 100644
index 0000000..8868d8b
--- /dev/null
+++ b/src/adapters/mars_http/pkg.generated.mbti
@@ -0,0 +1,18 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "bit-vcs/bithub/adapters/mars_http"
+
+import {
+ "mizchi/mars",
+}
+
+// Values
+pub fn create_app() -> @mars.Server
+
+// Errors
+
+// Types and methods
+
+// Type aliases
+
+// Traits
+
diff --git a/src/adapters/mars_http/server.mbt b/src/adapters/mars_http/server.mbt
new file mode 100644
index 0000000..34f546f
--- /dev/null
+++ b/src/adapters/mars_http/server.mbt
@@ -0,0 +1,8 @@
+///|
+pub fn create_app() -> @mars.Server {
+ let app = @mars.Server::new()
+ app
+ ..get("/", async fn(ctx) { ctx.text(@core.home_text()) })
+ .get("/healthz", async fn(ctx) { ctx.text(@core.healthz_text()) })
+ app
+}
diff --git a/src/cmd/main/main.mbt b/src/cmd/main/main.mbt
new file mode 100644
index 0000000..bc4db67
--- /dev/null
+++ b/src/cmd/main/main.mbt
@@ -0,0 +1,5 @@
+///|
+async fn main {
+ let app = @mars_http.create_app()
+ app.serve(host="127.0.0.1", port=3000)
+}
diff --git a/src/cmd/main/moon.pkg b/src/cmd/main/moon.pkg
new file mode 100644
index 0000000..e2ee62c
--- /dev/null
+++ b/src/cmd/main/moon.pkg
@@ -0,0 +1,9 @@
+import {
+ "moonbitlang/async",
+ "mizchi/mars",
+ "bit-vcs/bithub/adapters/mars_http" @mars_http,
+}
+
+options(
+ "is-main": true,
+)
diff --git a/src/cmd/main/pkg.generated.mbti b/src/cmd/main/pkg.generated.mbti
new file mode 100644
index 0000000..96aca0d
--- /dev/null
+++ b/src/cmd/main/pkg.generated.mbti
@@ -0,0 +1,13 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "bit-vcs/bithub/cmd/main"
+
+// Values
+
+// Errors
+
+// Types and methods
+
+// Type aliases
+
+// Traits
+
diff --git a/src/core/core.mbt b/src/core/core.mbt
new file mode 100644
index 0000000..2b283df
--- /dev/null
+++ b/src/core/core.mbt
@@ -0,0 +1,24 @@
+///|
+pub struct RouteSpec {
+ id : String
+ http_method : String
+ path : String
+}
+
+///|
+pub fn mars_route_specs() -> Array[RouteSpec] {
+ [
+ { id: "home", http_method: "GET", path: "/" },
+ { id: "healthz", http_method: "GET", path: "/healthz" },
+ ]
+}
+
+///|
+pub fn home_text() -> String {
+ "bithub: mars-first bootstrap"
+}
+
+///|
+pub fn healthz_text() -> String {
+ "ok"
+}
diff --git a/src/core/core_test.mbt b/src/core/core_test.mbt
new file mode 100644
index 0000000..31dbd78
--- /dev/null
+++ b/src/core/core_test.mbt
@@ -0,0 +1,15 @@
+///|
+test "mars_route_specs contains home and healthz" {
+ let routes = mars_route_specs()
+ inspect(routes.length(), content="2")
+ inspect(routes[0].http_method, content="GET")
+ inspect(routes[0].path, content="/")
+ inspect(routes[1].http_method, content="GET")
+ inspect(routes[1].path, content="/healthz")
+}
+
+///|
+test "texts are stable for adapter boundary" {
+ inspect(home_text(), content="bithub: mars-first bootstrap")
+ inspect(healthz_text(), content="ok")
+}
diff --git a/src/core/moon.pkg b/src/core/moon.pkg
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/core/moon.pkg
@@ -0,0 +1 @@
+
diff --git a/src/core/pkg.generated.mbti b/src/core/pkg.generated.mbti
new file mode 100644
index 0000000..06f725f
--- /dev/null
+++ b/src/core/pkg.generated.mbti
@@ -0,0 +1,23 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "bit-vcs/bithub/core"
+
+// Values
+pub fn healthz_text() -> String
+
+pub fn home_text() -> String
+
+pub fn mars_route_specs() -> Array[RouteSpec]
+
+// Errors
+
+// Types and methods
+pub struct RouteSpec {
+ id : String
+ http_method : String
+ path : String
+}
+
+// Type aliases
+
+// Traits
+
From 0c81f68e0f188bb414a4f29fbcd8c8e6903897bc Mon Sep 17 00:00:00 2001
From: mizchi
Date: Wed, 18 Feb 2026 16:18:02 +0900
Subject: [PATCH 2/6] refactor: switch bootstrap to js cloudflare entrypoint
---
README.md | 13 +++++++++----
docs/mars-sol-boundary.md | 3 ++-
moon.mod.json | 2 +-
src/adapters/mars_http/server.mbt | 4 ++--
src/cmd/main/main.mbt | 9 +++++++--
src/cmd/main/moon.pkg | 6 +-----
src/cmd/main/pkg.generated.mbti | 6 ++++++
7 files changed, 28 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index c51039c..d67134f 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,19 @@
- まずは `mars` で実装する
- `sol` へ引き上げられるように分解点を固定する
+- Cloudflare Workers (JS target) 前提で進める
分解方針は `/Users/mz/ghq/github.com/bit-vcs/bithub/docs/mars-sol-boundary.md` を参照。
-## Run
+## Check
```bash
-moon check
-moon test
-moon run src/cmd/main
+moon check --target js
+moon test --target js
```
+
+## Cloudflare Entrypoint
+
+`/Users/mz/ghq/github.com/bit-vcs/bithub/src/cmd/main/main.mbt` に
+`fetch(request, env, exec_ctx)` を公開し、`@mars.Server::to_handler_with_env` へ委譲する。
GitHub-like UI interface for bit
diff --git a/docs/mars-sol-boundary.md b/docs/mars-sol-boundary.md
index d0a1d4a..9fe7af2 100644
--- a/docs/mars-sol-boundary.md
+++ b/docs/mars-sol-boundary.md
@@ -12,7 +12,8 @@
- `adapters/mars_http`:
- `core` を `mars.Server` へ接続する層
- `cmd/main`:
- - 起動と設定の組み立てのみ
+ - Cloudflare 向け `fetch` エントリ公開のみ
+ - `@mars.Server::to_handler_with_env` に委譲する
## Migration Intention
diff --git a/moon.mod.json b/moon.mod.json
index 2753e60..5dde136 100644
--- a/moon.mod.json
+++ b/moon.mod.json
@@ -16,5 +16,5 @@
],
"description": "GitHub-like UI backend for bit",
"source": "src",
- "preferred-target": "native"
+ "preferred-target": "js"
}
diff --git a/src/adapters/mars_http/server.mbt b/src/adapters/mars_http/server.mbt
index 34f546f..8263759 100644
--- a/src/adapters/mars_http/server.mbt
+++ b/src/adapters/mars_http/server.mbt
@@ -2,7 +2,7 @@
pub fn create_app() -> @mars.Server {
let app = @mars.Server::new()
app
- ..get("/", async fn(ctx) { ctx.text(@core.home_text()) })
- .get("/healthz", async fn(ctx) { ctx.text(@core.healthz_text()) })
+ ..get("/", fn(ctx) { ctx.text(@core.home_text()) })
+ .get("/healthz", fn(ctx) { ctx.text(@core.healthz_text()) })
app
}
diff --git a/src/cmd/main/main.mbt b/src/cmd/main/main.mbt
index bc4db67..ae4cfa0 100644
--- a/src/cmd/main/main.mbt
+++ b/src/cmd/main/main.mbt
@@ -1,5 +1,10 @@
///|
-async fn main {
+pub fn fetch(
+ request : @mars.JsRequest,
+ env : @mars.JsEnv,
+ exec_ctx : @mars.JsExecCtx,
+) -> @js_async.Promise[@mars.JsResponse] {
let app = @mars_http.create_app()
- app.serve(host="127.0.0.1", port=3000)
+ let handler = app.to_handler_with_env(env, exec_ctx)
+ handler(request)
}
diff --git a/src/cmd/main/moon.pkg b/src/cmd/main/moon.pkg
index e2ee62c..15f5d3d 100644
--- a/src/cmd/main/moon.pkg
+++ b/src/cmd/main/moon.pkg
@@ -1,9 +1,5 @@
import {
- "moonbitlang/async",
+ "moonbitlang/async/js_async" @js_async,
"mizchi/mars",
"bit-vcs/bithub/adapters/mars_http" @mars_http,
}
-
-options(
- "is-main": true,
-)
diff --git a/src/cmd/main/pkg.generated.mbti b/src/cmd/main/pkg.generated.mbti
index 96aca0d..5c29b33 100644
--- a/src/cmd/main/pkg.generated.mbti
+++ b/src/cmd/main/pkg.generated.mbti
@@ -1,7 +1,13 @@
// Generated using `moon info`, DON'T EDIT IT
package "bit-vcs/bithub/cmd/main"
+import {
+ "mizchi/mars",
+ "moonbitlang/async/js_async",
+}
+
// Values
+pub fn fetch(@mars.JsRequest, @mars.JsEnv, @mars.JsExecCtx) -> @js_async.Promise[@mars.JsResponse]
// Errors
From b83d5f8ded0decf34a197f321f226ed7ab8a30b2 Mon Sep 17 00:00:00 2001
From: mizchi
Date: Wed, 18 Feb 2026 17:19:16 +0900
Subject: [PATCH 3/6] feat: add bithub file viewer with playwright and ci
---
.github/workflows/ci.yml | 69 ++++++
.gitignore | 3 +
README.md | 20 ++
bithub | 5 +
e2e/file-viewer.spec.ts | 26 ++
moon.mod.json | 9 +-
package.json | 12 +
playwright.config.ts | 21 ++
pnpm-lock.yaml | 52 ++++
src/adapters/mars_http/server.mbt | 17 +-
src/cmd/bithub/main.mbt | 52 ++++
src/cmd/bithub/moon.pkg | 22 ++
src/cmd/bithub/pkg.generated.mbti | 32 +++
src/cmd/bithub/viewer.mbt | 286 ++++++++++++++++++++++
src/cmd/bithub/viewer_test.mbt | 24 ++
src/core/api_state.mbt | 389 ++++++++++++++++++++++++++++++
src/core/core.mbt | 3 +
src/core/core_test.mbt | 41 +++-
src/core/moon.pkg | 9 +-
src/core/pkg.generated.mbti | 26 ++
20 files changed, 1110 insertions(+), 8 deletions(-)
create mode 100644 .github/workflows/ci.yml
create mode 100755 bithub
create mode 100644 e2e/file-viewer.spec.ts
create mode 100644 package.json
create mode 100644 playwright.config.ts
create mode 100644 pnpm-lock.yaml
create mode 100644 src/cmd/bithub/main.mbt
create mode 100644 src/cmd/bithub/moon.pkg
create mode 100644 src/cmd/bithub/pkg.generated.mbti
create mode 100644 src/cmd/bithub/viewer.mbt
create mode 100644 src/cmd/bithub/viewer_test.mbt
create mode 100644 src/core/api_state.mbt
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..36821f4
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,69 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ moon:
+ name: Moon check and test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install MoonBit CLI
+ run: |
+ curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
+ echo "$HOME/.moon/bin" >> "$GITHUB_PATH"
+
+ - name: Moon update
+ run: moon update
+
+ - name: Moon check (js)
+ run: moon check --target js
+
+ - name: Moon test (js)
+ run: moon test --target js
+
+ e2e:
+ name: Playwright e2e
+ runs-on: ubuntu-latest
+ needs: moon
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10
+
+ - name: Install MoonBit CLI
+ run: |
+ curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
+ echo "$HOME/.moon/bin" >> "$GITHUB_PATH"
+
+ - name: Moon update
+ run: moon update
+
+ - name: Install Node dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Install Playwright browser
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Run Playwright tests
+ run: pnpm test:e2e
diff --git a/.gitignore b/.gitignore
index 08977cf..1eff73e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@ _build/
target/
.mooncakes/
.DS_Store
+node_modules/
+playwright-report/
+test-results/
diff --git a/README.md b/README.md
index d67134f..c79a180 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,26 @@ moon check --target js
moon test --target js
```
+## E2E (Playwright)
+
+```bash
+pnpm install
+pnpm test:e2e
+```
+
+## Local Viewer (`bithub .`)
+
+現在のリポジトリを GitHub 風に閲覧する最小 UI を起動できます。
+
+```bash
+./bithub . # port 8787
+./bithub . 9000 # custom port
+```
+
+- `/` で `README.md` を優先表示
+- `/blob/` でファイル表示
+- UI は `mizchi/luna/x/components` ベースの最小構成
+
## Cloudflare Entrypoint
`/Users/mz/ghq/github.com/bit-vcs/bithub/src/cmd/main/main.mbt` に
diff --git a/bithub b/bithub
new file mode 100755
index 0000000..959d908
--- /dev/null
+++ b/bithub
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+moon run src/cmd/bithub --target js -- "$@"
diff --git a/e2e/file-viewer.spec.ts b/e2e/file-viewer.spec.ts
new file mode 100644
index 0000000..39b6197
--- /dev/null
+++ b/e2e/file-viewer.spec.ts
@@ -0,0 +1,26 @@
+import { test, expect } from '@playwright/test';
+
+test('root opens README.md by default', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page.getByRole('link', { name: /\[\*\] README\.md/ })).toBeVisible();
+ await expect(page.getByRole('heading', { level: 1, name: 'bithub' })).toBeVisible();
+ await expect(page.locator('main')).toContainText('README.md');
+});
+
+test('can open a source file from nav', async ({ page }) => {
+ await page.goto('/');
+
+ await page.getByRole('link', { name: 'src/cmd/bithub/main.mbt' }).click();
+
+ await expect(page).toHaveURL(/\/blob\/src\/cmd\/bithub\/main\.mbt$/);
+ await expect(page.locator('main')).toContainText('fn main');
+});
+
+test('path traversal is rejected', async ({ page }) => {
+ const response = await page.goto('/blob/..%2FREADME.md');
+
+ expect(response).not.toBeNull();
+ expect(response!.status()).toBe(400);
+ await expect(page.locator('main')).toContainText('Invalid path.');
+});
diff --git a/moon.mod.json b/moon.mod.json
index 5dde136..9e4d0eb 100644
--- a/moon.mod.json
+++ b/moon.mod.json
@@ -3,7 +3,12 @@
"version": "0.1.0",
"deps": {
"moonbitlang/async": "0.16.6",
- "mizchi/mars": "0.3.7"
+ "mizchi/mars": "0.3.7",
+ "mizchi/bit": "0.21.2",
+ "mizchi/markdown": "0.4.7",
+ "mizchi/luna": "0.12.3",
+ "moonbitlang/x": "0.4.40",
+ "mizchi/js": "0.10.14"
},
"readme": "README.md",
"repository": "https://github.com/bit-vcs/bithub",
@@ -17,4 +22,4 @@
"description": "GitHub-like UI backend for bit",
"source": "src",
"preferred-target": "js"
-}
+}
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a632d57
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "bit-vcs-bithub",
+ "private": true,
+ "scripts": {
+ "test:e2e": "playwright test",
+ "test:e2e:headed": "playwright test --headed",
+ "test:e2e:ui": "playwright test --ui"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.52.0"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..d6b12bf
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from '@playwright/test';
+
+const PORT = 4173;
+
+export default defineConfig({
+ testDir: './e2e',
+ timeout: 30_000,
+ expect: {
+ timeout: 5_000,
+ },
+ use: {
+ baseURL: `http://127.0.0.1:${PORT}`,
+ trace: 'on-first-retry',
+ },
+ webServer: {
+ command: `./bithub . ${PORT}`,
+ url: `http://127.0.0.1:${PORT}`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..cc72792
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,52 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.52.0
+ version: 1.58.2
+
+packages:
+
+ '@playwright/test@1.58.2':
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ playwright-core@1.58.2:
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.58.2:
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+snapshots:
+
+ '@playwright/test@1.58.2':
+ dependencies:
+ playwright: 1.58.2
+
+ fsevents@2.3.2:
+ optional: true
+
+ playwright-core@1.58.2: {}
+
+ playwright@1.58.2:
+ dependencies:
+ playwright-core: 1.58.2
+ optionalDependencies:
+ fsevents: 2.3.2
diff --git a/src/adapters/mars_http/server.mbt b/src/adapters/mars_http/server.mbt
index 8263759..1e22870 100644
--- a/src/adapters/mars_http/server.mbt
+++ b/src/adapters/mars_http/server.mbt
@@ -1,8 +1,19 @@
///|
pub fn create_app() -> @mars.Server {
let app = @mars.Server::new()
- app
- ..get("/", fn(ctx) { ctx.text(@core.home_text()) })
- .get("/healthz", fn(ctx) { ctx.text(@core.healthz_text()) })
+ let api = @core.ApiState::new()
+ let _ = app
+ ..get("/", fn(ctx) { ctx.html(api.render_home_html()) })
+ ..get("/healthz", fn(ctx) { ctx.text(@core.healthz_text()) })
+ ..get("/readme", fn(ctx) { ctx.html(api.render_readme_html()) })
+ ..get("/filer", fn(ctx) {
+ let path = ctx.query("path").unwrap_or("")
+ ctx.html(api.render_filer_html(path))
+ })
+ ..get("/file", fn(ctx) {
+ let path = ctx.query("path").unwrap_or("")
+ let res = api.render_file_response(path)
+ ctx.html(res.body, status=res.status)
+ })
app
}
diff --git a/src/cmd/bithub/main.mbt b/src/cmd/bithub/main.mbt
new file mode 100644
index 0000000..62e782b
--- /dev/null
+++ b/src/cmd/bithub/main.mbt
@@ -0,0 +1,52 @@
+///|
+fn parse_port(raw : String) -> Int {
+ @strconv.parse_int(raw) catch {
+ _ => 8787
+ }
+}
+
+///|
+fn parse_args() -> (String, Int) {
+ let argv = @nprocess.argv()
+ let repo = if argv.length() > 2 { argv[2] } else { "." }
+ let port_raw = if argv.length() > 3 { argv[3] } else { "8787" }
+ (repo, parse_port(port_raw))
+}
+
+///|
+fn route_response(state : RepoState, raw_url : String) -> PageResponse {
+ if raw_url == "/" || raw_url.has_prefix("/?") || raw_url.has_prefix("/#") {
+ return state.render_root()
+ }
+ if raw_url.has_prefix("/blob/") {
+ let rel_path = extract_blob_path(raw_url)
+ return state.render_blob(rel_path)
+ }
+ {
+ status: 404,
+ html: state.render_layout("(not found)", "Route not found.
"),
+ }
+}
+
+///|
+fn main {
+ let (repo_arg, port) = parse_args()
+ let state = RepoState::new(repo_arg)
+ let host = "127.0.0.1"
+ let server = @nhttp.createServer(requestListener=fn(
+ req : @nhttp.IncomingMessage,
+ res : @nhttp.ServerResponse,
+ ) {
+ let raw_url_opt : String? = @js.identity_option(req.as_any()._get("url"))
+ let raw_url = raw_url_opt.unwrap_or("/")
+ let response = route_response(state, raw_url)
+ res.set_statusCode(response.status)
+ res.setHeader("content-type", "text/html; charset=utf-8")
+ ignore(res.write(response.html))
+ res.end()
+ })
+ let _ = server.listen(port, host~, callback=fn() {
+ println("bithub: http://\{host}:\{port}/")
+ println("repo: \{state.root}")
+ })
+}
diff --git a/src/cmd/bithub/moon.pkg b/src/cmd/bithub/moon.pkg
new file mode 100644
index 0000000..f6a3129
--- /dev/null
+++ b/src/cmd/bithub/moon.pkg
@@ -0,0 +1,22 @@
+import {
+ "mizchi/js/core" @js,
+ "mizchi/js/node/fs" @nfs,
+ "mizchi/js/node/http" @nhttp,
+ "mizchi/js/node/path" @npath,
+ "mizchi/js/node/process" @nprocess,
+ "mizchi/luna" @luna,
+ "mizchi/luna/dom/static" @lstatic,
+ "mizchi/luna/x/components" @components,
+ "mizchi/markdown" @markdown,
+ "moonbitlang/core/strconv" @strconv,
+}
+
+options(
+ is_main: true,
+ link: { "js": { "format": "cjs" } },
+ targets: {
+ "main.mbt": [ "js" ],
+ "viewer.mbt": [ "js" ],
+ "viewer_test.mbt": [ "js" ],
+ },
+)
diff --git a/src/cmd/bithub/pkg.generated.mbti b/src/cmd/bithub/pkg.generated.mbti
new file mode 100644
index 0000000..7c377fb
--- /dev/null
+++ b/src/cmd/bithub/pkg.generated.mbti
@@ -0,0 +1,32 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "bit-vcs/bithub/cmd/bithub"
+
+// Values
+pub fn as_view_html(String, String) -> String
+
+pub fn extract_blob_path(String) -> String
+
+pub fn sanitize_rel_path(String) -> String?
+
+// Errors
+
+// Types and methods
+pub struct PageResponse {
+ status : Int
+ html : String
+}
+
+pub struct RepoState {
+ root : String
+ files : Array[String]
+ initial_path : String
+}
+pub fn RepoState::new(String) -> Self
+pub fn RepoState::render_blob(Self, String) -> PageResponse
+pub fn RepoState::render_layout(Self, String, String) -> String
+pub fn RepoState::render_root(Self) -> PageResponse
+
+// Type aliases
+
+// Traits
+
diff --git a/src/cmd/bithub/viewer.mbt b/src/cmd/bithub/viewer.mbt
new file mode 100644
index 0000000..2ef9cfb
--- /dev/null
+++ b/src/cmd/bithub/viewer.mbt
@@ -0,0 +1,286 @@
+///|
+pub struct PageResponse {
+ status : Int
+ html : String
+}
+
+///|
+pub struct RepoState {
+ root : String
+ files : Array[String]
+ initial_path : String
+}
+
+///|
+pub fn RepoState::new(root : String) -> RepoState {
+ let resolved = @npath.resolve([root])
+ let files = collect_repo_files(resolved, 240)
+ files.sort_by(fn(a, b) { String::compare(a, b) })
+ let initial_path = if files.contains("README.md") {
+ "README.md"
+ } else if files.length() > 0 {
+ files[0]
+ } else {
+ ""
+ }
+ { root: resolved, files, initial_path }
+}
+
+///|
+pub fn RepoState::render_root(self : RepoState) -> PageResponse {
+ if self.initial_path.length() == 0 {
+ return {
+ status: 200,
+ html: self.render_layout("(empty)", "No viewable files found.
"),
+ }
+ }
+ self.render_blob(self.initial_path)
+}
+
+///|
+pub fn RepoState::render_blob(
+ self : RepoState,
+ raw_path : String,
+) -> PageResponse {
+ let sanitized = sanitize_rel_path(raw_path)
+ match sanitized {
+ Some(rel_path) =>
+ match read_repo_file(self.root, rel_path) {
+ Some(content) => {
+ let body_html = as_view_html(rel_path, content)
+ { status: 200, html: self.render_layout(rel_path, body_html) }
+ }
+ None => {
+ let body_html = "File not found: " + rel_path + "
"
+ { status: 404, html: self.render_layout(rel_path, body_html) }
+ }
+ }
+ None => {
+ let body_html = "Invalid path.
"
+ { status: 400, html: self.render_layout("(invalid)", body_html) }
+ }
+ }
+}
+
+///|
+pub fn RepoState::render_layout(
+ self : RepoState,
+ selected_path : String,
+ body_html : String,
+) -> String {
+ let header_nodes : Array[@luna.Node[Unit, String]] = [
+ @components.link("/", [@luna.text("bithub")]),
+ @luna.text(" : " + self.root),
+ ]
+ let nav_nodes = build_nav_nodes(self.files, selected_path)
+ let main_nodes : Array[@luna.Node[Unit, String]] = [
+ @components.breadcrumb_from_path("/blob", [], selected_path),
+ @components.region(aria_label="file content", [@luna.raw_html(body_html)]),
+ ]
+ let footer_nodes : Array[@luna.Node[Unit, String]] = [
+ @luna.text("luna components minimal file viewer"),
+ ]
+ let page = @components.page_layout(
+ header=header_nodes,
+ nav_content=nav_nodes,
+ main_nodes,
+ footer_content=footer_nodes,
+ )
+ let css =
+ #|body{font-family:ui-sans-serif,system-ui,sans-serif;margin:0;padding:12px;line-height:1.4;}
+ #|nav a{display:block;text-decoration:none;padding:2px 0;}
+ #|main{margin-top:12px;}
+ #|pre{background:#f6f8fa;padding:12px;overflow:auto;}
+ #|code{font-family:ui-monospace,Menlo,monospace;}
+ let doc = @lstatic.document(
+ head_children=[
+ @lstatic.meta(charset="utf-8"),
+ @lstatic.title("bithub"),
+ @lstatic.style_(css),
+ ],
+ body_children=[page],
+ )
+ @lstatic.render_document(doc)
+}
+
+///|
+fn build_nav_nodes(
+ files : Array[String],
+ selected_path : String,
+) -> Array[@luna.Node[Unit, String]] {
+ let nodes : Array[@luna.Node[Unit, String]] = []
+ let limit = if files.length() < 120 { files.length() } else { 120 }
+ for i in 0.. limit {
+ nodes.push(@luna.text("..."))
+ }
+ nodes
+}
+
+///|
+fn collect_repo_files(root : String, limit : Int) -> Array[String] {
+ let out : Array[String] = []
+ collect_repo_files_recursive(root, "", out, limit)
+ out
+}
+
+///|
+priv enum EntryKind {
+ Dir
+ File
+ Other
+}
+
+///|
+fn path_kind(abs_path : String) -> EntryKind {
+ let st = @nfs.stat_sync(abs_path) catch { _ => return Other }
+ if st.isDirectory() {
+ Dir
+ } else if st.isFile() {
+ File
+ } else {
+ Other
+ }
+}
+
+///|
+fn collect_repo_files_recursive(
+ root : String,
+ rel_dir : String,
+ out : Array[String],
+ limit : Int,
+) -> Unit {
+ if out.length() >= limit {
+ return
+ }
+ let abs_dir = if rel_dir.length() == 0 {
+ root
+ } else {
+ @npath.join2(root, rel_dir)
+ }
+ let names = @nfs.readdir_sync(abs_dir) catch { _ => [] }
+ names.sort_by(fn(a, b) { String::compare(a, b) })
+ for name in names {
+ if out.length() >= limit {
+ return
+ }
+ if not(should_skip_name(name)) {
+ let rel_path = if rel_dir.length() == 0 {
+ name
+ } else {
+ rel_dir + "/" + name
+ }
+ let abs_path = @npath.join2(root, rel_path)
+ match path_kind(abs_path) {
+ Dir => collect_repo_files_recursive(root, rel_path, out, limit)
+ File => if is_viewable_file(rel_path) { out.push(rel_path) }
+ Other => ()
+ }
+ }
+ }
+}
+
+///|
+fn should_skip_name(name : String) -> Bool {
+ name == ".git" ||
+ name == ".mooncakes" ||
+ name == "_build" ||
+ name == "target" ||
+ name == "node_modules"
+}
+
+///|
+fn is_viewable_file(path : String) -> Bool {
+ if path.has_suffix(".png") ||
+ path.has_suffix(".jpg") ||
+ path.has_suffix(".jpeg") ||
+ path.has_suffix(".gif") ||
+ path.has_suffix(".webp") ||
+ path.has_suffix(".wasm") {
+ return false
+ }
+ true
+}
+
+///|
+fn read_repo_file(root : String, rel_path : String) -> String? {
+ let abs_path = @npath.join2(root, rel_path)
+ let text = @nfs.read_file_as_string(abs_path, encoding="utf-8") catch {
+ _ => return None
+ }
+ Some(text)
+}
+
+///|
+pub fn sanitize_rel_path(raw_path : String) -> String? {
+ let mut path = raw_path
+ while path.has_prefix("/") {
+ path = String::unsafe_substring(path, start=1, end=path.length())
+ }
+ while path.has_prefix("./") {
+ path = String::unsafe_substring(path, start=2, end=path.length())
+ }
+ if path.length() == 0 {
+ return None
+ }
+ if path.contains("..") {
+ return None
+ }
+ Some(path)
+}
+
+///|
+pub fn extract_blob_path(route_path : String) -> String {
+ let plain = strip_query_and_hash(route_path)
+ if plain.has_prefix("/blob/") {
+ String::unsafe_substring(plain, start=6, end=plain.length())
+ } else {
+ ""
+ }
+}
+
+///|
+fn strip_query_and_hash(path : String) -> String {
+ let query_pos = find_char(path, '?')
+ let hash_pos = find_char(path, '#')
+ let mut end = path.length()
+ if query_pos >= 0 && query_pos < end {
+ end = query_pos
+ }
+ if hash_pos >= 0 && hash_pos < end {
+ end = hash_pos
+ }
+ if end < path.length() {
+ String::unsafe_substring(path, start=0, end~)
+ } else {
+ path
+ }
+}
+
+///|
+fn find_char(s : String, target : Char) -> Int {
+ let mut idx = 0
+ for c in s {
+ if c == target {
+ return idx
+ }
+ idx += 1
+ }
+ -1
+}
+
+///|
+pub fn as_view_html(path : String, content : String) -> String {
+ if path.has_suffix(".md") || path.has_suffix(".markdown") {
+ return @markdown.md_to_html(content)
+ }
+ @markdown.md_to_html("```text\n" + content + "\n```")
+}
diff --git a/src/cmd/bithub/viewer_test.mbt b/src/cmd/bithub/viewer_test.mbt
new file mode 100644
index 0000000..b10e1d0
--- /dev/null
+++ b/src/cmd/bithub/viewer_test.mbt
@@ -0,0 +1,24 @@
+///|
+test "sanitize_rel_path blocks traversal" {
+ inspect(sanitize_rel_path("README.md"), content="Some(\"README.md\")")
+ inspect(sanitize_rel_path("/src/main.mbt"), content="Some(\"src/main.mbt\")")
+ inspect(sanitize_rel_path("../secret"), content="None")
+ inspect(sanitize_rel_path("a/../../b"), content="None")
+}
+
+///|
+test "extract_blob_path strips route prefix" {
+ inspect(extract_blob_path("/blob/README.md"), content="README.md")
+ inspect(
+ extract_blob_path("/blob/src/core/core.mbt"),
+ content="src/core/core.mbt",
+ )
+ inspect(extract_blob_path("/"), content="")
+}
+
+///|
+test "as_view_html wraps non-markdown in fenced block" {
+ let html = as_view_html("src/main.mbt", "fn main {}")
+ assert_true(html.contains(" @bitlib.WorkingTree
+priv struct KvWorkingTree {
+ fs : @bitfs.Fs
+ backing_fs : &@bit.RepoFileSystem
+ write_fs : &@bit.FileSystem
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with read_file(self, path) {
+ self.fs.read_file(self.backing_fs, path)
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with write_file(self, path, data) {
+ self.fs.write_file(path, data)
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with remove_file(self, path) {
+ self.fs.remove_file(path)
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with is_file(self, path) {
+ self.fs.is_file(self.backing_fs, path)
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with is_dir(self, path) {
+ self.fs.is_dir(self.backing_fs, path)
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with readdir(self, path) {
+ self.fs.readdir(self.backing_fs, path)
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with is_dirty(self) {
+ self.fs.is_dirty()
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with rollback(self) {
+ self.fs.rollback()
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with get_working_files(self) {
+ self.fs.get_working_files()
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with snapshot(
+ self,
+ message,
+ author,
+ timestamp,
+) {
+ let snapshot = self.fs.snapshot(
+ self.write_fs,
+ self.backing_fs,
+ message,
+ author,
+ timestamp,
+ )
+ snapshot.commit_id
+}
+
+///|
+impl @bitlib.WorkingTree for KvWorkingTree with checkout(self, commit_id) {
+ self.fs.checkout_snapshot(self.backing_fs, commit_id)
+}
+
+///|
+// Adapter: filesystem object db -> @bitlib.ObjectStore
+priv struct KvObjectStore {
+ backing_fs : &@bit.RepoFileSystem
+ write_fs : &@bit.FileSystem
+ git_dir : String
+}
+
+///|
+impl @bitlib.ObjectStore for KvObjectStore with get(self, id) {
+ let db = @bitlib.ObjectDb::load(self.backing_fs, self.git_dir)
+ db.get(self.backing_fs, id)
+}
+
+///|
+impl @bitlib.ObjectStore for KvObjectStore with put(self, obj_type, data) {
+ @bitlib.write_loose_object(self.write_fs, self.git_dir, obj_type, data)
+}
+
+///|
+impl @bitlib.ObjectStore for KvObjectStore with has(self, id) {
+ let db = @bitlib.ObjectDb::load(self.backing_fs, self.git_dir) catch {
+ _ => return false
+ }
+ let obj = db.get(self.backing_fs, id) catch { _ => return false }
+ obj is Some(_)
+}
+
+///|
+pub struct ApiState {
+ kv : @kv.Kv
+}
+
+///|
+pub fn ApiState::new() -> ApiState {
+ let fs = @bit.TestFs::new()
+ let bit_fs = @bitfs.Fs::empty("/repo/.git")
+ let tree : KvWorkingTree = { fs: bit_fs, backing_fs: fs, write_fs: fs }
+ let store : KvObjectStore = {
+ backing_fs: fs,
+ write_fs: fs,
+ git_dir: "/repo/.git",
+ }
+ let kv = @kv.Kv::new(
+ @kv.NodeId::new("bithub"),
+ tree,
+ store,
+ @bit.ObjectId::zero(),
+ )
+ seed_files(kv)
+ { kv, }
+}
+
+///|
+pub fn ApiState::render_home_html(_self : ApiState) -> String {
+ let body =
+ #|bithub
+ #|minimal github-like interface backed by bit kv.
+ #|
+ wrap_page("bithub", body)
+}
+
+///|
+pub fn ApiState::render_readme_html(self : ApiState) -> String {
+ let md = self.file_text("README.md").unwrap_or("# README.md\n\nnot found")
+ let html = @markdown.md_to_html(md)
+ wrap_page("README.md", "" + html + "")
+}
+
+///|
+pub fn ApiState::render_filer_html(
+ self : ApiState,
+ raw_path : String,
+) -> String {
+ let path = normalize_path(raw_path)
+ let entries = self.list_entries(path)
+ let buf = StringBuilder::new()
+ buf.write_string("filer
")
+ if path.length() == 0 {
+ buf.write_string("path: /
")
+ } else {
+ buf.write_string("path: /" + escape_html(path) + "
")
+ }
+ buf.write_string("")
+ wrap_page("filer", buf.to_string())
+}
+
+///|
+pub fn ApiState::render_file_response(
+ self : ApiState,
+ raw_path : String,
+) -> HtmlResponse {
+ let path = normalize_path(raw_path)
+ if path.length() == 0 {
+ return {
+ status: 404,
+ body: wrap_page("file", "file
not found
"),
+ }
+ }
+ match self.file_text(path) {
+ Some(content) => {
+ let body = "" +
+ escape_html(path) +
+ "
" +
+ escape_html(content) +
+ "
"
+ { status: 200, body: wrap_page(path, body) }
+ }
+ None => {
+ let body = "" + escape_html(path) + "
not found
"
+ { status: 404, body: wrap_page(path, body) }
+ }
+ }
+}
+
+///|
+pub fn ApiState::list_entries(
+ self : ApiState,
+ raw_path : String,
+) -> Array[FileEntry] {
+ let path = normalize_path(raw_path)
+ let names = self.kv.list(path)
+ names.sort_by(fn(a, b) { String::compare(a, b) })
+ let entries : Array[FileEntry] = []
+ for name in names {
+ let full_path = join_path(path, name)
+ entries.push({ name, path: full_path, is_dir: not(self.kv.has(full_path)) })
+ }
+ entries
+}
+
+///|
+pub fn ApiState::file_text(self : ApiState, raw_path : String) -> String? {
+ let path = normalize_path(raw_path)
+ match self.kv.get(path) {
+ Some(bytes) => Some(@utf8.decode_lossy(bytes))
+ None => None
+ }
+}
+
+///|
+fn seed_files(kv : @kv.Kv) -> Unit {
+ let readme =
+ #|# bithub
+ #|
+ #|A minimal self-browsing API/UI prototype powered by `bit kv`.
+ #|
+ #|- open `/filer` to browse this app
+ #|- open `/file?path=src/core/api_state.mbt` to inspect core source
+ kv.set_string("README.md", readme)
+ let moon_mod =
+ #|{
+ #| "name": "bit-vcs/bithub",
+ #| "preferred-target": "js",
+ #| "deps": {
+ #| "mizchi/bit": "0.21.2",
+ #| "mizchi/markdown": "0.4.7",
+ #| "mizchi/mars": "0.3.7"
+ #| }
+ #|}
+ kv.set_string("moon.mod.json", moon_mod)
+ let core_source =
+ #|pub struct RouteSpec {
+ #| id : String
+ #| http_method : String
+ #| path : String
+ #|}
+ #|
+ #|pub fn mars_route_specs() -> Array[RouteSpec] {
+ #| [
+ #| { id: "home", http_method: "GET", path: "/" },
+ #| { id: "readme", http_method: "GET", path: "/readme" },
+ #| { id: "filer", http_method: "GET", path: "/filer" },
+ #| ]
+ #|}
+ kv.set_string("src/core/core.mbt", core_source)
+ let api_state_source =
+ #|pub struct ApiState {
+ #| kv : @kv.Kv
+ #|}
+ #|
+ #|pub fn ApiState::render_filer_html(self : ApiState, path : String) -> String {
+ #| let entries = self.list_entries(path)
+ #| // minimal html
+ #| "filer
"
+ #|}
+ kv.set_string("src/core/api_state.mbt", api_state_source)
+ let mars_adapter =
+ #|pub fn create_app() -> @mars.Server {
+ #| let app = @mars.Server::new()
+ #| let api = @core.ApiState::new()
+ #| let _ = app
+ #| ..get("/", fn(ctx) { ctx.html(api.render_home_html()) })
+ #| ..get("/filer", fn(ctx) { ctx.html(api.render_filer_html("")) })
+ #| app
+ #|}
+ kv.set_string("src/adapters/mars_http/server.mbt", mars_adapter)
+ let main_entry =
+ #|pub fn fetch(
+ #| request : @mars.JsRequest,
+ #| env : @mars.JsEnv,
+ #| exec_ctx : @mars.JsExecCtx,
+ #|) -> @js_async.Promise[@mars.JsResponse] {
+ #| let app = @mars_http.create_app()
+ #| let handler = app.to_handler_with_env(env, exec_ctx)
+ #| handler(request)
+ #|}
+ kv.set_string("src/cmd/main/main.mbt", main_entry)
+ let notes =
+ #|Mars/Sol boundary note:
+ #|- keep core pure
+ #|- keep adapter replaceable
+ #|- cloudflare js target first
+ kv.set_string("docs/notes.txt", notes)
+}
+
+///|
+fn normalize_path(path : String) -> String {
+ let mut p = path
+ while p.length() > 0 && p.has_prefix("/") {
+ p = String::unsafe_substring(p, start=1, end=p.length())
+ }
+ while p.length() > 0 && p.has_suffix("/") {
+ p = String::unsafe_substring(p, start=0, end=p.length() - 1)
+ }
+ p
+}
+
+///|
+fn join_path(parent : String, child : String) -> String {
+ if parent.length() == 0 {
+ child
+ } else {
+ parent + "/" + child
+ }
+}
+
+///|
+fn wrap_page(title : String, body_html : String) -> String {
+ let style =
+ #|body{font-family:ui-monospace,Menlo,monospace;max-width:900px;margin:24px auto;padding:0 12px;}
+ #|nav{margin-bottom:12px;}
+ #|pre{background:#f6f8fa;padding:12px;overflow:auto;}
+ let nav =
+ #|
+ "" +
+ escape_html(title) +
+ "" +
+ nav +
+ "" +
+ body_html +
+ ""
+}
+
+///|
+fn escape_html(raw : String) -> String {
+ let buf = StringBuilder::new()
+ for c in raw {
+ match c {
+ '&' => buf.write_string("&")
+ '<' => buf.write_string("<")
+ '>' => buf.write_string(">")
+ '"' => buf.write_string(""")
+ '\'' => buf.write_string("'")
+ _ => buf.write_char(c)
+ }
+ }
+ buf.to_string()
+}
diff --git a/src/core/core.mbt b/src/core/core.mbt
index 2b283df..1d8e856 100644
--- a/src/core/core.mbt
+++ b/src/core/core.mbt
@@ -10,6 +10,9 @@ pub fn mars_route_specs() -> Array[RouteSpec] {
[
{ id: "home", http_method: "GET", path: "/" },
{ id: "healthz", http_method: "GET", path: "/healthz" },
+ { id: "readme", http_method: "GET", path: "/readme" },
+ { id: "filer", http_method: "GET", path: "/filer" },
+ { id: "file", http_method: "GET", path: "/file" },
]
}
diff --git a/src/core/core_test.mbt b/src/core/core_test.mbt
index 31dbd78..d202848 100644
--- a/src/core/core_test.mbt
+++ b/src/core/core_test.mbt
@@ -1,11 +1,14 @@
///|
-test "mars_route_specs contains home and healthz" {
+test "mars_route_specs contains API routes" {
let routes = mars_route_specs()
- inspect(routes.length(), content="2")
+ inspect(routes.length(), content="5")
inspect(routes[0].http_method, content="GET")
inspect(routes[0].path, content="/")
inspect(routes[1].http_method, content="GET")
inspect(routes[1].path, content="/healthz")
+ inspect(routes[2].path, content="/readme")
+ inspect(routes[3].path, content="/filer")
+ inspect(routes[4].path, content="/file")
}
///|
@@ -13,3 +16,37 @@ test "texts are stable for adapter boundary" {
inspect(home_text(), content="bithub: mars-first bootstrap")
inspect(healthz_text(), content="ok")
}
+
+///|
+test "ApiState renders README.md by markdown renderer" {
+ let api = ApiState::new()
+ let html = api.render_readme_html()
+ assert_true(html.contains(""))
+ assert_true(html.contains("bithub"))
+}
+
+///|
+test "ApiState renders filer with minimum links" {
+ let api = ApiState::new()
+ let root = api.render_filer_html("")
+ assert_true(root.contains("README.md"))
+ assert_true(root.contains("/file?path=README.md"))
+ assert_true(root.contains("moon.mod.json"))
+ assert_true(root.contains("/file?path=moon.mod.json"))
+ assert_true(root.contains("src"))
+ assert_true(root.contains("/filer?path=src"))
+ let core = api.render_filer_html("src/core")
+ assert_true(core.contains("api_state.mbt"))
+ assert_true(core.contains("/file?path=src/core/api_state.mbt"))
+}
+
+///|
+test "ApiState renders file content and missing file status" {
+ let api = ApiState::new()
+ let found = api.render_file_response("src/core/api_state.mbt")
+ inspect(found.status, content="200")
+ assert_true(found.body.contains("pub struct ApiState"))
+ let missing = api.render_file_response("missing.txt")
+ inspect(missing.status, content="404")
+ assert_true(missing.body.contains("not found"))
+}
diff --git a/src/core/moon.pkg b/src/core/moon.pkg
index 8b13789..2eca343 100644
--- a/src/core/moon.pkg
+++ b/src/core/moon.pkg
@@ -1 +1,8 @@
-
+import {
+ "mizchi/bit" @bit,
+ "mizchi/bit/lib" @bitlib,
+ "mizchi/bit/x/fs" @bitfs,
+ "mizchi/bit/x/kv" @kv,
+ "mizchi/markdown" @markdown,
+ "moonbitlang/core/encoding/utf8" @utf8,
+}
diff --git a/src/core/pkg.generated.mbti b/src/core/pkg.generated.mbti
index 06f725f..62a4b97 100644
--- a/src/core/pkg.generated.mbti
+++ b/src/core/pkg.generated.mbti
@@ -1,6 +1,10 @@
// Generated using `moon info`, DON'T EDIT IT
package "bit-vcs/bithub/core"
+import {
+ "mizchi/bit/x/kv",
+}
+
// Values
pub fn healthz_text() -> String
@@ -11,6 +15,28 @@ pub fn mars_route_specs() -> Array[RouteSpec]
// Errors
// Types and methods
+pub struct ApiState {
+ kv : @kv.Kv
+}
+pub fn ApiState::file_text(Self, String) -> String?
+pub fn ApiState::list_entries(Self, String) -> Array[FileEntry]
+pub fn ApiState::new() -> Self
+pub fn ApiState::render_file_response(Self, String) -> HtmlResponse
+pub fn ApiState::render_filer_html(Self, String) -> String
+pub fn ApiState::render_home_html(Self) -> String
+pub fn ApiState::render_readme_html(Self) -> String
+
+pub struct FileEntry {
+ name : String
+ path : String
+ is_dir : Bool
+}
+
+pub struct HtmlResponse {
+ status : Int
+ body : String
+}
+
pub struct RouteSpec {
id : String
http_method : String
From c0ac61ff36bd236a52d897779a4078fbc65ee76a Mon Sep 17 00:00:00 2001
From: mizchi
Date: Wed, 18 Feb 2026 20:29:39 +0900
Subject: [PATCH 4/6] feat: refactor bithub viewer and add benchmark workflow
---
.github/workflows/ci.yml | 19 +++
README.md | 13 ++
e2e/file-viewer.spec.ts | 9 ++
package.json | 1 +
src/cmd/bithub/bench_viewer_test.mbt | 38 +++++
src/cmd/bithub/hub_reader.mbt | 201 +++++++++++++++++++++++++++
src/cmd/bithub/main.mbt | 5 +
src/cmd/bithub/moon.pkg | 9 ++
src/cmd/bithub/repo_scan.mbt | 94 +++++++++++++
src/cmd/bithub/viewer.mbt | 176 ++++++++++-------------
src/cmd/bithub/viewer_test.mbt | 36 +++++
src/cmd/bithub_bench/main.mbt | 96 +++++++++++++
src/cmd/bithub_bench/moon.pkg | 12 ++
13 files changed, 610 insertions(+), 99 deletions(-)
create mode 100644 src/cmd/bithub/bench_viewer_test.mbt
create mode 100644 src/cmd/bithub/hub_reader.mbt
create mode 100644 src/cmd/bithub/repo_scan.mbt
create mode 100644 src/cmd/bithub_bench/main.mbt
create mode 100644 src/cmd/bithub_bench/moon.pkg
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 36821f4..57a8138 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -67,3 +67,22 @@ jobs:
- name: Run Playwright tests
run: pnpm test:e2e
+
+ bench:
+ name: Moon benchmark
+ runs-on: ubuntu-latest
+ needs: moon
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install MoonBit CLI
+ run: |
+ curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
+ echo "$HOME/.moon/bin" >> "$GITHUB_PATH"
+
+ - name: Moon update
+ run: moon update
+
+ - name: Run viewer benchmark (js)
+ run: moon bench -p bit-vcs/bithub/cmd/bithub --target js -f bench_viewer_test.mbt
diff --git a/README.md b/README.md
index c79a180..c4fa429 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,18 @@ pnpm install
pnpm test:e2e
```
+## Benchmark
+
+```bash
+moon bench -p bit-vcs/bithub/cmd/bithub --target js -f bench_viewer_test.mbt
+pnpm bench
+moon run src/cmd/bithub_bench --target js -- . 20
+```
+
+- `moon bench`: 標準ベンチハーネス
+- `pnpm bench`: 手早いサマリ表示(デフォルト設定)
+- `moon run ... -- `: 対象リポジトリと反復回数を明示指定
+
## Local Viewer (`bithub .`)
現在のリポジトリを GitHub 風に閲覧する最小 UI を起動できます。
@@ -35,6 +47,7 @@ pnpm test:e2e
- `/` で `README.md` を優先表示
- `/blob/` でファイル表示
+- `/issues` で `bit hub` の Issue 一覧表示
- UI は `mizchi/luna/x/components` ベースの最小構成
## Cloudflare Entrypoint
diff --git a/e2e/file-viewer.spec.ts b/e2e/file-viewer.spec.ts
index 39b6197..621ed2d 100644
--- a/e2e/file-viewer.spec.ts
+++ b/e2e/file-viewer.spec.ts
@@ -24,3 +24,12 @@ test('path traversal is rejected', async ({ page }) => {
expect(response!.status()).toBe(400);
await expect(page.locator('main')).toContainText('Invalid path.');
});
+
+test('issues list page is available', async ({ page }) => {
+ await page.goto('/');
+
+ await page.getByRole('link', { name: 'issues' }).click();
+
+ await expect(page).toHaveURL(/\/issues$/);
+ await expect(page.getByRole('heading', { level: 1, name: 'Issues' })).toBeVisible();
+});
diff --git a/package.json b/package.json
index a632d57..bf169c0 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,7 @@
"name": "bit-vcs-bithub",
"private": true,
"scripts": {
+ "bench": "moon run src/cmd/bithub_bench --target js -- . 20",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
diff --git a/src/cmd/bithub/bench_viewer_test.mbt b/src/cmd/bithub/bench_viewer_test.mbt
new file mode 100644
index 0000000..7f86c7c
--- /dev/null
+++ b/src/cmd/bithub/bench_viewer_test.mbt
@@ -0,0 +1,38 @@
+///| Benchmarks for bithub local viewer
+
+///| run: moon bench -p bit-vcs/bithub/cmd/bithub --target js -f bench_viewer_test.mbt
+
+///|
+let bench_state : RepoState = RepoState::new(".")
+
+///|
+test "bench scan_repo_files" (b : @bench.T) {
+ b.bench(fn() {
+ let files = scan_repo_files(".", 240)
+ b.keep(files.length())
+ })
+}
+
+///|
+test "bench load_repo_issues" (b : @bench.T) {
+ b.bench(fn() {
+ let issues = load_repo_issues(".", 120)
+ b.keep(issues.length())
+ })
+}
+
+///|
+test "bench render_root" (b : @bench.T) {
+ b.bench(fn() {
+ let res = bench_state.render_root()
+ b.keep(res.html.length())
+ })
+}
+
+///|
+test "bench render_issues" (b : @bench.T) {
+ b.bench(fn() {
+ let res = bench_state.render_issues()
+ b.keep(res.html.length())
+ })
+}
diff --git a/src/cmd/bithub/hub_reader.mbt b/src/cmd/bithub/hub_reader.mbt
new file mode 100644
index 0000000..41dcc7b
--- /dev/null
+++ b/src/cmd/bithub/hub_reader.mbt
@@ -0,0 +1,201 @@
+///|
+pub fn load_repo_issues(root : String, limit : Int) -> Array[IssueSummary] {
+ let git_dir = @npath.join2(root, ".git")
+ if not(@nfs.existsSync(git_dir)) {
+ return []
+ }
+ let os_fs = JsOsFs::new()
+ let objects : HubObjectStore = { fs: os_fs, rfs: os_fs, git_dir }
+ let refs : HubRefStore = { fs: os_fs, rfs: os_fs, git_dir }
+ let hub = @hub.Hub::load(objects, refs) catch { _ => return [] }
+ let issues = hub.list_issues(objects)
+ let result : Array[IssueSummary] = []
+ for issue in issues {
+ if result.length() < limit {
+ result.push(
+ issue_summary(issue.id(), issue.title(), issue.state().to_string()),
+ )
+ }
+ }
+ result
+}
+
+///|
+priv struct JsOsFs {
+ _dummy : Int
+}
+
+///|
+fn JsOsFs::new() -> JsOsFs {
+ { _dummy: 0 }
+}
+
+///|
+fn io_error(err : @fs.IOError) -> @bit.GitError {
+ @bit.GitError::IoError(err.to_string())
+}
+
+///|
+fn ensure_dir(path : String) -> Unit raise @bit.GitError {
+ let is_dir = @fs.is_dir(path) catch { _ => false }
+ if is_dir {
+ return
+ }
+ let parts = path.split("/").collect()
+ let mut current = ""
+ for part_view in parts {
+ let part = part_view.to_string()
+ if part.length() == 0 {
+ current = "/"
+ continue
+ }
+ current = if current.length() == 0 || current == "/" {
+ current + part
+ } else {
+ current + "/" + part
+ }
+ let exists = @fs.is_dir(current) catch { _ => false }
+ if not(exists) {
+ @fs.create_dir(current) catch {
+ err => raise io_error(err)
+ }
+ }
+ }
+}
+
+///|
+impl @bit.FileSystem for JsOsFs with mkdir_p(_self, path) {
+ ensure_dir(path)
+}
+
+///|
+impl @bit.FileSystem for JsOsFs with write_file(_self, path, content) {
+ @fs.write_bytes_to_file(path, content) catch {
+ err => raise io_error(err)
+ }
+}
+
+///|
+impl @bit.FileSystem for JsOsFs with write_string(_self, path, content) {
+ @fs.write_string_to_file(path, content) catch {
+ err => raise io_error(err)
+ }
+}
+
+///|
+impl @bit.FileSystem for JsOsFs with remove_file(_self, path) {
+ @fs.remove_file(path) catch {
+ err => raise io_error(err)
+ }
+}
+
+///|
+impl @bit.FileSystem for JsOsFs with remove_dir(_self, path) {
+ @fs.remove_dir(path) catch {
+ err => raise io_error(err)
+ }
+}
+
+///|
+impl @bit.RepoFileSystem for JsOsFs with read_file(_self, path) {
+ @fs.read_file_to_bytes(path) catch {
+ err => raise io_error(err)
+ }
+}
+
+///|
+impl @bit.RepoFileSystem for JsOsFs with readdir(_self, path) {
+ @fs.read_dir(path) catch {
+ err => raise io_error(err)
+ }
+}
+
+///|
+impl @bit.RepoFileSystem for JsOsFs with is_dir(_self, path) {
+ @fs.is_dir(path) catch {
+ _ => false
+ }
+}
+
+///|
+impl @bit.RepoFileSystem for JsOsFs with is_file(_self, path) {
+ @fs.is_file(path) catch {
+ _ => false
+ }
+}
+
+///|
+priv struct HubObjectStore {
+ fs : &@bit.FileSystem
+ rfs : &@bit.RepoFileSystem
+ git_dir : String
+}
+
+///|
+impl @bitlib.ObjectStore for HubObjectStore with get(self, id) {
+ let db = @bitlib.ObjectDb::load_lazy(self.rfs, self.git_dir)
+ db.get(self.rfs, id)
+}
+
+///|
+impl @bitlib.ObjectStore for HubObjectStore with put(self, obj_type, data) {
+ @bitlib.write_loose_object(self.fs, self.git_dir, obj_type, data)
+}
+
+///|
+impl @bitlib.ObjectStore for HubObjectStore with has(self, id) {
+ let db = @bitlib.ObjectDb::load_lazy(self.rfs, self.git_dir) catch {
+ _ => return false
+ }
+ let obj = db.get(self.rfs, id) catch { _ => return false }
+ obj is Some(_)
+}
+
+///|
+priv struct HubRefStore {
+ fs : &@bit.FileSystem
+ rfs : &@bit.RepoFileSystem
+ git_dir : String
+}
+
+///|
+impl @bitlib.RefStore for HubRefStore with resolve(self, ref_name) {
+ @bitlib.resolve_ref(self.rfs, self.git_dir, ref_name)
+}
+
+///|
+impl @bitlib.RefStore for HubRefStore with update(self, ref_name, id) {
+ match id {
+ Some(commit_id) => {
+ let ref_path = join_path(self.git_dir, ref_name)
+ let dir = parent_path(ref_path)
+ self.fs.mkdir_p(dir)
+ self.fs.write_string(ref_path, commit_id.to_hex() + "\n")
+ }
+ None => ()
+ }
+}
+
+///|
+impl @bitlib.RefStore for HubRefStore with list(self, _prefix) {
+ ignore(self)
+ []
+}
+
+///|
+fn join_path(a : String, b : String) -> String {
+ if a.has_suffix("/") {
+ a + b
+ } else {
+ a + "/" + b
+ }
+}
+
+///|
+fn parent_path(path : String) -> String {
+ match path.rev_find("/") {
+ None => "."
+ Some(0) => "."
+ Some(idx) => String::unsafe_substring(path, start=0, end=idx)
+ }
+}
diff --git a/src/cmd/bithub/main.mbt b/src/cmd/bithub/main.mbt
index 62e782b..923a902 100644
--- a/src/cmd/bithub/main.mbt
+++ b/src/cmd/bithub/main.mbt
@@ -18,6 +18,11 @@ fn route_response(state : RepoState, raw_url : String) -> PageResponse {
if raw_url == "/" || raw_url.has_prefix("/?") || raw_url.has_prefix("/#") {
return state.render_root()
}
+ if raw_url == "/issues" ||
+ raw_url.has_prefix("/issues?") ||
+ raw_url.has_prefix("/issues#") {
+ return state.render_issues()
+ }
if raw_url.has_prefix("/blob/") {
let rel_path = extract_blob_path(raw_url)
return state.render_blob(rel_path)
diff --git a/src/cmd/bithub/moon.pkg b/src/cmd/bithub/moon.pkg
index f6a3129..ad13408 100644
--- a/src/cmd/bithub/moon.pkg
+++ b/src/cmd/bithub/moon.pkg
@@ -1,4 +1,7 @@
import {
+ "mizchi/bit" @bit,
+ "mizchi/bit/lib" @bitlib,
+ "mizchi/bit/x/hub" @hub,
"mizchi/js/core" @js,
"mizchi/js/node/fs" @nfs,
"mizchi/js/node/http" @nhttp,
@@ -9,12 +12,18 @@ import {
"mizchi/luna/x/components" @components,
"mizchi/markdown" @markdown,
"moonbitlang/core/strconv" @strconv,
+ "moonbitlang/x/fs",
}
+import {
+ "moonbitlang/core/bench" @bench,
+} for "test"
+
options(
is_main: true,
link: { "js": { "format": "cjs" } },
targets: {
+ "bench_viewer_test.mbt": [ "js" ],
"main.mbt": [ "js" ],
"viewer.mbt": [ "js" ],
"viewer_test.mbt": [ "js" ],
diff --git a/src/cmd/bithub/repo_scan.mbt b/src/cmd/bithub/repo_scan.mbt
new file mode 100644
index 0000000..4585869
--- /dev/null
+++ b/src/cmd/bithub/repo_scan.mbt
@@ -0,0 +1,94 @@
+///|
+pub fn scan_repo_files(root : String, limit : Int) -> Array[String] {
+ let out : Array[String] = []
+ scan_repo_files_recursive(root, "", out, limit)
+ out.sort_by(fn(a, b) { String::compare(a, b) })
+ out
+}
+
+///|
+pub fn load_repo_file(root : String, rel_path : String) -> String? {
+ let abs_path = @npath.join2(root, rel_path)
+ let text = @nfs.read_file_as_string(abs_path, encoding="utf-8") catch {
+ _ => return None
+ }
+ Some(text)
+}
+
+///|
+priv enum EntryKind {
+ Dir
+ File
+ Other
+}
+
+///|
+fn path_kind(abs_path : String) -> EntryKind {
+ let st = @nfs.stat_sync(abs_path) catch { _ => return Other }
+ if st.isDirectory() {
+ Dir
+ } else if st.isFile() {
+ File
+ } else {
+ Other
+ }
+}
+
+///|
+fn scan_repo_files_recursive(
+ root : String,
+ rel_dir : String,
+ out : Array[String],
+ limit : Int,
+) -> Unit {
+ if out.length() >= limit {
+ return
+ }
+ let abs_dir = if rel_dir.length() == 0 {
+ root
+ } else {
+ @npath.join2(root, rel_dir)
+ }
+ let names = @nfs.readdir_sync(abs_dir) catch { _ => [] }
+ names.sort_by(fn(a, b) { String::compare(a, b) })
+ for name in names {
+ if out.length() >= limit {
+ return
+ }
+ if not(should_skip_name(name)) {
+ let rel_path = if rel_dir.length() == 0 {
+ name
+ } else {
+ rel_dir + "/" + name
+ }
+ let abs_path = @npath.join2(root, rel_path)
+ match path_kind(abs_path) {
+ Dir => scan_repo_files_recursive(root, rel_path, out, limit)
+ File => if is_viewable_file(rel_path) { out.push(rel_path) }
+ Other => ()
+ }
+ }
+ }
+}
+
+///|
+fn should_skip_name(name : String) -> Bool {
+ name == ".git" ||
+ name == ".mooncakes" ||
+ name == "_build" ||
+ name == "target" ||
+ name == "node_modules"
+}
+
+///|
+fn is_viewable_file(path : String) -> Bool {
+ if path.has_suffix(".png") ||
+ path.has_suffix(".jpg") ||
+ path.has_suffix(".jpeg") ||
+ path.has_suffix(".gif") ||
+ path.has_suffix(".webp") ||
+ path.has_suffix(".wasm") {
+ return false
+ }
+ true
+}
diff --git a/src/cmd/bithub/viewer.mbt b/src/cmd/bithub/viewer.mbt
index 2ef9cfb..645c3f4 100644
--- a/src/cmd/bithub/viewer.mbt
+++ b/src/cmd/bithub/viewer.mbt
@@ -4,26 +4,48 @@ pub struct PageResponse {
html : String
}
+///|
+pub struct IssueSummary {
+ id : String
+ title : String
+ state : String
+}
+
+///|
+pub fn issue_summary(
+ id : String,
+ title : String,
+ state : String,
+) -> IssueSummary {
+ { id, title, state }
+}
+
///|
pub struct RepoState {
root : String
files : Array[String]
initial_path : String
+ issues : Array[IssueSummary]
}
///|
pub fn RepoState::new(root : String) -> RepoState {
let resolved = @npath.resolve([root])
- let files = collect_repo_files(resolved, 240)
- files.sort_by(fn(a, b) { String::compare(a, b) })
- let initial_path = if files.contains("README.md") {
+ let files = scan_repo_files(resolved, 240)
+ let issues = load_repo_issues(resolved, 120)
+ let initial_path = select_initial_path(files)
+ { root: resolved, files, initial_path, issues }
+}
+
+///|
+fn select_initial_path(files : Array[String]) -> String {
+ if files.contains("README.md") {
"README.md"
} else if files.length() > 0 {
files[0]
} else {
""
}
- { root: resolved, files, initial_path }
}
///|
@@ -45,7 +67,7 @@ pub fn RepoState::render_blob(
let sanitized = sanitize_rel_path(raw_path)
match sanitized {
Some(rel_path) =>
- match read_repo_file(self.root, rel_path) {
+ match load_repo_file(self.root, rel_path) {
Some(content) => {
let body_html = as_view_html(rel_path, content)
{ status: 200, html: self.render_layout(rel_path, body_html) }
@@ -62,6 +84,14 @@ pub fn RepoState::render_blob(
}
}
+///|
+pub fn RepoState::render_issues(self : RepoState) -> PageResponse {
+ {
+ status: 200,
+ html: self.render_layout("(issues)", as_issues_html(self.issues)),
+ }
+}
+
///|
pub fn RepoState::render_layout(
self : RepoState,
@@ -70,6 +100,8 @@ pub fn RepoState::render_layout(
) -> String {
let header_nodes : Array[@luna.Node[Unit, String]] = [
@components.link("/", [@luna.text("bithub")]),
+ @luna.text(" | "),
+ @components.link("/issues", [@luna.text("issues")]),
@luna.text(" : " + self.root),
]
let nav_nodes = build_nav_nodes(self.files, selected_path)
@@ -103,6 +135,30 @@ pub fn RepoState::render_layout(
@lstatic.render_document(doc)
}
+///|
+pub fn as_issues_html(issues : Array[IssueSummary]) -> String {
+ let buf = StringBuilder::new()
+ buf.write_string("Issues
")
+ if issues.length() == 0 {
+ buf.write_string("No issues found.
")
+ return buf.to_string()
+ }
+ buf.write_string("")
+ for issue in issues {
+ buf.write_string(
+ "" +
+ escape_html(issue.id) +
+ " " +
+ escape_html(issue.title) +
+ " (" +
+ escape_html(issue.state) +
+ ") ",
+ )
+ }
+ buf.write_string("
")
+ buf.to_string()
+}
+
///|
fn build_nav_nodes(
files : Array[String],
@@ -125,100 +181,6 @@ fn build_nav_nodes(
nodes
}
-///|
-fn collect_repo_files(root : String, limit : Int) -> Array[String] {
- let out : Array[String] = []
- collect_repo_files_recursive(root, "", out, limit)
- out
-}
-
-///|
-priv enum EntryKind {
- Dir
- File
- Other
-}
-
-///|
-fn path_kind(abs_path : String) -> EntryKind {
- let st = @nfs.stat_sync(abs_path) catch { _ => return Other }
- if st.isDirectory() {
- Dir
- } else if st.isFile() {
- File
- } else {
- Other
- }
-}
-
-///|
-fn collect_repo_files_recursive(
- root : String,
- rel_dir : String,
- out : Array[String],
- limit : Int,
-) -> Unit {
- if out.length() >= limit {
- return
- }
- let abs_dir = if rel_dir.length() == 0 {
- root
- } else {
- @npath.join2(root, rel_dir)
- }
- let names = @nfs.readdir_sync(abs_dir) catch { _ => [] }
- names.sort_by(fn(a, b) { String::compare(a, b) })
- for name in names {
- if out.length() >= limit {
- return
- }
- if not(should_skip_name(name)) {
- let rel_path = if rel_dir.length() == 0 {
- name
- } else {
- rel_dir + "/" + name
- }
- let abs_path = @npath.join2(root, rel_path)
- match path_kind(abs_path) {
- Dir => collect_repo_files_recursive(root, rel_path, out, limit)
- File => if is_viewable_file(rel_path) { out.push(rel_path) }
- Other => ()
- }
- }
- }
-}
-
-///|
-fn should_skip_name(name : String) -> Bool {
- name == ".git" ||
- name == ".mooncakes" ||
- name == "_build" ||
- name == "target" ||
- name == "node_modules"
-}
-
-///|
-fn is_viewable_file(path : String) -> Bool {
- if path.has_suffix(".png") ||
- path.has_suffix(".jpg") ||
- path.has_suffix(".jpeg") ||
- path.has_suffix(".gif") ||
- path.has_suffix(".webp") ||
- path.has_suffix(".wasm") {
- return false
- }
- true
-}
-
-///|
-fn read_repo_file(root : String, rel_path : String) -> String? {
- let abs_path = @npath.join2(root, rel_path)
- let text = @nfs.read_file_as_string(abs_path, encoding="utf-8") catch {
- _ => return None
- }
- Some(text)
-}
-
///|
pub fn sanitize_rel_path(raw_path : String) -> String? {
let mut path = raw_path
@@ -284,3 +246,19 @@ pub fn as_view_html(path : String, content : String) -> String {
}
@markdown.md_to_html("```text\n" + content + "\n```")
}
+
+///|
+fn escape_html(raw : String) -> String {
+ let buf = StringBuilder::new()
+ for c in raw {
+ match c {
+ '&' => buf.write_string("&")
+ '<' => buf.write_string("<")
+ '>' => buf.write_string(">")
+ '"' => buf.write_string(""")
+ '\'' => buf.write_string("'")
+ _ => buf.write_char(c)
+ }
+ }
+ buf.to_string()
+}
diff --git a/src/cmd/bithub/viewer_test.mbt b/src/cmd/bithub/viewer_test.mbt
index b10e1d0..8a996c4 100644
--- a/src/cmd/bithub/viewer_test.mbt
+++ b/src/cmd/bithub/viewer_test.mbt
@@ -22,3 +22,39 @@ test "as_view_html wraps non-markdown in fenced block" {
assert_true(html.contains("ok
")
+ assert_true(html.contains("href=\"/issues\""))
+}
+
+///|
+test "as_issues_html renders issue summaries" {
+ let html = as_issues_html([issue_summary("issue-1", "Bug report", "open")])
+ assert_true(html.contains("Issues
"))
+ assert_true(html.contains("issue-1"))
+ assert_true(html.contains("Bug report"))
+ assert_true(html.contains("open"))
+}
+
+///|
+test "render_issues returns page response" {
+ let state = RepoState::new(".")
+ let page = state.render_issues()
+ inspect(page.status, content="200")
+ assert_true(page.html.contains("Issues
"))
+}
+
+///|
+test "scan_repo_files includes README.md in this repository" {
+ let files = scan_repo_files(".", 400)
+ assert_true(files.contains("README.md"))
+}
+
+///|
+test "load_repo_issues returns empty for path without .git" {
+ let issues = load_repo_issues("/__bithub_no_such_repo__", 10)
+ inspect(issues.length(), content="0")
+}
diff --git a/src/cmd/bithub_bench/main.mbt b/src/cmd/bithub_bench/main.mbt
new file mode 100644
index 0000000..20a565f
--- /dev/null
+++ b/src/cmd/bithub_bench/main.mbt
@@ -0,0 +1,96 @@
+///|
+struct BenchResult {
+ name : String
+ iterations : Int
+ min_ms : Int
+ max_ms : Int
+ avg_ms : Int
+ total_ms : Int
+}
+
+///|
+fn parse_iterations(raw : String) -> Int {
+ let parsed = @strconv.parse_int(raw) catch { _ => 20 }
+ if parsed > 0 {
+ parsed
+ } else {
+ 20
+ }
+}
+
+///|
+fn parse_bench_args() -> (String, Int) {
+ let argv = @nprocess.argv()
+ let repo = if argv.length() > 2 { argv[2] } else { "." }
+ let iterations = if argv.length() > 3 {
+ parse_iterations(argv[3])
+ } else {
+ 20
+ }
+ (repo, iterations)
+}
+
+///|
+fn run_bench(name : String, iterations : Int, task : () -> Unit) -> BenchResult {
+ let mut min_ms = 0
+ let mut max_ms = 0
+ let mut total_ms = 0
+ for i in 0.. max_ms {
+ max_ms = elapsed
+ }
+ }
+ total_ms += elapsed
+ }
+ { name, iterations, min_ms, max_ms, avg_ms: total_ms / iterations, total_ms }
+}
+
+///|
+fn print_result(result : BenchResult) -> Unit {
+ println(
+ "\{result.name}: n=\{result.iterations} min=\{result.min_ms}ms avg=\{result.avg_ms}ms max=\{result.max_ms}ms total=\{result.total_ms}ms",
+ )
+}
+
+///|
+fn main {
+ let (repo, iterations) = parse_bench_args()
+ println("bithub benchmark")
+ println("repo: \{repo}")
+ println("iterations: \{iterations}")
+
+ let repo_state_new = run_bench("RepoState::new", iterations, fn() {
+ ignore(@bithub.RepoState::new(repo))
+ })
+ let scan_files = run_bench("scan_repo_files", iterations, fn() {
+ ignore(@bithub.scan_repo_files(repo, 240))
+ })
+ let load_issues = run_bench("load_repo_issues", iterations, fn() {
+ ignore(@bithub.load_repo_issues(repo, 120))
+ })
+
+ let state = @bithub.RepoState::new(repo)
+ let render_root = run_bench("RepoState::render_root", iterations, fn() {
+ ignore(state.render_root())
+ })
+ let render_issues = run_bench("RepoState::render_issues", iterations, fn() {
+ ignore(state.render_issues())
+ })
+
+ println("---")
+ print_result(repo_state_new)
+ print_result(scan_files)
+ print_result(load_issues)
+ print_result(render_root)
+ print_result(render_issues)
+}
diff --git a/src/cmd/bithub_bench/moon.pkg b/src/cmd/bithub_bench/moon.pkg
new file mode 100644
index 0000000..9cdbe75
--- /dev/null
+++ b/src/cmd/bithub_bench/moon.pkg
@@ -0,0 +1,12 @@
+import {
+ "bit-vcs/bithub/cmd/bithub" @bithub,
+ "mizchi/js/builtins/date" @jdate,
+ "mizchi/js/node/process" @nprocess,
+ "moonbitlang/core/strconv" @strconv,
+}
+
+options(
+ is_main: true,
+ link: { "js": { "format": "cjs" } },
+ targets: { "main.mbt": [ "js" ] },
+)
From cba97690d68457a9e6b7397aa0cedc6f17660327 Mon Sep 17 00:00:00 2001
From: mizchi
Date: Wed, 18 Feb 2026 20:45:36 +0900
Subject: [PATCH 5/6] fix(ci): setup pnpm before setup-node cache
---
.github/workflows/ci.yml | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 57a8138..0385eec 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,17 +40,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
- with:
- version: 10
-
- name: Install MoonBit CLI
run: |
curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
From eb1da9d2eb9c9024b1725853382e69dc9fce5cdf Mon Sep 17 00:00:00 2001
From: mizchi
Date: Wed, 18 Feb 2026 20:51:25 +0900
Subject: [PATCH 6/6] test(e2e): expand smoke coverage for viewer routes
---
e2e/file-viewer.spec.ts | 38 ++++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/e2e/file-viewer.spec.ts b/e2e/file-viewer.spec.ts
index 621ed2d..e877da0 100644
--- a/e2e/file-viewer.spec.ts
+++ b/e2e/file-viewer.spec.ts
@@ -33,3 +33,41 @@ test('issues list page is available', async ({ page }) => {
await expect(page).toHaveURL(/\/issues$/);
await expect(page.getByRole('heading', { level: 1, name: 'Issues' })).toBeVisible();
});
+
+test('issues route works with query string', async ({ page }) => {
+ await page.goto('/issues?state=open');
+
+ await expect(page.getByRole('heading', { level: 1, name: 'Issues' })).toBeVisible();
+});
+
+test('blob route works with query string', async ({ page }) => {
+ const response = await page.goto('/blob/README.md?raw=1');
+
+ expect(response).not.toBeNull();
+ expect(response!.status()).toBe(200);
+ await expect(page.locator('main')).toContainText('README.md');
+});
+
+test('missing blob returns 404 page', async ({ page }) => {
+ const response = await page.goto('/blob/not-found-file.txt');
+
+ expect(response).not.toBeNull();
+ expect(response!.status()).toBe(404);
+ await expect(page.locator('main')).toContainText('File not found');
+});
+
+test('unknown route returns 404 page', async ({ page }) => {
+ const response = await page.goto('/__no_such_route__');
+
+ expect(response).not.toBeNull();
+ expect(response!.status()).toBe(404);
+ await expect(page.locator('main')).toContainText('Route not found.');
+});
+
+test('can navigate back to home from issues page', async ({ page }) => {
+ await page.goto('/issues');
+ await page.getByRole('banner').getByRole('link', { name: 'bithub' }).click();
+
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.locator('main')).toContainText('README.md');
+});