docutrack 0.1.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 +116 -0
- package/bin/docutrack.js +67 -0
- package/package.json +38 -0
- package/src/analyzer/complexity.js +145 -0
- package/src/analyzer/detect.js +124 -0
- package/src/analyzer/index.js +121 -0
- package/src/analyzer/parsers/express.js +110 -0
- package/src/analyzer/parsers/fastapi.js +89 -0
- package/src/commands/analyze.js +47 -0
- package/src/commands/badge.js +79 -0
- package/src/commands/check.js +187 -0
- package/src/commands/clear.js +17 -0
- package/src/commands/export.js +182 -0
- package/src/commands/init.js +182 -0
- package/src/commands/onboard.js +288 -0
- package/src/commands/scan.js +121 -0
- package/src/commands/serve.js +48 -0
- package/src/commands/status.js +94 -0
- package/src/utils/drift.js +167 -0
- package/src/utils/queue.js +62 -0
- package/src/utils/settings.js +69 -0
- package/src/utils/stale.js +80 -0
- package/src/viewer/index.html +1411 -0
- package/src/viewer/server.js +652 -0
- package/templates/ARCHITECTURE.md +51 -0
- package/templates/agents/documentalista.md +113 -0
- package/templates/claude-snippet.md +39 -0
- package/templates/commands/adr-new.md +58 -0
- package/templates/commands/arch-review.md +59 -0
- package/templates/commands/ask-docs.md +26 -0
- package/templates/commands/doc-map.md +50 -0
- package/templates/docs/api/.gitkeep +0 -0
- package/templates/docs/decisions/.gitkeep +0 -0
- package/templates/docs/modules/.gitkeep +0 -0
- package/templates/docutrack.config.json +13 -0
- package/templates/github/workflows/docutrack-docs.yml +42 -0
- package/templates/github/workflows/docutrack-gate.yml +31 -0
- package/templates/github/workflows/docutrack-pr.yml +93 -0
- package/templates/hooks/on-stop.js +39 -0
- package/templates/hooks/post-tool-use.js +52 -0
- package/templates/stacks/express/ARCHITECTURE.md +67 -0
- package/templates/stacks/express/documentalista.md +63 -0
- package/templates/stacks/fastapi/ARCHITECTURE.md +68 -0
- package/templates/stacks/fastapi/documentalista.md +88 -0
- package/templates/stacks/go/ARCHITECTURE.md +68 -0
- package/templates/stacks/go/documentalista.md +89 -0
- package/templates/stacks/monorepo/ARCHITECTURE.md +60 -0
- package/templates/stacks/monorepo/documentalista.md +59 -0
- package/templates/stacks/nextjs/ARCHITECTURE.md +76 -0
- package/templates/stacks/nextjs/documentalista.md +93 -0
|
@@ -0,0 +1,1411 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>DocuTrack</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
|
|
10
|
+
:root {
|
|
11
|
+
--bg: #ffffff;
|
|
12
|
+
--bg-sidebar: #f8fafc;
|
|
13
|
+
--bg-hover: #f1f5f9;
|
|
14
|
+
--bg-active: #eef2ff;
|
|
15
|
+
--border: #e2e8f0;
|
|
16
|
+
--text: #0f172a;
|
|
17
|
+
--text-muted: #64748b;
|
|
18
|
+
--text-sidebar: #374151;
|
|
19
|
+
--accent: #6366f1;
|
|
20
|
+
--accent-hover: #4f46e5;
|
|
21
|
+
--accent-light: #eef2ff;
|
|
22
|
+
--code-bg: #f1f5f9;
|
|
23
|
+
--code-text: #1e293b;
|
|
24
|
+
--green: #10b981; --green-bg: #dcfce7; --green-text: #166534;
|
|
25
|
+
--blue-bg: #dbeafe; --blue-text: #1e40af;
|
|
26
|
+
--yellow: #f59e0b; --yellow-bg: #fef3c7; --yellow-text: #92400e;
|
|
27
|
+
--orange-bg: #ffedd5; --orange-text: #9a3412;
|
|
28
|
+
--purple-bg: #ede9fe; --purple-text: #6d28d9;
|
|
29
|
+
--red: #ef4444; --red-bg: #fee2e2; --red-text: #991b1b;
|
|
30
|
+
--sidebar-w: 272px;
|
|
31
|
+
--header-h: 56px;
|
|
32
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
33
|
+
--font-mono: "Cascadia Code", "Fira Code", Consolas, monospace;
|
|
34
|
+
--radius: 6px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[data-theme="dark"] {
|
|
38
|
+
--bg: #0f172a; --bg-sidebar: #1e293b; --bg-hover: #334155; --bg-active: #312e81;
|
|
39
|
+
--border: #334155; --text: #f1f5f9; --text-muted: #94a3b8; --text-sidebar: #cbd5e1;
|
|
40
|
+
--accent: #818cf8; --accent-hover: #a5b4fc; --accent-light: #1e1b4b;
|
|
41
|
+
--code-bg: #1e293b; --code-text: #e2e8f0;
|
|
42
|
+
--green-bg: #14532d; --green-text: #86efac;
|
|
43
|
+
--blue-bg: #1e3a5f; --blue-text: #93c5fd;
|
|
44
|
+
--yellow-bg: #78350f; --yellow-text: #fde68a;
|
|
45
|
+
--orange-bg: #7c2d12; --orange-text: #fed7aa;
|
|
46
|
+
--purple-bg: #3b0764; --purple-text: #d8b4fe;
|
|
47
|
+
--red-bg: #7f1d1d; --red-text: #fca5a5;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
html, body { height: 100%; font-family: var(--font); color: var(--text); background: var(--bg); font-size: 15px; line-height: 1.6; }
|
|
51
|
+
|
|
52
|
+
/* ── Layout ── */
|
|
53
|
+
#app { display: grid; grid-template-columns: var(--sidebar-w) 1fr; grid-template-rows: var(--header-h) 1fr; height: 100vh; }
|
|
54
|
+
|
|
55
|
+
/* ── Header ── */
|
|
56
|
+
#header { grid-column: 1 / -1; background: var(--bg); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 16px; position: sticky; top: 0; z-index: 100; }
|
|
57
|
+
.logo { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 15px; color: var(--text); text-decoration: none; white-space: nowrap; cursor: pointer; }
|
|
58
|
+
.logo-icon { width: 28px; height: 28px; background: var(--accent); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
|
|
59
|
+
.header-sep { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; }
|
|
60
|
+
#project-name { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
|
|
61
|
+
.header-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
|
62
|
+
#coverage-badge { display: flex; align-items: center; gap: 6px; background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 20px; padding: 3px 10px; font-size: 12px; font-weight: 600; color: var(--text-muted); white-space: nowrap; }
|
|
63
|
+
.coverage-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
|
|
64
|
+
#theme-toggle { width: 32px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; }
|
|
65
|
+
#theme-toggle:hover { background: var(--bg-hover); }
|
|
66
|
+
.live-pill { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); }
|
|
67
|
+
.live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; flex-shrink: 0; }
|
|
68
|
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
|
69
|
+
|
|
70
|
+
/* ── Search ── */
|
|
71
|
+
#search-wrap { flex: 1; max-width: 380px; position: relative; }
|
|
72
|
+
#search { width: 100%; padding: 6px 12px 6px 34px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-sidebar); color: var(--text); font-size: 13px; outline: none; }
|
|
73
|
+
#search:focus { border-color: var(--accent); background: var(--bg); }
|
|
74
|
+
#search::placeholder { color: var(--text-muted); }
|
|
75
|
+
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; pointer-events: none; color: var(--text-muted); }
|
|
76
|
+
.search-kbd { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 10px; color: var(--text-muted); background: var(--border); padding: 1px 5px; border-radius: 3px; pointer-events: none; }
|
|
77
|
+
|
|
78
|
+
/* ── Sidebar ── */
|
|
79
|
+
#sidebar { background: var(--bg-sidebar); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px 0 80px; }
|
|
80
|
+
.nav-section { margin-bottom: 4px; }
|
|
81
|
+
.nav-section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: 8px 20px 4px; }
|
|
82
|
+
.nav-item { display: flex; align-items: center; gap: 8px; padding: 6px 20px; font-size: 13px; color: var(--text-sidebar); cursor: pointer; border-left: 2px solid transparent; }
|
|
83
|
+
.nav-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
84
|
+
.nav-item.active { background: var(--bg-active); color: var(--accent); border-left-color: var(--accent); font-weight: 500; }
|
|
85
|
+
.nav-icon { font-size: 14px; width: 16px; text-align: center; flex-shrink: 0; }
|
|
86
|
+
.nav-empty { font-size: 12px; color: var(--text-muted); padding: 4px 20px 8px; font-style: italic; }
|
|
87
|
+
.nav-badge { margin-left: auto; font-size: 11px; background: var(--accent-light); color: var(--accent); border-radius: 10px; padding: 0 6px; font-weight: 600; }
|
|
88
|
+
.nav-stale { margin-left: auto; font-size: 10px; background: var(--yellow-bg); color: var(--yellow-text); border-radius: 10px; padding: 0 5px; font-weight: 700; }
|
|
89
|
+
.stale-banner { background: var(--yellow-bg); border-top: 1px solid var(--yellow); padding: 8px 20px; font-size: 12px; color: var(--yellow-text); }
|
|
90
|
+
|
|
91
|
+
/* Coverage bar */
|
|
92
|
+
#coverage-section { margin-top: 16px; padding: 0 16px; }
|
|
93
|
+
.coverage-label { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; display: flex; justify-content: space-between; }
|
|
94
|
+
.coverage-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
|
95
|
+
.coverage-fill { height: 100%; background: var(--green); border-radius: 2px; transition: width .5s; }
|
|
96
|
+
.coverage-fill.warn { background: var(--yellow); }
|
|
97
|
+
.coverage-fill.bad { background: var(--red); }
|
|
98
|
+
|
|
99
|
+
/* ── Main ── */
|
|
100
|
+
#main { overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; min-height: 0; }
|
|
101
|
+
#doc-wrap { width: 100%; max-width: 800px; padding: 40px 48px 120px; }
|
|
102
|
+
|
|
103
|
+
/* Pending banner */
|
|
104
|
+
#pending-banner { display: none; background: var(--yellow-bg); border: 1px solid var(--yellow); border-radius: var(--radius); padding: 10px 16px; margin-bottom: 24px; font-size: 13px; color: var(--yellow-text); align-items: center; gap: 8px; }
|
|
105
|
+
#pending-banner.show { display: flex; }
|
|
106
|
+
|
|
107
|
+
/* ── Markdown ── */
|
|
108
|
+
#doc-content h1 { font-size: 28px; font-weight: 800; margin-bottom: 8px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
|
|
109
|
+
#doc-content h2 { font-size: 20px; font-weight: 700; margin-top: 36px; margin-bottom: 12px; }
|
|
110
|
+
#doc-content h3 { font-size: 16px; font-weight: 600; margin-top: 24px; margin-bottom: 8px; }
|
|
111
|
+
#doc-content h4 { font-size: 14px; font-weight: 600; margin-top: 16px; margin-bottom: 6px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
|
|
112
|
+
#doc-content p { margin-bottom: 14px; }
|
|
113
|
+
#doc-content ul, #doc-content ol { margin-bottom: 14px; padding-left: 24px; }
|
|
114
|
+
#doc-content li { margin-bottom: 4px; }
|
|
115
|
+
#doc-content a { color: var(--accent); text-decoration: none; }
|
|
116
|
+
#doc-content a:hover { text-decoration: underline; }
|
|
117
|
+
#doc-content blockquote { border-left: 3px solid var(--accent); padding: 8px 16px; margin: 16px 0; color: var(--text-muted); background: var(--accent-light); border-radius: 0 var(--radius) var(--radius) 0; }
|
|
118
|
+
#doc-content blockquote p { margin: 0; }
|
|
119
|
+
#doc-content code { font-family: var(--font-mono); font-size: 13px; background: var(--code-bg); color: var(--code-text); padding: 2px 6px; border-radius: 4px; }
|
|
120
|
+
#doc-content pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; overflow-x: auto; margin: 16px 0; }
|
|
121
|
+
#doc-content pre code { background: none; padding: 0; font-size: 13px; line-height: 1.6; }
|
|
122
|
+
#doc-content table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
|
|
123
|
+
#doc-content th { background: var(--bg-sidebar); font-weight: 600; text-align: left; padding: 10px 14px; border: 1px solid var(--border); }
|
|
124
|
+
#doc-content td { padding: 9px 14px; border: 1px solid var(--border); }
|
|
125
|
+
#doc-content tr:nth-child(even) td { background: var(--bg-sidebar); }
|
|
126
|
+
#doc-content hr { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
|
|
127
|
+
.mermaid-wrap { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin: 20px 0; overflow-x: auto; text-align: center; }
|
|
128
|
+
|
|
129
|
+
/* ── API Explorer ── */
|
|
130
|
+
.api-explorer { width: 100%; }
|
|
131
|
+
.api-explorer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; padding-bottom: 20px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
|
132
|
+
.base-url-wrap { display: flex; flex-direction: column; gap: 4px; min-width: 260px; }
|
|
133
|
+
.base-url-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
|
|
134
|
+
.base-url-row { display: flex; gap: 6px; align-items: center; }
|
|
135
|
+
.base-url-input { padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 13px; font-family: var(--font-mono); width: 260px; outline: none; }
|
|
136
|
+
.base-url-input:focus { border-color: var(--accent); }
|
|
137
|
+
.auth-toggle { padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text-muted); font-size: 12px; cursor: pointer; white-space: nowrap; }
|
|
138
|
+
.auth-toggle:hover { background: var(--bg-hover); }
|
|
139
|
+
.auth-row { margin-top: 8px; display: none; gap: 6px; align-items: center; }
|
|
140
|
+
.auth-row.show { display: flex; }
|
|
141
|
+
.auth-input { flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 13px; font-family: var(--font-mono); outline: none; }
|
|
142
|
+
.auth-input:focus { border-color: var(--accent); }
|
|
143
|
+
.auth-label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
|
|
144
|
+
|
|
145
|
+
.api-group { margin-bottom: 24px; }
|
|
146
|
+
.api-group-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin-bottom: 8px; padding: 0 2px; }
|
|
147
|
+
|
|
148
|
+
.endpoint-card { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 6px; overflow: hidden; transition: box-shadow .15s; }
|
|
149
|
+
.endpoint-card.open { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
|
150
|
+
.endpoint-header { display: flex; align-items: center; gap: 12px; padding: 11px 16px; cursor: pointer; background: var(--bg); user-select: none; }
|
|
151
|
+
.endpoint-header:hover { background: var(--bg-hover); }
|
|
152
|
+
.endpoint-path { font-family: var(--font-mono); font-size: 13px; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
153
|
+
.endpoint-summary { font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
|
|
154
|
+
.endpoint-chevron { color: var(--text-muted); font-size: 16px; transition: transform .2s; flex-shrink: 0; }
|
|
155
|
+
.endpoint-card.open .endpoint-chevron { transform: rotate(90deg); }
|
|
156
|
+
.endpoint-body { display: none; border-top: 1px solid var(--border); background: var(--bg); }
|
|
157
|
+
.endpoint-card.open .endpoint-body { display: block; }
|
|
158
|
+
.endpoint-section { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
|
159
|
+
.endpoint-section:last-child { border-bottom: none; }
|
|
160
|
+
.section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-bottom: 12px; }
|
|
161
|
+
|
|
162
|
+
/* Method badges */
|
|
163
|
+
.method-badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; letter-spacing: .05em; font-family: var(--font-mono); flex-shrink: 0; min-width: 54px; justify-content: center; }
|
|
164
|
+
.badge-get { background: var(--green-bg); color: var(--green-text); }
|
|
165
|
+
.badge-post { background: var(--blue-bg); color: var(--blue-text); }
|
|
166
|
+
.badge-put { background: var(--orange-bg); color: var(--orange-text); }
|
|
167
|
+
.badge-patch { background: var(--purple-bg); color: var(--purple-text); }
|
|
168
|
+
.badge-delete { background: var(--red-bg); color: var(--red-text); }
|
|
169
|
+
.badge-options,.badge-head { background: var(--code-bg); color: var(--text-muted); }
|
|
170
|
+
|
|
171
|
+
/* Params table */
|
|
172
|
+
.params-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
173
|
+
.params-table th { font-size: 11px; font-weight: 600; text-align: left; padding: 6px 10px; background: var(--bg-sidebar); border: 1px solid var(--border); color: var(--text-muted); }
|
|
174
|
+
.params-table td { padding: 7px 10px; border: 1px solid var(--border); font-family: var(--font-mono); font-size: 12px; }
|
|
175
|
+
.param-required { background: var(--red-bg); color: var(--red-text); padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; font-family: var(--font); }
|
|
176
|
+
.param-optional { background: var(--code-bg); color: var(--text-muted); padding: 1px 6px; border-radius: 3px; font-size: 10px; font-family: var(--font); }
|
|
177
|
+
|
|
178
|
+
/* Try it out */
|
|
179
|
+
.try-section { padding: 16px 20px; background: var(--bg-sidebar); }
|
|
180
|
+
.try-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; background: var(--accent); color: white; border: none; border-radius: var(--radius); font-size: 13px; font-weight: 600; cursor: pointer; }
|
|
181
|
+
.try-btn:hover { background: var(--accent-hover); }
|
|
182
|
+
.try-btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
183
|
+
.try-form { margin-top: 14px; display: none; }
|
|
184
|
+
.try-form.show { display: block; }
|
|
185
|
+
.try-field { margin-bottom: 10px; }
|
|
186
|
+
.try-field label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; color: var(--text-muted); font-family: var(--font-mono); }
|
|
187
|
+
.try-field input, .try-field textarea { width: 100%; padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 13px; font-family: var(--font-mono); outline: none; }
|
|
188
|
+
.try-field input:focus, .try-field textarea:focus { border-color: var(--accent); }
|
|
189
|
+
.try-field textarea { min-height: 90px; resize: vertical; }
|
|
190
|
+
.try-actions { display: flex; gap: 8px; margin-top: 12px; }
|
|
191
|
+
.execute-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 20px; background: var(--accent); color: white; border: none; border-radius: var(--radius); font-size: 13px; font-weight: 600; cursor: pointer; }
|
|
192
|
+
.execute-btn:hover { background: var(--accent-hover); }
|
|
193
|
+
.execute-btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
194
|
+
.cancel-try-btn { padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text-muted); font-size: 13px; cursor: pointer; }
|
|
195
|
+
.cancel-try-btn:hover { background: var(--bg-hover); }
|
|
196
|
+
|
|
197
|
+
/* Response panel */
|
|
198
|
+
.response-panel { margin-top: 14px; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
199
|
+
.response-bar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: var(--bg-sidebar); border-bottom: 1px solid var(--border); font-size: 12px; }
|
|
200
|
+
.status-chip { font-size: 12px; font-weight: 700; font-family: var(--font-mono); padding: 2px 8px; border-radius: 4px; }
|
|
201
|
+
.status-2xx { background: var(--green-bg); color: var(--green-text); }
|
|
202
|
+
.status-3xx { background: var(--blue-bg); color: var(--blue-text); }
|
|
203
|
+
.status-4xx { background: var(--yellow-bg); color: var(--yellow-text); }
|
|
204
|
+
.status-5xx { background: var(--red-bg); color: var(--red-text); }
|
|
205
|
+
.response-time { color: var(--text-muted); margin-left: auto; }
|
|
206
|
+
.response-body { padding: 14px; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; overflow-x: auto; max-height: 360px; overflow-y: auto; white-space: pre; background: var(--bg); }
|
|
207
|
+
.response-headers { padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
|
|
208
|
+
.cors-warn { margin-top: 10px; padding: 8px 12px; background: var(--yellow-bg); border-radius: var(--radius); font-size: 12px; color: var(--yellow-text); }
|
|
209
|
+
|
|
210
|
+
/* Welcome / empty states */
|
|
211
|
+
.empty-state { text-align: center; padding: 60px 40px; }
|
|
212
|
+
.empty-icon { font-size: 48px; margin-bottom: 16px; }
|
|
213
|
+
.empty-state h2 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
|
|
214
|
+
.empty-state p { color: var(--text-muted); max-width: 380px; margin: 0 auto 16px; }
|
|
215
|
+
.empty-state code { background: var(--code-bg); padding: 4px 10px; border-radius: var(--radius); font-family: var(--font-mono); font-size: 13px; }
|
|
216
|
+
|
|
217
|
+
/* Bootstrap panel */
|
|
218
|
+
.bootstrap-wrap { max-width: 580px; margin: 0 auto; padding: 48px 0; }
|
|
219
|
+
.bootstrap-hero { text-align: center; margin-bottom: 40px; }
|
|
220
|
+
.bootstrap-hero-icon { font-size: 52px; margin-bottom: 16px; }
|
|
221
|
+
.bootstrap-hero h1 { font-size: 26px; font-weight: 800; margin-bottom: 8px; }
|
|
222
|
+
.bootstrap-hero p { color: var(--text-muted); font-size: 15px; max-width: 420px; margin: 0 auto; }
|
|
223
|
+
.bootstrap-steps { display: flex; flex-direction: column; gap: 12px; margin-bottom: 32px; }
|
|
224
|
+
.bootstrap-step { display: flex; align-items: flex-start; gap: 14px; padding: 16px 18px; background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: var(--radius); }
|
|
225
|
+
.bootstrap-step.done { border-color: var(--green); background: var(--green-bg); }
|
|
226
|
+
.bootstrap-step.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
|
|
227
|
+
.step-num { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: white; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-top: 1px; }
|
|
228
|
+
.bootstrap-step.done .step-num { background: var(--green); }
|
|
229
|
+
.step-content { flex: 1; min-width: 0; }
|
|
230
|
+
.step-title { font-size: 14px; font-weight: 600; margin-bottom: 3px; }
|
|
231
|
+
.step-desc { font-size: 13px; color: var(--text-muted); }
|
|
232
|
+
.step-desc code { font-family: var(--font-mono); background: var(--code-bg); padding: 1px 5px; border-radius: 3px; font-size: 12px; }
|
|
233
|
+
.step-check { font-size: 18px; flex-shrink: 0; margin-top: 4px; }
|
|
234
|
+
.bootstrap-btn { width: 100%; padding: 14px 24px; background: var(--accent); color: white; border: none; border-radius: var(--radius); font-size: 15px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 10px; transition: background .15s; }
|
|
235
|
+
.bootstrap-btn:hover { background: var(--accent-hover); }
|
|
236
|
+
.bootstrap-btn:disabled { opacity: .6; cursor: not-allowed; }
|
|
237
|
+
.bootstrap-result { margin-top: 20px; padding: 16px 18px; background: var(--green-bg); border: 1px solid var(--green); border-radius: var(--radius); }
|
|
238
|
+
.bootstrap-result-title { font-weight: 700; color: var(--green-text); margin-bottom: 8px; font-size: 14px; }
|
|
239
|
+
.bootstrap-result-files { font-family: var(--font-mono); font-size: 12px; color: var(--green-text); line-height: 1.8; }
|
|
240
|
+
.bootstrap-claude { margin-top: 12px; padding: 14px 16px; background: var(--accent-light); border: 1px solid var(--accent); border-radius: var(--radius); }
|
|
241
|
+
.bootstrap-claude-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--accent); margin-bottom: 8px; }
|
|
242
|
+
.bootstrap-claude-cmd { font-family: var(--font-mono); font-size: 13px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; cursor: pointer; user-select: all; color: var(--text); }
|
|
243
|
+
.bootstrap-claude-cmd:hover { border-color: var(--accent); }
|
|
244
|
+
.copy-hint { font-size: 11px; color: var(--text-muted); margin-top: 6px; }
|
|
245
|
+
|
|
246
|
+
/* ── Health Check ── */
|
|
247
|
+
.health-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 32px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
|
|
248
|
+
.health-title { font-size: 26px; font-weight: 800; margin-bottom: 4px; }
|
|
249
|
+
.health-subtitle { font-size: 13px; color: var(--text-muted); }
|
|
250
|
+
.health-overall { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 700; }
|
|
251
|
+
.health-ok { background: var(--green-bg); color: var(--green-text); }
|
|
252
|
+
.health-warn { background: var(--yellow-bg); color: var(--yellow-text); }
|
|
253
|
+
.health-crit { background: var(--red-bg); color: var(--red-text); }
|
|
254
|
+
.health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 32px; }
|
|
255
|
+
.health-card { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
|
|
256
|
+
.health-card-val { font-size: 32px; font-weight: 800; line-height: 1; margin-bottom: 4px; }
|
|
257
|
+
.health-card-label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
|
|
258
|
+
.health-card.issue .health-card-val { color: var(--red); }
|
|
259
|
+
.health-card.warn-card .health-card-val { color: var(--yellow); }
|
|
260
|
+
.health-card.ok .health-card-val { color: var(--green); }
|
|
261
|
+
.health-section-title { font-size: 14px; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
|
262
|
+
.health-section { margin-bottom: 28px; }
|
|
263
|
+
.drift-row { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 6px; background: var(--bg); }
|
|
264
|
+
.drift-sev { font-size: 10px; font-weight: 800; padding: 2px 7px; border-radius: 3px; flex-shrink: 0; margin-top: 2px; }
|
|
265
|
+
.drift-sev.high { background: var(--red-bg); color: var(--red-text); }
|
|
266
|
+
.drift-sev.medium { background: var(--yellow-bg); color: var(--yellow-text); }
|
|
267
|
+
.drift-sev.low { background: var(--code-bg); color: var(--text-muted); }
|
|
268
|
+
.drift-module { font-weight: 600; font-size: 13px; }
|
|
269
|
+
.drift-detail { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
|
270
|
+
.drift-detail span { font-family: var(--font-mono); background: var(--code-bg); padding: 0 4px; border-radius: 3px; font-size: 11px; margin-right: 3px; }
|
|
271
|
+
.complexity-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 6px; background: var(--bg); }
|
|
272
|
+
.complexity-score { width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 800; flex-shrink: 0; }
|
|
273
|
+
.score-bad { background: var(--red-bg); color: var(--red-text); }
|
|
274
|
+
.score-warn { background: var(--yellow-bg); color: var(--yellow-text); }
|
|
275
|
+
.score-ok { background: var(--green-bg); color: var(--green-text); }
|
|
276
|
+
.complexity-file { font-family: var(--font-mono); font-size: 12px; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
277
|
+
.complexity-tags { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
|
|
278
|
+
.complexity-tag { font-size: 11px; background: var(--code-bg); color: var(--text-muted); padding: 1px 7px; border-radius: 3px; }
|
|
279
|
+
.complexity-tag.crit { background: var(--red-bg); color: var(--red-text); }
|
|
280
|
+
.health-empty { text-align: center; padding: 20px; color: var(--text-muted); font-size: 13px; }
|
|
281
|
+
.health-refresh { padding: 7px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text-muted); font-size: 12px; cursor: pointer; }
|
|
282
|
+
.health-refresh:hover { background: var(--bg-hover); }
|
|
283
|
+
|
|
284
|
+
/* Misc */
|
|
285
|
+
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .6s linear infinite; }
|
|
286
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
287
|
+
#sidebar::-webkit-scrollbar, #main::-webkit-scrollbar { width: 5px; }
|
|
288
|
+
#sidebar::-webkit-scrollbar-thumb, #main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
289
|
+
@media (max-width: 768px) { #app { grid-template-columns: 1fr; } #sidebar { display: none; } #doc-wrap { padding: 24px 20px 60px; } }
|
|
290
|
+
</style>
|
|
291
|
+
</head>
|
|
292
|
+
<body>
|
|
293
|
+
<div id="app">
|
|
294
|
+
|
|
295
|
+
<header id="header">
|
|
296
|
+
<div class="logo" onclick="goHome()">
|
|
297
|
+
<div class="logo-icon">📋</div>DocuTrack
|
|
298
|
+
</div>
|
|
299
|
+
<div class="header-sep"></div>
|
|
300
|
+
<span id="project-name">loading…</span>
|
|
301
|
+
<div id="search-wrap">
|
|
302
|
+
<span class="search-icon">🔍</span>
|
|
303
|
+
<input type="text" id="search" placeholder="Search docs…" autocomplete="off">
|
|
304
|
+
<span class="search-kbd">⌘K</span>
|
|
305
|
+
</div>
|
|
306
|
+
<div class="header-right">
|
|
307
|
+
<div id="coverage-badge">
|
|
308
|
+
<span class="coverage-dot" id="cov-dot"></span>
|
|
309
|
+
<span id="cov-pct">–%</span>
|
|
310
|
+
<span style="color:var(--text-muted);font-weight:400">coverage</span>
|
|
311
|
+
</div>
|
|
312
|
+
<button id="theme-toggle" title="Toggle dark mode">🌙</button>
|
|
313
|
+
<span class="live-pill"><span class="live-dot"></span>live</span>
|
|
314
|
+
</div>
|
|
315
|
+
</header>
|
|
316
|
+
|
|
317
|
+
<aside id="sidebar">
|
|
318
|
+
<div class="nav-section">
|
|
319
|
+
<div class="nav-section-title">Overview</div>
|
|
320
|
+
<div id="nav-arch"></div>
|
|
321
|
+
</div>
|
|
322
|
+
<div class="nav-section">
|
|
323
|
+
<div class="nav-section-title">Modules</div>
|
|
324
|
+
<div id="nav-modules"></div>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="nav-section">
|
|
327
|
+
<div class="nav-section-title">Decisions</div>
|
|
328
|
+
<div id="nav-decisions"></div>
|
|
329
|
+
</div>
|
|
330
|
+
<div class="nav-section">
|
|
331
|
+
<div class="nav-section-title">API</div>
|
|
332
|
+
<div id="nav-api-explorer"></div>
|
|
333
|
+
<div id="nav-api-docs"></div>
|
|
334
|
+
</div>
|
|
335
|
+
<div class="nav-section">
|
|
336
|
+
<div class="nav-section-title">Health</div>
|
|
337
|
+
<div id="nav-health"></div>
|
|
338
|
+
</div>
|
|
339
|
+
<div id="coverage-section">
|
|
340
|
+
<div class="coverage-label">
|
|
341
|
+
<span>Doc coverage</span>
|
|
342
|
+
<span id="cov-label">–%</span>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="coverage-bar"><div class="coverage-fill" id="cov-bar" style="width:0%"></div></div>
|
|
345
|
+
</div>
|
|
346
|
+
<div style="padding:12px 16px 8px;border-top:1px solid var(--border);margin-top:8px">
|
|
347
|
+
<button onclick="showRegenPanel()" style="width:100%;padding:7px 12px;border:1px solid var(--border);background:var(--bg);color:var(--text-muted);border-radius:var(--radius);font-size:12px;cursor:pointer;text-align:left;display:flex;align-items:center;gap:6px">
|
|
348
|
+
<span>✨</span><span>Regenerate docs</span>
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
</aside>
|
|
352
|
+
|
|
353
|
+
<main id="main">
|
|
354
|
+
<div id="doc-wrap">
|
|
355
|
+
<div id="pending-banner">
|
|
356
|
+
⚠ <strong id="pending-count">0</strong> file(s) modified without documentation.
|
|
357
|
+
Run <code style="margin-left:4px">docutrack status</code> for details.
|
|
358
|
+
</div>
|
|
359
|
+
<div id="doc-content">
|
|
360
|
+
<div class="empty-state">
|
|
361
|
+
<div class="empty-icon">📋</div>
|
|
362
|
+
<h2>Welcome to DocuTrack</h2>
|
|
363
|
+
<p>Living documentation for your project. Select a page from the sidebar.</p>
|
|
364
|
+
<span class="spinner"></span>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</main>
|
|
369
|
+
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
|
|
373
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
|
374
|
+
<script>
|
|
375
|
+
'use strict'
|
|
376
|
+
|
|
377
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
378
|
+
const state = {
|
|
379
|
+
tree: null,
|
|
380
|
+
active: null, // file path or 'api-explorer' or 'health-check'
|
|
381
|
+
query: '',
|
|
382
|
+
view: 'docs', // 'docs' | 'api-explorer' | 'health-check'
|
|
383
|
+
staleSet: new Set(),
|
|
384
|
+
healthBadge: 0, // number of critical health issues
|
|
385
|
+
lang: 'es', // 'es' | 'en' — language for AI doc generation
|
|
386
|
+
generating: false,
|
|
387
|
+
generationTotal: 0,
|
|
388
|
+
generationLog: [], // { icon, color, file } — persisted to replay on panel re-enter
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const api = {
|
|
392
|
+
spec: null,
|
|
393
|
+
baseUrl: localStorage.getItem('dt-baseUrl') || 'http://localhost:3000',
|
|
394
|
+
token: localStorage.getItem('dt-token') || '',
|
|
395
|
+
showAuth: false,
|
|
396
|
+
open: {}, // endpointId -> boolean
|
|
397
|
+
tryOpen: {}, // endpointId -> boolean
|
|
398
|
+
responses: {}, // endpointId -> response object
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Marked ────────────────────────────────────────────────────────────────
|
|
402
|
+
marked.use({
|
|
403
|
+
gfm: true,
|
|
404
|
+
renderer: {
|
|
405
|
+
code({ text, lang }) {
|
|
406
|
+
if (lang === 'mermaid') {
|
|
407
|
+
return `<div class="mermaid-wrap"><div class="mermaid">${esc(text)}</div></div>`
|
|
408
|
+
}
|
|
409
|
+
return `<pre><code class="language-${esc(lang||'')}">${esc(text)}</code></pre>`
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'loose' })
|
|
414
|
+
|
|
415
|
+
// ── Boot ──────────────────────────────────────────────────────────────────
|
|
416
|
+
async function boot() {
|
|
417
|
+
setTheme(localStorage.getItem('dt-theme') || 'light')
|
|
418
|
+
await refreshTree()
|
|
419
|
+
await refreshStatus()
|
|
420
|
+
connectSSE()
|
|
421
|
+
document.getElementById('search').addEventListener('input', e => {
|
|
422
|
+
state.query = e.target.value.trim()
|
|
423
|
+
renderNav()
|
|
424
|
+
})
|
|
425
|
+
document.addEventListener('keydown', e => {
|
|
426
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); document.getElementById('search').focus() }
|
|
427
|
+
if (e.key === 'Escape') document.getElementById('search').blur()
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── Tree / nav ─────────────────────────────────────────────────────────────
|
|
432
|
+
async function refreshTree() {
|
|
433
|
+
const tree = await get('/api/tree')
|
|
434
|
+
if (!tree) return
|
|
435
|
+
state.tree = tree
|
|
436
|
+
|
|
437
|
+
// Project name from package.json
|
|
438
|
+
try {
|
|
439
|
+
const r = await fetch('/api/content?path=package.json')
|
|
440
|
+
if (r.ok) { const j = JSON.parse(await r.text()); if (j.name) { document.getElementById('project-name').textContent = j.name; document.title = `DocuTrack — ${j.name}` } }
|
|
441
|
+
} catch { /* ok */ }
|
|
442
|
+
|
|
443
|
+
renderNav()
|
|
444
|
+
const hasModules = tree.modules?.length > 0
|
|
445
|
+
if (!state.active) {
|
|
446
|
+
if (hasModules && tree.architecture) loadFile(tree.architecture)
|
|
447
|
+
else showWelcome()
|
|
448
|
+
} else if (state.active === 'bootstrap' && hasModules) {
|
|
449
|
+
// Modules just appeared — navigate to architecture
|
|
450
|
+
if (tree.architecture) loadFile(tree.architecture)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function renderNav() {
|
|
455
|
+
const { tree, query, active } = state
|
|
456
|
+
if (!tree) return
|
|
457
|
+
const q = query.toLowerCase()
|
|
458
|
+
|
|
459
|
+
// Architecture
|
|
460
|
+
const archEl = document.getElementById('nav-arch')
|
|
461
|
+
if (tree.architecture) {
|
|
462
|
+
archEl.innerHTML = matches('architecture', q)
|
|
463
|
+
? navItem(tree.architecture, '🏗', 'Architecture', active === tree.architecture)
|
|
464
|
+
: ''
|
|
465
|
+
} else {
|
|
466
|
+
archEl.innerHTML = '<div class="nav-empty">Not created yet</div>'
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
renderSection('nav-modules', tree.modules, '📦', q, active)
|
|
470
|
+
renderSection('nav-decisions', tree.decisions, '📝', q, active)
|
|
471
|
+
|
|
472
|
+
// API Explorer special entry
|
|
473
|
+
const explorerEl = document.getElementById('nav-api-explorer')
|
|
474
|
+
const epCount = api.spec ? Object.values(api.spec.paths || {}).reduce((n, p) => n + Object.keys(p).length, 0) : 0
|
|
475
|
+
const explorerMatch = !q || 'api explorer'.includes(q)
|
|
476
|
+
explorerEl.innerHTML = explorerMatch
|
|
477
|
+
? `<div class="nav-item${active === 'api-explorer' ? ' active' : ''}" onclick="showApiExplorer()">
|
|
478
|
+
<span class="nav-icon">⚡</span>API Explorer
|
|
479
|
+
${epCount > 0 ? `<span class="nav-badge">${epCount}</span>` : ''}
|
|
480
|
+
</div>`
|
|
481
|
+
: ''
|
|
482
|
+
|
|
483
|
+
// API docs (markdown files)
|
|
484
|
+
renderSection('nav-api-docs', tree.api, '📄', q, active)
|
|
485
|
+
|
|
486
|
+
// Health Check nav entry
|
|
487
|
+
const healthMatch = !q || 'health check'.includes(q)
|
|
488
|
+
const healthHasIssue = state.healthBadge
|
|
489
|
+
document.getElementById('nav-health').innerHTML = healthMatch
|
|
490
|
+
? `<div class="nav-item${active === 'health-check' ? ' active' : ''}" onclick="showHealthCheck()">
|
|
491
|
+
<span class="nav-icon">🩺</span>Health Check
|
|
492
|
+
${healthHasIssue ? `<span class="nav-badge" style="background:var(--red-bg);color:var(--red-text)">${healthHasIssue}</span>` : ''}
|
|
493
|
+
</div>` : ''
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function renderSection(id, items, icon, q, active) {
|
|
497
|
+
const el = document.getElementById(id)
|
|
498
|
+
if (!items?.length) { el.innerHTML = '<div class="nav-empty">No docs yet</div>'; return }
|
|
499
|
+
const filtered = q ? items.filter(i => i.name.toLowerCase().includes(q)) : items
|
|
500
|
+
el.innerHTML = filtered.length
|
|
501
|
+
? filtered.map(i => navItem(i.path, icon, fmtName(i.name), active === i.path)).join('')
|
|
502
|
+
: ''
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function navItem(p, icon, label, isActive) {
|
|
506
|
+
const isStale = state.staleSet.has(p)
|
|
507
|
+
const staleTag = isStale ? '<span class="nav-stale">stale</span>' : ''
|
|
508
|
+
return `<div class="nav-item${isActive ? ' active' : ''}" onclick="loadFile('${ea(p)}')">`
|
|
509
|
+
+ `<span class="nav-icon">${icon}</span>${esc(label)}${staleTag}</div>`
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function fmtName(n) {
|
|
513
|
+
return n.replace(/^ADR-\d+-/, 'ADR: ').replace(/[-_]/g, ' ')
|
|
514
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function matches(str, q) { return !q || str.toLowerCase().includes(q) }
|
|
518
|
+
|
|
519
|
+
// ── Doc viewer ─────────────────────────────────────────────────────────────
|
|
520
|
+
async function loadFile(filePath) {
|
|
521
|
+
if (!filePath) return
|
|
522
|
+
state.active = filePath
|
|
523
|
+
state.view = 'docs'
|
|
524
|
+
renderNav()
|
|
525
|
+
|
|
526
|
+
const el = document.getElementById('doc-content')
|
|
527
|
+
el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
|
|
528
|
+
|
|
529
|
+
const r = await fetch(`/api/content?path=${encodeURIComponent(filePath)}`)
|
|
530
|
+
if (!r.ok) { el.innerHTML = `<p style="color:var(--red);padding:40px">Could not load: ${esc(filePath)}</p>`; return }
|
|
531
|
+
|
|
532
|
+
const text = await r.text()
|
|
533
|
+
el.innerHTML = marked.parse(text)
|
|
534
|
+
document.getElementById('main').scrollTo({ top: 0 })
|
|
535
|
+
await mermaid.run({ nodes: document.querySelectorAll('#doc-content .mermaid') })
|
|
536
|
+
|
|
537
|
+
// Show "Generate with AI" banner if ARCHITECTURE.md is still a template
|
|
538
|
+
if (filePath === 'ARCHITECTURE.md' && isArchTemplate(text)) {
|
|
539
|
+
const banner = document.createElement('div')
|
|
540
|
+
banner.id = 'arch-gen-banner'
|
|
541
|
+
banner.style.cssText = 'position:sticky;bottom:0;background:var(--bg);border-top:1px solid var(--border);padding:14px 24px;display:flex;align-items:center;gap:14px;flex-wrap:wrap'
|
|
542
|
+
banner.innerHTML = `
|
|
543
|
+
<span style="font-size:13px;color:var(--text-muted);flex:1">La arquitectura tiene contenido de plantilla sin completar.</span>
|
|
544
|
+
<button id="arch-gen-btn" onclick="generateArch()" style="padding:8px 18px;background:var(--accent);color:white;border:none;border-radius:var(--radius);font-size:13px;font-weight:600;cursor:pointer">✨ Generar con IA</button>`
|
|
545
|
+
document.getElementById('doc-wrap').appendChild(banner)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function showWelcome() {
|
|
550
|
+
const hasModules = state.tree?.modules?.length > 0
|
|
551
|
+
if (!hasModules) {
|
|
552
|
+
showBootstrap()
|
|
553
|
+
} else {
|
|
554
|
+
document.getElementById('doc-content').innerHTML = `
|
|
555
|
+
<div class="empty-state">
|
|
556
|
+
<div class="empty-icon">📋</div>
|
|
557
|
+
<h2>Welcome to DocuTrack</h2>
|
|
558
|
+
<p>Living documentation for your project. Select a page from the sidebar to get started.</p>
|
|
559
|
+
</div>`
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function showBootstrap() {
|
|
564
|
+
state.active = 'bootstrap'
|
|
565
|
+
const el = document.getElementById('doc-content')
|
|
566
|
+
el.innerHTML = `
|
|
567
|
+
<div class="bootstrap-wrap">
|
|
568
|
+
<div class="bootstrap-hero">
|
|
569
|
+
<div class="bootstrap-hero-icon">🚀</div>
|
|
570
|
+
<h1>Set up your docs</h1>
|
|
571
|
+
<p>This project doesn't have documentation yet. Let's generate it from your existing code in two steps.</p>
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
<div class="bootstrap-steps">
|
|
575
|
+
<div class="bootstrap-step done" id="step-init">
|
|
576
|
+
<span class="step-num">1</span>
|
|
577
|
+
<div class="step-content">
|
|
578
|
+
<div class="step-title">DocuTrack initialized</div>
|
|
579
|
+
<div class="step-desc">Hooks, templates, and slash commands are installed.</div>
|
|
580
|
+
</div>
|
|
581
|
+
<span class="step-check">✅</span>
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
<div class="bootstrap-step active" id="step-scan">
|
|
585
|
+
<span class="step-num">2</span>
|
|
586
|
+
<div class="step-content">
|
|
587
|
+
<div class="step-title">Scan your source files</div>
|
|
588
|
+
<div class="step-desc">Queues all existing files so the AI agent knows what to document.</div>
|
|
589
|
+
</div>
|
|
590
|
+
</div>
|
|
591
|
+
|
|
592
|
+
<div class="bootstrap-step" id="step-agent">
|
|
593
|
+
<span class="step-num">3</span>
|
|
594
|
+
<div class="step-content">
|
|
595
|
+
<div class="step-title">Run the documentalista agent</div>
|
|
596
|
+
<div class="step-desc">Tell Claude Code to document everything — docs appear here in real-time.</div>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
<button class="bootstrap-btn" id="scan-btn" onclick="runScan()">
|
|
602
|
+
<span id="scan-btn-icon">⚡</span>
|
|
603
|
+
<span id="scan-btn-label">Scan project files</span>
|
|
604
|
+
</button>
|
|
605
|
+
|
|
606
|
+
<div id="scan-result" style="display:none"></div>
|
|
607
|
+
</div>`
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function runScan() {
|
|
611
|
+
const btn = document.getElementById('scan-btn')
|
|
612
|
+
const icon = document.getElementById('scan-btn-icon')
|
|
613
|
+
const label = document.getElementById('scan-btn-label')
|
|
614
|
+
btn.disabled = true
|
|
615
|
+
icon.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;border-color:rgba(255,255,255,.3);border-top-color:white"></span>'
|
|
616
|
+
label.textContent = 'Scanning…'
|
|
617
|
+
|
|
618
|
+
let data
|
|
619
|
+
try {
|
|
620
|
+
const r = await fetch('/api/scan', { method: 'POST' })
|
|
621
|
+
data = await r.json()
|
|
622
|
+
} catch {
|
|
623
|
+
btn.disabled = false
|
|
624
|
+
icon.textContent = '⚡'
|
|
625
|
+
label.textContent = 'Scan project files'
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Mark step 2 done, activate step 3
|
|
630
|
+
document.getElementById('step-scan').classList.remove('active')
|
|
631
|
+
document.getElementById('step-scan').classList.add('done')
|
|
632
|
+
document.getElementById('step-scan').innerHTML += '<span class="step-check">✅</span>'
|
|
633
|
+
document.getElementById('step-agent').classList.add('active')
|
|
634
|
+
|
|
635
|
+
// Update button to disabled with success state
|
|
636
|
+
btn.style.background = 'var(--green)'
|
|
637
|
+
btn.innerHTML = `✓ ${data.queued} file${data.queued !== 1 ? 's' : ''} queued`
|
|
638
|
+
|
|
639
|
+
// Show result + Claude command
|
|
640
|
+
const preview = data.files?.slice(0, 4).map(f => ` ${f}`).join('\n')
|
|
641
|
+
+ (data.hasMore ? `\n …and more` : '')
|
|
642
|
+
|
|
643
|
+
document.getElementById('scan-result').style.display = 'block'
|
|
644
|
+
document.getElementById('scan-result').innerHTML = `
|
|
645
|
+
<div class="bootstrap-result">
|
|
646
|
+
<div class="bootstrap-result-title">✓ ${data.queued} files queued${data.skipped > 0 ? ` (${data.skipped} already queued)` : ''}</div>
|
|
647
|
+
${preview ? `<div class="bootstrap-result-files">${esc(preview)}</div>` : ''}
|
|
648
|
+
</div>
|
|
649
|
+
<div id="step3-generate" style="margin-top:12px">
|
|
650
|
+
<div style="display:flex;justify-content:center;gap:0;margin-bottom:12px;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;width:fit-content;margin-left:auto;margin-right:auto">
|
|
651
|
+
<button id="lang-es" onclick="setLang('es')" style="padding:6px 18px;border:none;background:var(--accent);color:white;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s">🇪🇸 Español</button>
|
|
652
|
+
<button id="lang-en" onclick="setLang('en')" style="padding:6px 18px;border:none;background:var(--bg-secondary);color:var(--text-muted);font-size:13px;cursor:pointer;transition:background .15s">🇺🇸 English</button>
|
|
653
|
+
</div>
|
|
654
|
+
<button class="bootstrap-btn" id="gen-btn" onclick="runGenerate()">
|
|
655
|
+
<span id="gen-btn-icon">✨</span>
|
|
656
|
+
<span id="gen-btn-label">Generate docs with AI</span>
|
|
657
|
+
</button>
|
|
658
|
+
<p style="text-align:center;font-size:12px;color:var(--text-muted);margin-top:8px">
|
|
659
|
+
Uses Claude Haiku — reads each file and writes the docs automatically
|
|
660
|
+
</p>
|
|
661
|
+
</div>`
|
|
662
|
+
|
|
663
|
+
// Refresh status badge
|
|
664
|
+
await refreshStatus()
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function setLang(lang) {
|
|
668
|
+
state.lang = lang
|
|
669
|
+
const es = document.getElementById('lang-es')
|
|
670
|
+
const en = document.getElementById('lang-en')
|
|
671
|
+
if (!es || !en) return
|
|
672
|
+
if (lang === 'es') {
|
|
673
|
+
es.style.background = 'var(--accent)'; es.style.color = 'white'
|
|
674
|
+
en.style.background = 'var(--bg-secondary)'; en.style.color = 'var(--text-muted)'
|
|
675
|
+
} else {
|
|
676
|
+
en.style.background = 'var(--accent)'; en.style.color = 'white'
|
|
677
|
+
es.style.background = 'var(--bg-secondary)'; es.style.color = 'var(--text-muted)'
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function runGenerate(force = false) {
|
|
682
|
+
const lang = state.lang || 'es'
|
|
683
|
+
const btn = document.getElementById('gen-btn')
|
|
684
|
+
if (!btn) return
|
|
685
|
+
btn.disabled = true
|
|
686
|
+
document.getElementById('gen-btn-icon').innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px;border-color:rgba(255,255,255,.3);border-top-color:white;display:inline-block"></span>'
|
|
687
|
+
document.getElementById('gen-btn-label').textContent = 'Starting…'
|
|
688
|
+
|
|
689
|
+
let data
|
|
690
|
+
try {
|
|
691
|
+
const r = await fetch('/api/generate', {
|
|
692
|
+
method: 'POST',
|
|
693
|
+
headers: { 'Content-Type': 'application/json' },
|
|
694
|
+
body: JSON.stringify({ lang, force }),
|
|
695
|
+
})
|
|
696
|
+
data = await r.json()
|
|
697
|
+
} catch (e) {
|
|
698
|
+
btn.disabled = false
|
|
699
|
+
document.getElementById('gen-btn-icon').textContent = '✨'
|
|
700
|
+
document.getElementById('gen-btn-label').textContent = 'Generate docs with AI'
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (data.error === 'no_api_key') {
|
|
705
|
+
btn.disabled = false
|
|
706
|
+
document.getElementById('gen-btn-icon').textContent = '✨'
|
|
707
|
+
document.getElementById('gen-btn-label').textContent = 'Generate docs with AI'
|
|
708
|
+
document.getElementById('step3-generate').innerHTML += `
|
|
709
|
+
<div style="margin-top:12px;padding:14px 16px;background:var(--yellow-bg);border:1px solid var(--yellow);border-radius:var(--radius);font-size:13px;color:var(--yellow-text)">
|
|
710
|
+
<strong>API key not found.</strong> Add to your shell or <code>.env.local</code>:<br>
|
|
711
|
+
<code style="display:block;margin-top:8px;font-size:12px;user-select:all">ANTHROPIC_API_KEY=sk-ant-...</code>
|
|
712
|
+
Then restart the server and try again.
|
|
713
|
+
</div>`
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (data.error) {
|
|
718
|
+
btn.disabled = false
|
|
719
|
+
document.getElementById('gen-btn-icon').textContent = '✨'
|
|
720
|
+
document.getElementById('gen-btn-label').textContent = 'Generate docs with AI'
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Generation started — swap UI to progress view
|
|
725
|
+
const total = data.total || 0
|
|
726
|
+
const stepAgent = document.getElementById('step-agent')
|
|
727
|
+
if (stepAgent) stepAgent.classList.add('done')
|
|
728
|
+
btn.style.display = 'none'
|
|
729
|
+
|
|
730
|
+
document.getElementById('step3-generate').innerHTML = `
|
|
731
|
+
<div id="gen-progress-wrap" style="margin-top:4px">
|
|
732
|
+
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px">
|
|
733
|
+
<span style="font-weight:600">Generating documentation…</span>
|
|
734
|
+
<span id="gen-counter" style="color:var(--text-muted)">0 / ${total}</span>
|
|
735
|
+
</div>
|
|
736
|
+
<div style="height:4px;background:var(--border);border-radius:2px;margin-bottom:14px;overflow:hidden">
|
|
737
|
+
<div id="gen-bar" style="height:100%;width:0%;background:var(--accent);border-radius:2px;transition:width .3s"></div>
|
|
738
|
+
</div>
|
|
739
|
+
<div id="gen-log" style="font-family:var(--font-mono);font-size:12px;max-height:220px;overflow-y:auto;display:flex;flex-direction:column;gap:4px"></div>
|
|
740
|
+
</div>`
|
|
741
|
+
|
|
742
|
+
state.generating = true
|
|
743
|
+
state.generationTotal = total
|
|
744
|
+
state.generationDone = 0
|
|
745
|
+
state.generationLog = []
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function handleProgressEvent(raw) {
|
|
749
|
+
// format: "progress:done/total:status:filepath"
|
|
750
|
+
if (!raw.startsWith('progress:')) return
|
|
751
|
+
const parts = raw.slice(9).split(':')
|
|
752
|
+
const counter = parts[0] // "3/31"
|
|
753
|
+
const status = parts[1] // done | skip | working | error
|
|
754
|
+
const file = parts.slice(2).join(':')
|
|
755
|
+
|
|
756
|
+
const [done, total] = counter.split('/').map(Number)
|
|
757
|
+
state.generationDone = done
|
|
758
|
+
|
|
759
|
+
const icon = status === 'done' ? '✓' : status === 'skip' ? '–' : status === 'error' ? '✗' : '…'
|
|
760
|
+
const color = status === 'done' ? 'var(--green-text)' : status === 'error' ? 'var(--red-text)' : status === 'skip' ? 'var(--text-muted)' : 'var(--accent)'
|
|
761
|
+
|
|
762
|
+
// Persist completed entries so we can replay when returning to the panel
|
|
763
|
+
if (status !== 'working') state.generationLog.push({ icon, color, file })
|
|
764
|
+
|
|
765
|
+
const bar = document.getElementById('gen-bar')
|
|
766
|
+
const cnt = document.getElementById('gen-counter')
|
|
767
|
+
const log = document.getElementById('gen-log')
|
|
768
|
+
|
|
769
|
+
if (bar) bar.style.width = `${Math.round((done / total) * 100)}%`
|
|
770
|
+
if (cnt) cnt.textContent = `${done} / ${total}`
|
|
771
|
+
if (!log) return
|
|
772
|
+
|
|
773
|
+
const row = document.createElement('div')
|
|
774
|
+
row.style.cssText = `display:flex;gap:8px;align-items:baseline;color:${color}`
|
|
775
|
+
row.innerHTML = `<span style="flex-shrink:0;font-weight:700">${icon}</span><span style="color:var(--text-muted)">${esc(file)}</span>`
|
|
776
|
+
|
|
777
|
+
if (status === 'working') {
|
|
778
|
+
const existing = log.querySelector('[data-working]')
|
|
779
|
+
if (existing) existing.remove()
|
|
780
|
+
row.setAttribute('data-working', '1')
|
|
781
|
+
}
|
|
782
|
+
log.appendChild(row)
|
|
783
|
+
log.scrollTop = log.scrollHeight
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function handleDoneEvent(raw) {
|
|
787
|
+
if (!raw.startsWith('done:')) return
|
|
788
|
+
state.generating = false
|
|
789
|
+
const count = raw.slice(5)
|
|
790
|
+
const log = document.getElementById('gen-log')
|
|
791
|
+
const bar = document.getElementById('gen-bar')
|
|
792
|
+
if (bar) bar.style.background = 'var(--green)'
|
|
793
|
+
const cnt = document.getElementById('gen-counter')
|
|
794
|
+
if (cnt) cnt.textContent = `${count} files documented`
|
|
795
|
+
if (log) {
|
|
796
|
+
const row = document.createElement('div')
|
|
797
|
+
row.style.cssText = 'margin-top:8px;font-weight:700;color:var(--green-text)'
|
|
798
|
+
row.textContent = `✓ Done! Documentation generated for ${count} files.`
|
|
799
|
+
log.appendChild(row)
|
|
800
|
+
}
|
|
801
|
+
// Show regenerate controls
|
|
802
|
+
const wrap = document.getElementById('gen-progress-wrap')
|
|
803
|
+
if (wrap) {
|
|
804
|
+
const lang = state.lang || 'es'
|
|
805
|
+
const otherLang = lang === 'es' ? 'en' : 'es'
|
|
806
|
+
const otherLabel = otherLang === 'es' ? '🇪🇸 Regenerate in Spanish' : '🇺🇸 Regenerate in English'
|
|
807
|
+
const div = document.createElement('div')
|
|
808
|
+
div.style.cssText = 'margin-top:16px;display:flex;gap:10px;align-items:center;flex-wrap:wrap'
|
|
809
|
+
div.innerHTML = `
|
|
810
|
+
<button onclick="setLang('${otherLang}');runGenerate(true)" style="padding:8px 16px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius);font-size:13px;cursor:pointer;flex-shrink:0">
|
|
811
|
+
${otherLabel}
|
|
812
|
+
</button>
|
|
813
|
+
<span style="font-size:12px;color:var(--text-muted)">Re-generates all docs in the selected language.</span>`
|
|
814
|
+
wrap.appendChild(div)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function showRegenPanel() {
|
|
819
|
+
state.active = 'regen'
|
|
820
|
+
renderNav()
|
|
821
|
+
const lang = state.lang || 'es'
|
|
822
|
+
const el = document.getElementById('doc-content')
|
|
823
|
+
|
|
824
|
+
if (state.generating) {
|
|
825
|
+
const done = state.generationDone || 0
|
|
826
|
+
const total = state.generationTotal || 0
|
|
827
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
|
828
|
+
el.innerHTML = `
|
|
829
|
+
<div style="max-width:540px;margin:60px auto;padding:0 24px">
|
|
830
|
+
<h2 style="font-size:20px;font-weight:700;margin-bottom:8px">Generando documentación…</h2>
|
|
831
|
+
<p style="color:var(--text-muted);font-size:14px;margin-bottom:24px">La generación continúa en segundo plano. Puedes navegar libremente.</p>
|
|
832
|
+
<div id="step3-generate">
|
|
833
|
+
<div id="gen-progress-wrap">
|
|
834
|
+
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px">
|
|
835
|
+
<span style="font-weight:600">Generando documentación…</span>
|
|
836
|
+
<span id="gen-counter" style="color:var(--text-muted)">${done} / ${total}</span>
|
|
837
|
+
</div>
|
|
838
|
+
<div style="height:4px;background:var(--border);border-radius:2px;margin-bottom:14px;overflow:hidden">
|
|
839
|
+
<div id="gen-bar" style="height:100%;width:${pct}%;background:var(--accent);border-radius:2px;transition:width .3s"></div>
|
|
840
|
+
</div>
|
|
841
|
+
<div id="gen-log" style="font-family:var(--font-mono);font-size:12px;max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:4px"></div>
|
|
842
|
+
</div>
|
|
843
|
+
</div>
|
|
844
|
+
</div>`
|
|
845
|
+
// Replay saved log entries
|
|
846
|
+
const log = el.querySelector('#gen-log')
|
|
847
|
+
for (const entry of state.generationLog) {
|
|
848
|
+
const row = document.createElement('div')
|
|
849
|
+
row.style.cssText = `display:flex;gap:8px;align-items:baseline;color:${entry.color}`
|
|
850
|
+
row.innerHTML = `<span style="flex-shrink:0;font-weight:700">${entry.icon}</span><span style="color:var(--text-muted)">${esc(entry.file)}</span>`
|
|
851
|
+
log.appendChild(row)
|
|
852
|
+
}
|
|
853
|
+
log.scrollTop = log.scrollHeight
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
el.innerHTML = `
|
|
858
|
+
<div style="max-width:540px;margin:60px auto;padding:0 24px">
|
|
859
|
+
<h2 style="font-size:20px;font-weight:700;margin-bottom:8px">Regenerar documentación</h2>
|
|
860
|
+
<p style="color:var(--text-muted);font-size:14px;margin-bottom:28px">Re-genera todos los docs desde los archivos fuente. Sobreescribe la documentación existente.</p>
|
|
861
|
+
|
|
862
|
+
<div style="margin-bottom:20px">
|
|
863
|
+
<div style="font-size:13px;font-weight:600;margin-bottom:8px">Idioma</div>
|
|
864
|
+
<div style="display:flex;gap:0;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;width:fit-content">
|
|
865
|
+
<button id="lang-es" onclick="setLang('es')" style="padding:8px 20px;border:none;background:${lang==='es'?'var(--accent)':'var(--bg-secondary)'};color:${lang==='es'?'white':'var(--text-muted)'};font-size:13px;font-weight:600;cursor:pointer">🇪🇸 Español</button>
|
|
866
|
+
<button id="lang-en" onclick="setLang('en')" style="padding:8px 20px;border:none;background:${lang==='en'?'var(--accent)':'var(--bg-secondary)'};color:${lang==='en'?'white':'var(--text-muted)'};font-size:13px;cursor:pointer">🇺🇸 English</button>
|
|
867
|
+
</div>
|
|
868
|
+
</div>
|
|
869
|
+
|
|
870
|
+
<button class="bootstrap-btn" id="gen-btn" onclick="runGenerate(true)" style="max-width:300px">
|
|
871
|
+
<span id="gen-btn-icon">✨</span>
|
|
872
|
+
<span id="gen-btn-label">Regenerar todos los docs</span>
|
|
873
|
+
</button>
|
|
874
|
+
<p style="font-size:12px;color:var(--text-muted);margin-top:10px">Usa Claude Haiku. Sobreescribe los archivos .md existentes.</p>
|
|
875
|
+
|
|
876
|
+
<div id="step3-generate" style="margin-top:16px"></div>
|
|
877
|
+
</div>`
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function isArchTemplate(text) {
|
|
881
|
+
// Solo es plantilla si tiene comentarios HTML placeholder O filas de tabla completamente vacías
|
|
882
|
+
const hasComments = /<!--\s*(Describe|describe|TODO|todo)/.test(text)
|
|
883
|
+
const emptyRows = (text.match(/^\|(\s*\|){3,}\s*$/mg) || []).length
|
|
884
|
+
return hasComments || emptyRows >= 3
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function generateArch() {
|
|
888
|
+
const btn = document.getElementById('arch-gen-btn')
|
|
889
|
+
if (btn) { btn.disabled = true; btn.textContent = '⏳ Generando…' }
|
|
890
|
+
|
|
891
|
+
try {
|
|
892
|
+
const r = await fetch('/api/generate-arch', {
|
|
893
|
+
method: 'POST',
|
|
894
|
+
headers: { 'Content-Type': 'application/json' },
|
|
895
|
+
body: JSON.stringify({ lang: state.lang || 'es' }),
|
|
896
|
+
})
|
|
897
|
+
const data = await r.json()
|
|
898
|
+
if (data.error === 'no_api_key') {
|
|
899
|
+
if (btn) { btn.disabled = false; btn.textContent = '✨ Generar con IA' }
|
|
900
|
+
const banner = document.getElementById('arch-gen-banner')
|
|
901
|
+
if (banner) banner.insertAdjacentHTML('beforeend', `<span style="color:var(--red-text);font-size:12px">API key no encontrada — agrega ANTHROPIC_API_KEY al entorno.</span>`)
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
// reload will come via SSE — just update button
|
|
905
|
+
if (btn) { btn.textContent = '✓ Listo' }
|
|
906
|
+
} catch {
|
|
907
|
+
if (btn) { btn.disabled = false; btn.textContent = '✨ Generar con IA' }
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function goHome() {
|
|
912
|
+
if (state.tree?.architecture) loadFile(state.tree.architecture)
|
|
913
|
+
else showWelcome()
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ── API Explorer ───────────────────────────────────────────────────────────
|
|
917
|
+
async function showApiExplorer() {
|
|
918
|
+
state.active = 'api-explorer'
|
|
919
|
+
state.view = 'api-explorer'
|
|
920
|
+
renderNav()
|
|
921
|
+
|
|
922
|
+
const el = document.getElementById('doc-content')
|
|
923
|
+
el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
|
|
924
|
+
document.getElementById('main').scrollTo({ top: 0 })
|
|
925
|
+
|
|
926
|
+
if (!api.spec) {
|
|
927
|
+
const spec = await get('/api/openapi')
|
|
928
|
+
api.spec = spec
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
renderApiExplorer()
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function renderApiExplorer() {
|
|
935
|
+
const el = document.getElementById('doc-content')
|
|
936
|
+
|
|
937
|
+
if (!api.spec || api.spec.error) {
|
|
938
|
+
el.innerHTML = `
|
|
939
|
+
<div class="empty-state">
|
|
940
|
+
<div class="empty-icon">⚡</div>
|
|
941
|
+
<h2>No API spec found</h2>
|
|
942
|
+
<p>Run the analyzer to auto-detect routes from your source code.</p>
|
|
943
|
+
<code>npx docutrack analyze</code>
|
|
944
|
+
<p style="margin-top:12px;font-size:13px">Or create <code>docs/api/openapi.json</code> manually (OpenAPI 3.0).</p>
|
|
945
|
+
</div>`
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const { info, paths } = api.spec
|
|
950
|
+
const groups = groupByTag(paths)
|
|
951
|
+
|
|
952
|
+
const totalEp = Object.values(paths).reduce((n, p) => n + Object.keys(p).length, 0)
|
|
953
|
+
|
|
954
|
+
el.innerHTML = `
|
|
955
|
+
<div class="api-explorer">
|
|
956
|
+
<div class="api-explorer-header">
|
|
957
|
+
<div>
|
|
958
|
+
<h1 style="margin-bottom:4px">
|
|
959
|
+
${esc(info.title)}
|
|
960
|
+
<span style="font-size:14px;color:var(--text-muted);font-weight:400">v${esc(info.version)}</span>
|
|
961
|
+
</h1>
|
|
962
|
+
<p style="color:var(--text-muted);font-size:13px;margin:4px 0 0">
|
|
963
|
+
${totalEp} endpoint${totalEp !== 1 ? 's' : ''} detected
|
|
964
|
+
</p>
|
|
965
|
+
</div>
|
|
966
|
+
<div class="base-url-wrap">
|
|
967
|
+
<span class="base-url-label">Base URL</span>
|
|
968
|
+
<div class="base-url-row">
|
|
969
|
+
<input class="base-url-input" id="base-url-input" type="text"
|
|
970
|
+
value="${ea(api.baseUrl)}" placeholder="http://localhost:3000"
|
|
971
|
+
oninput="api.baseUrl=this.value;localStorage.setItem('dt-baseUrl',this.value)">
|
|
972
|
+
<button class="auth-toggle" onclick="toggleAuthRow()">🔑 Auth</button>
|
|
973
|
+
</div>
|
|
974
|
+
<div class="auth-row${api.showAuth ? ' show' : ''}" id="auth-row">
|
|
975
|
+
<span class="auth-label">Bearer</span>
|
|
976
|
+
<input class="auth-input" id="auth-input" type="text" placeholder="your-token-here"
|
|
977
|
+
value="${ea(api.token)}"
|
|
978
|
+
oninput="api.token=this.value;localStorage.setItem('dt-token',this.value)">
|
|
979
|
+
</div>
|
|
980
|
+
</div>
|
|
981
|
+
</div>
|
|
982
|
+
${Object.entries(groups).map(([tag, eps]) => renderApiGroup(tag, eps)).join('')}
|
|
983
|
+
</div>`
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function groupByTag(paths) {
|
|
987
|
+
const groups = {}
|
|
988
|
+
if (!paths) return groups
|
|
989
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
990
|
+
for (const method of ['get','post','put','patch','delete','options','head']) {
|
|
991
|
+
if (!pathItem[method]) continue
|
|
992
|
+
const op = pathItem[method]
|
|
993
|
+
const tag = op.tags?.[0] || 'default'
|
|
994
|
+
if (!groups[tag]) groups[tag] = []
|
|
995
|
+
groups[tag].push({ path, method: method.toUpperCase(), ...op })
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return groups
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function renderApiGroup(tag, endpoints) {
|
|
1002
|
+
return `
|
|
1003
|
+
<div class="api-group">
|
|
1004
|
+
<div class="api-group-title">${esc(tag)}</div>
|
|
1005
|
+
${endpoints.map(ep => renderEndpointCard(ep)).join('')}
|
|
1006
|
+
</div>`
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function renderEndpointCard(ep) {
|
|
1010
|
+
const id = epId(ep.method, ep.path)
|
|
1011
|
+
const isOpen = api.open[id]
|
|
1012
|
+
const params = ep.parameters || []
|
|
1013
|
+
const hasBody = !!ep.requestBody
|
|
1014
|
+
const pathParams = params.filter(p => p.in === 'path')
|
|
1015
|
+
const queryParams = params.filter(p => p.in === 'query')
|
|
1016
|
+
const headerParams = params.filter(p => p.in === 'header')
|
|
1017
|
+
|
|
1018
|
+
return `
|
|
1019
|
+
<div class="endpoint-card${isOpen ? ' open' : ''}" id="card-${id}">
|
|
1020
|
+
<div class="endpoint-header" onclick="toggleEndpoint('${id}')">
|
|
1021
|
+
<span class="method-badge badge-${ep.method.toLowerCase()}">${ep.method}</span>
|
|
1022
|
+
<span class="endpoint-path">${esc(ep.path)}</span>
|
|
1023
|
+
${ep.summary ? `<span class="endpoint-summary">${esc(ep.summary)}</span>` : ''}
|
|
1024
|
+
<span class="endpoint-chevron">›</span>
|
|
1025
|
+
</div>
|
|
1026
|
+
<div class="endpoint-body">
|
|
1027
|
+
${ep.description ? `<div class="endpoint-section"><p style="color:var(--text-muted);font-size:14px;margin:0">${esc(ep.description)}</p></div>` : ''}
|
|
1028
|
+
${params.length ? `
|
|
1029
|
+
<div class="endpoint-section">
|
|
1030
|
+
<div class="section-title">Parameters</div>
|
|
1031
|
+
<table class="params-table">
|
|
1032
|
+
<thead><tr><th>Name</th><th>In</th><th>Type</th><th>Required</th></tr></thead>
|
|
1033
|
+
<tbody>${params.map(p => `
|
|
1034
|
+
<tr>
|
|
1035
|
+
<td>${esc(p.name)}</td>
|
|
1036
|
+
<td>${esc(p.in)}</td>
|
|
1037
|
+
<td>${esc(p.schema?.type || 'string')}</td>
|
|
1038
|
+
<td>${p.required ? '<span class="param-required">required</span>' : '<span class="param-optional">optional</span>'}</td>
|
|
1039
|
+
</tr>`).join('')}
|
|
1040
|
+
</tbody>
|
|
1041
|
+
</table>
|
|
1042
|
+
</div>` : ''}
|
|
1043
|
+
${hasBody ? `
|
|
1044
|
+
<div class="endpoint-section">
|
|
1045
|
+
<div class="section-title">Request Body</div>
|
|
1046
|
+
<pre style="background:var(--code-bg);padding:12px;border-radius:var(--radius);font-size:12px;overflow-x:auto">${esc(JSON.stringify(ep.requestBody?.content?.['application/json']?.schema || {}, null, 2))}</pre>
|
|
1047
|
+
</div>` : ''}
|
|
1048
|
+
${ep.responses ? `
|
|
1049
|
+
<div class="endpoint-section">
|
|
1050
|
+
<div class="section-title">Responses</div>
|
|
1051
|
+
${Object.entries(ep.responses).map(([code, r]) => `
|
|
1052
|
+
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;font-size:13px">
|
|
1053
|
+
<span class="status-chip ${statusClass(code)}">${code}</span>
|
|
1054
|
+
<span style="color:var(--text-muted)">${esc(r.description || '')}</span>
|
|
1055
|
+
</div>`).join('')}
|
|
1056
|
+
</div>` : ''}
|
|
1057
|
+
<div class="try-section">
|
|
1058
|
+
${renderTrySection(id, ep, pathParams, queryParams, headerParams, hasBody)}
|
|
1059
|
+
</div>
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>`
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function renderTrySection(id, ep, pathParams, queryParams, headerParams, hasBody) {
|
|
1065
|
+
const tryOpen = api.tryOpen[id]
|
|
1066
|
+
const resp = api.responses[id]
|
|
1067
|
+
|
|
1068
|
+
return `
|
|
1069
|
+
<button class="try-btn" onclick="toggleTry('${id}',event)">
|
|
1070
|
+
${tryOpen ? '✕ Close' : '▶ Try it out'}
|
|
1071
|
+
</button>
|
|
1072
|
+
<div class="try-form${tryOpen ? ' show' : ''}" id="try-${id}">
|
|
1073
|
+
${pathParams.map(p => `
|
|
1074
|
+
<div class="try-field">
|
|
1075
|
+
<label>${esc(p.name)} <span style="color:var(--red-text);font-size:10px">path</span></label>
|
|
1076
|
+
<input type="text" id="${id}-path-${esc(p.name)}" placeholder="${esc(p.name)}">
|
|
1077
|
+
</div>`).join('')}
|
|
1078
|
+
${queryParams.map(p => `
|
|
1079
|
+
<div class="try-field">
|
|
1080
|
+
<label>${esc(p.name)} <span style="color:var(--text-muted);font-size:10px">query</span></label>
|
|
1081
|
+
<input type="text" id="${id}-query-${esc(p.name)}" placeholder="${esc(p.schema?.type || 'string')}">
|
|
1082
|
+
</div>`).join('')}
|
|
1083
|
+
${hasBody ? `
|
|
1084
|
+
<div class="try-field">
|
|
1085
|
+
<label>Request Body <span style="color:var(--text-muted);font-size:10px">application/json</span></label>
|
|
1086
|
+
<textarea id="${id}-body" placeholder="{}"></textarea>
|
|
1087
|
+
</div>` : ''}
|
|
1088
|
+
<div class="try-actions">
|
|
1089
|
+
<button class="execute-btn" id="exec-${id}" onclick="executeRequest('${id}','${esc(ep.method)}','${ea(ep.path)}',${hasBody})">
|
|
1090
|
+
▶ Execute
|
|
1091
|
+
</button>
|
|
1092
|
+
<button class="cancel-try-btn" onclick="toggleTry('${id}',event)">Cancel</button>
|
|
1093
|
+
</div>
|
|
1094
|
+
${resp ? renderResponse(resp) : ''}
|
|
1095
|
+
</div>`
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function renderResponse(resp) {
|
|
1099
|
+
const cls = resp.status >= 500 ? 'status-5xx'
|
|
1100
|
+
: resp.status >= 400 ? 'status-4xx'
|
|
1101
|
+
: resp.status >= 300 ? 'status-3xx'
|
|
1102
|
+
: 'status-2xx'
|
|
1103
|
+
|
|
1104
|
+
let body = resp.body
|
|
1105
|
+
try { body = JSON.stringify(JSON.parse(body), null, 2) } catch { /* raw */ }
|
|
1106
|
+
|
|
1107
|
+
return `
|
|
1108
|
+
<div class="response-panel">
|
|
1109
|
+
<div class="response-bar">
|
|
1110
|
+
<span class="status-chip ${cls}">${resp.status} ${resp.statusText}</span>
|
|
1111
|
+
<span style="color:var(--text-muted);font-size:12px">${resp.contentType || ''}</span>
|
|
1112
|
+
<span class="response-time">${resp.ms}ms</span>
|
|
1113
|
+
</div>
|
|
1114
|
+
<div class="response-body">${esc(body)}</div>
|
|
1115
|
+
</div>
|
|
1116
|
+
${resp.corsError ? `<div class="cors-warn">⚠ CORS error — the API server must allow requests from this origin. Add <code>Access-Control-Allow-Origin: *</code> to its headers, or run DocuTrack on the same port.</div>` : ''}`
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ── Execute request ────────────────────────────────────────────────────────
|
|
1120
|
+
async function executeRequest(id, method, pathTemplate, hasBody) {
|
|
1121
|
+
const btn = document.getElementById(`exec-${id}`)
|
|
1122
|
+
btn.disabled = true
|
|
1123
|
+
btn.textContent = '…'
|
|
1124
|
+
|
|
1125
|
+
// Build URL
|
|
1126
|
+
let url = api.baseUrl.replace(/\/$/, '') + pathTemplate
|
|
1127
|
+
// Substitute path params
|
|
1128
|
+
url = url.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
1129
|
+
const input = document.getElementById(`${id}-path-${name}`)
|
|
1130
|
+
return input ? encodeURIComponent(input.value || `:${name}`) : `:${name}`
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
// Collect query params from spec
|
|
1134
|
+
const queryEl = document.querySelectorAll(`[id^="${id}-query-"]`)
|
|
1135
|
+
const qs = []
|
|
1136
|
+
queryEl.forEach(el => {
|
|
1137
|
+
const key = el.id.replace(`${id}-query-`, '')
|
|
1138
|
+
if (el.value) qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(el.value)}`)
|
|
1139
|
+
})
|
|
1140
|
+
if (qs.length) url += '?' + qs.join('&')
|
|
1141
|
+
|
|
1142
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
1143
|
+
if (api.token) headers['Authorization'] = `Bearer ${api.token}`
|
|
1144
|
+
|
|
1145
|
+
let body = undefined
|
|
1146
|
+
if (hasBody) {
|
|
1147
|
+
const bodyEl = document.getElementById(`${id}-body`)
|
|
1148
|
+
body = bodyEl?.value?.trim() || '{}'
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const t0 = Date.now()
|
|
1152
|
+
let resp
|
|
1153
|
+
|
|
1154
|
+
try {
|
|
1155
|
+
const fetchResp = await fetch(url, {
|
|
1156
|
+
method,
|
|
1157
|
+
headers,
|
|
1158
|
+
body: body && method !== 'GET' ? body : undefined,
|
|
1159
|
+
})
|
|
1160
|
+
const text = await fetchResp.text()
|
|
1161
|
+
resp = {
|
|
1162
|
+
status: fetchResp.status,
|
|
1163
|
+
statusText: fetchResp.statusText,
|
|
1164
|
+
contentType: fetchResp.headers.get('content-type') || '',
|
|
1165
|
+
body: text,
|
|
1166
|
+
ms: Date.now() - t0,
|
|
1167
|
+
corsError: false,
|
|
1168
|
+
}
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
const isCors = err.message?.toLowerCase().includes('fetch') || err.name === 'TypeError'
|
|
1171
|
+
resp = {
|
|
1172
|
+
status: 0,
|
|
1173
|
+
statusText: 'Network Error',
|
|
1174
|
+
contentType: '',
|
|
1175
|
+
body: err.message,
|
|
1176
|
+
ms: Date.now() - t0,
|
|
1177
|
+
corsError: isCors,
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
api.responses[id] = resp
|
|
1182
|
+
btn.disabled = false
|
|
1183
|
+
btn.textContent = '▶ Execute'
|
|
1184
|
+
|
|
1185
|
+
// Re-render just the try section by refreshing the response part
|
|
1186
|
+
const tryDiv = document.getElementById(`try-${id}`)
|
|
1187
|
+
// Remove old response panel if present
|
|
1188
|
+
tryDiv.querySelectorAll('.response-panel, .cors-warn').forEach(e => e.remove())
|
|
1189
|
+
tryDiv.insertAdjacentHTML('beforeend', renderResponse(resp))
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// ── Interaction helpers ────────────────────────────────────────────────────
|
|
1193
|
+
function toggleEndpoint(id) {
|
|
1194
|
+
api.open[id] = !api.open[id]
|
|
1195
|
+
const card = document.getElementById(`card-${id}`)
|
|
1196
|
+
if (api.open[id]) card.classList.add('open')
|
|
1197
|
+
else card.classList.remove('open')
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function toggleTry(id, e) {
|
|
1201
|
+
e.stopPropagation()
|
|
1202
|
+
api.tryOpen[id] = !api.tryOpen[id]
|
|
1203
|
+
const form = document.getElementById(`try-${id}`)
|
|
1204
|
+
if (api.tryOpen[id]) form.classList.add('show')
|
|
1205
|
+
else form.classList.remove('show')
|
|
1206
|
+
// Update button label
|
|
1207
|
+
const btn = form.previousElementSibling
|
|
1208
|
+
if (btn?.classList.contains('try-btn')) {
|
|
1209
|
+
btn.textContent = api.tryOpen[id] ? '✕ Close' : '▶ Try it out'
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function toggleAuthRow() {
|
|
1214
|
+
api.showAuth = !api.showAuth
|
|
1215
|
+
document.getElementById('auth-row').classList.toggle('show', api.showAuth)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ── Status ─────────────────────────────────────────────────────────────────
|
|
1219
|
+
async function refreshStatus() {
|
|
1220
|
+
const s = await get('/api/status')
|
|
1221
|
+
if (!s) return
|
|
1222
|
+
const pct = s.coverage
|
|
1223
|
+
document.getElementById('cov-pct').textContent = `${pct}%`
|
|
1224
|
+
document.getElementById('cov-label').textContent = `${pct}%`
|
|
1225
|
+
const bar = document.getElementById('cov-bar')
|
|
1226
|
+
bar.style.width = `${pct}%`
|
|
1227
|
+
bar.className = 'coverage-fill' + (pct < 50 ? ' bad' : pct < 80 ? ' warn' : '')
|
|
1228
|
+
document.getElementById('cov-dot').style.background = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--yellow)' : 'var(--red)'
|
|
1229
|
+
const banner = document.getElementById('pending-banner')
|
|
1230
|
+
if (s.pending > 0) { document.getElementById('pending-count').textContent = s.pending; banner.classList.add('show') }
|
|
1231
|
+
else banner.classList.remove('show')
|
|
1232
|
+
|
|
1233
|
+
// Store stale map for nav rendering
|
|
1234
|
+
state.staleSet = new Set((s.stale || []).map(x => x.doc))
|
|
1235
|
+
renderNav()
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// ── Health Check ───────────────────────────────────────────────────────────
|
|
1239
|
+
async function showHealthCheck() {
|
|
1240
|
+
state.active = 'health-check'
|
|
1241
|
+
state.view = 'health-check'
|
|
1242
|
+
renderNav()
|
|
1243
|
+
|
|
1244
|
+
const el = document.getElementById('doc-content')
|
|
1245
|
+
el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
|
|
1246
|
+
document.getElementById('main').scrollTo({ top: 0 })
|
|
1247
|
+
|
|
1248
|
+
const data = await get('/api/check')
|
|
1249
|
+
if (!data) {
|
|
1250
|
+
el.innerHTML = `<div class="empty-state"><div class="empty-icon">🩺</div><h2>Health check unavailable</h2><p>Make sure DocuTrack is initialized.</p></div>`
|
|
1251
|
+
return
|
|
1252
|
+
}
|
|
1253
|
+
renderHealthCheck(data)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function renderHealthCheck(data) {
|
|
1257
|
+
const el = document.getElementById('doc-content')
|
|
1258
|
+
const { pending, stale, drift, complexity, ok } = data
|
|
1259
|
+
|
|
1260
|
+
const criticalCount = complexity.critical?.length || 0
|
|
1261
|
+
const highDrift = (drift || []).filter(d => d.severity === 'high').length
|
|
1262
|
+
const totalIssues = pending + stale + highDrift + criticalCount
|
|
1263
|
+
state.healthBadge = totalIssues
|
|
1264
|
+
renderNav()
|
|
1265
|
+
|
|
1266
|
+
const overallClass = ok ? 'health-ok' : totalIssues > 3 ? 'health-crit' : 'health-warn'
|
|
1267
|
+
const overallText = ok ? '✓ Healthy' : `⚠ ${totalIssues} issue${totalIssues !== 1 ? 's' : ''}`
|
|
1268
|
+
|
|
1269
|
+
const scoreCard = (val, label, cls) => `
|
|
1270
|
+
<div class="health-card ${cls}">
|
|
1271
|
+
<div class="health-card-val">${val}</div>
|
|
1272
|
+
<div class="health-card-label">${label}</div>
|
|
1273
|
+
</div>`
|
|
1274
|
+
|
|
1275
|
+
const cards = [
|
|
1276
|
+
scoreCard(pending, 'Pending files', pending > 0 ? 'warn-card' : 'ok'),
|
|
1277
|
+
scoreCard(stale, 'Stale docs', stale > 0 ? 'warn-card' : 'ok'),
|
|
1278
|
+
scoreCard(drift.length, 'Drifted modules', highDrift > 0 ? 'issue' : drift.length > 0 ? 'warn-card' : 'ok'),
|
|
1279
|
+
scoreCard(complexity.summary?.critical || 0, 'Complex files', criticalCount > 0 ? 'issue' : 'ok'),
|
|
1280
|
+
].join('')
|
|
1281
|
+
|
|
1282
|
+
const driftSection = drift.length > 0 ? `
|
|
1283
|
+
<div class="health-section">
|
|
1284
|
+
<div class="health-section-title">📐 Documentation Drift</div>
|
|
1285
|
+
${drift.map(d => `
|
|
1286
|
+
<div class="drift-row">
|
|
1287
|
+
<span class="drift-sev ${d.severity}">${d.severity.toUpperCase()}</span>
|
|
1288
|
+
<div>
|
|
1289
|
+
<div class="drift-module">${esc(d.module)}</div>
|
|
1290
|
+
${d.undocumented?.length ? `<div class="drift-detail">+undocumented: ${d.undocumented.map(n => `<span>${esc(n)}</span>`).join('')}</div>` : ''}
|
|
1291
|
+
${d.orphaned?.length ? `<div class="drift-detail" style="margin-top:3px">−orphaned: ${d.orphaned.map(n => `<span>${esc(n)}</span>`).join('')}</div>` : ''}
|
|
1292
|
+
</div>
|
|
1293
|
+
</div>`).join('')}
|
|
1294
|
+
</div>` : ''
|
|
1295
|
+
|
|
1296
|
+
const complexFiles = complexity.critical || []
|
|
1297
|
+
const complexSection = complexFiles.length > 0 ? `
|
|
1298
|
+
<div class="health-section">
|
|
1299
|
+
<div class="health-section-title">📊 Complexity Hotspots</div>
|
|
1300
|
+
${complexFiles.map(f => {
|
|
1301
|
+
const scoreClass = f.score < 40 ? 'score-bad' : f.score < 70 ? 'score-warn' : 'score-ok'
|
|
1302
|
+
return `
|
|
1303
|
+
<div class="complexity-row">
|
|
1304
|
+
<div class="complexity-score ${scoreClass}">${f.score}</div>
|
|
1305
|
+
<div class="complexity-file" title="${esc(f.file)}">${esc(f.file)}</div>
|
|
1306
|
+
<div class="complexity-tags">
|
|
1307
|
+
${f.warnings.map(w => `<span class="complexity-tag${w.level === 'critical' ? ' crit' : ''}">${esc(w.message)}</span>`).join('')}
|
|
1308
|
+
</div>
|
|
1309
|
+
</div>`}).join('')}
|
|
1310
|
+
</div>` : ''
|
|
1311
|
+
|
|
1312
|
+
const allGood = drift.length === 0 && complexFiles.length === 0 && pending === 0 && stale === 0
|
|
1313
|
+
const emptyState = allGood ? `
|
|
1314
|
+
<div class="health-empty" style="padding:40px">
|
|
1315
|
+
<div style="font-size:48px;margin-bottom:12px">✅</div>
|
|
1316
|
+
<p style="font-size:16px;font-weight:700;color:var(--text)">Documentation is fully healthy.</p>
|
|
1317
|
+
<p>No pending files, no stale docs, no drift, no complexity issues.</p>
|
|
1318
|
+
</div>` : ''
|
|
1319
|
+
|
|
1320
|
+
el.innerHTML = `
|
|
1321
|
+
<div style="max-width:800px">
|
|
1322
|
+
<div class="health-header">
|
|
1323
|
+
<div>
|
|
1324
|
+
<div class="health-title">Health Check</div>
|
|
1325
|
+
<div class="health-subtitle">Documentation quality analysis</div>
|
|
1326
|
+
</div>
|
|
1327
|
+
<div style="display:flex;align-items:center;gap:10px">
|
|
1328
|
+
<span class="health-overall ${overallClass}">${overallText}</span>
|
|
1329
|
+
<button class="health-refresh" onclick="showHealthCheck()">↻ Refresh</button>
|
|
1330
|
+
</div>
|
|
1331
|
+
</div>
|
|
1332
|
+
<div class="health-grid">${cards}</div>
|
|
1333
|
+
${emptyState}
|
|
1334
|
+
${driftSection}
|
|
1335
|
+
${complexSection}
|
|
1336
|
+
${(pending > 0 || stale > 0) ? `
|
|
1337
|
+
<div class="health-section">
|
|
1338
|
+
<div class="health-section-title">💡 Suggested Actions</div>
|
|
1339
|
+
<div style="font-size:13px;color:var(--text-muted);line-height:2">
|
|
1340
|
+
${pending > 0 ? `<div>→ Run the <strong>documentalista</strong> subagent to clear ${pending} pending file${pending !== 1 ? 's' : ''}</div>` : ''}
|
|
1341
|
+
${stale > 0 ? `<div>→ Update ${stale} stale doc${stale !== 1 ? 's' : ''}, then run <code>docutrack clear</code></div>` : ''}
|
|
1342
|
+
${highDrift > 0 ? `<div>→ Add missing exports to docs for high-drift modules above</div>` : ''}
|
|
1343
|
+
${criticalCount > 0 ? `<div>→ Consider splitting the complex files listed above</div>` : ''}
|
|
1344
|
+
</div>
|
|
1345
|
+
</div>` : ''}
|
|
1346
|
+
</div>`
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ── SSE ─────────────────────────────────────────────────────────────────────
|
|
1350
|
+
function connectSSE() {
|
|
1351
|
+
const es = new EventSource('/events')
|
|
1352
|
+
es.onmessage = async (e) => {
|
|
1353
|
+
if (e.data.startsWith('progress:')) { handleProgressEvent(e.data); return }
|
|
1354
|
+
if (e.data.startsWith('done:')) { handleDoneEvent(e.data); return }
|
|
1355
|
+
if (e.data !== 'reload') return
|
|
1356
|
+
|
|
1357
|
+
await refreshTree()
|
|
1358
|
+
await refreshStatus()
|
|
1359
|
+
|
|
1360
|
+
if (state.view === 'api-explorer') {
|
|
1361
|
+
api.spec = await get('/api/openapi')
|
|
1362
|
+
renderApiExplorer()
|
|
1363
|
+
} else if (state.view === 'health-check') {
|
|
1364
|
+
const data = await get('/api/check')
|
|
1365
|
+
if (data) renderHealthCheck(data)
|
|
1366
|
+
} else if (state.active && state.active !== 'bootstrap' && state.active !== 'regen') {
|
|
1367
|
+
await loadFile(state.active)
|
|
1368
|
+
}
|
|
1369
|
+
// If we were on bootstrap and modules now exist → navigate to arch
|
|
1370
|
+
if (state.active === 'bootstrap' && state.tree?.modules?.length > 0) {
|
|
1371
|
+
if (state.tree.architecture) loadFile(state.tree.architecture)
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
es.onerror = () => setTimeout(connectSSE, 3000)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// ── Theme ──────────────────────────────────────────────────────────────────
|
|
1378
|
+
document.getElementById('theme-toggle').addEventListener('click', () => {
|
|
1379
|
+
setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark')
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1382
|
+
function setTheme(t) {
|
|
1383
|
+
document.documentElement.setAttribute('data-theme', t)
|
|
1384
|
+
document.getElementById('theme-toggle').textContent = t === 'dark' ? '☀️' : '🌙'
|
|
1385
|
+
localStorage.setItem('dt-theme', t)
|
|
1386
|
+
mermaid.initialize({ startOnLoad: false, theme: t === 'dark' ? 'dark' : 'neutral', securityLevel: 'loose' })
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// ── Utils ──────────────────────────────────────────────────────────────────
|
|
1390
|
+
async function get(url) {
|
|
1391
|
+
try { const r = await fetch(url); return r.ok ? r.json() : null } catch { return null }
|
|
1392
|
+
}
|
|
1393
|
+
function esc(s) {
|
|
1394
|
+
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')
|
|
1395
|
+
}
|
|
1396
|
+
function ea(s) { return String(s||'').replace(/'/g,"\\'") }
|
|
1397
|
+
function epId(method, path) {
|
|
1398
|
+
return `${method}-${path}`.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
|
1399
|
+
}
|
|
1400
|
+
function statusClass(code) {
|
|
1401
|
+
const n = parseInt(code)
|
|
1402
|
+
if (n >= 500) return 'status-5xx'
|
|
1403
|
+
if (n >= 400) return 'status-4xx'
|
|
1404
|
+
if (n >= 300) return 'status-3xx'
|
|
1405
|
+
return 'status-2xx'
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
boot()
|
|
1409
|
+
</script>
|
|
1410
|
+
</body>
|
|
1411
|
+
</html>
|