docutrack 0.1.0 → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 novolabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,116 +1,160 @@
1
+ <div align="center">
2
+
1
3
  # DocuTrack
2
4
 
3
- **Plugin de Claude Code que documenta automáticamente lo que construyes.**
5
+ **Your AI agent writes code. DocuTrack makes sure it documents what it builds.**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/docutrack?color=6366f1&label=npm)](https://www.npmjs.com/package/docutrack)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-6366f1)](LICENSE)
9
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-6366f1)](https://nodejs.org)
10
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-6366f1)](CONTRIBUTING.md)
4
11
 
5
- DocuTrack engancha los lifecycle hooks de Claude Code para registrar cada archivo modificado y generar documentación técnica en tiempo real — sin interrumpir tu flujo de trabajo.
12
+ </div>
6
13
 
7
14
  ---
8
15
 
9
- ## Instalación
16
+ ## The problem
17
+
18
+ Claude Code edits 30 files in a session. When it's done, none of them have updated documentation. You end up with a codebase that works but no one understands — including you, three months later.
19
+
20
+ ## The solution
21
+
22
+ DocuTrack hooks into Claude Code's lifecycle events to automatically queue every modified file and generate technical documentation using AI — without interrupting your workflow.
10
23
 
11
24
  ```bash
12
25
  npx docutrack init
13
26
  ```
14
27
 
15
- Detecta tu stack automáticamente (Next.js, FastAPI, Express, Go, monorepo) y configura todo en segundos.
28
+ That's it. From that point on, every file your AI agent touches gets documented.
16
29
 
17
30
  ---
18
31
 
19
- ## ¿Qué hace?
32
+ ## How it works
33
+
34
+ ```
35
+ Claude edits a file
36
+
37
+ PostToolUse hook fires → file added to .docutrack/queue.json
38
+
39
+ Session ends → documentalista subagent runs
40
+
41
+ Docs written to docs/modules/ and docs/api/
42
+
43
+ docutrack serve → beautiful web viewer at localhost:4242
44
+ ```
45
+
46
+ DocuTrack installs two hooks in your Claude Code settings:
20
47
 
21
- - **Hook PostToolUse** — cada vez que Claude edita un archivo, lo encola automáticamente
22
- - **Hook Stop** — al terminar la sesión, el subagente `documentalista` genera los docs de todo lo pendiente
23
- - **Visor web** — interfaz tipo Notion en `localhost:4242` con sidebar, renderizado Markdown, API Explorer interactivo y Health Check
24
- - **Generación con IA** — escanea proyectos existentes y genera toda la documentación con un clic desde el visor
25
- - **Sin dependencias** — llama a la API de Anthropic directo via `https` nativo de Node.js
48
+ - **PostToolUse** — fires after every file edit, queues the file
49
+ - **Stop** — fires at end of session, triggers the documentalista subagent
26
50
 
27
51
  ---
28
52
 
29
- ## Uso rápido
53
+ ## Web viewer
54
+
55
+ Run `docutrack serve` to open a Notion-like documentation viewer at `http://localhost:4242`:
56
+
57
+ - **Modules** — auto-generated docs for every source file
58
+ - **Architecture** — AI-generated overview of your tech stack, structure, and data flow
59
+ - **API Explorer** — interactive Swagger-like explorer built from your route files
60
+ - **Decisions** — Architecture Decision Records (ADRs)
61
+ - **Health Check** — drift analysis, complexity heatmap, stale doc detection
62
+ - **Multilingual** — generates docs in Spanish or English, switchable from the UI
63
+
64
+ ### Bootstrapping an existing project
65
+
66
+ Already have a codebase? No problem. Open the viewer and click **"✨ Regenerate docs"** — DocuTrack scans all your source files and generates documentation for everything, no terminal needed.
67
+
68
+ ---
69
+
70
+ ## Quick start
30
71
 
31
72
  ```bash
32
- # Inicializar en tu proyecto
73
+ # Initialize in your project
33
74
  npx docutrack init
34
75
 
35
- # Abrir el visor de documentación
76
+ # Open the documentation viewer
36
77
  docutrack serve
78
+ # → http://localhost:4242
37
79
 
38
- # Escanear un proyecto existente y generar docs
39
- # → Usar el botón "Regenerar docs" en el visor
80
+ # Check documentation health
81
+ docutrack check
82
+ ```
40
83
 
41
- # Ver estado de cobertura
42
- docutrack status
84
+ To use AI generation, set your Anthropic API key:
43
85
 
44
- # Health check completo (drift, complejidad, stale)
45
- docutrack check
86
+ ```bash
87
+ export ANTHROPIC_API_KEY=sk-ant-...
88
+ # or add it to .env.local in your project
46
89
  ```
47
90
 
48
91
  ---
49
92
 
50
- ## Comandos
93
+ ## Commands
51
94
 
52
- | Comando | Descripción |
95
+ | Command | Description |
53
96
  |---------|-------------|
54
- | `docutrack init` | Inicializa DocuTrack en el proyecto actual |
55
- | `docutrack serve` | Abre el visor web en el puerto 4242 |
56
- | `docutrack scan` | Encola todos los archivos fuente existentes |
57
- | `docutrack status` | Muestra cobertura, pendientes y docs desactualizados |
58
- | `docutrack check` | Health check: drift, complejidad, stale |
59
- | `docutrack analyze` | Detecta rutas y genera `docs/api/openapi.json` |
60
- | `docutrack onboard` | Genera `docs/ONBOARDING.md` |
61
- | `docutrack export` | Exporta a Mintlify o Docusaurus |
62
- | `docutrack badge` | Genera badge SVG de cobertura |
97
+ | `docutrack init` | Initialize DocuTrack in the current project |
98
+ | `docutrack serve` | Open the web viewer at port 4242 |
99
+ | `docutrack scan` | Queue all existing source files for documentation |
100
+ | `docutrack status` | Show coverage, pending files, and stale docs |
101
+ | `docutrack check` | Full health check: drift, complexity, stale |
102
+ | `docutrack analyze` | Auto-detect routes and generate `docs/api/openapi.json` |
103
+ | `docutrack onboard` | Generate `docs/ONBOARDING.md` for new team members |
104
+ | `docutrack export` | Export to Mintlify or Docusaurus format |
105
+ | `docutrack badge` | Generate coverage badge SVG for your README |
63
106
 
64
107
  ---
65
108
 
66
- ## Templates soportados
109
+ ## Stack templates
67
110
 
68
- Detección automática o manual con `--template`:
111
+ DocuTrack auto-detects your stack from `package.json`, `go.mod`, etc. You can also specify it manually:
69
112
 
70
- - `nextjs` — Next.js App Router
71
- - `fastapi` Python FastAPI
72
- - `express` Node.js Express / Fastify
73
- - `monorepo` Turborepo / pnpm workspaces
74
- - `go` Go modules
113
+ ```bash
114
+ docutrack init --template nextjs # Next.js App Router
115
+ docutrack init --template fastapi # Python FastAPI
116
+ docutrack init --template express # Node.js Express / Fastify
117
+ docutrack init --template monorepo # Turborepo / pnpm workspaces
118
+ docutrack init --template go # Go modules
119
+ ```
120
+
121
+ Each template ships with a stack-specific `documentalista` subagent that understands your framework's conventions.
75
122
 
76
123
  ---
77
124
 
78
- ## Estructura generada
125
+ ## What gets generated
79
126
 
80
127
  ```
81
128
  docs/
82
- ├── modules/ un .md por módulo/componente
83
- ├── api/ docs de rutas API + openapi.json
84
- └── decisions/ ← Architecture Decision Records (ADRs)
85
- ARCHITECTURE.md vista general del proyecto (auto-generada con IA)
129
+ ├── modules/ one .md per source file (responsibility, exports, dependencies)
130
+ ├── api/ one .md per API route + openapi.json
131
+ └── decisions/ ← Architecture Decision Records
132
+ ARCHITECTURE.md AI-generated: tech stack, app structure, data flow, module map
133
+ docs/ONBOARDING.md ← AI-generated: setup guide, conventions, key modules
86
134
  ```
87
135
 
88
136
  ---
89
137
 
90
- ## Visor web
91
-
92
- ```bash
93
- docutrack serve
94
- # → http://localhost:4242
95
- ```
138
+ ## Zero dependencies
96
139
 
97
- Incluye:
98
- - Sidebar con todos los módulos, decisiones y rutas API
99
- - **API Explorer** interactivo estilo Swagger
100
- - **Health Check**: drift de código vs docs, mapa de complejidad
101
- - **Generación desde la UI**: escanea y documenta sin abrir la terminal
102
- - Toggle de idioma Español / English
140
+ DocuTrack calls the Anthropic API directly using Node.js built-in `https` — no SDK, no extra packages to install, nothing added to your `node_modules`.
103
141
 
104
142
  ---
105
143
 
106
- ## Requisitos
144
+ ## Requirements
107
145
 
108
146
  - Node.js 18+
109
- - Claude Code CLI
110
- - `ANTHROPIC_API_KEY` en el entorno o en `.env.local` (solo para generación con IA)
147
+ - [Claude Code](https://claude.ai/code) CLI
148
+ - `ANTHROPIC_API_KEY` (only required for AI doc generation)
149
+
150
+ ---
151
+
152
+ ## Contributing
153
+
154
+ Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) to get started.
111
155
 
112
156
  ---
113
157
 
114
- ## Licencia
158
+ ## License
115
159
 
116
- MIT [mnovoaq](https://github.com/mnovoaq)
160
+ MIT © [novolabs](https://github.com/mnovoaq)
package/package.json CHANGED
@@ -1,38 +1,38 @@
1
- {
2
- "name": "docutrack",
3
- "version": "0.1.0",
4
- "description": "Claude Code plugin that forces AI agents to document what they build — automatically.",
5
- "keywords": [
6
- "claude-code",
7
- "claude",
8
- "documentation",
9
- "ai-agents",
10
- "architecture",
11
- "developer-tools"
12
- ],
13
- "license": "MIT",
14
- "type": "commonjs",
15
- "main": "./bin/docutrack.js",
16
- "bin": {
17
- "docutrack": "bin/docutrack.js"
18
- },
19
- "files": [
20
- "bin/",
21
- "src/",
22
- "templates/"
23
- ],
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/mnovoaq/docutrack.git"
27
- },
28
- "homepage": "https://github.com/mnovoaq/docutrack#readme",
29
- "bugs": {
30
- "url": "https://github.com/mnovoaq/docutrack/issues"
31
- },
32
- "engines": {
33
- "node": ">=18.0.0"
34
- },
35
- "scripts": {
36
- "test": "node --test src/**/*.test.js"
37
- }
38
- }
1
+ {
2
+ "name": "docutrack",
3
+ "version": "0.1.1",
4
+ "description": "Claude Code plugin that forces AI agents to document what they build — automatically.",
5
+ "keywords": [
6
+ "claude-code",
7
+ "claude",
8
+ "documentation",
9
+ "ai-agents",
10
+ "architecture",
11
+ "developer-tools"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "commonjs",
15
+ "main": "./bin/docutrack.js",
16
+ "bin": {
17
+ "docutrack": "bin/docutrack.js"
18
+ },
19
+ "files": [
20
+ "bin/",
21
+ "src/",
22
+ "templates/"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/mnovoaq/docutrack.git"
27
+ },
28
+ "homepage": "https://github.com/mnovoaq/docutrack#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/mnovoaq/docutrack/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ },
35
+ "scripts": {
36
+ "test": "node --test src/utils/queue.test.js src/viewer/server.test.js"
37
+ }
38
+ }
@@ -23,24 +23,25 @@ function isIgnored(filePath) {
23
23
  return IGNORED_PREFIXES.some(prefix => filePath.startsWith(prefix))
24
24
  }
25
25
 
26
- function read() {
27
- if (!fs.existsSync(QUEUE_PATH)) return { ...EMPTY_QUEUE }
26
+ function read(queuePath = QUEUE_PATH) {
27
+ if (!fs.existsSync(queuePath)) return { ...EMPTY_QUEUE }
28
28
  try {
29
- return JSON.parse(fs.readFileSync(QUEUE_PATH, 'utf8'))
29
+ return JSON.parse(fs.readFileSync(queuePath, 'utf8'))
30
30
  } catch {
31
31
  return { ...EMPTY_QUEUE }
32
32
  }
33
33
  }
34
34
 
35
- function write(queue) {
36
- fs.writeFileSync(QUEUE_PATH, JSON.stringify(queue, null, 2))
35
+ function write(queue, queuePath = QUEUE_PATH) {
36
+ fs.mkdirSync(path.dirname(queuePath), { recursive: true })
37
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2))
37
38
  }
38
39
 
39
- function add(filePath) {
40
+ function add(filePath, queuePath = QUEUE_PATH) {
40
41
  const normalized = filePath.replace(/\\/g, '/')
41
42
  if (isIgnored(normalized)) return
42
43
 
43
- const queue = read()
44
+ const queue = read(queuePath)
44
45
  const alreadyQueued = queue.pending.some(e => e.file === normalized)
45
46
  if (alreadyQueued) return
46
47
 
@@ -48,11 +49,11 @@ function add(filePath) {
48
49
  file: normalized,
49
50
  addedAt: new Date().toISOString(),
50
51
  })
51
- write(queue)
52
+ write(queue, queuePath)
52
53
  }
53
54
 
54
- function clear() {
55
- write({ pending: [], lastClear: new Date().toISOString() })
55
+ function clear(queuePath = QUEUE_PATH) {
56
+ write({ pending: [], lastClear: new Date().toISOString() }, queuePath)
56
57
  }
57
58
 
58
59
  function pendingCount() {
@@ -0,0 +1,54 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, before, after } = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+ const os = require('os')
8
+
9
+ const { read, add, clear } = require('./queue')
10
+
11
+ let tmpDir
12
+
13
+ before(() => {
14
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docutrack-test-'))
15
+ })
16
+
17
+ after(() => {
18
+ fs.rmSync(tmpDir, { recursive: true, force: true })
19
+ })
20
+
21
+ describe('queue', () => {
22
+ it('read returns empty queue when file does not exist', () => {
23
+ const q = read(path.join(tmpDir, 'nonexistent', 'queue.json'))
24
+ assert.deepEqual(q.pending, [])
25
+ assert.equal(q.lastClear, null)
26
+ })
27
+
28
+ it('add writes a file to the queue', () => {
29
+ const qPath = path.join(tmpDir, 'queue.json')
30
+ add('src/utils/foo.ts', qPath)
31
+ const q = read(qPath)
32
+ assert.equal(q.pending.length, 1)
33
+ assert.equal(q.pending[0].file, 'src/utils/foo.ts')
34
+ assert.ok(q.pending[0].addedAt)
35
+ })
36
+
37
+ it('add does not duplicate existing entries', () => {
38
+ const qPath = path.join(tmpDir, 'queue2.json')
39
+ const existing = { pending: [{ file: 'src/foo.ts', addedAt: new Date().toISOString() }], lastClear: null }
40
+ fs.writeFileSync(qPath, JSON.stringify(existing, null, 2))
41
+ add('src/foo.ts', qPath)
42
+ const q = read(qPath)
43
+ assert.equal(q.pending.length, 1)
44
+ })
45
+
46
+ it('clear empties the queue', () => {
47
+ const qPath = path.join(tmpDir, 'queue3.json')
48
+ add('src/foo.ts', qPath)
49
+ clear(qPath)
50
+ const q = read(qPath)
51
+ assert.equal(q.pending.length, 0)
52
+ assert.ok(q.lastClear)
53
+ })
54
+ })
@@ -287,6 +287,37 @@ html, body { height: 100%; font-family: var(--font); color: var(--text); backgro
287
287
  #sidebar::-webkit-scrollbar, #main::-webkit-scrollbar { width: 5px; }
288
288
  #sidebar::-webkit-scrollbar-thumb, #main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
289
289
  @media (max-width: 768px) { #app { grid-template-columns: 1fr; } #sidebar { display: none; } #doc-wrap { padding: 24px 20px 60px; } }
290
+
291
+ /* ── Command Palette ── */
292
+ .palette-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.48); z-index: 500; display: none; align-items: flex-start; justify-content: center; padding-top: 14vh; }
293
+ .palette-overlay.open { display: flex; }
294
+ .palette-box { width: 100%; max-width: 560px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 24px 64px rgba(0,0,0,.28); overflow: hidden; }
295
+ .palette-input-wrap { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--border); }
296
+ .palette-search-icon { font-size: 16px; color: var(--text-muted); flex-shrink: 0; }
297
+ #palette-input { flex: 1; border: none; outline: none; font-size: 15px; background: transparent; color: var(--text); }
298
+ #palette-input::placeholder { color: var(--text-muted); }
299
+ .palette-esc { font-size: 11px; background: var(--border); color: var(--text-muted); padding: 2px 7px; border-radius: 4px; flex-shrink: 0; cursor: pointer; }
300
+ .palette-results { max-height: 380px; overflow-y: auto; }
301
+ .palette-group-header { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: 8px 16px 4px; background: var(--bg-sidebar); }
302
+ .palette-item { display: flex; align-items: center; gap: 10px; padding: 10px 16px; cursor: pointer; font-size: 13px; border-left: 2px solid transparent; }
303
+ .palette-item.selected { background: var(--bg-active); color: var(--accent); border-left-color: var(--accent); }
304
+ .palette-item-icon { width: 20px; text-align: center; flex-shrink: 0; }
305
+ .palette-item-name { flex: 1; }
306
+ .palette-item-section { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
307
+ .palette-item-snippet { font-size: 11px; color: var(--text-muted); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
308
+ .palette-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13px; }
309
+ .palette-footer { padding: 8px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); display: flex; gap: 14px; background: var(--bg-sidebar); }
310
+ .palette-footer kbd { background: var(--border); padding: 1px 5px; border-radius: 3px; font-family: var(--font-mono); }
311
+
312
+ /* ── Copy button ── */
313
+ .code-block-wrap { position: relative; }
314
+ .copy-btn { position: absolute; top: 8px; right: 8px; padding: 3px 9px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text-muted); font-size: 11px; cursor: pointer; opacity: 0; transition: opacity .15s; z-index: 1; font-family: var(--font); }
315
+ .code-block-wrap:hover .copy-btn { opacity: 1; }
316
+ .copy-btn.copied { color: var(--green-text); border-color: var(--green); background: var(--green-bg); }
317
+
318
+ /* ── Heading anchors ── */
319
+ .heading-anchor { opacity: 0; margin-left: 6px; font-size: .72em; color: var(--text-muted); text-decoration: none; transition: opacity .15s; vertical-align: middle; }
320
+ h2:hover .heading-anchor, h3:hover .heading-anchor { opacity: 1; }
290
321
  </style>
291
322
  </head>
292
323
  <body>
@@ -369,6 +400,23 @@ html, body { height: 100%; font-family: var(--font); color: var(--text); backgro
369
400
 
370
401
  </div>
371
402
 
403
+ <!-- Command Palette -->
404
+ <div id="palette-overlay" class="palette-overlay" onclick="onPaletteOverlayClick(event)">
405
+ <div class="palette-box">
406
+ <div class="palette-input-wrap">
407
+ <span class="palette-search-icon">🔍</span>
408
+ <input id="palette-input" placeholder="Search docs, jump to…" autocomplete="off" spellcheck="false">
409
+ <span class="palette-esc" onclick="closePalette()">ESC</span>
410
+ </div>
411
+ <div id="palette-results" class="palette-results"></div>
412
+ <div class="palette-footer">
413
+ <span><kbd>↑↓</kbd> navigate</span>
414
+ <span><kbd>↵</kbd> open</span>
415
+ <span><kbd>ESC</kbd> close</span>
416
+ </div>
417
+ </div>
418
+ </div>
419
+
372
420
  <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
373
421
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
374
422
  <script>
@@ -406,7 +454,12 @@ marked.use({
406
454
  if (lang === 'mermaid') {
407
455
  return `<div class="mermaid-wrap"><div class="mermaid">${esc(text)}</div></div>`
408
456
  }
409
- return `<pre><code class="language-${esc(lang||'')}">${esc(text)}</code></pre>`
457
+ return `<div class="code-block-wrap"><pre><code class="language-${esc(lang||'')}">${esc(text)}</code></pre><button class="copy-btn" onclick="copyCode(this)">Copy</button></div>`
458
+ },
459
+ heading({ text, depth }) {
460
+ if (depth === 1) return `<h1>${text}</h1>\n`
461
+ const anchor = text.replace(/<[^>]+>/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
462
+ return `<h${depth} id="${anchor}">${text}<a class="heading-anchor" href="#${anchor}" onclick="copyAnchor(this,event)">#</a></h${depth}>\n`
410
463
  }
411
464
  }
412
465
  })
@@ -422,9 +475,25 @@ async function boot() {
422
475
  state.query = e.target.value.trim()
423
476
  renderNav()
424
477
  })
478
+ document.getElementById('palette-input').addEventListener('input', e => {
479
+ renderPaletteResults(e.target.value.trim())
480
+ })
425
481
  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()
482
+ const palOpen = document.getElementById('palette-overlay').classList.contains('open')
483
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
484
+ e.preventDefault()
485
+ palOpen ? closePalette() : openPalette()
486
+ return
487
+ }
488
+ if (e.key === 'Escape') {
489
+ if (palOpen) { closePalette(); return }
490
+ document.getElementById('search').blur()
491
+ }
492
+ if (palOpen) {
493
+ if (e.key === 'ArrowDown') { e.preventDefault(); paletteIdx = Math.min(paletteIdx + 1, paletteItems.length - 1); updatePaletteSelection(); scrollPaletteItem() }
494
+ else if (e.key === 'ArrowUp') { e.preventDefault(); paletteIdx = Math.max(paletteIdx - 1, 0); updatePaletteSelection(); scrollPaletteItem() }
495
+ else if (e.key === 'Enter') { e.preventDefault(); selectPaletteItem(paletteIdx) }
496
+ }
428
497
  })
429
498
  }
430
499
 
@@ -664,6 +733,13 @@ async function runScan() {
664
733
  await refreshStatus()
665
734
  }
666
735
 
736
+ function confirmRegen() {
737
+ const total = state.generationTotal || '?'
738
+ const lang = state.lang === 'es' ? 'español' : 'inglés'
739
+ if (!confirm(`¿Regenerar toda la documentación en ${lang}?\n\nEsto sobreescribirá todos los archivos .md existentes.`)) return
740
+ runGenerate(true)
741
+ }
742
+
667
743
  function setLang(lang) {
668
744
  state.lang = lang
669
745
  const es = document.getElementById('lang-es')
@@ -743,6 +819,7 @@ async function runGenerate(force = false) {
743
819
  state.generationTotal = total
744
820
  state.generationDone = 0
745
821
  state.generationLog = []
822
+ state.generationStartMs = Date.now()
746
823
  }
747
824
 
748
825
  function handleProgressEvent(raw) {
@@ -767,7 +844,16 @@ function handleProgressEvent(raw) {
767
844
  const log = document.getElementById('gen-log')
768
845
 
769
846
  if (bar) bar.style.width = `${Math.round((done / total) * 100)}%`
770
- if (cnt) cnt.textContent = `${done} / ${total}`
847
+ if (cnt) {
848
+ let eta = ''
849
+ if (done > 2 && state.generationStartMs) {
850
+ const elapsed = (Date.now() - state.generationStartMs) / 1000
851
+ const secsPerFile = elapsed / done
852
+ const remaining = Math.round(secsPerFile * (total - done))
853
+ eta = remaining > 5 ? ` — ~${remaining}s restantes` : ''
854
+ }
855
+ cnt.textContent = `${done} / ${total}${eta}`
856
+ }
771
857
  if (!log) return
772
858
 
773
859
  const row = document.createElement('div')
@@ -867,7 +953,7 @@ function showRegenPanel() {
867
953
  </div>
868
954
  </div>
869
955
 
870
- <button class="bootstrap-btn" id="gen-btn" onclick="runGenerate(true)" style="max-width:300px">
956
+ <button class="bootstrap-btn" id="gen-btn" onclick="confirmRegen()" style="max-width:300px">
871
957
  <span id="gen-btn-icon">✨</span>
872
958
  <span id="gen-btn-label">Regenerar todos los docs</span>
873
959
  </button>
@@ -1405,6 +1491,150 @@ function statusClass(code) {
1405
1491
  return 'status-2xx'
1406
1492
  }
1407
1493
 
1494
+ // ── Command Palette ───────────────────────────────────────────────────────────
1495
+ let paletteIdx = 0
1496
+ let paletteItems = []
1497
+ let paletteSearchTimer = null
1498
+
1499
+ function openPalette() {
1500
+ document.getElementById('palette-overlay').classList.add('open')
1501
+ const inp = document.getElementById('palette-input')
1502
+ inp.value = ''
1503
+ inp.focus()
1504
+ renderPaletteResults('')
1505
+ }
1506
+
1507
+ function closePalette() {
1508
+ document.getElementById('palette-overlay').classList.remove('open')
1509
+ }
1510
+
1511
+ function onPaletteOverlayClick(e) {
1512
+ if (e.target === document.getElementById('palette-overlay')) closePalette()
1513
+ }
1514
+
1515
+ function renderPaletteResults(q) {
1516
+ const { tree } = state
1517
+ paletteItems = []
1518
+ const lq = q.toLowerCase()
1519
+
1520
+ if (tree) {
1521
+ if (tree.architecture && (!q || 'architecture'.includes(lq))) {
1522
+ paletteItems.push({ icon: '🏗', name: 'Architecture', section: 'Overview', action: () => loadFile(tree.architecture) })
1523
+ }
1524
+ const addItems = (items, icon, section, fn) => {
1525
+ if (!items?.length) return
1526
+ for (const item of items) {
1527
+ const name = fmtName(item.name)
1528
+ if (!q || name.toLowerCase().includes(lq) || item.name.toLowerCase().includes(lq)) {
1529
+ paletteItems.push({ icon, name, section, action: () => fn(item) })
1530
+ }
1531
+ }
1532
+ }
1533
+ addItems(tree.modules, '📦', 'Modules', m => loadFile(m.path))
1534
+ addItems(tree.decisions, '📝', 'Decisions', d => loadFile(d.path))
1535
+ addItems(tree.api, '📄', 'API', a => loadFile(a.path))
1536
+ }
1537
+ if (!q || 'api explorer'.includes(lq)) paletteItems.push({ icon: '⚡', name: 'API Explorer', section: 'Views', action: showApiExplorer })
1538
+ if (!q || 'health check'.includes(lq)) paletteItems.push({ icon: '🩺', name: 'Health Check', section: 'Views', action: showHealthCheck })
1539
+ if (!q || 'regenerate docs'.includes(lq)) paletteItems.push({ icon: '✨', name: 'Regenerate docs', section: 'Actions', action: showRegenPanel })
1540
+
1541
+ paintPalette()
1542
+
1543
+ // Debounced content search
1544
+ clearTimeout(paletteSearchTimer)
1545
+ if (q.length >= 2) {
1546
+ paletteSearchTimer = setTimeout(() => fetchContentResults(q), 250)
1547
+ }
1548
+ }
1549
+
1550
+ async function fetchContentResults(q) {
1551
+ try {
1552
+ const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
1553
+ if (!r.ok) return
1554
+ const hits = await r.json()
1555
+ if (!hits.length) return
1556
+ // Append content hits (avoid duplicates with name matches)
1557
+ const existing = new Set(paletteItems.map(i => i._path).filter(Boolean))
1558
+ for (const hit of hits) {
1559
+ if (existing.has(hit.path)) continue
1560
+ paletteItems.push({ icon: '🔎', name: hit.title, section: 'Content', snippet: hit.snippet, _path: hit.path, action: () => loadFile(hit.path) })
1561
+ }
1562
+ paintPalette()
1563
+ } catch { /* ok */ }
1564
+ }
1565
+
1566
+ function paintPalette() {
1567
+ const container = document.getElementById('palette-results')
1568
+ if (!container) return
1569
+
1570
+ if (!paletteItems.length) {
1571
+ container.innerHTML = '<div class="palette-empty">No results</div>'
1572
+ return
1573
+ }
1574
+
1575
+ const groups = {}
1576
+ for (const item of paletteItems) {
1577
+ if (!groups[item.section]) groups[item.section] = []
1578
+ groups[item.section].push(item)
1579
+ }
1580
+
1581
+ let flatIdx = 0
1582
+ let html = ''
1583
+ for (const [section, items] of Object.entries(groups)) {
1584
+ html += `<div class="palette-group-header">${esc(section)}</div>`
1585
+ for (const item of items) {
1586
+ const i = flatIdx++
1587
+ const sel = i === paletteIdx ? ' selected' : ''
1588
+ html += `<div class="palette-item${sel}" onmouseenter="paletteIdx=${i};updatePaletteSelection()" onclick="selectPaletteItem(${i})">
1589
+ <span class="palette-item-icon">${item.icon}</span>
1590
+ <span class="palette-item-name">${esc(item.name)}</span>
1591
+ ${item.snippet ? `<span class="palette-item-snippet">${esc(item.snippet)}</span>` : `<span class="palette-item-section">${esc(item.section)}</span>`}
1592
+ </div>`
1593
+ }
1594
+ }
1595
+ container.innerHTML = html
1596
+ }
1597
+
1598
+ function selectPaletteItem(i) {
1599
+ closePalette()
1600
+ paletteItems[i]?.action()
1601
+ }
1602
+
1603
+ function updatePaletteSelection() {
1604
+ document.querySelectorAll('#palette-results .palette-item').forEach((el, i) => {
1605
+ el.classList.toggle('selected', i === paletteIdx)
1606
+ })
1607
+ }
1608
+
1609
+ function scrollPaletteItem() {
1610
+ const items = document.querySelectorAll('#palette-results .palette-item')
1611
+ items[paletteIdx]?.scrollIntoView({ block: 'nearest' })
1612
+ }
1613
+
1614
+ // ── Copy code ────────────────────────────────────────────────────────────────
1615
+ function copyCode(btn) {
1616
+ const code = btn.previousElementSibling?.querySelector('code')
1617
+ if (!code) return
1618
+ navigator.clipboard.writeText(code.textContent).then(() => {
1619
+ btn.textContent = '✓ Copied'
1620
+ btn.classList.add('copied')
1621
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied') }, 1500)
1622
+ }).catch(() => {})
1623
+ }
1624
+
1625
+ // ── Heading anchors ──────────────────────────────────────────────────────────
1626
+ function copyAnchor(a, e) {
1627
+ e.preventDefault()
1628
+ const url = location.href.split('#')[0] + a.getAttribute('href')
1629
+ navigator.clipboard.writeText(url).then(() => {
1630
+ const orig = a.textContent
1631
+ a.textContent = '✓'
1632
+ setTimeout(() => { a.textContent = orig }, 1200)
1633
+ }).catch(() => {
1634
+ location.hash = a.getAttribute('href')
1635
+ })
1636
+ }
1637
+
1408
1638
  boot()
1409
1639
  </script>
1410
1640
  </body>
@@ -44,6 +44,7 @@ class DocuTrackServer {
44
44
  if (p === '/api/scan' && req.method === 'POST') return this.serveScan(res)
45
45
  if (p === '/api/generate' && req.method === 'POST') return this.serveGenerate(res, req)
46
46
  if (p === '/api/generate-arch' && req.method === 'POST') return this.serveGenerateArch(res, req)
47
+ if (p === '/api/search') return this.serveSearch(res, reqUrl.searchParams.get('q'))
47
48
  if (p === '/events') return this.serveSSE(req, res)
48
49
 
49
50
  res.writeHead(404, { 'Content-Type': 'text/plain' })
@@ -572,6 +573,47 @@ Instructions:
572
573
  .replace(/\[([^\]]+)\]/g, '{$1}')
573
574
  }
574
575
 
576
+ serveSearch(res, q) {
577
+ res.writeHead(200, { 'Content-Type': 'application/json' })
578
+ if (!q || q.length < 2) return res.end(JSON.stringify([]))
579
+
580
+ const lq = q.toLowerCase()
581
+ const results = []
582
+
583
+ const searchFile = (fullPath, relPath) => {
584
+ if (results.length >= 8) return
585
+ try {
586
+ const content = fs.readFileSync(fullPath, 'utf8')
587
+ const lc = content.toLowerCase()
588
+ const idx = lc.indexOf(lq)
589
+ if (idx === -1) return
590
+ const start = Math.max(0, idx - 50)
591
+ const end = Math.min(content.length, idx + lq.length + 100)
592
+ const snippet = (start > 0 ? '…' : '') + content.slice(start, end).replace(/[#*`\n]/g, ' ').replace(/\s+/g, ' ').trim() + (end < content.length ? '…' : '')
593
+ const titleMatch = content.match(/^#\s+(.+)/m)
594
+ const title = titleMatch ? titleMatch[1].trim() : path.basename(relPath, '.md')
595
+ results.push({ path: relPath.replace(/\\/g, '/'), title, snippet })
596
+ } catch { /* skip */ }
597
+ }
598
+
599
+ const archPath = path.join(this.root, 'ARCHITECTURE.md')
600
+ if (fs.existsSync(archPath)) searchFile(archPath, 'ARCHITECTURE.md')
601
+
602
+ const walk = (dir) => {
603
+ if (results.length >= 8 || !fs.existsSync(dir)) return
604
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
605
+ if (e.isDirectory()) walk(path.join(dir, e.name))
606
+ else if (e.name.endsWith('.md') && e.name !== '.gitkeep') {
607
+ const full = path.join(dir, e.name)
608
+ searchFile(full, path.relative(this.root, full))
609
+ }
610
+ }
611
+ }
612
+ walk(path.join(this.root, 'docs'))
613
+
614
+ res.end(JSON.stringify(results))
615
+ }
616
+
575
617
  serveSSE(req, res) {
576
618
  res.writeHead(200, {
577
619
  'Content-Type': 'text/event-stream',
@@ -0,0 +1,50 @@
1
+ 'use strict'
2
+
3
+ const { describe, it } = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+
6
+ const DocuTrackServer = require('./server')
7
+
8
+ const s = new DocuTrackServer('/fake/root', 4242)
9
+
10
+ describe('moduleDocName', () => {
11
+ it('drops leading src/ for shallow files', () => {
12
+ assert.equal(s.moduleDocName('src/utils.ts'), 'utils')
13
+ })
14
+
15
+ it('uses last 2 segments for deeper paths', () => {
16
+ assert.equal(s.moduleDocName('app/dashboard/SearchBar.tsx'), 'dashboard-SearchBar')
17
+ })
18
+
19
+ it('handles lib/ prefix', () => {
20
+ assert.equal(s.moduleDocName('lib/rules-engine.ts'), 'rules-engine')
21
+ })
22
+
23
+ it('handles deeply nested paths', () => {
24
+ assert.equal(s.moduleDocName('src/components/ui/Button.tsx'), 'ui-Button')
25
+ })
26
+ })
27
+
28
+ describe('routeDocName', () => {
29
+ it('converts simple route', () => {
30
+ assert.equal(s.routeDocName('app/api/documentos/route.ts'), 'documentos')
31
+ })
32
+
33
+ it('strips dynamic segments brackets', () => {
34
+ assert.equal(s.routeDocName('app/api/documentos/[id]/evaluar/route.ts'), 'documentos-id-evaluar')
35
+ })
36
+
37
+ it('handles nested api routes', () => {
38
+ assert.equal(s.routeDocName('app/api/metricas/route.ts'), 'metricas')
39
+ })
40
+ })
41
+
42
+ describe('fileToApiPath', () => {
43
+ it('converts dynamic segments to OpenAPI params', () => {
44
+ assert.equal(s.fileToApiPath('app/api/documentos/[id]/route.ts'), '/api/documentos/{id}')
45
+ })
46
+
47
+ it('handles simple routes', () => {
48
+ assert.equal(s.fileToApiPath('app/api/status/route.ts'), '/api/status')
49
+ })
50
+ })