forgedev 1.2.0 → 1.4.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 +57 -10
- package/bin/chainproof.js +126 -0
- package/bin/devforge.js +1 -1
- package/package.json +25 -7
- package/src/chainproof-bridge.js +330 -0
- package/src/ci-mode.js +85 -0
- package/src/claude-configurator.js +171 -78
- package/src/cli.js +30 -7
- package/src/composer.js +242 -214
- 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 +76 -12
- package/src/menu.js +178 -0
- package/src/prompts.js +5 -12
- package/src/recommender.js +163 -30
- package/src/scanner.js +57 -2
- package/src/uat-generator.js +204 -189
- package/src/update-check.js +9 -4
- package/src/update.js +57 -13
- package/src/utils.js +162 -5
- 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/.gitignore.template +3 -0
- package/templates/base/docs/uat/UAT_TEMPLATE.md.template +1 -1
- 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 +19 -5
- package/templates/claude-code/agents/chief-of-staff.md +42 -8
- package/templates/claude-code/agents/code-quality-reviewer.md +14 -0
- package/templates/claude-code/agents/database-reviewer.md +15 -1
- 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 +36 -1
- package/templates/claude-code/agents/loop-operator.md +27 -13
- package/templates/claude-code/agents/planner.md +21 -7
- package/templates/claude-code/agents/product-strategist.md +24 -10
- 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 +14 -0
- package/templates/claude-code/agents/spec-validator.md +15 -1
- package/templates/claude-code/agents/tdd-guide.md +21 -7
- package/templates/claude-code/agents/uat-validator.md +14 -0
- package/templates/claude-code/claude-md/base.md +14 -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 +53 -31
- package/templates/claude-code/commands/fix-loop.md +211 -0
- package/templates/claude-code/commands/full-audit.md +36 -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 -40
- 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 +2 -2
- package/templates/claude-code/hooks/scripts/autofix-python.mjs +1 -1
- package/templates/claude-code/hooks/scripts/autofix-typescript.mjs +1 -1
- package/templates/claude-code/hooks/scripts/code-hygiene.mjs +293 -0
- 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 +5 -5
- package/templates/claude-code/skills/nextjs/SKILL.md +1 -1
- package/templates/claude-code/skills/playwright/SKILL.md +5 -5
- package/templates/claude-code/skills/security-api/SKILL.md +1 -1
- package/templates/claude-code/skills/security-web/SKILL.md +1 -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/__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 +3 -0
- package/templates/infra/k8s/k8s/deployment.yml.template +70 -0
- package/templates/infra/k8s/k8s/hpa.yml.template +24 -0
- package/templates/infra/k8s/k8s/ingress.yml.template +26 -0
- package/templates/infra/k8s/k8s/kustomization.yml.template +13 -0
- package/templates/infra/k8s/k8s/namespace.yml.template +4 -0
- package/templates/infra/k8s/k8s/networkpolicy.yml.template +41 -0
- package/templates/infra/k8s/k8s/secrets.yml.template +10 -0
- package/templates/infra/k8s/k8s/service.yml.template +15 -0
- package/templates/testing/load/k6/README.md.template +48 -0
- package/templates/testing/load/k6/load-test.js.template +57 -0
- package/docs/00-README.md +0 -310
- package/docs/01-universal-prompt-library.md +0 -1049
- package/docs/02-claude-code-mastery-playbook.md +0 -283
- package/docs/03-multi-agent-verification.md +0 -565
- package/docs/04-errata-and-verification-checklist.md +0 -284
- package/docs/05-universal-scaffolder-vision.md +0 -452
- package/docs/06-confidence-assessment-and-repo-prompt.md +0 -407
- package/docs/errata.md +0 -58
- package/docs/multi-agent-verification.md +0 -66
- package/docs/playbook.md +0 -95
- package/docs/prompt-library.md +0 -160
- package/docs/uat/UAT_CHECKLIST.csv +0 -9
- package/docs/uat/UAT_TEMPLATE.md +0 -163
- package/templates/claude-code/commands/done.md +0 -19
- /package/{docs/plans/.gitkeep → templates/docs-portal/fastapi/backend/app/portal/__init__.py} +0 -0
|
@@ -5,10 +5,10 @@ description: Git branching, commits, PR workflow, and conflict resolution patter
|
|
|
5
5
|
|
|
6
6
|
## Branching Strategy
|
|
7
7
|
|
|
8
|
-
- `main`
|
|
9
|
-
- `feat/<name>`
|
|
10
|
-
- `fix/<name>`
|
|
11
|
-
- `chore/<name>`
|
|
8
|
+
- `main` - production-ready code, never commit directly
|
|
9
|
+
- `feat/<name>` - new features, branch from main
|
|
10
|
+
- `fix/<name>` - bug fixes, branch from main
|
|
11
|
+
- `chore/<name>` - maintenance, config changes, dependency updates
|
|
12
12
|
|
|
13
13
|
Always create a feature branch before starting work:
|
|
14
14
|
```bash
|
|
@@ -59,6 +59,6 @@ If rebase gets messy: `git rebase --abort` and start over.
|
|
|
59
59
|
|
|
60
60
|
- Never force-push to `main`
|
|
61
61
|
- Never commit `.env` files, credentials, or large binaries
|
|
62
|
-
- Never rebase shared branches (`main`, `develop`)
|
|
62
|
+
- Never rebase shared branches (`main`, `develop`). Only rebase your own feature branches
|
|
63
63
|
- Update feature branches with `git rebase origin/main`, not `git merge main` (force-push your branch after)
|
|
64
64
|
- Always pull before pushing: `git pull --rebase` (pulls current branch's upstream)
|
|
@@ -33,7 +33,7 @@ description: Next.js 15 App Router patterns and best practices
|
|
|
33
33
|
- Intercepting routes `(.)` for modals
|
|
34
34
|
|
|
35
35
|
## Common Mistakes
|
|
36
|
-
- Avoid Client Components with `useEffect` for data fetching
|
|
36
|
+
- Avoid Client Components with `useEffect` for data fetching. Prefer async Server Components instead
|
|
37
37
|
- Don't pass functions as props from Server to Client Components
|
|
38
38
|
- Don't use `router.push` when `<Link>` works
|
|
39
39
|
- Don't create API routes for data that Server Components can fetch directly
|
|
@@ -12,11 +12,11 @@ description: Playwright E2E testing patterns and best practices
|
|
|
12
12
|
- Use page object pattern for complex pages
|
|
13
13
|
|
|
14
14
|
## Selector Priority
|
|
15
|
-
1. `getByRole()
|
|
16
|
-
2. `getByLabel()
|
|
17
|
-
3. `getByText()
|
|
18
|
-
4. `getByTestId()
|
|
19
|
-
- Avoid CSS selectors or XPath
|
|
15
|
+
1. `getByRole()`: buttons, links, headings (best for accessibility)
|
|
16
|
+
2. `getByLabel()`: form inputs by label
|
|
17
|
+
3. `getByText()`: visible text content
|
|
18
|
+
4. `getByTestId()`: `data-testid` attributes (last resort)
|
|
19
|
+
- Avoid CSS selectors or XPath. They break when styles change
|
|
20
20
|
|
|
21
21
|
## Assertions
|
|
22
22
|
- Use `expect(locator)` for element assertions
|
|
@@ -20,7 +20,7 @@ description: API security best practices
|
|
|
20
20
|
- Validate file uploads (size, type, extension)
|
|
21
21
|
|
|
22
22
|
## SQL Injection Prevention
|
|
23
|
-
- Use SQLAlchemy ORM
|
|
23
|
+
- Use SQLAlchemy ORM. Never raw SQL strings
|
|
24
24
|
- If raw SQL needed, use `text()` with bound parameters
|
|
25
25
|
- Never interpolate user input into queries
|
|
26
26
|
|
|
@@ -6,7 +6,7 @@ description: Web application security best practices
|
|
|
6
6
|
# Web Application Security
|
|
7
7
|
|
|
8
8
|
## XSS Prevention
|
|
9
|
-
- React escapes by default
|
|
9
|
+
- React escapes by default. Never use `dangerouslySetInnerHTML`
|
|
10
10
|
- Sanitize user input before rendering
|
|
11
11
|
- Use Content Security Policy headers
|
|
12
12
|
- Validate URLs before using in `href` or `src`
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: testing-patterns
|
|
3
|
-
description: Universal testing principles
|
|
3
|
+
description: Universal testing principles - test pyramid, AAA pattern, mocking strategies, and coverage targets
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Test Pyramid
|
|
7
7
|
|
|
8
8
|
```
|
|
9
|
-
/ E2E \
|
|
10
|
-
/ Integration \
|
|
11
|
-
/ Unit Tests
|
|
9
|
+
/ E2E \ - Few, slow, high confidence
|
|
10
|
+
/ Integration \ - Some, medium speed
|
|
11
|
+
/ Unit Tests \- Many, fast, focused
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
- **Unit tests** (70%): Test individual functions in isolation. Fast, many.
|
|
@@ -21,14 +21,14 @@ Every test follows this structure:
|
|
|
21
21
|
|
|
22
22
|
```
|
|
23
23
|
test('should calculate total with tax', () => {
|
|
24
|
-
// Arrange
|
|
24
|
+
// Arrange: set up test data
|
|
25
25
|
const items = [{ price: 10 }, { price: 20 }];
|
|
26
26
|
const taxRate = 0.1;
|
|
27
27
|
|
|
28
|
-
// Act
|
|
28
|
+
// Act: execute the function
|
|
29
29
|
const total = calculateTotal(items, taxRate);
|
|
30
30
|
|
|
31
|
-
// Assert
|
|
31
|
+
// Assert: verify the result
|
|
32
32
|
expect(total).toBe(33);
|
|
33
33
|
});
|
|
34
34
|
```
|
|
@@ -52,7 +52,7 @@ test('should calculate total with tax', () => {
|
|
|
52
52
|
|
|
53
53
|
| What | When to Mock |
|
|
54
54
|
|------|-------------|
|
|
55
|
-
| External APIs | Always
|
|
55
|
+
| External APIs | Always. They're slow and unreliable |
|
|
56
56
|
| Database | Integration tests use real DB, unit tests mock |
|
|
57
57
|
| Time/Date | When testing time-dependent logic |
|
|
58
58
|
| File system | When testing file operations |
|
|
@@ -84,7 +84,7 @@ Use descriptive names that explain the scenario:
|
|
|
84
84
|
|
|
85
85
|
- **80% minimum** for all code
|
|
86
86
|
- **100% required** for: auth logic, financial calculations, security-critical code
|
|
87
|
-
- Coverage measures lines hit, not correctness
|
|
87
|
+
- Coverage measures lines hit, not correctness. High coverage with weak assertions is useless
|
|
88
88
|
- Focus on meaningful assertions, not just line coverage
|
|
89
89
|
|
|
90
90
|
## Common Anti-Patterns
|
package/templates/docs-portal/fastapi/backend/app/portal/__pycache__/docs_reader.cpython-314.pyc
ADDED
|
Binary file
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Reads and organizes markdown files from the docs/ directory."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
import bleach
|
|
9
|
+
import markdown
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
DOCS_ROOT = Path(__file__).resolve().parent.parent.parent.parent / "docs"
|
|
14
|
+
|
|
15
|
+
ALLOWED_HTML_TAGS = [
|
|
16
|
+
"a",
|
|
17
|
+
"p",
|
|
18
|
+
"ul",
|
|
19
|
+
"ol",
|
|
20
|
+
"li",
|
|
21
|
+
"strong",
|
|
22
|
+
"em",
|
|
23
|
+
"code",
|
|
24
|
+
"pre",
|
|
25
|
+
"blockquote",
|
|
26
|
+
"hr",
|
|
27
|
+
"br",
|
|
28
|
+
"h1",
|
|
29
|
+
"h2",
|
|
30
|
+
"h3",
|
|
31
|
+
"h4",
|
|
32
|
+
"h5",
|
|
33
|
+
"h6",
|
|
34
|
+
"table",
|
|
35
|
+
"thead",
|
|
36
|
+
"tbody",
|
|
37
|
+
"tr",
|
|
38
|
+
"th",
|
|
39
|
+
"td",
|
|
40
|
+
]
|
|
41
|
+
ALLOWED_HTML_ATTRIBUTES = {
|
|
42
|
+
"a": ["href", "title"],
|
|
43
|
+
"th": ["align"],
|
|
44
|
+
"td": ["align"],
|
|
45
|
+
}
|
|
46
|
+
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
|
|
47
|
+
|
|
48
|
+
CATEGORY_META: dict[str, dict] = {
|
|
49
|
+
"prd": {"label": "Product", "description": "Product requirements and roadmap", "icon": "📋", "order": 1},
|
|
50
|
+
"sdd": {"label": "Architecture", "description": "System design and technical architecture", "icon": "🏗️", "order": 2},
|
|
51
|
+
"uat": {"label": "Testing", "description": "Test plans, results, and acceptance criteria", "icon": "✅", "order": 3},
|
|
52
|
+
"sessions": {"label": "Session Logs", "description": "Testing session results and bug reports", "icon": "📝", "order": 4},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class DocFile:
|
|
58
|
+
slug: str
|
|
59
|
+
title: str
|
|
60
|
+
content: str
|
|
61
|
+
html: str
|
|
62
|
+
category: str
|
|
63
|
+
category_label: str
|
|
64
|
+
last_modified: datetime
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class DocCategory:
|
|
69
|
+
id: str
|
|
70
|
+
label: str
|
|
71
|
+
description: str
|
|
72
|
+
icon: str
|
|
73
|
+
order: int
|
|
74
|
+
docs: list[dict] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_title(content: str, filename: str) -> str:
|
|
78
|
+
for line in content.split("\n"):
|
|
79
|
+
line = line.strip()
|
|
80
|
+
if line.startswith("# ") and not line.startswith("##"):
|
|
81
|
+
return line[2:].strip()
|
|
82
|
+
return filename.replace(".md", "").replace("-", " ").replace("_", " ").title()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _render_markdown(content: str) -> str:
|
|
86
|
+
md = markdown.Markdown(extensions=["tables", "fenced_code", "toc"])
|
|
87
|
+
html = md.convert(content)
|
|
88
|
+
return bleach.clean(
|
|
89
|
+
html,
|
|
90
|
+
tags=ALLOWED_HTML_TAGS,
|
|
91
|
+
attributes=ALLOWED_HTML_ATTRIBUTES,
|
|
92
|
+
protocols=ALLOWED_PROTOCOLS,
|
|
93
|
+
strip=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_categories() -> list[DocCategory]:
|
|
98
|
+
if not DOCS_ROOT.exists():
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
categories: list[DocCategory] = []
|
|
102
|
+
|
|
103
|
+
for entry in sorted(DOCS_ROOT.iterdir()):
|
|
104
|
+
if not entry.is_dir():
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
category_id = entry.name
|
|
108
|
+
meta = CATEGORY_META.get(category_id, {
|
|
109
|
+
"label": category_id.capitalize(),
|
|
110
|
+
"description": "",
|
|
111
|
+
"icon": "📄",
|
|
112
|
+
"order": 99,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
md_files = sorted(entry.glob("*.md"))
|
|
116
|
+
if not md_files:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
docs = []
|
|
120
|
+
for f in md_files:
|
|
121
|
+
try:
|
|
122
|
+
content = f.read_text(encoding="utf-8")
|
|
123
|
+
docs.append({
|
|
124
|
+
"slug": f.stem,
|
|
125
|
+
"title": _extract_title(content, f.name),
|
|
126
|
+
})
|
|
127
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
128
|
+
logger.warning("Skipping docs file %s: %s", f.name, exc)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
categories.append(DocCategory(
|
|
132
|
+
id=category_id,
|
|
133
|
+
label=meta["label"],
|
|
134
|
+
description=meta["description"],
|
|
135
|
+
icon=meta["icon"],
|
|
136
|
+
order=meta["order"],
|
|
137
|
+
docs=docs,
|
|
138
|
+
))
|
|
139
|
+
|
|
140
|
+
# Top-level markdown files
|
|
141
|
+
top_level = sorted(DOCS_ROOT.glob("*.md"))
|
|
142
|
+
if top_level:
|
|
143
|
+
docs = []
|
|
144
|
+
for f in top_level:
|
|
145
|
+
content = f.read_text(encoding="utf-8")
|
|
146
|
+
docs.append({
|
|
147
|
+
"slug": f.stem,
|
|
148
|
+
"title": _extract_title(content, f.name),
|
|
149
|
+
})
|
|
150
|
+
categories.append(DocCategory(
|
|
151
|
+
id="_general",
|
|
152
|
+
label="General",
|
|
153
|
+
description="Project documentation",
|
|
154
|
+
icon="📄",
|
|
155
|
+
order=99,
|
|
156
|
+
docs=docs,
|
|
157
|
+
))
|
|
158
|
+
|
|
159
|
+
return sorted(categories, key=lambda c: c.order)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_doc(category: str, slug: str) -> DocFile | None:
|
|
163
|
+
if category == "_general":
|
|
164
|
+
file_path = DOCS_ROOT / f"{slug}.md"
|
|
165
|
+
else:
|
|
166
|
+
file_path = DOCS_ROOT / category / f"{slug}.md"
|
|
167
|
+
|
|
168
|
+
# Prevent path traversal — resolved path must stay within DOCS_ROOT
|
|
169
|
+
docs_root_resolved = DOCS_ROOT.resolve()
|
|
170
|
+
resolved = file_path.resolve()
|
|
171
|
+
try:
|
|
172
|
+
resolved.relative_to(docs_root_resolved)
|
|
173
|
+
except ValueError:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
if not resolved.exists():
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
content = resolved.read_text(encoding="utf-8")
|
|
180
|
+
stat = resolved.stat()
|
|
181
|
+
meta = CATEGORY_META.get(category, {"label": "General"})
|
|
182
|
+
|
|
183
|
+
return DocFile(
|
|
184
|
+
slug=slug,
|
|
185
|
+
title=_extract_title(content, f"{slug}.md"),
|
|
186
|
+
content=content,
|
|
187
|
+
html=_render_markdown(content),
|
|
188
|
+
category=category,
|
|
189
|
+
category_label=meta["label"],
|
|
190
|
+
last_modified=datetime.fromtimestamp(stat.st_mtime),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_all_docs() -> list[DocFile]:
|
|
195
|
+
docs: list[DocFile] = []
|
|
196
|
+
for cat in get_categories():
|
|
197
|
+
for doc_meta in cat.docs:
|
|
198
|
+
doc = get_doc(cat.id, doc_meta["slug"])
|
|
199
|
+
if doc:
|
|
200
|
+
docs.append(doc)
|
|
201
|
+
return docs
|
|
@@ -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}}")
|