docugate 0.2.0 → 0.2.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.
@@ -1,6 +1,7 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import { createServer } from 'node:http';
2
3
  import { existsSync, mkdirSync, readFileSync, realpathSync, watch, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
+ import { join, relative, resolve, isAbsolute } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { loadTours, matchRoute, parseTourFile, routeToFilename, serializeTourFile } from './tour.js';
6
7
  // ---------------------------------------------------------------------------
@@ -94,7 +95,7 @@ function handleGetTour(req, res, root) {
94
95
  sendJson(res, 404, { error: 'not found' });
95
96
  return;
96
97
  }
97
- sendJson(res, 200, { route: match.route, title: match.title, stops: match.stops });
98
+ sendJson(res, 200, { route: match.route, title: match.title, file: match.file, stops: match.stops });
98
99
  }
99
100
  function handleGetEvents(req, res, root) {
100
101
  const tourDir = join(root, '.docugate', 'tour');
@@ -134,7 +135,7 @@ async function handlePutStop(req, res, root) {
134
135
  sendJson(res, 400, { error: 'invalid JSON body' });
135
136
  return;
136
137
  }
137
- const { route, title: bodyTitle, stop } = body;
138
+ const { route, title: bodyTitle, stop, previousTarget } = body;
138
139
  if (!route || !stop) {
139
140
  sendJson(res, 400, { error: 'body must have route and stop' });
140
141
  return;
@@ -161,8 +162,9 @@ async function handlePutStop(req, res, root) {
161
162
  stops: [],
162
163
  };
163
164
  }
164
- // Upsert: find by target and update, or append.
165
- const idx = tour.stops.findIndex((s) => s.target === stop.target);
165
+ // Upsert: find the stop being edited (by its old target when the edit
166
+ // changed it) and update it in place, or append a new one.
167
+ const idx = tour.stops.findIndex((s) => s.target === (previousTarget ?? stop.target));
166
168
  if (idx >= 0) {
167
169
  tour.stops[idx] = stop;
168
170
  }
@@ -172,6 +174,91 @@ async function handlePutStop(req, res, root) {
172
174
  writeFileSync(filepath, serializeTourFile(tour));
173
175
  sendJson(res, 200, { ok: true });
174
176
  }
177
+ /** DELETE /stop { route, target }: removes one stop, leaving the rest of the file as it was. */
178
+ async function handleDeleteStop(req, res, root) {
179
+ let body;
180
+ try {
181
+ body = JSON.parse(await readBody(req));
182
+ }
183
+ catch {
184
+ sendJson(res, 400, { error: 'invalid JSON body' });
185
+ return;
186
+ }
187
+ const existing = loadTours(root).find((t) => t.route === body.route);
188
+ if (!existing?.file || !body.target) {
189
+ sendJson(res, 404, { error: 'no such stop' });
190
+ return;
191
+ }
192
+ const filepath = join(root, existing.file);
193
+ const tour = parseTourFile(readFileSync(filepath, 'utf8'), existing.file);
194
+ const before = tour.stops.length;
195
+ tour.stops = tour.stops.filter((s) => s.target !== body.target);
196
+ if (tour.stops.length === before) {
197
+ sendJson(res, 404, { error: 'no such stop' });
198
+ return;
199
+ }
200
+ writeFileSync(filepath, serializeTourFile(tour));
201
+ sendJson(res, 200, { ok: true });
202
+ }
203
+ /**
204
+ * The editor `docugate tour serve` was started from, as the command that opens
205
+ * a file at a line. DOCUGATE_EDITOR wins; otherwise the integrated terminal's
206
+ * environment says which VS Code family editor it is.
207
+ */
208
+ export function editorCommand(env = process.env) {
209
+ if (env.DOCUGATE_EDITOR)
210
+ return env.DOCUGATE_EDITOR;
211
+ const hint = `${env.VSCODE_GIT_ASKPASS_NODE ?? ''} ${env.VSCODE_CWD ?? ''} ${env.CURSOR_TRACE_ID ? 'cursor' : ''}`.toLowerCase();
212
+ if (hint.includes('cursor'))
213
+ return 'cursor';
214
+ if (hint.includes('windsurf'))
215
+ return 'windsurf';
216
+ if (hint.includes('insiders'))
217
+ return 'code-insiders';
218
+ return 'code';
219
+ }
220
+ /** The 1-based line of the first occurrence of `find` in `text`, or 1. */
221
+ export function lineOf(text, find) {
222
+ if (!find)
223
+ return 1;
224
+ const index = text.split(/\r?\n/).findIndex((line) => line.includes(find));
225
+ return index >= 0 ? index + 1 : 1;
226
+ }
227
+ /**
228
+ * POST /open { path, find? }: opens a file of this repository in the editor,
229
+ * at the line that contains `find` (the element's data-tour value), so "Code"
230
+ * in the pill jumps straight to the element. Only files inside the repository.
231
+ */
232
+ async function handleOpen(req, res, root) {
233
+ let body;
234
+ try {
235
+ body = JSON.parse(await readBody(req));
236
+ }
237
+ catch {
238
+ sendJson(res, 400, { error: 'invalid JSON body' });
239
+ return;
240
+ }
241
+ const abs = resolve(root, body.path ?? '');
242
+ const rel = relative(root, abs);
243
+ if (!body.path || rel.startsWith('..') || isAbsolute(rel) || !existsSync(abs) || /["%^&|<>`$]/.test(abs)) {
244
+ sendJson(res, 404, { error: 'no such file in this repository' });
245
+ return;
246
+ }
247
+ const line = lineOf(readFileSync(abs, 'utf8'), body.find);
248
+ const command = editorCommand();
249
+ // One command string, the path quoted: editor launchers are .cmd files on
250
+ // Windows, which only run through a shell.
251
+ const child = spawn(`${command} -g "${abs}:${line}"`, { shell: true, stdio: 'ignore' });
252
+ child.on('error', () => sendJson(res, 501, { error: `could not run ${command}` }));
253
+ child.on('exit', (code) => {
254
+ if (res.headersSent)
255
+ return;
256
+ if (code === 0)
257
+ sendJson(res, 200, { ok: true, line });
258
+ else
259
+ sendJson(res, 501, { error: `could not run ${command}`, line });
260
+ });
261
+ }
175
262
  function handleGetPill(res) {
176
263
  // pill/dist/pill.js is resolved relative to this package's root directory.
177
264
  // import.meta.url points at dist/tour-server.js at runtime, so go up one level.
@@ -194,6 +281,18 @@ export function startTourServer(root, port) {
194
281
  }
195
282
  const url = new URL(req.url ?? '/', 'http://localhost');
196
283
  const path = url.pathname;
284
+ // The pill runs on the app's own port, so a JSON PUT from it is a
285
+ // cross-origin request: the browser asks first, and must get a yes.
286
+ if (req.method === 'OPTIONS') {
287
+ res.writeHead(204, {
288
+ 'Access-Control-Allow-Origin': '*',
289
+ 'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE',
290
+ 'Access-Control-Allow-Headers': 'Content-Type',
291
+ 'Access-Control-Max-Age': '600',
292
+ });
293
+ res.end();
294
+ return;
295
+ }
197
296
  if (req.method === 'GET' && path === '/tour') {
198
297
  handleGetTour(req, res, root);
199
298
  }
@@ -205,6 +304,16 @@ export function startTourServer(root, port) {
205
304
  sendJson(res, 500, { error: err.message });
206
305
  });
207
306
  }
307
+ else if (req.method === 'DELETE' && path === '/stop') {
308
+ handleDeleteStop(req, res, root).catch((err) => {
309
+ sendJson(res, 500, { error: err.message });
310
+ });
311
+ }
312
+ else if (req.method === 'POST' && path === '/open') {
313
+ handleOpen(req, res, root).catch((err) => {
314
+ sendJson(res, 500, { error: err.message });
315
+ });
316
+ }
208
317
  else if (req.method === 'GET' && path === '/pill.js') {
209
318
  handleGetPill(res);
210
319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docugate",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Command-line tools for DocuGate: set up, check and prepare the documentation DocuGate reads straight from your repository.",
5
5
  "type": "module",
6
6
  "bin": {