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 +21 -0
- package/README.md +103 -59
- package/package.json +38 -38
- package/src/utils/queue.js +11 -10
- package/src/utils/queue.test.js +54 -0
- package/src/viewer/index.html +235 -5
- package/src/viewer/server.js +42 -0
- package/src/viewer/server.test.js +50 -0
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
|
-
**
|
|
5
|
+
**Your AI agent writes code. DocuTrack makes sure it documents what it builds.**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/docutrack)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](https://nodejs.org)
|
|
10
|
+
[](CONTRIBUTING.md)
|
|
4
11
|
|
|
5
|
-
|
|
12
|
+
</div>
|
|
6
13
|
|
|
7
14
|
---
|
|
8
15
|
|
|
9
|
-
##
|
|
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
|
-
|
|
28
|
+
That's it. From that point on, every file your AI agent touches gets documented.
|
|
16
29
|
|
|
17
30
|
---
|
|
18
31
|
|
|
19
|
-
##
|
|
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
|
-
- **
|
|
22
|
-
- **
|
|
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
|
-
##
|
|
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
|
-
#
|
|
73
|
+
# Initialize in your project
|
|
33
74
|
npx docutrack init
|
|
34
75
|
|
|
35
|
-
#
|
|
76
|
+
# Open the documentation viewer
|
|
36
77
|
docutrack serve
|
|
78
|
+
# → http://localhost:4242
|
|
37
79
|
|
|
38
|
-
#
|
|
39
|
-
|
|
80
|
+
# Check documentation health
|
|
81
|
+
docutrack check
|
|
82
|
+
```
|
|
40
83
|
|
|
41
|
-
|
|
42
|
-
docutrack status
|
|
84
|
+
To use AI generation, set your Anthropic API key:
|
|
43
85
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
##
|
|
93
|
+
## Commands
|
|
51
94
|
|
|
52
|
-
|
|
|
95
|
+
| Command | Description |
|
|
53
96
|
|---------|-------------|
|
|
54
|
-
| `docutrack init` |
|
|
55
|
-
| `docutrack serve` |
|
|
56
|
-
| `docutrack scan` |
|
|
57
|
-
| `docutrack status` |
|
|
58
|
-
| `docutrack check` |
|
|
59
|
-
| `docutrack analyze` |
|
|
60
|
-
| `docutrack onboard` |
|
|
61
|
-
| `docutrack export` |
|
|
62
|
-
| `docutrack badge` |
|
|
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
|
-
##
|
|
109
|
+
## Stack templates
|
|
67
110
|
|
|
68
|
-
|
|
111
|
+
DocuTrack auto-detects your stack from `package.json`, `go.mod`, etc. You can also specify it manually:
|
|
69
112
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
##
|
|
125
|
+
## What gets generated
|
|
79
126
|
|
|
80
127
|
```
|
|
81
128
|
docs/
|
|
82
|
-
├── modules/
|
|
83
|
-
├── api/
|
|
84
|
-
└── decisions/
|
|
85
|
-
ARCHITECTURE.md
|
|
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
|
-
##
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
docutrack serve
|
|
94
|
-
# → http://localhost:4242
|
|
95
|
-
```
|
|
138
|
+
## Zero dependencies
|
|
96
139
|
|
|
97
|
-
|
|
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
|
-
##
|
|
144
|
+
## Requirements
|
|
107
145
|
|
|
108
146
|
- Node.js 18+
|
|
109
|
-
- Claude Code CLI
|
|
110
|
-
- `ANTHROPIC_API_KEY`
|
|
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
|
-
##
|
|
158
|
+
## License
|
|
115
159
|
|
|
116
|
-
MIT
|
|
160
|
+
MIT © [novolabs](https://github.com/mnovoaq)
|
package/package.json
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "docutrack",
|
|
3
|
-
"version": "0.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
|
|
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
|
+
}
|
package/src/utils/queue.js
CHANGED
|
@@ -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(
|
|
26
|
+
function read(queuePath = QUEUE_PATH) {
|
|
27
|
+
if (!fs.existsSync(queuePath)) return { ...EMPTY_QUEUE }
|
|
28
28
|
try {
|
|
29
|
-
return JSON.parse(fs.readFileSync(
|
|
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.
|
|
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
|
+
})
|
package/src/viewer/index.html
CHANGED
|
@@ -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
|
-
|
|
427
|
-
if (e.key === '
|
|
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)
|
|
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="
|
|
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>
|
package/src/viewer/server.js
CHANGED
|
@@ -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
|
+
})
|