| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import type { Hono } from "hono"; |
| 3 | import { deleteCookie, getCookie, setCookie } from "hono/cookie"; |
| 4 | import { html, raw } from "hono/html"; |
| 5 | import type { Child, FC, PropsWithChildren } from "hono/jsx"; |
| 6 | import type { Account, Repo } from "../lib/directory.js"; |
| 7 | import type { Env } from "../env.js"; |
| 8 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 9 | import { HttpError, randomToken } from "../lib/util.js"; |
| 10 | import { atLeast, type Caller } from "../repo/types.js"; |
| 11 | import type { repoStub } from "../http/common.js"; |
| 12 | import { CSS, ICONS, JS, type IconName } from "./assets.js"; |
| 13 | import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js"; |
| 14 | import { fenceLangs, renderMarkdown, type MarkdownBase } from "./markdown.js"; |
| 15 | import { diffLineTokens, fenceHighlighter, langForPath, LIMITS, ready, tokenize, type Lang, type TokLine } from "./highlight.js"; |
| 16 | import { KSelect, kumoAssets } from "./kumo.js"; |
| 17 | import { MailStore, type WatchLevel } from "../mail/prefs.js"; |
| 18 | import { SeoHead, SITE } from "./seo.js"; |
| 19 | import { avatarPath } from "../http/avatars.js"; |
| 20 | |
| 21 | export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>; |
| 22 | export type Stub = ReturnType<typeof repoStub>; |
| 23 | |
| 24 | // ---------------------------------------------------------------- small helpers |
| 25 | |
| 26 | export const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/"); |
| 27 | export const repoUrl = (r: Pick<Repo, "owner_login" | "name">): string => `/${r.owner_login}/${r.name}`; |
| 28 | export const shortSha = (sha: string): string => sha.slice(0, 7); |
| 29 | export const firstLine = (s: string): string => s.split("\n")[0] ?? ""; |
| 30 | |
| 31 | export function publicUrl(c: AppContext): string { |
| 32 | return (c.env.PUBLIC_URL || new URL(c.req.url).origin).replace(/\/$/, ""); |
| 33 | } |
| 34 | |
| 35 | export function cloneUrl(c: AppContext, repo: Repo): string { |
| 36 | return `${publicUrl(c)}/${repo.owner_login}/${repo.name}.git`; |
| 37 | } |
| 38 | |
| 39 | export function fmtBytes(n: number | undefined | null): string { |
| 40 | if (n === undefined || n === null) return ""; |
| 41 | if (n < 1024) return `${n} B`; |
| 42 | if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; |
| 43 | if (n < 1024 ** 3) return `${(n / 1024 / 1024).toFixed(1)} MB`; |
| 44 | return `${(n / 1024 ** 3).toFixed(2)} GB`; |
| 45 | } |
| 46 | |
| 47 | export function fmtDuration(ms: number): string { |
| 48 | if (!Number.isFinite(ms) || ms < 0) return ""; |
| 49 | const s = Math.round(ms / 1000); |
| 50 | if (s < 60) return `${s}s`; |
| 51 | const m = Math.floor(s / 60); |
| 52 | if (m < 60) return `${m}m ${s % 60}s`; |
| 53 | return `${Math.floor(m / 60)}h ${m % 60}m`; |
| 54 | } |
| 55 | |
| 56 | export function duration(start: string | null | undefined, end: string | null | undefined): string { |
| 57 | if (!start) return ""; |
| 58 | return fmtDuration((end ? Date.parse(end) : Date.now()) - Date.parse(start)); |
| 59 | } |
| 60 | |
| 61 | export function ago(iso: string | null | undefined): string { |
| 62 | if (!iso) return ""; |
| 63 | const t = Date.parse(iso); |
| 64 | if (!Number.isFinite(t)) return iso; |
| 65 | const s = Math.round((Date.now() - t) / 1000); |
| 66 | if (s < 45) return "just now"; |
| 67 | const m = Math.round(s / 60); |
| 68 | if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`; |
| 69 | const h = Math.round(m / 60); |
| 70 | if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`; |
| 71 | const d = Math.round(h / 24); |
| 72 | if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`; |
| 73 | return new Date(t).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); |
| 74 | } |
| 75 | |
| 76 | export const Time: FC<{ at: string | null | undefined }> = ({ at }) => (at ? <time datetime={at} title={at}>{ago(at)}</time> : <span />); |
| 77 | |
| 78 | export const Icon: FC<{ name: IconName; class?: string }> = ({ name, class: cls }) => <span class={`st ${cls ?? ""}`}>{raw(ICONS[name])}</span>; |
| 79 | |
| 80 | /** Status icon for runs, jobs, checks and steps. */ |
| 81 | export const Status: FC<{ status?: string | null; conclusion?: string | null }> = ({ status, conclusion }) => { |
| 82 | if (status && status !== "completed") { |
| 83 | if (status === "in_progress") return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>; |
| 84 | return <span class="st warn" title={status === "waiting" ? "Waiting" : status === "pending" ? "Pending" : "Queued"}>{raw(ICONS.queued)}</span>; |
| 85 | } |
| 86 | switch (conclusion) { |
| 87 | case "success": |
| 88 | return <span class="st ok" title="Success">{raw(ICONS.success)}</span>; |
| 89 | case "failure": |
| 90 | case "timed_out": |
| 91 | case "startup_failure": |
| 92 | return <span class="st bad" title={conclusion === "timed_out" ? "Timed out" : "Failed"}>{raw(ICONS.failure)}</span>; |
| 93 | case "cancelled": |
| 94 | return <span class="st faint" title="Cancelled">{raw(ICONS.cancelled)}</span>; |
| 95 | case "skipped": |
| 96 | return <span class="st faint" title="Skipped">{raw(ICONS.skipped)}</span>; |
| 97 | case "in_progress": |
| 98 | return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>; |
| 99 | case "queued": |
| 100 | case "waiting": |
| 101 | case "pending": |
| 102 | return <span class="st warn" title="Queued">{raw(ICONS.queued)}</span>; |
| 103 | default: |
| 104 | return <span class="st faint" title={conclusion ?? "Neutral"}>{raw(ICONS.neutral)}</span>; |
| 105 | } |
| 106 | }; |
| 107 | |
| 108 | /** CSS pixels of each avatar size, and the stored square (2×) served for it. */ |
| 109 | const AVATAR_PX = { sm: [20, 64], lg: [40, 128], xl: [96, 256] } as const; |
| 110 | |
| 111 | /** Profile picture, or generated initials. Pass the account's avatar_version when it is at hand, so a new upload shows at once. */ |
| 112 | export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl"; version?: number | null }> = ({ login, kind, size, version }) => { |
| 113 | const [css, px] = AVATAR_PX[size ?? "sm"]; |
| 114 | return <img class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} src={avatarPath(login, px, version)} width={css} height={css} alt="" loading="lazy" decoding="async" />; |
| 115 | }; |
| 116 | |
| 117 | /** |
| 118 | * Linked actor name with an "agent" badge and the human it acts for. People imported from |
| 119 | * GitHub without a Diffinery account (kind "github", or logins stored as "github:<login>") |
| 120 | * are shown with a "GitHub" badge and no link. |
| 121 | */ |
| 122 | export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean; version?: number | null }> = ({ login, kind, onBehalfOf, avatar = true, version }) => |
| 123 | kind === "github" || login.startsWith("github:") ? ( |
| 124 | <span class="who" title="Imported from GitHub; not linked to a Diffinery account"> |
| 125 | {avatar ? <Avatar login={login.replace(/^github:/, "")} /> : null} |
| 126 | <span>{login.replace(/^github:/, "")}</span> |
| 127 | <span class="badge">GitHub</span> |
| 128 | </span> |
| 129 | ) : ( |
| 130 | <span class="who"> |
| 131 | {avatar ? <Avatar login={login} kind={kind} version={version} /> : null} |
| 132 | {kind === "system" ? <span>{login}</span> : <a href={`/${encodeURIComponent(login)}`} style="color:inherit">{login}</a>} |
| 133 | {kind === "agent" ? <span class="badge agent">agent</span> : null} |
| 134 | {kind === "system" ? <span class="badge">bot</span> : null} |
| 135 | {onBehalfOf ? ( |
| 136 | <span class="muted small" style="font-weight:400"> |
| 137 | on behalf of <a href={`/${encodeURIComponent(onBehalfOf)}`}>{onBehalfOf}</a> |
| 138 | </span> |
| 139 | ) : null} |
| 140 | </span> |
| 141 | ); |
| 142 | |
| 143 | export const Label: FC<{ name: string; color?: string; href?: string }> = ({ name, color, href }) => { |
| 144 | const c = color && /^[0-9a-fA-F]{6}$/.test(color) ? `#${color}` : "#8b929c"; |
| 145 | return href ? ( |
| 146 | <a class="lbl" style={`--lc:${c}`} href={href}> |
| 147 | {name} |
| 148 | </a> |
| 149 | ) : ( |
| 150 | <span class="lbl" style={`--lc:${c}`}>{name}</span> |
| 151 | ); |
| 152 | }; |
| 153 | |
| 154 | export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = async ({ text, base, empty }) => |
| 155 | text.trim() ? ( |
| 156 | <div class="markdown">{raw(renderMarkdown(text, { base, highlight: await fenceHighlighter(fenceLangs(text)) }))}</div> |
| 157 | ) : ( |
| 158 | <p class="muted m0"> |
| 159 | <em>{empty ?? "No description provided."}</em> |
| 160 | </p> |
| 161 | ); |
| 162 | |
| 163 | /** Highlighted code tokens (children of an element with class "sh"). */ |
| 164 | export const tokens = (line: TokLine): Child[] => line.map((t) => (t.style ? <span style={t.style}>{t.text}</span> : t.text)); |
| 165 | |
| 166 | /** CSRF-protected POST form. `avatar` makes it a multipart picture upload (shrunk in the browser first, see assets.ts). */ |
| 167 | export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string; avatar?: boolean }>> = (p) => ( |
| 168 | <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style} enctype={p.avatar ? "multipart/form-data" : undefined} data-avatar={p.avatar ? "1" : undefined}> |
| 169 | <input type="hidden" name={CSRF_FIELD} value={p.t} /> |
| 170 | {p.children} |
| 171 | </form> |
| 172 | ); |
| 173 | |
| 174 | /** Copy-to-clipboard button: small by default; `size="base"` next to 36px fields, `icon` for an icon-only button. */ |
| 175 | export const CopyButton: FC<{ text: string; label?: string; class?: string; size?: "sm" | "base"; icon?: boolean }> = ({ text, label, class: cls, size = "sm", icon }) => ( |
| 176 | <button type="button" class={`btn ${size === "sm" ? "sm" : ""} ${icon ? "icon" : ""} ${cls ?? ""}`} data-copy={text} data-copy-icon={icon ? "1" : undefined} title="Copy to clipboard" aria-label={icon ? "Copy to clipboard" : undefined}> |
| 177 | {icon ? raw(ICONS.copy) : (label ?? "Copy")} |
| 178 | </button> |
| 179 | ); |
| 180 | |
| 181 | export const Pager: FC<{ page: number; hasNext: boolean; href: (page: number) => string }> = ({ page, hasNext, href }) => |
| 182 | page > 1 || hasNext ? ( |
| 183 | <div class="pager"> |
| 184 | {page > 1 ? <a class="btn sm" href={href(page - 1)}>← Newer</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">← Newer</span>} |
| 185 | <span class="muted small" style="align-self:center">Page {page}</span> |
| 186 | {hasNext ? <a class="btn sm" href={href(page + 1)}>Older →</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">Older →</span>} |
| 187 | </div> |
| 188 | ) : null; |
| 189 | |
| 190 | export function qs(params: Record<string, string | number | undefined | null | false>): string { |
| 191 | const u = new URLSearchParams(); |
| 192 | for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "" && v !== false) u.set(k, String(v)); |
| 193 | const s = u.toString(); |
| 194 | return s ? `?${s}` : ""; |
| 195 | } |
| 196 | |
| 197 | /** A required route parameter (404 when missing). */ |
| 198 | export function prm(c: AppContext, name: string): string { |
| 199 | const v = c.req.param(name); |
| 200 | if (!v) throw new HttpError(404, "not found"); |
| 201 | return v; |
| 202 | } |
| 203 | |
| 204 | export const pageNum = (c: AppContext): number => Math.max(1, Math.min(1000, parseInt(c.req.query("page") ?? "1", 10) || 1)); |
| 205 | |
| 206 | // ---------------------------------------------------------------- errors, flash and actions |
| 207 | |
| 208 | export function statusOf(e: unknown): { status: number; message: string } { |
| 209 | if (e instanceof HttpError) return { status: e.status, message: e.detail }; |
| 210 | const msg = (e as Error)?.message ?? String(e); |
| 211 | const m = /^(\d{3}): ([\s\S]*)$/.exec(msg); |
| 212 | if (m) return { status: Number(m[1]), message: m[2]! }; |
| 213 | return { status: 500, message: "internal error" }; |
| 214 | } |
| 215 | |
| 216 | const FLASH = "dfy_flash"; |
| 217 | |
| 218 | export function flash(c: AppContext, kind: "error" | "notice", message: string): void { |
| 219 | setCookie(c, FLASH, encodeURIComponent(JSON.stringify({ kind, message: message.slice(0, 600) })), { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 }); |
| 220 | } |
| 221 | |
| 222 | function takeFlash(c: AppContext): { kind: "error" | "notice"; message: string } | null { |
| 223 | const v = getCookie(c, FLASH); |
| 224 | if (!v) return null; |
| 225 | deleteCookie(c, FLASH, { path: "/", secure: true }); |
| 226 | try { |
| 227 | const f = JSON.parse(decodeURIComponent(v)) as { kind: string; message: string }; |
| 228 | if (typeof f.message !== "string") return null; |
| 229 | return { kind: f.kind === "notice" ? "notice" : "error", message: f.message }; |
| 230 | } catch { |
| 231 | return null; |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | /** Run a state-changing action and redirect (303); client errors become a flash message on `back`. */ |
| 236 | export async function act(c: AppContext, back: string, fn: () => Promise<string | void>, notice?: string): Promise<Response> { |
| 237 | try { |
| 238 | const to = await fn(); |
| 239 | if (notice) flash(c, "notice", notice); |
| 240 | return c.redirect(to ?? back, 303); |
| 241 | } catch (e) { |
| 242 | const s = statusOf(e); |
| 243 | if (s.status >= 500) throw e; |
| 244 | flash(c, "error", s.message); |
| 245 | return c.redirect(back, 303); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | /** Register a CSRF-checked POST route. */ |
| 250 | export function post(app: WebApp, path: string, handler: (c: AppContext, form: FormData) => Promise<Response>, opts?: { maxBytes?: number }): void { |
| 251 | app.post(path, async (c) => handler(c, await verifyCsrf(c, opts?.maxBytes))); |
| 252 | } |
| 253 | |
| 254 | export function principalAccount(c: AppContext): Account | null { |
| 255 | const p = c.get("principal"); |
| 256 | return p?.type === "account" ? p.account : null; |
| 257 | } |
| 258 | |
| 259 | // ---------------------------------------------------------------- layout |
| 260 | |
| 261 | export interface RepoCtx { |
| 262 | repo: Repo; |
| 263 | caller: Caller; |
| 264 | tab?: "code" | "issues" | "pulls" | "actions" | "packages" | "settings" | ""; |
| 265 | /** Current ref, used by the palette's "go to file". */ |
| 266 | ref?: string; |
| 267 | counts?: { issues?: number; pulls?: number }; |
| 268 | } |
| 269 | |
| 270 | export interface PageOpts { |
| 271 | title: string; |
| 272 | repo?: RepoCtx; |
| 273 | /** WebSocket topics for live updates (e.g. "run:abc,pr:3"). */ |
| 274 | live?: string; |
| 275 | liveMode?: "reload" | "log"; |
| 276 | lastSeq?: number; |
| 277 | status?: number; |
| 278 | /** Full-width page (diffs, logs). */ |
| 279 | wide?: boolean; |
| 280 | /** Meta description for search results and link previews. */ |
| 281 | description?: string; |
| 282 | /** The home page: a descriptive title and structured data for search engines. */ |
| 283 | home?: boolean; |
| 284 | /** Extra elements for <head> (e.g. alternate links). */ |
| 285 | head?: Child; |
| 286 | /** A page script, run after the site's own (it gets the page's CSP nonce). */ |
| 287 | script?: string; |
| 288 | /** Reload the page every this many seconds (progress pages without live updates). */ |
| 289 | refresh?: number; |
| 290 | } |
| 291 | |
| 292 | export const WATCH_OPTIONS = [ |
| 293 | { value: "participating", label: "Participating and @mentions" }, |
| 294 | { value: "all", label: "All activity" }, |
| 295 | { value: "ignore", label: "Ignore" }, |
| 296 | ]; |
| 297 | |
| 298 | /** Watch / unwatch control in the repository header. */ |
| 299 | const WatchControl: FC<{ base: string; level: WatchLevel; t: string }> = ({ base, level, t }) => ( |
| 300 | <form method="post" action={`${base}/subscription`} class="inline" title="Which activity in this repository notifies you"> |
| 301 | <input type="hidden" name={CSRF_FIELD} value={t} /> |
| 302 | <KSelect name="level" options={WATCH_OPTIONS.map((o) => ({ value: o.value, label: o.value === "participating" ? "Watch: participating" : o.value === "all" ? "Watch: all activity" : "Watch: ignore" }))} value={level} size="sm" label="Watch" autosubmit /> |
| 303 | </form> |
| 304 | ); |
| 305 | |
| 306 | const RepoHeader: FC<{ r: RepoCtx; watch?: WatchLevel; t: string }> = ({ r, watch, t }) => { |
| 307 | const base = repoUrl(r.repo); |
| 308 | const tab = r.tab ?? ""; |
| 309 | const vis = r.repo.visibility; |
| 310 | return ( |
| 311 | <div class="repohead"> |
| 312 | <div class="wrap"> |
| 313 | {watch ? ( |
| 314 | <div style="float:right"> |
| 315 | <WatchControl base={base} level={watch} t={t} /> |
| 316 | </div> |
| 317 | ) : null} |
| 318 | <h1> |
| 319 | <span class="st faint">{raw(vis === "public" ? ICONS.globe : ICONS.lock)}</span> |
| 320 | <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a> |
| 321 | <span class="faint">/</span> |
| 322 | <strong> |
| 323 | <a href={base}>{r.repo.name}</a> |
| 324 | </strong> |
| 325 | <span class="badge">{vis}</span> |
| 326 | {r.repo.archived ? <span class="badge amber">archived</span> : null} |
| 327 | </h1> |
| 328 | {r.repo.description ? <p class="muted m0">{r.repo.description}</p> : null} |
| 329 | <nav class="tabs"> |
| 330 | <a href={base} class={tab === "code" ? "on" : ""}> |
| 331 | {raw(ICONS.code)} Code |
| 332 | </a> |
| 333 | <a href={`${base}/issues`} class={tab === "issues" ? "on" : ""}> |
| 334 | {raw(ICONS.issueOpen)} Issues {r.counts?.issues ? <span class="n">{r.counts.issues}</span> : null} |
| 335 | </a> |
| 336 | <a href={`${base}/pulls`} class={tab === "pulls" ? "on" : ""}> |
| 337 | {raw(ICONS.pr)} Pull requests {r.counts?.pulls ? <span class="n">{r.counts.pulls}</span> : null} |
| 338 | </a> |
| 339 | <a href={`${base}/actions`} class={tab === "actions" ? "on" : ""}> |
| 340 | {raw(ICONS.play)} Actions |
| 341 | </a> |
| 342 | <a href={`${base}/packages`} class={tab === "packages" ? "on" : ""}> |
| 343 | {raw(ICONS.package)} Packages |
| 344 | </a> |
| 345 | {atLeast(r.caller.role, "admin") ? ( |
| 346 | <a href={`${base}/settings`} class={tab === "settings" ? "on" : ""}> |
| 347 | {raw(ICONS.gear)} Settings |
| 348 | </a> |
| 349 | ) : null} |
| 350 | </nav> |
| 351 | </div> |
| 352 | </div> |
| 353 | ); |
| 354 | }; |
| 355 | |
| 356 | function csp(nonce: string, c: AppContext): string { |
| 357 | const host = new URL(c.req.url).host; |
| 358 | return [ |
| 359 | "default-src 'self'", |
| 360 | `script-src 'nonce-${nonce}'`, |
| 361 | "style-src 'self' 'unsafe-inline'", |
| 362 | // blob: previews a picture before it is uploaded. |
| 363 | "img-src 'self' https: data: blob:", |
| 364 | `connect-src 'self' wss://${host} ws://${host}`, |
| 365 | "font-src 'self'", |
| 366 | "object-src 'none'", |
| 367 | "base-uri 'none'", |
| 368 | "form-action 'self'", |
| 369 | "frame-ancestors 'none'", |
| 370 | ].join("; "); |
| 371 | } |
| 372 | |
| 373 | /** Render a full page. */ |
| 374 | export function render(c: AppContext, opts: PageOpts, body: Child): Response | Promise<Response> { |
| 375 | const p = c.get("principal"); |
| 376 | const me = p?.type === "account" ? p.account : null; |
| 377 | if (opts.repo && me?.status === "active" && me.kind === "user" && c.env.DB) { |
| 378 | return new MailStore(c.env.DB) |
| 379 | .watchLevel(me.id, opts.repo.repo.id) |
| 380 | .catch(() => "participating" as const) |
| 381 | .then((w) => renderPage(c, opts, body, w)); |
| 382 | } |
| 383 | return renderPage(c, opts, body); |
| 384 | } |
| 385 | |
| 386 | export type Appearance = "light" | "dark"; |
| 387 | export const APPEARANCE_COOKIE = "dfy_theme"; |
| 388 | |
| 389 | /** The chosen theme, or null to follow the system. Signed-out visitors keep the last choice made in this browser. */ |
| 390 | export function appearanceOf(c: AppContext): Appearance | null { |
| 391 | const p = c.get("principal"); |
| 392 | const v = p?.type === "account" ? p.account.appearance : getCookie(c, APPEARANCE_COOKIE); |
| 393 | return v === "light" || v === "dark" ? v : null; |
| 394 | } |
| 395 | |
| 396 | function renderPage(c: AppContext, opts: PageOpts, body: Child, watch?: WatchLevel): Response | Promise<Response> { |
| 397 | csrfToken(c); |
| 398 | const nonce = randomToken(16); |
| 399 | const fl = takeFlash(c); |
| 400 | const p = c.get("principal"); |
| 401 | const me = p?.type === "account" ? p.account : null; |
| 402 | const active = me?.status === "active"; |
| 403 | c.header("content-security-policy", csp(nonce, c)); |
| 404 | c.header("x-frame-options", "DENY"); |
| 405 | c.header("x-content-type-options", "nosniff"); |
| 406 | c.header("referrer-policy", "same-origin"); |
| 407 | c.header("cache-control", "private, no-store"); |
| 408 | const r = opts.repo; |
| 409 | const title = opts.home ? `${SITE.name}: ${SITE.tagline}` : opts.title ? `${opts.title} · ${SITE.name}` : SITE.name; |
| 410 | const theme = appearanceOf(c); |
| 411 | const page = ( |
| 412 | <html lang="en" data-theme={theme ?? undefined} data-mode={theme ?? undefined}> |
| 413 | <head> |
| 414 | <meta charset="utf-8" /> |
| 415 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 416 | <meta name="color-scheme" content={theme ?? "light dark"} /> |
| 417 | {opts.refresh ? <meta http-equiv="refresh" content={String(opts.refresh)} /> : null} |
| 418 | <title>{title}</title> |
| 419 | {SeoHead(c, { |
| 420 | title, |
| 421 | description: opts.description ?? (r?.repo.description ? `${r.repo.description} · ${r.repo.owner_login}/${r.repo.name} on Diffinery` : r ? `${r.repo.owner_login}/${r.repo.name} on Diffinery` : undefined), |
| 422 | noindex: (opts.status ?? 200) >= 400 || (r !== undefined && r.repo.visibility !== "public"), |
| 423 | home: opts.home, |
| 424 | nonce, |
| 425 | })} |
| 426 | <style>{raw(CSS)}</style> |
| 427 | <link rel="stylesheet" href={kumoAssets().css} /> |
| 428 | {opts.head ?? null} |
| 429 | </head> |
| 430 | <body |
| 431 | data-repo={r ? `${r.repo.owner_login}/${r.repo.name}` : undefined} |
| 432 | data-ref={r?.ref ?? r?.repo.default_branch} |
| 433 | data-live={opts.live} |
| 434 | data-live-mode={opts.live ? (opts.liveMode ?? "reload") : undefined} |
| 435 | data-last-seq={opts.lastSeq !== undefined ? String(opts.lastSeq) : undefined} |
| 436 | > |
| 437 | <header class="top"> |
| 438 | <div class="wrap"> |
| 439 | <a class="brand" href="/"> |
| 440 | {raw(ICONS.logo)} Diffinery |
| 441 | </a> |
| 442 | {active ? ( |
| 443 | <button type="button" class="pal-btn" data-palette="1" aria-label="Open command palette"> |
| 444 | {raw(ICONS.search)} |
| 445 | <span>Jump to…</span> |
| 446 | <kbd>Ctrl K</kbd> |
| 447 | </button> |
| 448 | ) : null} |
| 449 | <nav> |
| 450 | <a href="/docs" title="Documentation"> |
| 451 | Docs |
| 452 | </a> |
| 453 | {active && me ? ( |
| 454 | <> |
| 455 | <a href="/new" title="New repository"> |
| 456 | {raw(ICONS.plus)} |
| 457 | </a> |
| 458 | <a href="/notifications" title="Notifications"> |
| 459 | {raw(ICONS.bell)} |
| 460 | </a> |
| 461 | <a href="/settings" title="Settings" class="who"> |
| 462 | <Avatar login={me.login} version={me.avatar_version} /> <span>{me.login}</span> |
| 463 | </a> |
| 464 | </> |
| 465 | ) : null} |
| 466 | {p?.type === "account" && p.via === "session" ? ( |
| 467 | <form method="post" action="/auth/logout" class="inline"> |
| 468 | <input type="hidden" name={CSRF_FIELD} value={csrfToken(c)} /> |
| 469 | <button class="navbtn">Sign out</button> |
| 470 | </form> |
| 471 | ) : null} |
| 472 | {!p || p.type === "anonymous" ? ( |
| 473 | c.req.path === "/login" ? null : ( |
| 474 | <a href={`/login${c.req.path === "/" ? "" : `?next=${encodeURIComponent(c.req.path)}`}`} class="btn primary sm"> |
| 475 | Sign in |
| 476 | </a> |
| 477 | ) |
| 478 | ) : null} |
| 479 | </nav> |
| 480 | </div> |
| 481 | </header> |
| 482 | {r ? <RepoHeader r={r} watch={watch} t={csrfToken(c)} /> : null} |
| 483 | <main class="wrap" style={opts.wide ? "max-width:none" : undefined}> |
| 484 | {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null} |
| 485 | {body} |
| 486 | </main> |
| 487 | {active ? ( |
| 488 | <dialog id="palette" aria-label="Command palette"> |
| 489 | <input type="text" placeholder="Jump to a repository, file, page or #number…" autocomplete="off" spellcheck={false} /> |
| 490 | <ul role="listbox"></ul> |
| 491 | <div class="foot"> |
| 492 | <span> |
| 493 | <kbd>↑</kbd> <kbd>↓</kbd> navigate |
| 494 | </span> |
| 495 | <span> |
| 496 | <kbd>Enter</kbd> open |
| 497 | </span> |
| 498 | <span> |
| 499 | <kbd>g</kbd> then <kbd>c</kbd>/<kbd>i</kbd>/<kbd>p</kbd>/<kbd>a</kbd> code, issues, pulls, actions |
| 500 | </span> |
| 501 | </div> |
| 502 | </dialog> |
| 503 | ) : null} |
| 504 | <script nonce={nonce} src={kumoAssets().js} defer></script> |
| 505 | <script nonce={nonce}>{raw(JS)}</script> |
| 506 | {opts.script ? <script nonce={nonce}>{raw(opts.script)}</script> : null} |
| 507 | </body> |
| 508 | </html> |
| 509 | ); |
| 510 | // `html` waits for async components (highlighted Markdown and diffs). |
| 511 | return c.html(html`<!doctype html>${page}`, (opts.status ?? 200) as 200); |
| 512 | } |
| 513 | |
| 514 | /** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */ |
| 515 | export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> { |
| 516 | const s = statusOf(e); |
| 517 | if (s.status >= 500) console.error(e); |
| 518 | // Signed-out visitors who open a page go straight to sign-in, and come back afterwards. |
| 519 | if (s.status === 401 && c.req.method === "GET" && c.get("principal")?.type === "anonymous" && !c.req.path.startsWith("/auth/")) { |
| 520 | const url = new URL(c.req.url); |
| 521 | return c.redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`, 302); |
| 522 | } |
| 523 | const titles: Record<number, string> = { 401: "Sign in required", 403: "Access denied", 404: "Not found", 405: "Not allowed", 409: "Conflict", 422: "Could not complete the request" }; |
| 524 | const title = titles[s.status] ?? (s.status >= 500 ? "Something went wrong" : "Request failed"); |
| 525 | const p = c.get("principal"); |
| 526 | return render( |
| 527 | c, |
| 528 | { title, status: s.status }, |
| 529 | <div class="blank" style="max-width:640px;margin:48px auto"> |
| 530 | <div class="faint" style="font-size:48px;font-weight:700;letter-spacing:-.03em">{s.status}</div> |
| 531 | <h1>{title}</h1> |
| 532 | {s.status === 401 ? ( |
| 533 | <> |
| 534 | <p class="muted">{p?.type === "account" ? s.message : "Sign in with your passkey to continue."}</p> |
| 535 | <p> |
| 536 | <a class="btn primary" href={`/login?next=${encodeURIComponent(c.req.path)}`}> |
| 537 | Sign in |
| 538 | </a> |
| 539 | </p> |
| 540 | </> |
| 541 | ) : s.status === 404 ? ( |
| 542 | <p class="muted">The page or repository does not exist, or you do not have access to it.</p> |
| 543 | ) : ( |
| 544 | <p class="muted">{s.status >= 500 ? "An unexpected error occurred. Please try again." : s.message}</p> |
| 545 | )} |
| 546 | {s.status === 403 && p?.type === "account" && p.account.status === "pending" ? ( |
| 547 | <p> |
| 548 | <a class="btn primary" href="/"> |
| 549 | Request access |
| 550 | </a> |
| 551 | </p> |
| 552 | ) : s.status === 401 ? null : ( |
| 553 | <p> |
| 554 | <a class="btn" href="/"> |
| 555 | Go to dashboard |
| 556 | </a> |
| 557 | </p> |
| 558 | )} |
| 559 | </div>, |
| 560 | ); |
| 561 | } |
| 562 | |
| 563 | // ---------------------------------------------------------------- refs and paths |
| 564 | |
| 565 | /** Path segments after a fixed number of leading segments, decoded. */ |
| 566 | export function restOfPath(c: AppContext, skip: number): string { |
| 567 | const parts = new URL(c.req.url).pathname.split("/").slice(skip + 1); |
| 568 | return parts |
| 569 | .filter((p) => p !== "") |
| 570 | .map((p) => { |
| 571 | try { |
| 572 | return decodeURIComponent(p); |
| 573 | } catch { |
| 574 | return p; |
| 575 | } |
| 576 | }) |
| 577 | .join("/"); |
| 578 | } |
| 579 | |
| 580 | /** Split "feature/x/src/a.ts" into the longest matching ref and the path inside it. */ |
| 581 | export async function splitRefPath(stub: Stub, rest: string): Promise<{ ref: string; path: string }> { |
| 582 | const segs = rest.split("/").filter(Boolean); |
| 583 | if (segs.length <= 1) return { ref: segs[0] ?? "", path: "" }; |
| 584 | const names = new Set((await stub.refs("refs/")).map((r) => r.name.replace(/^refs\/(heads|tags)\//, ""))); |
| 585 | for (let i = segs.length; i >= 1; i--) { |
| 586 | const cand = segs.slice(0, i).join("/"); |
| 587 | if (names.has(cand)) return { ref: cand, path: segs.slice(i).join("/") }; |
| 588 | } |
| 589 | return { ref: segs[0]!, path: segs.slice(1).join("/") }; |
| 590 | } |
| 591 | |
| 592 | /** Branch/tag switcher; `to` builds the target URL for a ref. */ |
| 593 | export const RefSwitcher: FC<{ current: string; branches: string[]; tags: string[]; to: (ref: string) => string }> = ({ current, branches, tags, to }) => ( |
| 594 | <details class="menu"> |
| 595 | <summary class="btn sm"> |
| 596 | {raw(/^[0-9a-f]{40}$/.test(current) ? ICONS.commit : tags.includes(current) && !branches.includes(current) ? ICONS.tag : ICONS.branch)} |
| 597 | <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span> |
| 598 | {raw(ICONS.caret)} |
| 599 | </summary> |
| 600 | <div class="pop"> |
| 601 | <h4>Branches</h4> |
| 602 | {branches.length ? ( |
| 603 | branches.map((b) => ( |
| 604 | <a href={to(b)}> |
| 605 | {b === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {b} |
| 606 | </a> |
| 607 | )) |
| 608 | ) : ( |
| 609 | <p class="muted small" style="margin:4px 8px">No branches</p> |
| 610 | )} |
| 611 | {tags.length ? <h4>Tags</h4> : null} |
| 612 | {tags.map((t) => ( |
| 613 | <a href={to(t)}> |
| 614 | {t === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {t} |
| 615 | </a> |
| 616 | ))} |
| 617 | </div> |
| 618 | </details> |
| 619 | ); |
| 620 | |
| 621 | export async function refNames(stub: Stub): Promise<{ branches: string[]; tags: string[] }> { |
| 622 | const refs = await stub.refs("refs/"); |
| 623 | return { |
| 624 | branches: refs.filter((r) => r.name.startsWith("refs/heads/")).map((r) => r.name.slice(11)), |
| 625 | tags: refs.filter((r) => r.name.startsWith("refs/tags/")).map((r) => r.name.slice(10)), |
| 626 | }; |
| 627 | } |
| 628 | |
| 629 | // ---------------------------------------------------------------- diffs |
| 630 | |
| 631 | export interface DiffHunk { |
| 632 | oldStart: number; |
| 633 | oldLines: number; |
| 634 | newStart: number; |
| 635 | newLines: number; |
| 636 | lines: string[]; |
| 637 | } |
| 638 | |
| 639 | export interface DiffFile { |
| 640 | path: string; |
| 641 | status: "added" | "modified" | "deleted"; |
| 642 | binary: boolean; |
| 643 | additions: number; |
| 644 | deletions: number; |
| 645 | hunks: DiffHunk[]; |
| 646 | truncated?: boolean; |
| 647 | } |
| 648 | |
| 649 | export interface LineThread { |
| 650 | side: "LEFT" | "RIGHT"; |
| 651 | line: number; |
| 652 | node: Child; |
| 653 | } |
| 654 | |
| 655 | const StatBar: FC<{ a: number; d: number }> = ({ a, d }) => { |
| 656 | const total = a + d; |
| 657 | const blocks = 5; |
| 658 | const ga = total ? Math.round((a / total) * blocks) : 0; |
| 659 | const gd = total ? Math.min(blocks - ga, Math.round((d / total) * blocks)) : 0; |
| 660 | return ( |
| 661 | <span class="bar" aria-hidden="true"> |
| 662 | {Array.from({ length: blocks }, (_, i) => ( |
| 663 | <i class={i < ga ? "a" : i < ga + gd ? "d" : ""} /> |
| 664 | ))} |
| 665 | </span> |
| 666 | ); |
| 667 | }; |
| 668 | |
| 669 | export const DiffStats: FC<{ files: number; additions: number; deletions: number }> = ({ files, additions, deletions }) => ( |
| 670 | <span class="row" style="gap:10px"> |
| 671 | <span> |
| 672 | <b>{files}</b> file{files === 1 ? "" : "s"} changed |
| 673 | </span> |
| 674 | <span class="stat-add">+{additions}</span> |
| 675 | <span class="stat-del">−{deletions}</span> |
| 676 | <StatBar a={additions} d={deletions} /> |
| 677 | </span> |
| 678 | ); |
| 679 | |
| 680 | export interface DiffOptions { |
| 681 | /** Link to view the file at the new revision. */ |
| 682 | viewHref?: (path: string) => string | null; |
| 683 | /** Show "+" buttons for line comments (requires the line comment template on the page). */ |
| 684 | commentable?: boolean; |
| 685 | /** Existing review threads per file path. */ |
| 686 | threads?: Map<string, LineThread[]>; |
| 687 | } |
| 688 | |
| 689 | const LINE_BUDGET = 20000; |
| 690 | const HIGHLIGHT_BUDGET = 3000; |
| 691 | |
| 692 | const diffLines = (f: DiffFile): number => f.hunks.reduce((n, h) => n + h.lines.length, 0); |
| 693 | |
| 694 | /** Syntax tokens per file, hunk and line, for the rendered files that fit the highlighting budget. */ |
| 695 | async function diffTokens(files: DiffFile[]): Promise<Map<DiffFile, Array<Array<TokLine | null>>>> { |
| 696 | const out = new Map<DiffFile, Array<Array<TokLine | null>>>(); |
| 697 | const picked: Array<[DiffFile, Lang]> = []; |
| 698 | let budget = LINE_BUDGET; |
| 699 | let hlBudget = HIGHLIGHT_BUDGET; |
| 700 | for (const f of files) { |
| 701 | const lines = diffLines(f); |
| 702 | const rendered = budget > 0 && !f.binary && !f.truncated; |
| 703 | budget -= lines; |
| 704 | const lang = langForPath(f.path); |
| 705 | if (!rendered || !lang || lines > LIMITS.lines || lines > hlBudget) continue; |
| 706 | hlBudget -= lines; |
| 707 | picked.push([f, lang]); |
| 708 | } |
| 709 | const hl = picked.length ? await ready(picked.map(([, l]) => l)) : null; |
| 710 | if (hl) for (const [f, lang] of picked) out.set(f, f.hunks.map((h) => diffLineTokens(h.lines, (code) => tokenize(hl, code, lang)))); |
| 711 | return out; |
| 712 | } |
| 713 | |
| 714 | /** Unified diff with line numbers; each file is collapsible. */ |
| 715 | export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = async ({ files, opts = {} }) => { |
| 716 | const toks = await diffTokens(files); |
| 717 | let budget = LINE_BUDGET; |
| 718 | return ( |
| 719 | <div> |
| 720 | {files.map((f) => { |
| 721 | const lines = diffLines(f); |
| 722 | const big = lines > 800; |
| 723 | const ft = toks.get(f); |
| 724 | const render = budget > 0 && !f.binary && !f.truncated; |
| 725 | budget -= lines; |
| 726 | const threads = opts.threads?.get(f.path) ?? []; |
| 727 | const view = opts.viewHref?.(f.path); |
| 728 | return ( |
| 729 | <details class="file" open={!big && render} id={`diff-${encodeURIComponent(f.path)}`}> |
| 730 | <summary class="filehead"> |
| 731 | <span class={`badge ${f.status === "added" ? "green" : f.status === "deleted" ? "red" : "amber"}`}>{f.status}</span> |
| 732 | <span class="mono grow" style="font-weight:600;word-break:break-all">{f.path}</span> |
| 733 | <span class="stat-add small">+{f.additions}</span> |
| 734 | <span class="stat-del small">−{f.deletions}</span> |
| 735 | {view && f.status !== "deleted" ? ( |
| 736 | <a class="btn sm" href={view}> |
| 737 | View file |
| 738 | </a> |
| 739 | ) : null} |
| 740 | </summary> |
| 741 | {f.binary ? ( |
| 742 | <div class="empty">Binary file not shown.</div> |
| 743 | ) : f.truncated ? ( |
| 744 | <div class="empty">This diff is too large to display.</div> |
| 745 | ) : !render ? ( |
| 746 | <div class="empty">Diff not rendered to keep this page fast. {view ? <a href={view}>View the file</a> : null}</div> |
| 747 | ) : f.hunks.length === 0 ? ( |
| 748 | <div class="empty">{f.status === "added" ? "Empty file added." : f.status === "deleted" ? "Empty file deleted." : "Mode change only."}</div> |
| 749 | ) : ( |
| 750 | <div class="code-wrap"> |
| 751 | <table class="diff"> |
| 752 | {f.hunks.map((h, hi) => { |
| 753 | const ht = ft?.[hi]; |
| 754 | let oldN = h.oldStart; |
| 755 | let newN = h.newStart; |
| 756 | return ( |
| 757 | <> |
| 758 | <tr class="hunk"> |
| 759 | {opts.commentable ? <td class="act" /> : null} |
| 760 | <td class="ln" /> |
| 761 | <td class="ln" /> |
| 762 | <td class="code"> |
| 763 | @@ -{h.oldStart},{h.oldLines} +{h.newStart},{h.newLines} @@ |
| 764 | </td> |
| 765 | </tr> |
| 766 | {h.lines.map((l, li) => { |
| 767 | const k = l[0]; |
| 768 | const lt = ht?.[li]; |
| 769 | if (k === "\\") { |
| 770 | return ( |
| 771 | <tr class="meta"> |
| 772 | {opts.commentable ? <td class="act" /> : null} |
| 773 | <td class="ln" /> |
| 774 | <td class="ln" /> |
| 775 | <td class="code">{l}</td> |
| 776 | </tr> |
| 777 | ); |
| 778 | } |
| 779 | const cls = k === "+" ? "add" : k === "-" ? "del" : "ctx"; |
| 780 | const o = k === "+" ? null : oldN++; |
| 781 | const n = k === "-" ? null : newN++; |
| 782 | const side = k === "-" ? "LEFT" : "RIGHT"; |
| 783 | const lineNo = side === "LEFT" ? o : n; |
| 784 | const here = threads.filter((t) => t.side === side && t.line === lineNo); |
| 785 | return ( |
| 786 | <> |
| 787 | <tr class={cls}> |
| 788 | {opts.commentable ? ( |
| 789 | <td class="act"> |
| 790 | <button type="button" class="addc" title="Comment on this line" data-comment-line={String(lineNo)} data-path={f.path} data-side={side}> |
| 791 | + |
| 792 | </button> |
| 793 | </td> |
| 794 | ) : null} |
| 795 | <td class="ln">{o ?? ""}</td> |
| 796 | <td class="ln">{n ?? ""}</td> |
| 797 | <td class={lt ? "code sh" : "code"}> |
| 798 | {k === "+" || k === "-" ? k : " "} |
| 799 | {lt ? tokens(lt) : l.slice(1)} |
| 800 | </td> |
| 801 | </tr> |
| 802 | {here.length ? ( |
| 803 | <tr class="thread"> |
| 804 | <td colspan={opts.commentable ? 4 : 3}>{here.map((t) => t.node)}</td> |
| 805 | </tr> |
| 806 | ) : null} |
| 807 | </> |
| 808 | ); |
| 809 | })} |
| 810 | </> |
| 811 | ); |
| 812 | })} |
| 813 | </table> |
| 814 | </div> |
| 815 | )} |
| 816 | </details> |
| 817 | ); |
| 818 | })} |
| 819 | </div> |
| 820 | ); |
| 821 | }; |
| 822 | |
| 823 | /** Changed-files summary list with anchors into the diff. */ |
| 824 | export const FileSummary: FC<{ files: DiffFile[] }> = ({ files }) => |
| 825 | files.length > 3 ? ( |
| 826 | <details class="box"> |
| 827 | <summary class="head" style="cursor:pointer"> |
| 828 | <span>Files ({files.length})</span> |
| 829 | </summary> |
| 830 | <ul class="list"> |
| 831 | {files.map((f) => ( |
| 832 | <li style="padding:6px 14px"> |
| 833 | <a class="mono grow" href={`#diff-${encodeURIComponent(f.path)}`}> |
| 834 | {f.path} |
| 835 | </a> |
| 836 | <span class="stat-add small">+{f.additions}</span> |
| 837 | <span class="stat-del small">−{f.deletions}</span> |
| 838 | </li> |
| 839 | ))} |
| 840 | </ul> |
| 841 | </details> |
| 842 | ) : null; |
| 843 | |
| 844 | /** Commit list row. */ |
| 845 | export const CommitRow: FC<{ base: string; sha: string; message: string; author: { name: string; email?: string; timestamp: number }; signed?: boolean; extra?: Child }> = ({ base, sha, message, author, signed, extra }) => ( |
| 846 | <li> |
| 847 | <div class="grow"> |
| 848 | <a class="title" href={`${base}/commit/${sha}`}> |
| 849 | {firstLine(message)} |
| 850 | </a> |
| 851 | <div class="meta"> |
| 852 | <b>{author.name}</b> committed <Time at={new Date(author.timestamp * 1000).toISOString()} /> |
| 853 | {signed ? <span class="badge green" style="margin-left:6px">signed</span> : null} |
| 854 | </div> |
| 855 | </div> |
| 856 | {extra} |
| 857 | <span class="btngroup"> |
| 858 | <CopyButton text={sha} icon /> |
| 859 | <a class="btn sm mono" href={`${base}/commit/${sha}`} title="View commit"> |
| 860 | {shortSha(sha)} |
| 861 | </a> |
| 862 | <a class="btn sm icon" href={`${base}/tree/${sha}`} title="Browse the repository at this point" aria-label="Browse the repository at this point"> |
| 863 | {raw(ICONS.code)} |
| 864 | </a> |
| 865 | </span> |
| 866 | </li> |
| 867 | ); |
| 868 | |
| 869 | /** Look up agents' owners for "on behalf of" labels. */ |
| 870 | export async function onBehalfOf(c: AppContext, actors: Array<{ login: string; kind: string }>): Promise<Map<string, string>> { |
| 871 | const out = new Map<string, string>(); |
| 872 | const dir = c.get("dir"); |
| 873 | const logins = [...new Set(actors.filter((a) => a.kind === "agent").map((a) => a.login))].slice(0, 20); |
| 874 | await Promise.all( |
| 875 | logins.map(async (login) => { |
| 876 | const a = await dir.accountByLogin(login); |
| 877 | if (a?.owner_id) { |
| 878 | const owner = await dir.account(a.owner_id); |
| 879 | if (owner) out.set(login, owner.login); |
| 880 | } |
| 881 | }), |
| 882 | ); |
| 883 | return out; |
| 884 | } |
| 885 | |
| 886 | export const ROLES = ["read", "triage", "write", "maintain", "admin"] as const; |
| 887 | |
| 888 | /** "write" → "Write", for showing a stored value as a label. */ |
| 889 | export const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1); |
| 890 | |
| 891 | /** The browser's check for usernames, organization and agent names; the server's isValidLogin agrees (no dots). */ |
| 892 | export const LOGIN_PATTERN = "[A-Za-z0-9][A-Za-z0-9_\\-]{0,99}"; |