codetac 0.1.0 → 0.2.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/package.json +10 -5
- package/readme.md +81 -14
- package/src/ai.mjs +4 -2
- package/src/boundaries.mjs +12 -9
- package/src/browser/bar.js +5 -3
- package/src/cli.mjs +251 -33
- package/src/detect-python.mjs +286 -0
- package/src/detect.mjs +21 -6
- package/src/diagnose.mjs +35 -5
- package/src/digest.mjs +18 -4
- package/src/page.mjs +19 -7
- package/src/panel.mjs +56 -14
- package/src/python/codetac_py/__init__.py +88 -0
- package/src/python/codetac_py/boundaries.py +435 -0
- package/src/python/codetac_py/capture.py +351 -0
- package/src/python/codetac_py/context.py +50 -0
- package/src/python/codetac_py/detail.py +333 -0
- package/src/python/codetac_py/files.py +135 -0
- package/src/python/codetac_py/frameworks.py +72 -0
- package/src/python/codetac_py/hooks.py +72 -0
- package/src/python/codetac_py/jinja_map.py +132 -0
- package/src/python/codetac_py/network.py +704 -0
- package/src/python/codetac_py/page.py +513 -0
- package/src/python/codetac_py/project.py +60 -0
- package/src/python/codetac_py/redact.py +104 -0
- package/src/python/codetac_py/servers.py +514 -0
- package/src/python/codetac_py/sitecustomize.py +62 -0
- package/src/python/codetac_py/writer.py +237 -0
- package/src/python/probe.py +99 -0
- package/src/recording.mjs +22 -4
- package/src/runtime.mjs +13 -1
- package/src/sentences.mjs +4 -0
- package/src/store.mjs +61 -9
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Deteção de projetos Python (Etapa 12 Python, especificação 3.10): sinais,
|
|
2
|
+
// interpretador, arranque e porta. Usado pelo detect.mjs; o caminho Node não
|
|
3
|
+
// muda. Uma parte Python tem os mesmos campos que uma parte Node, mais
|
|
4
|
+
// `language: 'python'` e `python` (interpretador, versão, módulos).
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
export const MINIMUM = [3, 12];
|
|
11
|
+
const PROBE = fileURLToPath(new URL('./python/probe.py', import.meta.url));
|
|
12
|
+
const SIGNALS = ['pyproject.toml', 'uv.lock', 'poetry.lock', 'Pipfile', 'Procfile', 'setup.py'];
|
|
13
|
+
const ENVIRONMENTS = ['.venv', 'venv', 'env', '.env'];
|
|
14
|
+
const SKIP = new Set(['node_modules', '__pycache__', 'site-packages', 'tests', 'test', 'migrations', 'alembic', 'docs', 'dist', 'build',
|
|
15
|
+
'scripts', 'static', 'templates', 'venv', 'env']);
|
|
16
|
+
const PREFERRED = ['main.py', 'app.py', 'server.py', 'api.py', 'application.py', 'wsgi.py', 'asgi.py', '__init__.py'];
|
|
17
|
+
const DEFAULT_PORT = { FastAPI: 8000, Flask: 5000 };
|
|
18
|
+
// Tools a declared command may start with; they run as `python -m <tool>` with the project's interpreter.
|
|
19
|
+
const MODULE_TOOLS = new Set(['uvicorn', 'flask', 'fastapi', 'gunicorn', 'hypercorn']);
|
|
20
|
+
const RUNNERS = /^(?:uv run|poetry run|pipenv run|pdm run|rye run)\s+/;
|
|
21
|
+
const SERVER_OF = { uvicorn: 'uvicorn', fastapi: 'uvicorn', flask: 'werkzeug', gunicorn: 'gunicorn', hypercorn: 'hypercorn', granian: 'granian', waitress: 'waitress' };
|
|
22
|
+
|
|
23
|
+
function readText(path, limit = 200_000) {
|
|
24
|
+
try { return readFileSync(path, 'utf8').slice(0, limit); } catch { return ''; }
|
|
25
|
+
}
|
|
26
|
+
const isDirectory = path => { try { return statSync(path).isDirectory(); } catch { return false; } };
|
|
27
|
+
const binary = (environment, name) => process.platform === 'win32' ? join(environment, 'Scripts', `${name}.exe`) : join(environment, 'bin', name);
|
|
28
|
+
|
|
29
|
+
export function versionBelow(version, minimum = MINIMUM) {
|
|
30
|
+
const [major, minor] = String(version).split('.').map(Number);
|
|
31
|
+
return major < minimum[0] || (major === minimum[0] && minor < minimum[1]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A folder is a Python project with its own files (not only a virtual environment).
|
|
35
|
+
export function hasPythonSignals(folder) {
|
|
36
|
+
if (SIGNALS.some(name => existsSync(join(folder, name)))) return true;
|
|
37
|
+
try {
|
|
38
|
+
const names = readdirSync(folder);
|
|
39
|
+
if (names.some(name => /^requirements.*\.txt$/.test(name))) return true;
|
|
40
|
+
return ENVIRONMENTS.some(name => existsSync(join(folder, name, 'pyvenv.cfg'))) && names.some(name => name.endsWith('.py'));
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The project's own environment: a virtual environment in the folder, or the
|
|
45
|
+
// one poetry or pipenv keeps elsewhere (only when the tool is installed).
|
|
46
|
+
export function findEnvironment(folder) {
|
|
47
|
+
for (const name of ENVIRONMENTS) {
|
|
48
|
+
const environment = join(folder, name);
|
|
49
|
+
if (existsSync(join(environment, 'pyvenv.cfg')) && existsSync(binary(environment, 'python'))) {
|
|
50
|
+
return { path: environment, interpreter: binary(environment, 'python'), source: name };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const external = [['poetry.lock', 'poetry', ['env', 'info', '--path']], ['Pipfile', 'pipenv', ['--venv']]];
|
|
54
|
+
for (const [file, tool, args] of external) {
|
|
55
|
+
if (!existsSync(join(folder, file))) continue;
|
|
56
|
+
const result = spawnSync(tool, args, { cwd: folder, encoding: 'utf8', timeout: 15_000 });
|
|
57
|
+
const path = result.status === 0 ? result.stdout.trim().split('\n').pop() : '';
|
|
58
|
+
if (path && existsSync(binary(path, 'python'))) return { path, interpreter: binary(path, 'python'), source: tool };
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// What the interpreter has: version, modules, missing dependencies (src/python/probe.py).
|
|
64
|
+
export function probe(interpreter, folder) {
|
|
65
|
+
const result = spawnSync(interpreter, [PROBE, folder], { cwd: folder, encoding: 'utf8', timeout: 20_000,
|
|
66
|
+
env: { ...process.env, PYTHONPATH: '', PYTHONDONTWRITEBYTECODE: '1' } });
|
|
67
|
+
try { return JSON.parse(result.stdout.trim().split('\n').pop()); } catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The application object: FastAPI(...) or Flask(...) assigned at module level.
|
|
71
|
+
export function findApp(folder) {
|
|
72
|
+
const found = [];
|
|
73
|
+
const visit = (directory, depth) => {
|
|
74
|
+
let names = [];
|
|
75
|
+
try { names = readdirSync(directory).sort((a, b) => (PREFERRED.indexOf(a) + 1 || 99) - (PREFERRED.indexOf(b) + 1 || 99)); } catch { return; }
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
if (name.startsWith('.') || SKIP.has(name)) continue;
|
|
78
|
+
const path = join(directory, name);
|
|
79
|
+
if (name.endsWith('.py')) {
|
|
80
|
+
const text = readText(path);
|
|
81
|
+
const assigned = text.match(/^(\w+)\s*(?::[^=\n]+)?=\s*(FastAPI|Flask)\(/m);
|
|
82
|
+
const factory = !assigned && /\bFlask\(/.test(text) && text.match(/^def (create_app|make_app)\(/m);
|
|
83
|
+
if (assigned || factory) {
|
|
84
|
+
const file = relative(folder, path);
|
|
85
|
+
const module = file.replace(/\.py$/, '').replace(/[\\/]__init__$/, '').split(sep).join('.');
|
|
86
|
+
found.push({ file, module, variable: assigned ? assigned[1] : `${factory[1]}()`, framework: assigned ? assigned[2] : 'Flask', depth,
|
|
87
|
+
main: /^if __name__ == ['"]__main__['"]/m.test(text), text });
|
|
88
|
+
}
|
|
89
|
+
} else if (depth < 2 && isDirectory(path) && !existsSync(join(path, 'pyvenv.cfg'))) visit(path, depth + 1);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
visit(folder, 0);
|
|
93
|
+
found.sort((a, b) => a.depth - b.depth);
|
|
94
|
+
return found[0] ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Commands the project declares: Procfile, task runners in pyproject.toml,
|
|
98
|
+
// Makefile and README. Only those that start a Python server in development.
|
|
99
|
+
const SERVER_COMMAND = /^(?:(?:uv run|poetry run|pipenv run|pdm run|rye run)\s+)?(?:uvicorn\s+\S+:\S+|fastapi\s+(?:dev|run)\b|flask\b.*\brun\b|gunicorn\s|hypercorn\s|granian\s|python3?\s+(?:-\S+\s+)*[\w./-]+\.py\b|python3?\s+-m\s+(?:uvicorn|flask|fastapi|gunicorn|hypercorn))/;
|
|
100
|
+
const SERVES = /\b(?:app|application|server|api)\.run\(|\buvicorn\.run\(|\bserve\(|\bmake_server\(|\bsocketio\.run\(|\brun_simple\(/;
|
|
101
|
+
export function declaredCommands(folder) {
|
|
102
|
+
const found = [];
|
|
103
|
+
const add = (command, source) => {
|
|
104
|
+
const clean = command.trim().replace(/^\$\s+/, '').replace(/\s+#.*$/, '');
|
|
105
|
+
if (SERVER_COMMAND.test(clean) && !found.some(item => item.command === clean)) found.push({ command: clean, source });
|
|
106
|
+
};
|
|
107
|
+
const procfile = readText(join(folder, 'Procfile')).match(/^web:\s*(.+)$/m);
|
|
108
|
+
if (procfile) add(procfile[1], 'Procfile');
|
|
109
|
+
const pyproject = readText(join(folder, 'pyproject.toml'));
|
|
110
|
+
for (const section of pyproject.split(/^\[/m).filter(part => /^tool\.(taskipy\.tasks|poe\.tasks|pdm\.scripts)\]/.test(part))) {
|
|
111
|
+
for (const match of section.matchAll(/^(dev|start|run|serve|server)\s*=\s*(?:\{[^}]*?cmd\s*=\s*)?["'](.+?)["']/gm)) add(match[2], `pyproject.toml (${match[1]})`);
|
|
112
|
+
}
|
|
113
|
+
const makefile = readText(join(folder, 'Makefile'));
|
|
114
|
+
for (const match of makefile.matchAll(/^(dev|run|start|serve|server):[^\n]*\n((?:\t[^\n]*\n?)+)/gm)) {
|
|
115
|
+
for (const line of match[2].split('\n')) add(line.replace(/^\t@?/, ''), `Makefile (${match[1]})`);
|
|
116
|
+
}
|
|
117
|
+
// README: only commands for development (reload, dev, debug, flask run, python file.py).
|
|
118
|
+
const readme = ['README.md', 'readme.md', 'README.rst', 'README'].map(name => readText(join(folder, name))).find(Boolean) ?? '';
|
|
119
|
+
for (const line of readme.split('\n')) {
|
|
120
|
+
const clean = line.trim().replace(/^\$\s+/, '').replace(/^`|`$/g, '');
|
|
121
|
+
if (/--reload|\bdev\b|--debug|flask\b.*\brun\b|^python3?\s+[\w./-]+\.py/.test(clean)) add(clean, 'README');
|
|
122
|
+
}
|
|
123
|
+
// A command that points at a file or module missing here belongs to another folder;
|
|
124
|
+
// `python file.py` counts only when that file starts a server (not a data script).
|
|
125
|
+
return found.filter(({ command }) => {
|
|
126
|
+
const script = command.match(/python3?\s+(?:-\S+\s+)*([\w./-]+\.py)\b/)?.[1];
|
|
127
|
+
if (script) return existsSync(join(folder, script)) && SERVES.test(readText(join(folder, script)));
|
|
128
|
+
const file = command.match(/fastapi\s+(?:dev|run)\s+([\w./-]+\.py)/)?.[1];
|
|
129
|
+
if (file) return existsSync(join(folder, file));
|
|
130
|
+
const target = command.match(/(?:uvicorn|gunicorn|hypercorn|granian)\s+(?:.*?\s)?([\w.]+):\w+/)?.[1];
|
|
131
|
+
if (target) return existsSync(join(folder, ...target.split('.')) + '.py') || existsSync(join(folder, ...target.split('.'), '__init__.py'));
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function portFromCommand(command) {
|
|
137
|
+
const match = command.match(/(?:--port[= ]|-p[= ]?)(\d{2,5})\b/) ?? command.match(/(?:--bind|-b)[= ]\S*:(\d{2,5})\b/)
|
|
138
|
+
?? command.match(/\$\{PORT:-(\d{2,5})\}/);
|
|
139
|
+
return match ? Number(match[1]) : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function pythonPort(folder, { command = '', app = null, framework = null } = {}) {
|
|
143
|
+
const fromCommand = portFromCommand(command);
|
|
144
|
+
if (fromCommand) return { value: fromCommand, source: 'comando' };
|
|
145
|
+
for (const name of ['.env.local', '.env.development', '.env', '.flaskenv']) {
|
|
146
|
+
if (isDirectory(join(folder, name))) continue;
|
|
147
|
+
const match = readText(join(folder, name)).match(/^\s*(?:export\s+)?(?:FLASK_RUN_|APP_|UVICORN_)?PORT\s*=\s*["']?(\d{2,5})/m);
|
|
148
|
+
if (match) return { value: Number(match[1]), source: name };
|
|
149
|
+
}
|
|
150
|
+
// python app.py: the port the file itself passes (app.run(port=…), uvicorn.run(…, port=…)).
|
|
151
|
+
if (app && /python/.test(command)) {
|
|
152
|
+
const match = app.text.match(/\bport\s*=\s*(?:int\(\s*os\.(?:environ\.get|getenv)\(\s*["']PORT["']\s*,\s*["']?)?(\d{2,5})/);
|
|
153
|
+
if (match) return { value: Number(match[1]), source: app.file };
|
|
154
|
+
}
|
|
155
|
+
const name = framework ?? app?.framework;
|
|
156
|
+
if (/fastapi dev|uvicorn/.test(command) || name === 'FastAPI') return { value: 8000, source: 'omissão do uvicorn' };
|
|
157
|
+
if (/flask/.test(command) || name === 'Flask') return { value: 5000, source: 'omissão do Flask' };
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The command as argv, with the project's interpreter and the port.
|
|
162
|
+
export function commandFor(part, port) {
|
|
163
|
+
const interpreter = part.python.interpreter ?? 'python3';
|
|
164
|
+
if (part.python.declared) {
|
|
165
|
+
let text = part.python.declared.replace(RUNNERS, '');
|
|
166
|
+
text = text.replace(/\$\{PORT(?::-\d+)?\}|\$PORT\b/g, String(port));
|
|
167
|
+
if (portFromCommand(text) && !/\$\{PORT/.test(part.python.declared)) {
|
|
168
|
+
text = text.replace(/(--port[= ]|-p[= ]?)\d{2,5}\b/, `$1${port}`).replace(/((?:--bind|-b)[= ]\S*:)\d{2,5}\b/, `$1${port}`);
|
|
169
|
+
}
|
|
170
|
+
const [first, ...rest] = text.split(/\s+/).filter(Boolean);
|
|
171
|
+
// A known server without a port option gets one: the port must be ours to choose.
|
|
172
|
+
const tool = first === 'python' || first === 'python3' ? (rest[0] === '-m' ? rest[1] : null) : first;
|
|
173
|
+
if (!portFromCommand(text) && !/\$\{?PORT/.test(part.python.declared)) {
|
|
174
|
+
if (tool === 'uvicorn' || tool === 'hypercorn' || (tool === 'fastapi' && /\b(dev|run)\b/.test(text)) || (tool === 'flask' && /\brun\b/.test(text))) rest.push('--port', String(port));
|
|
175
|
+
else if (tool === 'gunicorn') rest.push('--bind', `127.0.0.1:${port}`);
|
|
176
|
+
}
|
|
177
|
+
if (/^python3?$/.test(first)) return [interpreter, ...rest];
|
|
178
|
+
if (MODULE_TOOLS.has(first)) return [interpreter, '-m', first, ...rest];
|
|
179
|
+
return [first, ...rest];
|
|
180
|
+
}
|
|
181
|
+
const { app } = part.python;
|
|
182
|
+
switch (part.python.how) {
|
|
183
|
+
case 'fastapi dev': return [interpreter, '-m', 'fastapi', 'dev', app.file, '--port', String(port)];
|
|
184
|
+
case 'uvicorn': return [interpreter, '-m', 'uvicorn', `${app.module}:${app.variable}`, '--reload', '--port', String(port)];
|
|
185
|
+
case 'flask': return [interpreter, '-m', 'flask', '--app', app.variable.endsWith('()') ? `${app.module}:${app.variable}` : app.module, 'run', '--debug', '--port', String(port)];
|
|
186
|
+
case 'python': return [interpreter, app.file];
|
|
187
|
+
default: return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Things to say before starting (limits of the capture for this command).
|
|
192
|
+
function notesFor(command, python) {
|
|
193
|
+
const notes = [];
|
|
194
|
+
const text = command.join(' ');
|
|
195
|
+
if (python.version && versionBelow(python.version)) {
|
|
196
|
+
notes.push(`O Python do projeto é o ${python.version}: a app corre em modo mínimo (pedidos, fronteiras e browser, sem seguir as funções). Para as ver, use o Python ${MINIMUM.join('.')} ou mais recente no ambiente do projeto.`);
|
|
197
|
+
}
|
|
198
|
+
const flags = text.match(/python3?(?:\.\d+)?\s+((?:-[a-zA-Z]+\s+)+)/)?.[1] ?? '';
|
|
199
|
+
const ignored = flags.split(/\s+/).filter(flag => /^-[a-zA-Z]*[EIS]/.test(flag));
|
|
200
|
+
if (ignored.length) notes.push(`O comando usa ${ignored.join(' ')}: com essa opção o Python ignora o PYTHONPATH ou o site, e a app corre sem ser observada. Retire-a para usar o CodeTAC.`);
|
|
201
|
+
if (/\bgranian\b/.test(text)) notes.push('O granian chama a app a partir de Rust e o CodeTAC ainda não o reconhece: a app corre, mas os pedidos não ficam com dossier. Com o uvicorn, fica tudo visível.');
|
|
202
|
+
if (/\bhypercorn\b/.test(text)) notes.push('O hypercorn ainda não é reconhecido: a app corre, mas os pedidos não ficam com dossier. Com o uvicorn, fica tudo visível.');
|
|
203
|
+
return notes;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// One folder with a Python project: how to start it.
|
|
207
|
+
export function describePythonFolder(folder, top = folder) {
|
|
208
|
+
if (!hasPythonSignals(folder)) return null;
|
|
209
|
+
const environment = findEnvironment(folder);
|
|
210
|
+
const probed = environment ? probe(environment.interpreter, folder) : null;
|
|
211
|
+
const modules = new Set(probed?.modules ?? []);
|
|
212
|
+
const app = findApp(folder);
|
|
213
|
+
const declared = declaredCommands(folder)[0] ?? null;
|
|
214
|
+
const django = existsSync(join(folder, 'manage.py'));
|
|
215
|
+
let how = null;
|
|
216
|
+
if (declared) how = 'declared';
|
|
217
|
+
else if (app?.framework === 'FastAPI') {
|
|
218
|
+
// Without an environment, the command is the one it will have once created.
|
|
219
|
+
if (!probed || (modules.has('fastapi_cli') && modules.has('fastapi.__main__'))) how = 'fastapi dev';
|
|
220
|
+
else if (modules.has('uvicorn')) how = 'uvicorn';
|
|
221
|
+
else if (app.main) how = 'python';
|
|
222
|
+
else how = 'uvicorn';
|
|
223
|
+
} else if (app?.framework === 'Flask') how = modules.has('flask') || !probed ? 'flask' : app.main ? 'python' : 'flask';
|
|
224
|
+
const python = { interpreter: environment?.interpreter ?? null, environment: environment?.path ?? null, source: environment?.source ?? null,
|
|
225
|
+
version: probed?.version ?? null, modules: [...modules], missing: probed?.missing ?? [], app, how, declared: declared?.command ?? null,
|
|
226
|
+
declaredSource: declared?.source ?? null, django };
|
|
227
|
+
const framework = app?.framework ?? (/fastapi|uvicorn/.test(declared?.command ?? '') ? 'FastAPI' : /flask/.test(declared?.command ?? '') ? 'Flask' : null);
|
|
228
|
+
const port = how ? pythonPort(folder, { command: declared?.command ?? (how === 'python' ? 'python' : how), app, framework }) : null;
|
|
229
|
+
const part = {
|
|
230
|
+
folder, part: relative(top, folder) || '.', name: basename(folder), language: 'python', manager: existsSync(join(folder, 'uv.lock')) ? 'uv'
|
|
231
|
+
: existsSync(join(folder, 'poetry.lock')) ? 'poetry' : existsSync(join(folder, 'Pipfile')) ? 'pipenv' : 'pip',
|
|
232
|
+
stack: framework, script: declared ? { name: declared.source, command: declared.command } : null, entry: null,
|
|
233
|
+
command: null, foreign: null, port, installed: Boolean(probed) && !python.missing.length, workspaces: false, python,
|
|
234
|
+
};
|
|
235
|
+
if (how) part.command = commandFor(part, port?.value ?? DEFAULT_PORT[framework] ?? 8000);
|
|
236
|
+
// python app.py: the server is the one the file starts, known only when it runs.
|
|
237
|
+
part.server = SERVER_OF[part.command?.find(token => SERVER_OF[token]) ?? ''] ?? null;
|
|
238
|
+
part.notes = part.command ? notesFor(part.command, python) : [];
|
|
239
|
+
// The configuration the app may need (cp .env.example .env): the user's to make.
|
|
240
|
+
if (part.command && !existsSync(join(folder, '.env')) && ['.env.example', '.env.sample', '.env.template'].some(name => existsSync(join(folder, name)))) {
|
|
241
|
+
part.notes.push('O projeto tem um .env.example, mas não um .env. Se a app precisar da configuração, copie-o (cp .env.example .env) e preencha-o.');
|
|
242
|
+
}
|
|
243
|
+
return part;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The same part on another port (a taken port, or one the others must follow).
|
|
247
|
+
export function moveTo(part, port) {
|
|
248
|
+
const command = commandFor(part, port);
|
|
249
|
+
const moved = { ...part, port: { value: port, source: 'escolhida pelo CodeTAC' }, command: command ?? part.command };
|
|
250
|
+
// python app.py, or a declared command without a port option: the PORT variable.
|
|
251
|
+
if (!command || !command.join(' ').includes(String(port))) moved.env = { ...part.env, PORT: String(port) };
|
|
252
|
+
return moved;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The environment of a Python part: its virtual environment first in PATH.
|
|
256
|
+
export function environmentOf(part) {
|
|
257
|
+
const env = { ...part.env };
|
|
258
|
+
if (part.python?.environment && part.python.source !== 'poetry' && part.python.source !== 'pipenv') env.VIRTUAL_ENV = part.python.environment;
|
|
259
|
+
if (part.python?.environment) env.PATH = [join(part.python.environment, process.platform === 'win32' ? 'Scripts' : 'bin'), process.env.PATH].join(process.platform === 'win32' ? ';' : ':');
|
|
260
|
+
return env;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Interpreters on this computer, newest first (for a new environment).
|
|
264
|
+
export function interpreters() {
|
|
265
|
+
const names = ['python3.14', 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3', 'python'];
|
|
266
|
+
const found = [];
|
|
267
|
+
for (const name of names) {
|
|
268
|
+
const result = spawnSync(name, ['-c', 'import sys; print("%d.%d.%d" % sys.version_info[:3]); print(sys.executable)'], { encoding: 'utf8', timeout: 10_000 });
|
|
269
|
+
if (result.status !== 0) continue;
|
|
270
|
+
const [version, executable] = result.stdout.trim().split('\n');
|
|
271
|
+
if (!found.some(item => item.executable === executable)) found.push({ name, version, executable });
|
|
272
|
+
}
|
|
273
|
+
const key = version => version.split('.').map(Number).reduce((total, part) => total * 1000 + part, 0);
|
|
274
|
+
return found.sort((a, b) => key(b.version) - key(a.version));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The Vite proxy of a frontend: which ports it sends to, and the variable that sets each one.
|
|
278
|
+
export function proxyOf(folder, script = '') {
|
|
279
|
+
// The configuration named in the script (vite --config ./config/vite.config.ts), else the usual names.
|
|
280
|
+
const named = String(script).match(/--config[= ](\S+)/)?.[1];
|
|
281
|
+
const text = [...(named ? [named] : []), 'vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.mts'].map(name => readText(join(folder, name))).find(Boolean) ?? '';
|
|
282
|
+
if (!/\bproxy\b/.test(text)) return null;
|
|
283
|
+
const variables = [...text.matchAll(/process\.env\.(\w+)\s*(?:\?\?|\|\|)\s*['"]?(\d{2,5})/g)].map(match => ({ variable: match[1], port: Number(match[2]) }));
|
|
284
|
+
const literal = [...text.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})\b/g)].map(match => Number(match[1]));
|
|
285
|
+
return { variables, literal };
|
|
286
|
+
}
|
package/src/detect.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// fica em `missing`, para o comando perguntar ao utilizador.
|
|
4
4
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
5
|
import { basename, join, relative } from 'node:path';
|
|
6
|
+
import { describePythonFolder, hasPythonSignals } from './detect-python.mjs';
|
|
6
7
|
|
|
7
8
|
// Ordem de preferência dos scripts de desenvolvimento.
|
|
8
9
|
const SCRIPTS = ['dev', 'start:dev', 'develop', 'serve', 'dev:server', 'server', 'start'];
|
|
@@ -26,7 +27,8 @@ const STACKS = [
|
|
|
26
27
|
];
|
|
27
28
|
const ENTRIES = ['server.js', 'server.mjs', 'server.ts', 'index.js', 'index.mjs', 'index.ts', 'app.js', 'app.ts',
|
|
28
29
|
'src/server.ts', 'src/server.js', 'src/index.ts', 'src/index.js', 'src/main.ts', 'src/app.ts', 'server/index.ts', 'server/index.js'];
|
|
29
|
-
const SKIP_FOLDERS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'public', 'docs', 'test', 'tests', 'e2e', 'scripts'
|
|
30
|
+
const SKIP_FOLDERS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'public', 'docs', 'test', 'tests', 'e2e', 'scripts',
|
|
31
|
+
'venv', 'env', '__pycache__', 'site-packages']);
|
|
30
32
|
|
|
31
33
|
function readJson(path) {
|
|
32
34
|
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
@@ -138,7 +140,8 @@ function childFolders(root) {
|
|
|
138
140
|
if (name.startsWith('.') || SKIP_FOLDERS.has(name)) continue;
|
|
139
141
|
const path = join(folder, name);
|
|
140
142
|
try { if (!statSync(path).isDirectory()) continue; } catch { continue; }
|
|
141
|
-
|
|
143
|
+
// A folder with its own package.json, or a Python project (api/, backend/…).
|
|
144
|
+
if (existsSync(join(path, 'package.json')) || hasPythonSignals(path)) found.push(path);
|
|
142
145
|
else if (depth < 1) visit(path, depth + 1);
|
|
143
146
|
if (depth < 1 && ['apps', 'packages'].includes(name)) visit(path, depth + 1);
|
|
144
147
|
}
|
|
@@ -175,15 +178,23 @@ function plainEntry(root) {
|
|
|
175
178
|
// package.json whose script already starts everything is enough on its own;
|
|
176
179
|
// otherwise the parts with their own scripts are offered (client + server…).
|
|
177
180
|
export function detectProject(root) {
|
|
178
|
-
const
|
|
179
|
-
|
|
181
|
+
const node = describeFolder(root);
|
|
182
|
+
// A package.json that starts nothing (only tooling) does not hide a Python app in the same folder.
|
|
183
|
+
const top = (node?.command ? node : null) ?? describePythonFolder(root) ?? node ?? plainEntry(root);
|
|
184
|
+
const parts = childFolders(root).map(folder => describeFolder(folder, root) ?? describePythonFolder(folder, root)).filter(part => part?.command);
|
|
180
185
|
let start = [];
|
|
181
186
|
// Without a "dev" script, several "dev:*" scripts (dev:server + dev:client…)
|
|
182
187
|
// are started together: that is how such projects run in development.
|
|
183
188
|
const pieces = top ? devPieces(top) : [];
|
|
184
189
|
if (pieces.length > 1) start = pieces;
|
|
185
|
-
|
|
190
|
+
// A frontend at the top and a Python API in a subfolder (backend/, api/): both run,
|
|
191
|
+
// unless the top script already starts Python itself.
|
|
192
|
+
else if (top?.command && top.language !== 'python' && !/^(python3?|uvicorn)$/.test(top.foreign ?? '') && parts.some(part => part.language === 'python')) {
|
|
193
|
+
start = [top, ...parts.filter(part => part.language === 'python')];
|
|
194
|
+
} else if (top?.command) start = [top];
|
|
186
195
|
else if (parts.length === 1) start = parts;
|
|
196
|
+
// A frontend and a Python API (web/ + api/): both run in development.
|
|
197
|
+
else if (!top?.command && parts.some(part => part.language === 'python') && parts.some(part => part.language !== 'python')) start = parts;
|
|
187
198
|
const missing = [];
|
|
188
199
|
if (!top && !parts.length) missing.push('package');
|
|
189
200
|
else if (!start.length && parts.length > 1) missing.push('part');
|
|
@@ -193,6 +204,10 @@ export function detectProject(root) {
|
|
|
193
204
|
|
|
194
205
|
// The sentence the command shows before starting.
|
|
195
206
|
export function describeStart(part) {
|
|
196
|
-
const
|
|
207
|
+
const shown = part.language === 'python' && part.python.interpreter
|
|
208
|
+
? part.command.map(token => token === part.python.interpreter ? relative(part.folder, token) || token : token) : part.command;
|
|
209
|
+
const what = part.language === 'python'
|
|
210
|
+
? `${shown.join(' ')}${part.python.declared ? ` (de ${part.python.declaredSource})` : ''}${part.python.version ? ` · Python ${part.python.version}` : ''}`
|
|
211
|
+
: part.script ? `${part.command.join(' ')} (script «${part.script.name}»: ${part.script.command})` : part.command.join(' ');
|
|
197
212
|
return `${part.part === '.' ? '' : `[${part.part}] `}${part.stack ? `${part.stack} · ` : ''}${what}`;
|
|
198
213
|
}
|
package/src/diagnose.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import http from 'node:http';
|
|
|
5
5
|
import { join, relative } from 'node:path';
|
|
6
6
|
import { dataDirectory } from './home.mjs';
|
|
7
7
|
import { detectProject, describeStart } from './detect.mjs';
|
|
8
|
+
import { MINIMUM, versionBelow } from './detect-python.mjs';
|
|
8
9
|
import { recordingsOf, summarize } from './recording.mjs';
|
|
9
10
|
import { describeConfig, loadConfig } from './ai.mjs';
|
|
10
11
|
|
|
@@ -18,6 +19,7 @@ const REASONS = {
|
|
|
18
19
|
'generator-skipped': 'generators (correm, mas não aparecem)',
|
|
19
20
|
'parameter-redeclaration-skipped': 'funções que redeclaram um parâmetro (correm, mas não aparecem)',
|
|
20
21
|
'direct-eval-skipped': 'funções com eval direto (correm, mas não aparecem)',
|
|
22
|
+
'template-lines-unmapped': 'templates sem ligação às linhas do ficheiro (aparecem como um só passo)',
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
function ping(port) {
|
|
@@ -41,6 +43,23 @@ function panelConfig(port) {
|
|
|
41
43
|
});
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
// A Python part: interpreter, version, dependencies, server and start (Etapa 12 Python).
|
|
47
|
+
function pythonPart(part, { ok, bad, note }) {
|
|
48
|
+
const label = part.part === '.' ? '' : `[${part.part}] `;
|
|
49
|
+
const { python } = part;
|
|
50
|
+
if (!python.interpreter) bad(`${label}Sem ambiente Python (.venv).`, 'codetac cria-o e instala as dependências (pergunta antes; --sim responde que sim).');
|
|
51
|
+
else if (!python.version) bad(`${label}O Python do ambiente (${relative(part.folder, python.interpreter)}) não respondeu.`, 'O ambiente pode estar partido: apague o .venv e volte a correr codetac.');
|
|
52
|
+
else if (versionBelow(python.version)) note(`${label}Python ${python.version} (${relative(part.folder, python.interpreter)}): só modo mínimo (pedidos, fronteiras e browser, sem as funções).`,
|
|
53
|
+
`Crie o ambiente com o Python ${MINIMUM.join('.')} ou mais recente.`);
|
|
54
|
+
else ok(`${label}Python ${python.version} (${relative(part.folder, python.interpreter) || python.interpreter}): consegue seguir as funções do projeto.`);
|
|
55
|
+
if (python.interpreter && python.missing.length) bad(`${label}Faltam dependências: ${python.missing.slice(0, 8).join(', ')}${python.missing.length > 8 ? '…' : ''}.`, 'codetac instala-as (pergunta antes).');
|
|
56
|
+
ok(`Arranque: ${describeStart(part)}`);
|
|
57
|
+
if (part.server) ok(`${label}Servidor: ${part.server}.`);
|
|
58
|
+
if (part.port) ok(`${label}Porta provável: ${part.port.value} (${part.port.source}). Se estiver ocupada, o comando escolhe outra.`);
|
|
59
|
+
// The version note is already the first line.
|
|
60
|
+
for (const text of (part.notes ?? []).filter(text => !text.startsWith('O Python do projeto'))) note(`${label}${text}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
export async function diagnose(root, { panelPort = 4000, out = text => process.stdout.write(`${text}\n`) } = {}) {
|
|
45
64
|
let problems = 0;
|
|
46
65
|
const ok = text => out(` ✓ ${text}`);
|
|
@@ -58,8 +77,12 @@ export async function diagnose(root, { panelPort = 4000, out = text => process.s
|
|
|
58
77
|
const project = detectProject(root);
|
|
59
78
|
if (project.missing.includes('package')) bad('Não há package.json nem ficheiro de servidor (server.js, index.js…) nesta pasta.', 'Corra o comando na pasta da app, ou indique o arranque: codetac . -- node server.js');
|
|
60
79
|
else if (project.missing.includes('part')) note(`Tem várias partes: ${project.parts.map(part => part.part).join(', ')}. O comando pergunta quais arrancar.`);
|
|
61
|
-
else if (project.missing.includes('command')
|
|
80
|
+
else if (project.missing.includes('command') && project.top?.language === 'python') {
|
|
81
|
+
if (project.top.python.django) bad('Projeto Django: o CodeTAC ainda não o suporta (só FastAPI e Flask).');
|
|
82
|
+
else bad('Projeto Python, mas não encontrei a app (FastAPI(...) ou Flask(...)) nem um comando no Procfile, Makefile ou README.', 'Indique o comando: codetac . -- uvicorn main:app --reload');
|
|
83
|
+
} else if (project.missing.includes('command')) bad('O package.json não tem um script de arranque (dev, start…).', 'Indique o comando: codetac . -- <comando>');
|
|
62
84
|
for (const part of project.start.length ? project.start : project.parts) {
|
|
85
|
+
if (part.language === 'python') { pythonPart(part, { ok, bad, note }); continue; }
|
|
63
86
|
ok(`Arranque: ${describeStart(part)}`);
|
|
64
87
|
if (part.port) ok(`Porta provável: ${part.port.value} (${part.port.source}). A porta real é lida quando a app arranca.`);
|
|
65
88
|
if (!part.installed) bad(`As dependências de ${part.part === '.' ? 'o projeto' : part.part} não estão instaladas.`, `${part.manager === 'bun' ? 'npm' : part.manager} install (ou codetac --sim, que instala)`);
|
|
@@ -85,16 +108,23 @@ export async function diagnose(root, { panelPort = 4000, out = text => process.s
|
|
|
85
108
|
return problems ? 1 : 0;
|
|
86
109
|
}
|
|
87
110
|
const summary = summarize(last.folder);
|
|
88
|
-
|
|
111
|
+
// Processes by runtime; a supervisor (reloader) serves nothing and is left out.
|
|
112
|
+
const serving = summary.starts.filter(start => !summary.supervisors.has(start.process));
|
|
113
|
+
const pythons = [...new Set(serving.filter(start => start.python).map(start => start.python))];
|
|
114
|
+
const nodes = serving.filter(start => !start.python).length;
|
|
115
|
+
const runtimes = [nodes && `${nodes} Node`, pythons.length && `${serving.length - nodes} Python ${pythons.join(', ')}`].filter(Boolean).join(', ');
|
|
116
|
+
ok(`${last.name} (${new Date(last.changed).toLocaleString('pt-PT')}), ${serving.length} processo(s) observados${runtimes ? ` (${runtimes})` : ''}.`);
|
|
117
|
+
if (summary.supervisors.size) ok(`${summary.supervisors.size} processo(s) supervisor do reloader (só vigiam os ficheiros; não contam).`);
|
|
118
|
+
if (summary.servers.size) ok(`Servidor: ${[...summary.servers].join(', ')}.`);
|
|
89
119
|
const minimal = summary.starts.find(start => start.level === 'minimo');
|
|
90
120
|
if (minimal) note(`Modo mínimo: as funções do projeto não foram seguidas (${minimal.reason ?? 'sem motivo registado'}). Pedidos, fronteiras e browser sim.`,
|
|
91
121
|
'Se a app arranca sem o CodeTAC mas não com ele, envie estas linhas e as mensagens do arranque.');
|
|
92
122
|
if (summary.ports.size) ok(`Portas abertas pela app: ${[...summary.ports].join(', ')}.`);
|
|
93
|
-
else if (!summary.requests) bad('A captura não viu a app abrir nenhuma porta.', 'A app arrancou? Se o servidor não for Node (bun, deno…), não é observado.');
|
|
123
|
+
else if (!summary.requests) bad('A captura não viu a app abrir nenhuma porta.', 'A app arrancou? Se o servidor não for Node nem Python (bun, deno…), não é observado.');
|
|
94
124
|
if (!minimal) {
|
|
95
125
|
if (summary.files) ok(`Ficheiros do projeto preparados: ${summary.files} (${summary.functions} funções).`);
|
|
96
126
|
else if (summary.requests) bad('Nenhum ficheiro do projeto passou pelo CodeTAC.',
|
|
97
|
-
'O servidor pode estar a correr código já empacotado sem source map, ou fora da pasta do projeto. Os
|
|
127
|
+
'O servidor pode estar a correr código já empacotado sem source map, ou fora da pasta do projeto. Os dossiers mostram só pedidos e fronteiras.');
|
|
98
128
|
const failed = summary.failed.length;
|
|
99
129
|
if (failed) {
|
|
100
130
|
note(`${failed} ficheiro(s) correm sem ser seguidos (não foi possível prepará-los):`);
|
|
@@ -112,7 +142,7 @@ export async function diagnose(root, { panelPort = 4000, out = text => process.s
|
|
|
112
142
|
} else note('Ainda não chegou nenhum pedido.', 'Abra a app no browser e use-a.');
|
|
113
143
|
if (summary.pages) ok(`Páginas servidas com a barra do CodeTAC: ${summary.pages}.`);
|
|
114
144
|
else if (summary.requests && !summary.actions.size) note('Nenhuma página HTML passou pelo servidor observado: a barra não foi injetada.',
|
|
115
|
-
'Se a página vem de outro servidor (
|
|
145
|
+
'Se a página vem de outro servidor (um processo que o CodeTAC não observa), abra-a através do servidor observado da app.');
|
|
116
146
|
if (summary.actions.size) ok(`Ações do browser gravadas: ${summary.actions.size}.`);
|
|
117
147
|
else if (summary.pages) note('A barra está na página, mas ainda não chegou nenhuma ação.', 'Clique em algo na app. Se nada aparecer, veja a consola do browser (erros de CSP?).');
|
|
118
148
|
|
package/src/digest.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Vista agrupada do
|
|
1
|
+
// Vista agrupada do dossier (Fase 3): árvore de passos com o ruído agrupado,
|
|
2
2
|
// uma frase de finalidade por função e por fronteira, e o resumo dos efeitos
|
|
3
3
|
// permanentes. Tudo deriva dos factos gravados; nada é removido, só agrupado
|
|
4
4
|
// (cada grupo guarda os passos originais para a expansão).
|
|
@@ -46,7 +46,7 @@ function generated(node, root) {
|
|
|
46
46
|
return within.split('/').slice(0, -1).some(part => part.startsWith('.'));
|
|
47
47
|
}
|
|
48
48
|
function trivial(node) {
|
|
49
|
-
return node.type === 'function' && !node.children.length && !node.error && !node.detail && node.finished !== false
|
|
49
|
+
return node.type === 'function' && !node.opaque && !node.children.length && !node.error && !node.detail && node.finished !== false
|
|
50
50
|
&& (node.durationMs ?? 0) < TRIVIAL_MS && (node.endLine == null || node.endLine - node.line <= TRIVIAL_LINES);
|
|
51
51
|
}
|
|
52
52
|
const sum = (nodes, key = 'durationMs') => nodes.reduce((total, node) => total + (node[key] ?? 0), 0);
|
|
@@ -154,10 +154,16 @@ function blocks(nodes, ctx) {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
// What a function did, from the boundaries in its subtree and the functions it called.
|
|
157
|
+
// Code compiled from a template (Jinja, and any engine whose functions keep
|
|
158
|
+
// the template's file): what it does is produce HTML.
|
|
159
|
+
const TEMPLATE_FILE = /\.(html?|jinja2?|j2|njk|twig|hbs|ejs|mustache|liquid)$/i;
|
|
160
|
+
|
|
157
161
|
function functionSentence(node, ctx) {
|
|
158
162
|
const t = texts(ctx.lang);
|
|
159
163
|
const facts = collect(node);
|
|
160
164
|
const parts = [];
|
|
165
|
+
const template = TEMPLATE_FILE.test(node.file ?? '');
|
|
166
|
+
if (template && !node.opaque) parts.push(t.templatePart(node.file.split('/').pop()));
|
|
161
167
|
const db = facts.boundaries.filter(item => item.kind === 'base-de-dados');
|
|
162
168
|
const structure = db.filter(item => STRUCTURE.has(item.operation));
|
|
163
169
|
if (structure.length) parts.push(t.dbSetupPart(structure.length));
|
|
@@ -187,7 +193,9 @@ function functionSentence(node, ctx) {
|
|
|
187
193
|
const auth = [...new Set(kinds('autenticação').map(item => item.provider ?? item.library))];
|
|
188
194
|
if (auth.length) parts.push(t.authPart(names(auth, t.and)));
|
|
189
195
|
const callees = [...new Set(node.children.filter(item => item.type === 'function' && !generated(item, ctx.root)).map(item => item.function))];
|
|
190
|
-
|
|
196
|
+
// An opaque step (compiled code, an unmapped template): only what it is.
|
|
197
|
+
if (node.opaque) parts.push(t.opaque);
|
|
198
|
+
else if (!facts.boundaries.length && !template) parts.push(t.noBoundary);
|
|
191
199
|
if (callees.length) parts.push(t.calls(names(callees, t.and)));
|
|
192
200
|
if (node.error) parts.push(t.error);
|
|
193
201
|
else if (node.finished === false) parts.push(t.unfinished);
|
|
@@ -307,7 +315,13 @@ export function digestAction(dossier, { lang = 'pt-PT' } = {}) {
|
|
|
307
315
|
const trigger = dossier.timeline.find(item => item.type === 'trigger')?.trigger;
|
|
308
316
|
const a = t.action;
|
|
309
317
|
const opening = !trigger ? a.continuation : trigger.event === 'submit' ? a.submit(dossier.label) : trigger.event === 'change' ? a.change(dossier.label) : a.click(dossier.label);
|
|
310
|
-
|
|
318
|
+
// Requests of the page (fetch, XHR) and those of a full navigation (a form
|
|
319
|
+
// that loads the next page): the server parts of each new document.
|
|
320
|
+
// Requests to another origin (an API on another port without a proxy, an
|
|
321
|
+
// outside service) are requests too: named with their host.
|
|
322
|
+
const requests = timeline.flatMap(item => item.type === 'request' && item.browser ? [a.request(item.browser.method,
|
|
323
|
+
item.browser.sameOrigin || !item.browser.host ? item.browser.path : `${item.browser.host}${item.browser.path}`, item.browser.status)]
|
|
324
|
+
: item.type === 'document' ? (item.server ?? []).map(part => a.request(part.request.method, part.request.path, part.request.status)) : []);
|
|
311
325
|
const navigations = timeline.filter(item => item.type === 'navigation' || item.type === 'document').map(item => a.navigates(item.path ?? item.page?.path));
|
|
312
326
|
const screens = dossier.timeline.filter(item => item.type === 'screen');
|
|
313
327
|
const changed = screens.some(item => { const s = item.screen ?? {}; return s.added || s.removed || s.text || s.attributes || s.title || s.stateChanged?.length; });
|
package/src/page.mjs
CHANGED
|
@@ -156,6 +156,22 @@ function withoutLength(headers) {
|
|
|
156
156
|
return Object.fromEntries(Object.entries(headers).filter(([key]) => !DROP.has(key.toLowerCase())));
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Where the tag goes: after <head ...>; in a whole document without a head,
|
|
160
|
+
// before <body>, or else after <html ...> or <!doctype html> (error pages such
|
|
161
|
+
// as werkzeug's have neither head nor body). Fragments (partial HTML
|
|
162
|
+
// responses) are left intact: -1. null while more of the body is needed.
|
|
163
|
+
// The same rule is in src/python/codetac_py/page.py.
|
|
164
|
+
export function tagPosition(view, final) {
|
|
165
|
+
const head = view.match(/<head(?:\s[^>]*)?>/i);
|
|
166
|
+
if (head) return head.index + head[0].length;
|
|
167
|
+
if (!final) return null;
|
|
168
|
+
const body = view.search(/<body[\s>]/i);
|
|
169
|
+
if (body >= 0) return body;
|
|
170
|
+
const opening = view.match(/^(?:\s|<!--[\s\S]*?-->)*(?:<!doctype html[^>]*>(?:\s|<!--[\s\S]*?-->)*)?<html(?:\s[^>]*)?>/i)
|
|
171
|
+
?? view.match(/^(?:\s|<!--[\s\S]*?-->)*<!doctype html[^>]*>/i);
|
|
172
|
+
return opening ? opening[0].length : -1;
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
export function injectScript(request, response) {
|
|
160
176
|
delete request.headers['accept-encoding'];
|
|
161
177
|
let mode = null; // null: undecided; 'pass'; 'buffer'; 'done'
|
|
@@ -181,15 +197,11 @@ export function injectScript(request, response) {
|
|
|
181
197
|
const decide = self => { if (mode === null && !self.headersSent) self.writeHead(self.statusCode); if (mode === null) mode = 'pass'; };
|
|
182
198
|
const toBuffer = (chunk, encoding) => Buffer.isBuffer(chunk) ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk)
|
|
183
199
|
: Buffer.from(String(chunk), typeof encoding === 'string' ? encoding : 'utf8');
|
|
184
|
-
// Inserts the tag after <head ...>, or before <body> when there is no head.
|
|
185
|
-
// Fragments (no head/body, as in partial HTML responses) are left intact.
|
|
186
200
|
function take(final) {
|
|
187
201
|
const all = Buffer.concat(chunks);
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (at < 0 && (final || all.length > MAX_BUFFER)) at = view.search(/<body[\s>]/i);
|
|
192
|
-
if (at < 0) return final || all.length > MAX_BUFFER ? all : null;
|
|
202
|
+
const at = tagPosition(all.toString('latin1'), final || all.length > MAX_BUFFER);
|
|
203
|
+
if (at === null) return null;
|
|
204
|
+
if (at < 0) return all;
|
|
193
205
|
return Buffer.concat([all.subarray(0, at), Buffer.from(TAG), all.subarray(at)]);
|
|
194
206
|
}
|
|
195
207
|
|