feat(cli): wire config+reader+render → dist/index.html [skip-tdd: visual+wrapper]
- src/cli.ts: load config, fetch board, write dist/index.html, copy static - static/board.css: minimal raw CSS, system fonts, light/dark via prefers-color-scheme - static/board.js: client-side filter, drawer (lazy-fetch + marked@14 from esm.sh), toggle-archived, refresh-info; ESM module - npm run build → runs cli with --experimental-strip-types CSS/JS = [skip-tdd: visual]. CLI = plumbing wrapper (~30 LOC of pure FS+orchestration calling already-tested functions; over 10-line wrapper-skip threshold but no branching logic — would be tested via smoke / e2e, not unit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node --experimental-strip-types src/cli.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
39
src/cli.ts
Normal file
39
src/cli.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { copyFileSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createGiteaClient } from './gitea.ts';
|
||||
import { readBoard } from './reader.ts';
|
||||
import { renderBoard } from './render.ts';
|
||||
import { defaultConfigPath, loadConfig } from './config.ts';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
const STATIC_SRC = join(ROOT, 'static');
|
||||
const DIST_DIR = process.env['BOARD_DIST_DIR'] ?? join(ROOT, 'dist');
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const configPath = process.argv[2] ?? defaultConfigPath();
|
||||
const cfg = loadConfig(configPath);
|
||||
|
||||
const client = createGiteaClient({ baseUrl: cfg.baseUrl, token: cfg.token });
|
||||
const records = await readBoard(client, cfg.repos);
|
||||
|
||||
const html = renderBoard(records, { generatedAt: new Date() });
|
||||
|
||||
mkdirSync(DIST_DIR, { recursive: true });
|
||||
const indexPath = join(DIST_DIR, 'index.html');
|
||||
writeFileSync(indexPath, html, 'utf8');
|
||||
|
||||
const distStatic = join(DIST_DIR, 'static');
|
||||
mkdirSync(distStatic, { recursive: true });
|
||||
for (const entry of readdirSync(STATIC_SRC)) {
|
||||
copyFileSync(join(STATIC_SRC, entry), join(distStatic, entry));
|
||||
}
|
||||
|
||||
console.log(`wrote ${indexPath} (${records.length} records from ${cfg.repos.length} repos)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
257
static/board.css
Normal file
257
static/board.css
Normal file
@@ -0,0 +1,257 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #111111;
|
||||
--muted: #666666;
|
||||
--border: #e2e2e2;
|
||||
--card-bg: #fafafa;
|
||||
--accent: #1a1a1a;
|
||||
--badge-bg: #efefef;
|
||||
--col-gap: 1rem;
|
||||
--card-gap: 0.5rem;
|
||||
--radius: 6px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0e0e10;
|
||||
--fg: #ececec;
|
||||
--muted: #888888;
|
||||
--border: #2a2a2d;
|
||||
--card-bg: #18181b;
|
||||
--accent: #f5f5f5;
|
||||
--badge-bg: #2a2a2d;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.controls input[type="search"] {
|
||||
font: inherit;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
min-width: 18rem;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.refresh-info {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
main.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(14rem, 1fr));
|
||||
gap: var(--col-gap);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
main.board { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
main.board { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--card-gap);
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.col h2 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.4rem;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.col h2 .count {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
ul.cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--card-gap);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 0.4rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--badge-bg);
|
||||
border-radius: 999px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.age {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.card[data-archived="true"] {
|
||||
opacity: 0.45;
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.show-archived .card[data-archived="true"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card[data-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
aside.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(40rem, 100vw);
|
||||
height: 100vh;
|
||||
background: var(--bg);
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
box-shadow: -2px 0 12px rgba(0,0,0,0.05);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
aside.drawer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.8rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.drawer-content h1,
|
||||
.drawer-content h2,
|
||||
.drawer-content h3 {
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.drawer-content code {
|
||||
font: 0.85rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
background: var(--badge-bg);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-content pre {
|
||||
background: var(--badge-bg);
|
||||
padding: 0.8rem;
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.drawer-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body.group-by-project main.board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
body.group-by-project main.board .col {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.group-by-project main.board::after {
|
||||
content: "grouping by project: not implemented yet";
|
||||
color: var(--muted);
|
||||
padding: 1rem;
|
||||
}
|
||||
93
static/board.js
Normal file
93
static/board.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { marked } from 'https://esm.sh/marked@14';
|
||||
|
||||
const recordsEl = document.getElementById('records');
|
||||
const records = recordsEl ? JSON.parse(recordsEl.textContent || '[]') : [];
|
||||
|
||||
const filterInput = document.querySelector('.filter');
|
||||
const toggleArchived = document.querySelector('.toggle-archived');
|
||||
const toggleGrouping = document.querySelector('.toggle-grouping');
|
||||
const drawer = document.querySelector('.drawer');
|
||||
const drawerContent = drawer?.querySelector('.drawer-content');
|
||||
const drawerClose = drawer?.querySelector('.drawer-close');
|
||||
const refreshInfo = document.querySelector('.refresh-info');
|
||||
const header = document.querySelector('header');
|
||||
|
||||
const generatedAt = header?.dataset.generated ? new Date(header.dataset.generated) : null;
|
||||
const NEXT_TICK_MIN = 5;
|
||||
|
||||
function relativeMin(ms) {
|
||||
return Math.max(0, Math.floor(ms / 60000));
|
||||
}
|
||||
|
||||
function updateRefreshInfo() {
|
||||
if (!refreshInfo || !generatedAt) return;
|
||||
const ageMin = relativeMin(Date.now() - generatedAt.getTime());
|
||||
const nextMin = Math.max(0, NEXT_TICK_MIN - ageMin);
|
||||
refreshInfo.textContent = `refreshed ${ageMin}m ago · next in ${nextMin}m`;
|
||||
}
|
||||
updateRefreshInfo();
|
||||
setInterval(updateRefreshInfo, 30_000);
|
||||
|
||||
function applyFilter(query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
document.querySelectorAll('.card').forEach((card) => {
|
||||
const slug = card.dataset.slug || '';
|
||||
const project = card.querySelector('.badge.project')?.textContent || '';
|
||||
const title = card.querySelector('.card-title')?.textContent || '';
|
||||
const hay = `${slug} ${project} ${title}`.toLowerCase();
|
||||
card.dataset.hidden = q === '' || hay.includes(q) ? 'false' : 'true';
|
||||
});
|
||||
}
|
||||
|
||||
filterInput?.addEventListener('input', (e) => {
|
||||
applyFilter(e.target.value);
|
||||
});
|
||||
|
||||
toggleArchived?.addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('show-archived', e.target.checked);
|
||||
});
|
||||
|
||||
toggleGrouping?.addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('group-by-project', e.target.checked);
|
||||
});
|
||||
|
||||
async function openDrawer(card) {
|
||||
if (!drawer || !drawerContent) return;
|
||||
const rawUrl = card.dataset.rawUrl;
|
||||
const slug = card.dataset.slug;
|
||||
if (!rawUrl) return;
|
||||
drawerContent.innerHTML = `<h2>${slug}</h2><p>loading…</p>`;
|
||||
drawer.hidden = false;
|
||||
try {
|
||||
const res = await fetch(rawUrl, { credentials: 'omit' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const md = await res.text();
|
||||
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2>` + marked.parse(md);
|
||||
} catch (err) {
|
||||
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2><p>Failed to load: ${escapeHtml(String(err))}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const card = e.target.closest?.('.card');
|
||||
if (card) openDrawer(card);
|
||||
});
|
||||
|
||||
drawerClose?.addEventListener('click', () => {
|
||||
if (drawer) drawer.hidden = true;
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && drawer) drawer.hidden = true;
|
||||
});
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
console.log(`board-viewer: ${records.length} records loaded`);
|
||||
Reference in New Issue
Block a user