diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0385eec
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,88 @@
+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 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: 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
+
+ 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/.gitignore b/.gitignore
new file mode 100644
index 0000000..1eff73e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+_build/
+target/
+.mooncakes/
+.DS_Store
+node_modules/
+playwright-report/
+test-results/
diff --git a/README.md b/README.md
index 4526eaa..c4fa429 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,57 @@
# bithub
+
+`bit` と連携する GitHub-like UI のための実験リポジトリです。
+
+## Current Direction
+
+- まずは `mars` で実装する
+- `sol` へ引き上げられるように分解点を固定する
+- Cloudflare Workers (JS target) 前提で進める
+
+分解方針は `/Users/mz/ghq/github.com/bit-vcs/bithub/docs/mars-sol-boundary.md` を参照。
+
+## Check
+
+```bash
+moon check --target js
+moon test --target js
+```
+
+## E2E (Playwright)
+
+```bash
+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 を起動できます。
+
+```bash
+./bithub . # port 8787
+./bithub . 9000 # custom port
+```
+
+- `/` で `README.md` を優先表示
+- `/blob/` でファイル表示
+- `/issues` で `bit hub` の Issue 一覧表示
+- UI は `mizchi/luna/x/components` ベースの最小構成
+
+## 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/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/docs/mars-sol-boundary.md b/docs/mars-sol-boundary.md
new file mode 100644
index 0000000..9fe7af2
--- /dev/null
+++ b/docs/mars-sol-boundary.md
@@ -0,0 +1,28 @@
+# Mars/Sol Boundary Notes (bithub)
+
+## Goal
+
+`bithub` は当面 `mars` で機能を作り、`sol` への引き上げ可能性を維持する。
+
+## Design Rule
+
+- `core`:
+ - 画面/機能の契約と純粋ロジック
+ - `mars` / `sol` 依存を入れない
+- `adapters/mars_http`:
+ - `core` を `mars.Server` へ接続する層
+- `cmd/main`:
+ - Cloudflare 向け `fetch` エントリ公開のみ
+ - `@mars.Server::to_handler_with_env` に委譲する
+
+## 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/e2e/file-viewer.spec.ts b/e2e/file-viewer.spec.ts
new file mode 100644
index 0000000..e877da0
--- /dev/null
+++ b/e2e/file-viewer.spec.ts
@@ -0,0 +1,73 @@
+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.');
+});
+
+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();
+});
+
+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');
+});
diff --git a/moon.mod.json b/moon.mod.json
new file mode 100644
index 0000000..9e4d0eb
--- /dev/null
+++ b/moon.mod.json
@@ -0,0 +1,25 @@
+{
+ "name": "bit-vcs/bithub",
+ "version": "0.1.0",
+ "deps": {
+ "moonbitlang/async": "0.16.6",
+ "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",
+ "license": "MIT",
+ "keywords": [
+ "bithub",
+ "bit",
+ "mars",
+ "sol"
+ ],
+ "description": "GitHub-like UI backend for bit",
+ "source": "src",
+ "preferred-target": "js"
+}
\ No newline at end of file
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/package.json b/package.json
new file mode 100644
index 0000000..bf169c0
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "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"
+ },
+ "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/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..1e22870
--- /dev/null
+++ b/src/adapters/mars_http/server.mbt
@@ -0,0 +1,19 @@
+///|
+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("/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/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
new file mode 100644
index 0000000..923a902
--- /dev/null
+++ b/src/cmd/bithub/main.mbt
@@ -0,0 +1,57 @@
+///|
+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 == "/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)
+ }
+ {
+ 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..ad13408
--- /dev/null
+++ b/src/cmd/bithub/moon.pkg
@@ -0,0 +1,31 @@
+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,
+ "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,
+ "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/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/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
new file mode 100644
index 0000000..645c3f4
--- /dev/null
+++ b/src/cmd/bithub/viewer.mbt
@@ -0,0 +1,264 @@
+///|
+pub struct PageResponse {
+ status : Int
+ 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 = 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 {
+ ""
+ }
+}
+
+///|
+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 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) }
+ }
+ 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_issues(self : RepoState) -> PageResponse {
+ {
+ status: 200,
+ html: self.render_layout("(issues)", as_issues_html(self.issues)),
+ }
+}
+
+///|
+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(" | "),
+ @components.link("/issues", [@luna.text("issues")]),
+ @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)
+}
+
+///|
+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],
+ 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
+}
+
+///|
+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```")
+}
+
+///|
+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
new file mode 100644
index 0000000..8a996c4
--- /dev/null
+++ b/src/cmd/bithub/viewer_test.mbt
@@ -0,0 +1,60 @@
+///|
+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("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" ] },
+)
diff --git a/src/cmd/main/main.mbt b/src/cmd/main/main.mbt
new file mode 100644
index 0000000..ae4cfa0
--- /dev/null
+++ b/src/cmd/main/main.mbt
@@ -0,0 +1,10 @@
+///|
+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)
+}
diff --git a/src/cmd/main/moon.pkg b/src/cmd/main/moon.pkg
new file mode 100644
index 0000000..15f5d3d
--- /dev/null
+++ b/src/cmd/main/moon.pkg
@@ -0,0 +1,5 @@
+import {
+ "moonbitlang/async/js_async" @js_async,
+ "mizchi/mars",
+ "bit-vcs/bithub/adapters/mars_http" @mars_http,
+}
diff --git a/src/cmd/main/pkg.generated.mbti b/src/cmd/main/pkg.generated.mbti
new file mode 100644
index 0000000..5c29b33
--- /dev/null
+++ b/src/cmd/main/pkg.generated.mbti
@@ -0,0 +1,19 @@
+// 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
+
+// Types and methods
+
+// Type aliases
+
+// Traits
+
diff --git a/src/core/api_state.mbt b/src/core/api_state.mbt
new file mode 100644
index 0000000..6d3fefe
--- /dev/null
+++ b/src/core/api_state.mbt
@@ -0,0 +1,389 @@
+///|
+pub struct FileEntry {
+ name : String
+ path : String
+ is_dir : Bool
+}
+
+///|
+pub struct HtmlResponse {
+ status : Int
+ body : String
+}
+
+///|
+// Adapter: @bitfs.Fs -> @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
new file mode 100644
index 0000000..1d8e856
--- /dev/null
+++ b/src/core/core.mbt
@@ -0,0 +1,27 @@
+///|
+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" },
+ { id: "readme", http_method: "GET", path: "/readme" },
+ { id: "filer", http_method: "GET", path: "/filer" },
+ { id: "file", http_method: "GET", path: "/file" },
+ ]
+}
+
+///|
+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..d202848
--- /dev/null
+++ b/src/core/core_test.mbt
@@ -0,0 +1,52 @@
+///|
+test "mars_route_specs contains API routes" {
+ let routes = mars_route_specs()
+ 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")
+}
+
+///|
+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
new file mode 100644
index 0000000..2eca343
--- /dev/null
+++ b/src/core/moon.pkg
@@ -0,0 +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
new file mode 100644
index 0000000..62a4b97
--- /dev/null
+++ b/src/core/pkg.generated.mbti
@@ -0,0 +1,49 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "bit-vcs/bithub/core"
+
+import {
+ "mizchi/bit/x/kv",
+}
+
+// Values
+pub fn healthz_text() -> String
+
+pub fn home_text() -> String
+
+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
+ path : String
+}
+
+// Type aliases
+
+// Traits
+