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 +