forgedev 1.1.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -10
- package/bin/chainproof.js +126 -0
- package/bin/devforge.js +2 -1
- package/package.json +33 -7
- package/src/chainproof-bridge.js +330 -0
- package/src/ci-mode.js +85 -0
- package/src/claude-configurator.js +87 -49
- package/src/cli.js +35 -12
- package/src/composer.js +159 -34
- package/src/doctor-checks-chainproof.js +106 -0
- package/src/doctor-checks.js +39 -20
- package/src/doctor-prompts.js +9 -9
- package/src/doctor.js +37 -4
- package/src/guided.js +3 -3
- package/src/index.js +31 -10
- package/src/init-mode.js +64 -11
- package/src/menu.js +178 -0
- package/src/prompts.js +5 -12
- package/src/recommender.js +134 -10
- package/src/scanner.js +57 -2
- package/src/uat-generator.js +204 -189
- package/src/update-check.js +9 -4
- package/src/update.js +1 -1
- package/src/utils.js +65 -6
- package/templates/ai/guardrails-py/backend/app/ai/__init__.py +29 -0
- package/templates/ai/guardrails-py/backend/app/ai/audit_log.py +133 -0
- package/templates/ai/guardrails-py/backend/app/ai/client.py.template +323 -0
- package/templates/ai/guardrails-py/backend/app/ai/health.py.template +157 -0
- package/templates/ai/guardrails-py/backend/app/ai/input_guard.py +98 -0
- package/templates/ai/guardrails-ts/src/lib/ai/audit-log.ts.template +164 -0
- package/templates/ai/guardrails-ts/src/lib/ai/client.ts.template +403 -0
- package/templates/ai/guardrails-ts/src/lib/ai/health.ts.template +165 -0
- package/templates/ai/guardrails-ts/src/lib/ai/index.ts.template +17 -0
- package/templates/ai/guardrails-ts/src/lib/ai/input-guard.ts.template +124 -0
- package/templates/auth/nextauth/src/lib/auth.ts.template +12 -7
- package/templates/backend/express/Dockerfile.template +18 -0
- package/templates/backend/express/package.json.template +33 -0
- package/templates/backend/express/src/index.ts.template +34 -0
- package/templates/backend/express/src/routes/health.ts.template +27 -0
- package/templates/backend/express/tsconfig.json +17 -0
- package/templates/backend/fastapi/backend/Dockerfile.template +5 -0
- package/templates/backend/fastapi/backend/app/api/health.py.template +1 -1
- package/templates/backend/fastapi/backend/app/core/config.py.template +1 -1
- package/templates/backend/fastapi/backend/app/core/errors.py +1 -1
- package/templates/backend/fastapi/backend/app/main.py.template +3 -1
- package/templates/backend/fastapi/backend/requirements.txt.template +2 -0
- package/templates/backend/hono/Dockerfile.template +18 -0
- package/templates/backend/hono/package.json.template +31 -0
- package/templates/backend/hono/src/index.ts.template +32 -0
- package/templates/backend/hono/src/routes/health.ts.template +27 -0
- package/templates/backend/hono/tsconfig.json +18 -0
- package/templates/base/docs/plans/.gitkeep +0 -0
- package/templates/base/docs/uat/UAT_CHECKLIST.csv.template +2 -0
- package/templates/base/docs/uat/UAT_TEMPLATE.md.template +22 -0
- package/templates/chainproof/base/.chainproof/config.json.template +11 -0
- package/templates/chainproof/base/.chainproof/mcp-server.mjs +310 -0
- package/templates/chainproof/base/.mcp.json +9 -0
- package/templates/chainproof/fastapi/.chainproof/middleware.json.template +14 -0
- package/templates/chainproof/nextjs/.chainproof/hooks.json.template +19 -0
- package/templates/chainproof/polyglot/.chainproof/config.json.template +21 -0
- package/templates/claude-code/agents/architect.md +25 -11
- package/templates/claude-code/agents/build-error-resolver.md +22 -7
- package/templates/claude-code/agents/chief-of-staff.md +42 -8
- package/templates/claude-code/agents/code-quality-reviewer.md +15 -1
- package/templates/claude-code/agents/database-reviewer.md +16 -2
- package/templates/claude-code/agents/deep-reviewer.md +191 -0
- package/templates/claude-code/agents/doc-updater.md +19 -5
- package/templates/claude-code/agents/docs-lookup.md +19 -5
- package/templates/claude-code/agents/e2e-runner.md +26 -12
- package/templates/claude-code/agents/enforcement-gate.md +102 -0
- package/templates/claude-code/agents/frontend-builder.md +188 -0
- package/templates/claude-code/agents/harness-optimizer.md +61 -0
- package/templates/claude-code/agents/loop-operator.md +27 -12
- package/templates/claude-code/agents/planner.md +21 -7
- package/templates/claude-code/agents/product-strategist.md +138 -0
- package/templates/claude-code/agents/production-readiness.md +14 -0
- package/templates/claude-code/agents/prompt-auditor.md +115 -0
- package/templates/claude-code/agents/refactor-cleaner.md +22 -8
- package/templates/claude-code/agents/security-reviewer.md +15 -0
- package/templates/claude-code/agents/spec-validator.md +45 -1
- package/templates/claude-code/agents/tdd-guide.md +21 -7
- package/templates/claude-code/agents/uat-validator.md +18 -0
- package/templates/claude-code/claude-md/base.md +15 -7
- package/templates/claude-code/claude-md/fastapi.md +8 -8
- package/templates/claude-code/claude-md/fullstack.md +6 -6
- package/templates/claude-code/claude-md/hono.md +18 -0
- package/templates/claude-code/claude-md/nextjs.md +5 -5
- package/templates/claude-code/claude-md/remix.md +18 -0
- package/templates/claude-code/commands/audit-security.md +14 -0
- package/templates/claude-code/commands/audit-spec.md +14 -0
- package/templates/claude-code/commands/audit-wiring.md +14 -0
- package/templates/claude-code/commands/build-fix.md +28 -0
- package/templates/claude-code/commands/build-ui.md +59 -0
- package/templates/claude-code/commands/code-review.md +54 -26
- package/templates/claude-code/commands/fix-loop.md +211 -0
- package/templates/claude-code/commands/full-audit.md +37 -8
- package/templates/claude-code/commands/generate-prd.md +1 -1
- package/templates/claude-code/commands/generate-sdd.md +74 -0
- package/templates/claude-code/commands/generate-uat.md +107 -35
- package/templates/claude-code/commands/help.md +68 -0
- package/templates/claude-code/commands/live-uat.md +268 -0
- package/templates/claude-code/commands/optimize-claude-md.md +15 -1
- package/templates/claude-code/commands/plan.md +3 -3
- package/templates/claude-code/commands/pre-pr.md +57 -19
- package/templates/claude-code/commands/product-strategist.md +21 -0
- package/templates/claude-code/commands/resume-session.md +10 -10
- package/templates/claude-code/commands/run-uat.md +59 -2
- package/templates/claude-code/commands/save-session.md +10 -10
- package/templates/claude-code/commands/simplify.md +36 -0
- package/templates/claude-code/commands/tdd.md +17 -18
- package/templates/claude-code/commands/verify-all.md +24 -0
- package/templates/claude-code/commands/verify-intent.md +55 -0
- package/templates/claude-code/commands/workflows.md +52 -37
- package/templates/claude-code/hooks/polyglot.json +10 -1
- package/templates/claude-code/hooks/python.json +10 -1
- package/templates/claude-code/hooks/scripts/autofix-polyglot.mjs +20 -10
- package/templates/claude-code/hooks/scripts/autofix-python.mjs +4 -5
- package/templates/claude-code/hooks/scripts/autofix-typescript.mjs +4 -4
- package/templates/claude-code/hooks/scripts/code-hygiene.mjs +293 -0
- package/templates/claude-code/hooks/scripts/guard-protected-files.mjs +2 -2
- package/templates/claude-code/hooks/scripts/pre-commit-gate.mjs +207 -0
- package/templates/claude-code/hooks/typescript.json +10 -1
- package/templates/claude-code/skills/ai-prompts/SKILL.md +119 -41
- package/templates/claude-code/skills/git-workflow/SKILL.md +6 -6
- package/templates/claude-code/skills/nextjs/SKILL.md +1 -1
- package/templates/claude-code/skills/playwright/SKILL.md +6 -5
- package/templates/claude-code/skills/security-api/SKILL.md +1 -1
- package/templates/claude-code/skills/security-web/SKILL.md +2 -1
- package/templates/claude-code/skills/testing-patterns/SKILL.md +9 -9
- package/templates/database/prisma-postgres/{.env.example → .env.example.template} +1 -0
- package/templates/database/sqlalchemy-postgres/{.env.example → .env.example.template} +1 -0
- package/templates/docs-portal/fastapi/backend/app/portal/__init__.py +0 -0
- package/templates/docs-portal/fastapi/backend/app/portal/__pycache__/docs_reader.cpython-314.pyc +0 -0
- package/templates/docs-portal/fastapi/backend/app/portal/docs_reader.py +201 -0
- package/templates/docs-portal/fastapi/backend/app/portal/html_renderer.py +229 -0
- package/templates/docs-portal/fastapi/backend/app/portal/router.py.template +35 -0
- package/templates/docs-portal/nextjs/src/app/portal/[category]/[slug]/page.tsx +81 -0
- package/templates/docs-portal/nextjs/src/app/portal/[category]/page.tsx +65 -0
- package/templates/docs-portal/nextjs/src/app/portal/layout.tsx.template +54 -0
- package/templates/docs-portal/nextjs/src/app/portal/page.tsx +85 -0
- package/templates/docs-portal/nextjs/src/components/portal/markdown-renderer.tsx +101 -0
- package/templates/docs-portal/nextjs/src/components/portal/mobile-portal-nav.tsx +81 -0
- package/templates/docs-portal/nextjs/src/components/portal/portal-nav.tsx +86 -0
- package/templates/docs-portal/nextjs/src/lib/docs.ts +139 -0
- package/templates/frontend/nextjs/package.json.template +3 -1
- package/templates/frontend/react/index.html.template +12 -0
- package/templates/frontend/react/package.json.template +34 -0
- package/templates/frontend/react/src/App.tsx.template +10 -0
- package/templates/frontend/react/src/index.css +1 -0
- package/templates/frontend/react/src/main.tsx +10 -0
- package/templates/frontend/react/tsconfig.json +17 -0
- package/templates/frontend/react/vite.config.ts.template +15 -0
- package/templates/frontend/react/vitest.config.ts +9 -0
- package/templates/frontend/remix/app/root.tsx.template +31 -0
- package/templates/frontend/remix/app/routes/_index.tsx.template +19 -0
- package/templates/frontend/remix/app/routes/api.health.ts.template +10 -0
- package/templates/frontend/remix/app/tailwind.css +1 -0
- package/templates/frontend/remix/package.json.template +39 -0
- package/templates/frontend/remix/tsconfig.json +18 -0
- package/templates/frontend/remix/vite.config.ts.template +7 -0
- package/templates/infra/github-actions/.github/workflows/ci.yml.template +52 -0
- package/templates/testing/pytest/backend/tests/__init__.py +0 -0
- package/templates/testing/pytest/backend/tests/conftest.py.template +11 -0
- package/templates/testing/pytest/backend/tests/test_health.py.template +10 -0
- package/templates/testing/vitest/vitest.config.ts.template +18 -0
- package/CLAUDE.md +0 -38
- package/templates/claude-code/commands/done.md +0 -19
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Renders portal pages as self-contained HTML. No external template engine required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from html import escape as esc
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from app.portal.docs_reader import DocCategory, DocFile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _base(title: str, project: str, categories: list[DocCategory], content: str) -> str:
|
|
12
|
+
title = esc(title)
|
|
13
|
+
project = esc(project)
|
|
14
|
+
nav_items = []
|
|
15
|
+
for cat in categories:
|
|
16
|
+
dropdown_links = "".join(
|
|
17
|
+
f'<a href="/portal/{esc(cat.id)}/{esc(d["slug"])}" class="dropdown-item">{esc(d["title"])}</a>'
|
|
18
|
+
for d in cat.docs
|
|
19
|
+
)
|
|
20
|
+
nav_items.append(f"""
|
|
21
|
+
<div class="nav-dropdown">
|
|
22
|
+
<button class="nav-btn" aria-haspopup="true" aria-expanded="false">{esc(cat.icon)} {esc(cat.label)} ▾</button>
|
|
23
|
+
<div class="dropdown-content" role="menu">{dropdown_links}</div>
|
|
24
|
+
</div>
|
|
25
|
+
""")
|
|
26
|
+
|
|
27
|
+
nav_html = "".join(nav_items)
|
|
28
|
+
|
|
29
|
+
return f"""<!DOCTYPE html>
|
|
30
|
+
<html lang="en">
|
|
31
|
+
<head>
|
|
32
|
+
<meta charset="utf-8">
|
|
33
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
34
|
+
<title>{title} — {project}</title>
|
|
35
|
+
<style>
|
|
36
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
37
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a1a; background: #f8f9fa; }}
|
|
38
|
+
|
|
39
|
+
/* Header */
|
|
40
|
+
.header {{ background: #fff; border-bottom: 1px solid #e5e7eb; position: sticky; top: 0; z-index: 40; }}
|
|
41
|
+
.header-inner {{ max-width: 1200px; margin: 0 auto; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; height: 64px; }}
|
|
42
|
+
.logo {{ display: flex; align-items: center; gap: 10px; text-decoration: none; color: #1a1a1a; font-weight: 600; font-size: 16px; }}
|
|
43
|
+
.logo-icon {{ width: 32px; height: 32px; background: #1a1a1a; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 14px; }}
|
|
44
|
+
.back-link {{ font-size: 14px; color: #6b7280; text-decoration: none; }}
|
|
45
|
+
.back-link:hover {{ color: #374151; }}
|
|
46
|
+
|
|
47
|
+
/* Nav */
|
|
48
|
+
.nav {{ display: flex; align-items: center; gap: 4px; }}
|
|
49
|
+
.nav a.overview {{ padding: 8px 12px; border-radius: 8px; font-size: 14px; font-weight: 500; color: #4b5563; text-decoration: none; }}
|
|
50
|
+
.nav a.overview:hover {{ background: #f3f4f6; color: #1a1a1a; }}
|
|
51
|
+
.nav-dropdown {{ position: relative; }}
|
|
52
|
+
.nav-btn {{ padding: 8px 12px; border-radius: 8px; font-size: 14px; font-weight: 500; color: #4b5563; background: none; border: none; cursor: pointer; }}
|
|
53
|
+
.nav-btn:hover {{ background: #f3f4f6; color: #1a1a1a; }}
|
|
54
|
+
.dropdown-content {{ display: none; position: absolute; top: 100%; left: 0; margin-top: 4px; min-width: 240px; background: #fff; border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,.1); border: 1px solid #e5e7eb; padding: 8px 0; z-index: 50; }}
|
|
55
|
+
.nav-dropdown:hover .dropdown-content {{ display: block; }}
|
|
56
|
+
.dropdown-item {{ display: block; padding: 10px 16px; font-size: 14px; color: #374151; text-decoration: none; }}
|
|
57
|
+
.dropdown-item:hover {{ background: #f3f4f6; color: #1a1a1a; }}
|
|
58
|
+
|
|
59
|
+
/* Content */
|
|
60
|
+
.content {{ max-width: 1200px; margin: 0 auto; padding: 32px 24px; }}
|
|
61
|
+
|
|
62
|
+
/* Cards */
|
|
63
|
+
.card-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }}
|
|
64
|
+
.card {{ background: #fff; border-radius: 12px; border: 1px solid #e5e7eb; padding: 24px; transition: box-shadow .15s; }}
|
|
65
|
+
.card:hover {{ box-shadow: 0 4px 12px rgba(0,0,0,.08); }}
|
|
66
|
+
.card-icon {{ font-size: 28px; margin-bottom: 12px; }}
|
|
67
|
+
.card h2 {{ font-size: 18px; font-weight: 600; margin-bottom: 4px; }}
|
|
68
|
+
.card p {{ font-size: 14px; color: #6b7280; margin-bottom: 16px; }}
|
|
69
|
+
.card-link {{ display: block; font-size: 14px; color: #2563eb; text-decoration: none; padding: 2px 0; }}
|
|
70
|
+
.card-link:hover {{ color: #1d4ed8; }}
|
|
71
|
+
|
|
72
|
+
/* Doc article */
|
|
73
|
+
.article {{ background: #fff; border-radius: 12px; border: 1px solid #e5e7eb; padding: 48px; max-width: 900px; margin: 0 auto; }}
|
|
74
|
+
.breadcrumb {{ font-size: 14px; color: #6b7280; margin-bottom: 24px; }}
|
|
75
|
+
.breadcrumb a {{ color: #6b7280; text-decoration: none; }}
|
|
76
|
+
.breadcrumb a:hover {{ color: #374151; }}
|
|
77
|
+
.doc-meta {{ font-size: 13px; color: #9ca3af; margin-top: 8px; }}
|
|
78
|
+
|
|
79
|
+
/* Rendered markdown */
|
|
80
|
+
.prose h1 {{ font-size: 28px; font-weight: 700; margin: 32px 0 16px; }}
|
|
81
|
+
.prose h1:first-child {{ margin-top: 0; }}
|
|
82
|
+
.prose h2 {{ font-size: 22px; font-weight: 600; margin: 32px 0 12px; padding-bottom: 8px; border-bottom: 1px solid #e5e7eb; }}
|
|
83
|
+
.prose h3 {{ font-size: 18px; font-weight: 600; margin: 24px 0 8px; }}
|
|
84
|
+
.prose p {{ color: #374151; line-height: 1.7; margin: 12px 0; }}
|
|
85
|
+
.prose ul, .prose ol {{ margin: 12px 0; padding-left: 24px; color: #374151; }}
|
|
86
|
+
.prose li {{ margin: 6px 0; line-height: 1.6; }}
|
|
87
|
+
.prose a {{ color: #2563eb; text-decoration: underline; }}
|
|
88
|
+
.prose a:hover {{ color: #1d4ed8; }}
|
|
89
|
+
.prose code {{ background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 13px; }}
|
|
90
|
+
.prose pre {{ background: #1e293b; color: #e2e8f0; padding: 16px; border-radius: 8px; overflow-x: auto; margin: 16px 0; }}
|
|
91
|
+
.prose pre code {{ background: none; padding: 0; color: inherit; }}
|
|
92
|
+
.prose blockquote {{ border-left: 4px solid #bfdbfe; background: #eff6ff; padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 16px 0; }}
|
|
93
|
+
.prose table {{ width: 100%; border-collapse: collapse; margin: 16px 0; border-radius: 8px; overflow: hidden; border: 1px solid #e5e7eb; }}
|
|
94
|
+
.prose th {{ background: #f9fafb; padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: #4b5563; text-transform: uppercase; letter-spacing: .05em; }}
|
|
95
|
+
.prose td {{ padding: 12px 16px; font-size: 14px; color: #374151; border-top: 1px solid #f3f4f6; }}
|
|
96
|
+
|
|
97
|
+
/* Recent list */
|
|
98
|
+
.recent {{ background: #fff; border-radius: 12px; border: 1px solid #e5e7eb; padding: 24px; margin-top: 24px; }}
|
|
99
|
+
.recent h2 {{ font-size: 18px; font-weight: 600; margin-bottom: 16px; }}
|
|
100
|
+
.recent-item {{ display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-top: 1px solid #f3f4f6; }}
|
|
101
|
+
.recent-item:first-of-type {{ border-top: none; }}
|
|
102
|
+
.recent-item a {{ font-size: 14px; font-weight: 500; color: #1a1a1a; text-decoration: none; }}
|
|
103
|
+
.recent-item a:hover {{ color: #2563eb; }}
|
|
104
|
+
.recent-item .meta {{ font-size: 12px; color: #9ca3af; }}
|
|
105
|
+
|
|
106
|
+
/* Empty state */
|
|
107
|
+
.empty {{ text-align: center; padding: 80px 24px; }}
|
|
108
|
+
.empty .icon {{ font-size: 48px; margin-bottom: 16px; }}
|
|
109
|
+
.empty h1 {{ font-size: 24px; font-weight: 700; margin-bottom: 8px; }}
|
|
110
|
+
.empty p {{ color: #6b7280; max-width: 400px; margin: 0 auto; }}
|
|
111
|
+
|
|
112
|
+
@media (max-width: 768px) {{
|
|
113
|
+
.card-grid {{ grid-template-columns: 1fr; }}
|
|
114
|
+
.article {{ padding: 24px; }}
|
|
115
|
+
.nav {{ display: none; }}
|
|
116
|
+
}}
|
|
117
|
+
</style>
|
|
118
|
+
</head>
|
|
119
|
+
<body>
|
|
120
|
+
<header class="header">
|
|
121
|
+
<div class="header-inner">
|
|
122
|
+
<div style="display:flex;align-items:center;gap:24px;">
|
|
123
|
+
<a href="/portal" class="logo">
|
|
124
|
+
<div class="logo-icon">{esc(project[0]) if project else "?"}</div>
|
|
125
|
+
{project}
|
|
126
|
+
</a>
|
|
127
|
+
<nav class="nav">
|
|
128
|
+
<a href="/portal" class="overview">Overview</a>
|
|
129
|
+
{nav_html}
|
|
130
|
+
</nav>
|
|
131
|
+
</div>
|
|
132
|
+
<a href="/docs" class="back-link">API docs</a>
|
|
133
|
+
</div>
|
|
134
|
+
</header>
|
|
135
|
+
<div class="content">
|
|
136
|
+
{content}
|
|
137
|
+
</div>
|
|
138
|
+
</body>
|
|
139
|
+
</html>"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def render_portal_home(categories: list[DocCategory], recent: list[DocFile], project: str) -> str:
|
|
143
|
+
if not categories:
|
|
144
|
+
content = """
|
|
145
|
+
<div class="empty">
|
|
146
|
+
<div class="icon">📄</div>
|
|
147
|
+
<h1>No documentation yet</h1>
|
|
148
|
+
<p>Documentation will appear here once it has been generated. Ask your development team to run the documentation generators.</p>
|
|
149
|
+
</div>
|
|
150
|
+
"""
|
|
151
|
+
return _base("Portal", project, categories, content)
|
|
152
|
+
|
|
153
|
+
cards = ""
|
|
154
|
+
for cat in categories:
|
|
155
|
+
links = "".join(
|
|
156
|
+
f'<a href="/portal/{esc(cat.id)}/{esc(d["slug"])}" class="card-link">{esc(d["title"])}</a>'
|
|
157
|
+
for d in cat.docs
|
|
158
|
+
)
|
|
159
|
+
cards += f"""
|
|
160
|
+
<div class="card">
|
|
161
|
+
<div class="card-icon">{esc(cat.icon)}</div>
|
|
162
|
+
<h2>{esc(cat.label)}</h2>
|
|
163
|
+
<p>{esc(cat.description)}</p>
|
|
164
|
+
{links}
|
|
165
|
+
</div>
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
recent_items = ""
|
|
169
|
+
for doc in recent:
|
|
170
|
+
date_str = doc.last_modified.strftime("%b %d, %Y")
|
|
171
|
+
recent_items += f"""
|
|
172
|
+
<div class="recent-item">
|
|
173
|
+
<div>
|
|
174
|
+
<a href="/portal/{esc(doc.category)}/{esc(doc.slug)}">{esc(doc.title)}</a>
|
|
175
|
+
<span class="meta" style="margin-left:8px;">{esc(doc.category_label)}</span>
|
|
176
|
+
</div>
|
|
177
|
+
<span class="meta">{date_str}</span>
|
|
178
|
+
</div>
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
content = f"""
|
|
182
|
+
<h1 style="font-size:28px;font-weight:700;margin-bottom:8px;">Project Documentation</h1>
|
|
183
|
+
<p style="color:#6b7280;margin-bottom:24px;">Everything you need to know about this project — requirements, architecture, test results, and more.</p>
|
|
184
|
+
<div class="card-grid">{cards}</div>
|
|
185
|
+
<div class="recent">
|
|
186
|
+
<h2>Recently Updated</h2>
|
|
187
|
+
{recent_items}
|
|
188
|
+
</div>
|
|
189
|
+
"""
|
|
190
|
+
return _base("Portal", project, categories, content)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def render_category_page(cat: DocCategory, categories: list[DocCategory], project: str) -> str:
|
|
194
|
+
items = ""
|
|
195
|
+
for d in cat.docs:
|
|
196
|
+
items += f"""
|
|
197
|
+
<a href="/portal/{esc(cat.id)}/{esc(d["slug"])}" class="card" style="text-decoration:none;display:block;">
|
|
198
|
+
<h2 style="color:#1a1a1a;">{esc(d["title"])}</h2>
|
|
199
|
+
</a>
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
content = f"""
|
|
203
|
+
<div class="breadcrumb">
|
|
204
|
+
<a href="/portal">Portal</a> / <span>{esc(cat.label)}</span>
|
|
205
|
+
</div>
|
|
206
|
+
<h1 style="font-size:28px;font-weight:700;margin-bottom:8px;">{esc(cat.icon)} {esc(cat.label)}</h1>
|
|
207
|
+
<p style="color:#6b7280;margin-bottom:24px;">{esc(cat.description)}</p>
|
|
208
|
+
<div class="card-grid">{items}</div>
|
|
209
|
+
"""
|
|
210
|
+
return _base(cat.label, project, categories, content)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def render_doc_page(doc: DocFile, categories: list[DocCategory], project: str) -> str:
|
|
214
|
+
date_str = doc.last_modified.strftime("%B %d, %Y")
|
|
215
|
+
# doc.html is already rendered markdown — titles and metadata need escaping
|
|
216
|
+
content = f"""
|
|
217
|
+
<div class="article">
|
|
218
|
+
<div class="breadcrumb">
|
|
219
|
+
<a href="/portal">Portal</a> /
|
|
220
|
+
<a href="/portal/{esc(doc.category)}">{esc(doc.category_label)}</a> /
|
|
221
|
+
<span>{esc(doc.title)}</span>
|
|
222
|
+
</div>
|
|
223
|
+
<h1 style="font-size:28px;font-weight:700;margin-bottom:4px;">{esc(doc.title)}</h1>
|
|
224
|
+
<p class="doc-meta">Last updated {date_str}</p>
|
|
225
|
+
<hr style="margin:24px 0;border:none;border-top:1px solid #e5e7eb;">
|
|
226
|
+
<div class="prose">{doc.html}</div>
|
|
227
|
+
</div>
|
|
228
|
+
"""
|
|
229
|
+
return _base(doc.title, project, categories, content)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Portal routes — serves project documentation as HTML pages."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request
|
|
4
|
+
from fastapi.responses import HTMLResponse
|
|
5
|
+
|
|
6
|
+
from app.portal.docs_reader import get_categories, get_doc, get_all_docs
|
|
7
|
+
from app.portal.html_renderer import render_portal_home, render_category_page, render_doc_page
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/portal", tags=["portal"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/", response_class=HTMLResponse)
|
|
13
|
+
async def portal_home(request: Request):
|
|
14
|
+
categories = get_categories()
|
|
15
|
+
all_docs = get_all_docs()
|
|
16
|
+
recent = sorted(all_docs, key=lambda d: d.last_modified, reverse=True)[:5]
|
|
17
|
+
return render_portal_home(categories, recent, "{{PROJECT_NAME_PASCAL}}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("/{category}", response_class=HTMLResponse)
|
|
21
|
+
async def category_page(category: str):
|
|
22
|
+
categories = get_categories()
|
|
23
|
+
cat = next((c for c in categories if c.id == category), None)
|
|
24
|
+
if not cat:
|
|
25
|
+
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
|
26
|
+
return render_category_page(cat, categories, "{{PROJECT_NAME_PASCAL}}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/{category}/{slug}", response_class=HTMLResponse)
|
|
30
|
+
async def doc_page(category: str, slug: str):
|
|
31
|
+
doc = get_doc(category, slug)
|
|
32
|
+
if not doc:
|
|
33
|
+
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
|
34
|
+
categories = get_categories()
|
|
35
|
+
return render_doc_page(doc, categories, "{{PROJECT_NAME_PASCAL}}")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { getCategories, getDoc } from '@/lib/docs';
|
|
4
|
+
import { MarkdownRenderer } from '@/components/portal/markdown-renderer';
|
|
5
|
+
|
|
6
|
+
export function generateStaticParams() {
|
|
7
|
+
const categories = getCategories();
|
|
8
|
+
const params: { category: string; slug: string }[] = [];
|
|
9
|
+
for (const cat of categories) {
|
|
10
|
+
for (const doc of cat.docs) {
|
|
11
|
+
params.push({ category: cat.id, slug: doc.slug });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return params;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function DocPage({ params }: { params: Promise<{ category: string; slug: string }> }) {
|
|
18
|
+
const { category, slug } = await params;
|
|
19
|
+
const doc = getDoc(category, slug);
|
|
20
|
+
|
|
21
|
+
if (!doc) notFound();
|
|
22
|
+
|
|
23
|
+
const categories = getCategories();
|
|
24
|
+
const cat = categories.find((c) => c.id === category);
|
|
25
|
+
|
|
26
|
+
// Find prev/next docs within the same category
|
|
27
|
+
const catDocs = cat?.docs || [];
|
|
28
|
+
const currentIndex = catDocs.findIndex((d) => d.slug === slug);
|
|
29
|
+
const prevDoc = currentIndex > 0 ? catDocs[currentIndex - 1] : null;
|
|
30
|
+
const nextDoc = currentIndex < catDocs.length - 1 ? catDocs[currentIndex + 1] : null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="max-w-4xl mx-auto">
|
|
34
|
+
{/* Breadcrumb */}
|
|
35
|
+
<div className="flex items-center gap-2 text-sm text-gray-500 mb-6">
|
|
36
|
+
<Link href="/portal" className="hover:text-gray-700 transition-colors">Portal</Link>
|
|
37
|
+
<span>/</span>
|
|
38
|
+
<Link href={`/portal/${category}`} className="hover:text-gray-700 transition-colors">
|
|
39
|
+
{doc.categoryLabel}
|
|
40
|
+
</Link>
|
|
41
|
+
<span>/</span>
|
|
42
|
+
<span className="text-gray-900">{doc.title}</span>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{/* Document */}
|
|
46
|
+
<article className="bg-white rounded-xl border border-gray-200 p-8 md:p-12">
|
|
47
|
+
<div className="mb-8 pb-6 border-b border-gray-200">
|
|
48
|
+
<h1 className="text-3xl font-bold text-gray-900">{doc.title}</h1>
|
|
49
|
+
<p className="text-sm text-gray-400 mt-2">
|
|
50
|
+
Last updated {`${doc.lastModified.getUTCFullYear()}-${String(doc.lastModified.getUTCMonth() + 1).padStart(2, '0')}-${String(doc.lastModified.getUTCDate()).padStart(2, '0')}`}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
<MarkdownRenderer content={doc.content} />
|
|
54
|
+
</article>
|
|
55
|
+
|
|
56
|
+
{/* Prev/Next navigation */}
|
|
57
|
+
{(prevDoc || nextDoc) && (
|
|
58
|
+
<div className="flex justify-between mt-6 gap-4">
|
|
59
|
+
{prevDoc ? (
|
|
60
|
+
<Link
|
|
61
|
+
href={`/portal/${category}/${prevDoc.slug}`}
|
|
62
|
+
className="flex-1 bg-white rounded-xl border border-gray-200 p-4 hover:shadow-md transition-shadow"
|
|
63
|
+
>
|
|
64
|
+
<span className="text-xs text-gray-400">Previous</span>
|
|
65
|
+
<p className="text-sm font-medium text-gray-900 mt-1">{prevDoc.title}</p>
|
|
66
|
+
</Link>
|
|
67
|
+
) : <div className="flex-1" />}
|
|
68
|
+
{nextDoc ? (
|
|
69
|
+
<Link
|
|
70
|
+
href={`/portal/${category}/${nextDoc.slug}`}
|
|
71
|
+
className="flex-1 bg-white rounded-xl border border-gray-200 p-4 hover:shadow-md transition-shadow text-right"
|
|
72
|
+
>
|
|
73
|
+
<span className="text-xs text-gray-400">Next</span>
|
|
74
|
+
<p className="text-sm font-medium text-gray-900 mt-1">{nextDoc.title}</p>
|
|
75
|
+
</Link>
|
|
76
|
+
) : <div className="flex-1" />}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { getCategories, getDoc } from '@/lib/docs';
|
|
4
|
+
|
|
5
|
+
export function generateStaticParams() {
|
|
6
|
+
return getCategories().map((cat) => ({ category: cat.id }));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default async function CategoryPage({ params }: { params: Promise<{ category: string }> }) {
|
|
10
|
+
const { category } = await params;
|
|
11
|
+
const categories = getCategories();
|
|
12
|
+
const cat = categories.find((c) => c.id === category);
|
|
13
|
+
|
|
14
|
+
if (!cat) notFound();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="space-y-6">
|
|
18
|
+
<div>
|
|
19
|
+
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
|
20
|
+
<Link href="/portal" className="hover:text-gray-700 transition-colors">Portal</Link>
|
|
21
|
+
<span>/</span>
|
|
22
|
+
<span className="text-gray-900">{cat.label}</span>
|
|
23
|
+
</div>
|
|
24
|
+
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
|
25
|
+
<span>{cat.icon}</span>
|
|
26
|
+
<span>{cat.label}</span>
|
|
27
|
+
</h1>
|
|
28
|
+
{cat.description && (
|
|
29
|
+
<p className="mt-2 text-gray-600">{cat.description}</p>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div className="grid grid-cols-1 gap-3">
|
|
34
|
+
{cat.docs.map((doc) => {
|
|
35
|
+
const full = getDoc(category, doc.slug);
|
|
36
|
+
// Get first paragraph as preview
|
|
37
|
+
const firstLine = full?.content
|
|
38
|
+
.split('\n')
|
|
39
|
+
.filter((line) => line.trim() && !line.startsWith('#'))
|
|
40
|
+
[0] ?? '';
|
|
41
|
+
const preview = firstLine.slice(0, 200);
|
|
42
|
+
const isTruncated = firstLine.length > 200;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Link
|
|
46
|
+
key={doc.slug}
|
|
47
|
+
href={`/portal/${category}/${doc.slug}`}
|
|
48
|
+
className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md transition-shadow block"
|
|
49
|
+
>
|
|
50
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-1">{doc.title}</h2>
|
|
51
|
+
{preview && (
|
|
52
|
+
<p className="text-sm text-gray-500 line-clamp-2">{preview}{isTruncated ? '...' : ''}</p>
|
|
53
|
+
)}
|
|
54
|
+
{full && (
|
|
55
|
+
<p className="text-xs text-gray-400 mt-2">
|
|
56
|
+
Updated {`${full.lastModified.getUTCFullYear()}-${String(full.lastModified.getUTCMonth() + 1).padStart(2, '0')}-${String(full.lastModified.getUTCDate()).padStart(2, '0')}`}
|
|
57
|
+
</p>
|
|
58
|
+
)}
|
|
59
|
+
</Link>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { getCategories } from '@/lib/docs';
|
|
3
|
+
import { PortalNav } from '@/components/portal/portal-nav';
|
|
4
|
+
import { MobilePortalNav } from '@/components/portal/mobile-portal-nav';
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
title: 'Project Portal — {{PROJECT_NAME_PASCAL}}',
|
|
8
|
+
description: 'Project documentation, requirements, architecture, and test results',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
|
12
|
+
const categories = getCategories();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="min-h-screen bg-gray-50">
|
|
16
|
+
{/* Header */}
|
|
17
|
+
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
|
|
18
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
19
|
+
<div className="flex items-center justify-between h-16">
|
|
20
|
+
<div className="flex items-center gap-6">
|
|
21
|
+
<Link href="/portal" className="flex items-center gap-2.5">
|
|
22
|
+
<div className="w-8 h-8 bg-gray-900 rounded-lg flex items-center justify-center">
|
|
23
|
+
<span className="text-white text-sm font-bold">
|
|
24
|
+
{'{{PROJECT_NAME_PASCAL}}'.charAt(0)}
|
|
25
|
+
</span>
|
|
26
|
+
</div>
|
|
27
|
+
<span className="font-semibold text-gray-900">{{PROJECT_NAME_PASCAL}}</span>
|
|
28
|
+
</Link>
|
|
29
|
+
<div className="hidden md:block">
|
|
30
|
+
<PortalNav categories={categories} />
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="flex items-center gap-3">
|
|
34
|
+
<Link
|
|
35
|
+
href="/"
|
|
36
|
+
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
|
37
|
+
>
|
|
38
|
+
Back to app
|
|
39
|
+
</Link>
|
|
40
|
+
<div className="md:hidden">
|
|
41
|
+
<MobilePortalNav categories={categories} />
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</header>
|
|
47
|
+
|
|
48
|
+
{/* Content */}
|
|
49
|
+
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
50
|
+
{children}
|
|
51
|
+
</main>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { getCategories, getAllDocs } from '@/lib/docs';
|
|
3
|
+
|
|
4
|
+
export default function PortalHome() {
|
|
5
|
+
const categories = getCategories();
|
|
6
|
+
const allDocs = getAllDocs();
|
|
7
|
+
|
|
8
|
+
const recentDocs = [...allDocs]
|
|
9
|
+
.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime())
|
|
10
|
+
.slice(0, 5);
|
|
11
|
+
|
|
12
|
+
if (categories.length === 0) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="text-center py-20">
|
|
15
|
+
<div className="text-5xl mb-4">📄</div>
|
|
16
|
+
<h1 className="text-2xl font-bold text-gray-900 mb-2">No documentation yet</h1>
|
|
17
|
+
<p className="text-gray-600 max-w-md mx-auto">
|
|
18
|
+
Documentation will appear here once it has been generated.
|
|
19
|
+
Ask your development team to run the documentation generators.
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-8">
|
|
27
|
+
{/* Welcome */}
|
|
28
|
+
<div>
|
|
29
|
+
<h1 className="text-3xl font-bold text-gray-900">Project Documentation</h1>
|
|
30
|
+
<p className="mt-2 text-gray-600">
|
|
31
|
+
Everything you need to know about this project — requirements, architecture, test results, and more.
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
{/* Category cards */}
|
|
36
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
37
|
+
{categories.map((cat) => (
|
|
38
|
+
<div
|
|
39
|
+
key={cat.id}
|
|
40
|
+
className="bg-white rounded-xl border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
|
41
|
+
>
|
|
42
|
+
<div className="text-3xl mb-3">{cat.icon}</div>
|
|
43
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-1">{cat.label}</h2>
|
|
44
|
+
<p className="text-sm text-gray-500 mb-4">{cat.description}</p>
|
|
45
|
+
<div className="space-y-1">
|
|
46
|
+
{cat.docs.map((doc) => (
|
|
47
|
+
<Link
|
|
48
|
+
key={doc.slug}
|
|
49
|
+
href={`/portal/${cat.id}/${doc.slug}`}
|
|
50
|
+
className="block text-sm text-blue-600 hover:text-blue-800 transition-colors py-0.5"
|
|
51
|
+
>
|
|
52
|
+
{doc.title}
|
|
53
|
+
</Link>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{/* Recently updated */}
|
|
61
|
+
{recentDocs.length > 0 && (
|
|
62
|
+
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
63
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recently Updated</h2>
|
|
64
|
+
<div className="divide-y divide-gray-100">
|
|
65
|
+
{recentDocs.map((doc) => (
|
|
66
|
+
<Link
|
|
67
|
+
key={`${doc.category}/${doc.slug}`}
|
|
68
|
+
href={`/portal/${doc.category}/${doc.slug}`}
|
|
69
|
+
className="flex items-center justify-between py-3 hover:bg-gray-50 -mx-3 px-3 rounded-lg transition-colors"
|
|
70
|
+
>
|
|
71
|
+
<div>
|
|
72
|
+
<span className="text-sm font-medium text-gray-900">{doc.title}</span>
|
|
73
|
+
<span className="ml-2 text-xs text-gray-400">{doc.categoryLabel}</span>
|
|
74
|
+
</div>
|
|
75
|
+
<span className="text-xs text-gray-400">
|
|
76
|
+
{`${doc.lastModified.getUTCFullYear()}-${String(doc.lastModified.getUTCMonth() + 1).padStart(2, '0')}-${String(doc.lastModified.getUTCDate()).padStart(2, '0')}`}
|
|
77
|
+
</span>
|
|
78
|
+
</Link>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import ReactMarkdown from 'react-markdown';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
|
|
6
|
+
export function MarkdownRenderer({ content }: { content: string }) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="prose prose-gray max-w-none">
|
|
9
|
+
<ReactMarkdown
|
|
10
|
+
remarkPlugins={[remarkGfm]}
|
|
11
|
+
components={{
|
|
12
|
+
// Clean table rendering
|
|
13
|
+
table: ({ children }) => (
|
|
14
|
+
<div className="overflow-x-auto my-6 rounded-lg border border-gray-200">
|
|
15
|
+
<table className="min-w-full divide-y divide-gray-200">{children}</table>
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
thead: ({ children }) => (
|
|
19
|
+
<thead className="bg-gray-50">{children}</thead>
|
|
20
|
+
),
|
|
21
|
+
th: ({ children }) => (
|
|
22
|
+
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
|
23
|
+
{children}
|
|
24
|
+
</th>
|
|
25
|
+
),
|
|
26
|
+
td: ({ children }) => (
|
|
27
|
+
<td className="px-4 py-3 text-sm text-gray-700 border-t border-gray-100">{children}</td>
|
|
28
|
+
),
|
|
29
|
+
// Status badges
|
|
30
|
+
code: ({ children, className }) => {
|
|
31
|
+
const isBlock = className?.startsWith('language-');
|
|
32
|
+
if (isBlock) {
|
|
33
|
+
return (
|
|
34
|
+
<code className={`${className} block overflow-x-auto`}>{children}</code>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
// Inline status badges
|
|
38
|
+
const text = String(children);
|
|
39
|
+
if (text === 'PASS' || text === '✅ Built') {
|
|
40
|
+
return <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{text}</span>;
|
|
41
|
+
}
|
|
42
|
+
if (text === 'FAIL' || text === 'CRITICAL') {
|
|
43
|
+
return <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">{text}</span>;
|
|
44
|
+
}
|
|
45
|
+
if (text === '🚧 In Progress') {
|
|
46
|
+
return <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">{text}</span>;
|
|
47
|
+
}
|
|
48
|
+
if (text === '📋 Planned') {
|
|
49
|
+
return <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">{text}</span>;
|
|
50
|
+
}
|
|
51
|
+
return <code className="px-1.5 py-0.5 rounded bg-gray-100 text-sm font-mono text-gray-800">{children}</code>;
|
|
52
|
+
},
|
|
53
|
+
// Headings with anchors
|
|
54
|
+
h1: ({ children }) => {
|
|
55
|
+
const id = String(children).toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
|
56
|
+
return <h1 id={id} className="text-3xl font-bold text-gray-900 mt-8 mb-4 first:mt-0">{children}</h1>;
|
|
57
|
+
},
|
|
58
|
+
h2: ({ children }) => {
|
|
59
|
+
const id = String(children).toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
|
60
|
+
return <h2 id={id} className="text-2xl font-semibold text-gray-900 mt-8 mb-3 pb-2 border-b border-gray-200">{children}</h2>;
|
|
61
|
+
},
|
|
62
|
+
h3: ({ children }) => {
|
|
63
|
+
const id = String(children).toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
|
64
|
+
return <h3 id={id} className="text-xl font-semibold text-gray-800 mt-6 mb-2">{children}</h3>;
|
|
65
|
+
},
|
|
66
|
+
// Clean list rendering
|
|
67
|
+
ul: ({ children }) => (
|
|
68
|
+
<ul className="list-disc list-outside ml-5 space-y-1.5 text-gray-700">{children}</ul>
|
|
69
|
+
),
|
|
70
|
+
ol: ({ children }) => (
|
|
71
|
+
<ol className="list-decimal list-outside ml-5 space-y-1.5 text-gray-700">{children}</ol>
|
|
72
|
+
),
|
|
73
|
+
// Paragraphs
|
|
74
|
+
p: ({ children }) => (
|
|
75
|
+
<p className="text-gray-700 leading-relaxed my-3">{children}</p>
|
|
76
|
+
),
|
|
77
|
+
// Links — only allow safe protocols
|
|
78
|
+
a: ({ href, children }) => {
|
|
79
|
+
const safeHref = href && /^(https?:\/\/|mailto:|\/|#)/.test(href) ? href : undefined;
|
|
80
|
+
const isExternal = safeHref?.startsWith('http');
|
|
81
|
+
return (
|
|
82
|
+
<a
|
|
83
|
+
href={safeHref}
|
|
84
|
+
className="text-blue-600 hover:text-blue-800 underline decoration-blue-300 hover:decoration-blue-500 transition-colors"
|
|
85
|
+
{...(isExternal && { target: '_blank', rel: 'noopener noreferrer' })}
|
|
86
|
+
>
|
|
87
|
+
{children}
|
|
88
|
+
</a>
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
// Blockquotes
|
|
92
|
+
blockquote: ({ children }) => (
|
|
93
|
+
<blockquote className="border-l-4 border-blue-200 bg-blue-50 pl-4 py-2 my-4 rounded-r-lg text-gray-700">
|
|
94
|
+
{children}
|
|
95
|
+
</blockquote>
|
|
96
|
+
),
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|