asljs-rq-cli 0.1.1 → 0.1.3
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.md +44 -13
- package/README.md +21 -27
- package/dist/cli.d.ts +5 -9
- package/dist/cli.js +19 -62
- package/dist/config.d.ts +2 -2
- package/dist/config.js +15 -16
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/docs/Changing the graph.md +3 -3
- package/docs/Querying the graph.md +2 -2
- package/docs/rq agents.md +2 -2
- package/docs/rq check.md +3 -3
- package/docs/rq config.md +5 -5
- package/docs/rq coverage.md +2 -2
- package/docs/rq test.md +4 -4
- package/package.json +14 -14
- package/skills/requirements-authoring.md +2 -2
- package/skills/requirements-change.md +2 -2
- package/skills/requirements-coverage.md +2 -2
- package/bin/rq-mcp.js +0 -22
- package/dist/mcp.d.ts +0 -28
- package/dist/mcp.js +0 -70
- package/dist/view-script.d.ts +0 -41
- package/dist/view-script.js +0 -39
- package/dist/view.d.ts +0 -39
- package/dist/view.js +0 -321
- package/docs/rq view.md +0 -101
- package/docs/rq-mcp.md +0 -79
package/bin/rq-mcp.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { main }
|
|
4
|
-
from '../dist/mcp.js';
|
|
5
|
-
|
|
6
|
-
main(
|
|
7
|
-
process.argv.slice(2),
|
|
8
|
-
{ cwd: process.cwd(),
|
|
9
|
-
env: process.env,
|
|
10
|
-
stdout: process.stderr,
|
|
11
|
-
stderr: process.stderr },
|
|
12
|
-
process.stdin,
|
|
13
|
-
line => process.stdout.write(line))
|
|
14
|
-
.catch(
|
|
15
|
-
error => {
|
|
16
|
-
console.error(
|
|
17
|
-
error instanceof Error
|
|
18
|
-
? error.message
|
|
19
|
-
: String(error));
|
|
20
|
-
|
|
21
|
-
process.exitCode = 1;
|
|
22
|
-
});
|
package/dist/mcp.d.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { type McpServerInfo, type McpTool } from 'asljs-mcp';
|
|
2
|
-
import { Io } from 'asljs-rq';
|
|
3
|
-
import { Server } from 'node:http';
|
|
4
|
-
import { Readable } from 'node:stream';
|
|
5
|
-
export declare const SERVER_NAME = "asljs-rq";
|
|
6
|
-
/**
|
|
7
|
-
* The tools of the MCP server: a tool per `rq` command, run as that command
|
|
8
|
-
* line in `io.cwd`, or in its `workingDir`, with its output as the result.
|
|
9
|
-
*/
|
|
10
|
-
export declare function createTools(io: Io, servers?: Server[]): McpTool[];
|
|
11
|
-
/**
|
|
12
|
-
* Serves the `rq` commands as MCP tools over a line-delimited JSON-RPC
|
|
13
|
-
* stream, e.g. standard input and output. Resolves when the input ends,
|
|
14
|
-
* and closes the servers `view` started.
|
|
15
|
-
*/
|
|
16
|
-
export declare function runMcpServer(io: Io, input: Readable, write: (line: string) => void): Promise<void>;
|
|
17
|
-
/**
|
|
18
|
-
* The `rq-mcp` executable: serves the tools with a logger provider made
|
|
19
|
-
* from `--loglevel`, `--logfile`, `--logformat` and the `RQ_LOG_`
|
|
20
|
-
* variables, and disposes it once the input ends. Standard output carries the
|
|
21
|
-
* protocol, so a log level without a log file other than stdout throws here,
|
|
22
|
-
* before the first request is read.
|
|
23
|
-
*/
|
|
24
|
-
export declare function main(args: string[], io: Io, input: Readable, write: (line: string) => void): Promise<void>;
|
|
25
|
-
/**
|
|
26
|
-
* The name and the version of the installed package.
|
|
27
|
-
*/
|
|
28
|
-
export declare function serverInfo(): McpServerInfo;
|
package/dist/mcp.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { createLoggerProvider, readLoggerOptions } from 'asljs-logging';
|
|
2
|
-
import { commandTools, serveLines } from 'asljs-mcp';
|
|
3
|
-
import { getLogger } from 'asljs-rq';
|
|
4
|
-
import { createRequire } from 'node:module';
|
|
5
|
-
import { createCli, runCli } from './cli.js';
|
|
6
|
-
export const SERVER_NAME = 'asljs-rq';
|
|
7
|
-
/**
|
|
8
|
-
* The tools of the MCP server: a tool per `rq` command, run as that command
|
|
9
|
-
* line in `io.cwd`, or in its `workingDir`, with its output as the result.
|
|
10
|
-
*/
|
|
11
|
-
export function createTools(io, servers = []) {
|
|
12
|
-
return commandTools(createCli([], io).cli, async (args) => {
|
|
13
|
-
let stdout = '';
|
|
14
|
-
let stderr = '';
|
|
15
|
-
const exitCode = await runCli(args, { ...io,
|
|
16
|
-
stdout: { write: (text) => {
|
|
17
|
-
stdout += text;
|
|
18
|
-
} },
|
|
19
|
-
stderr: { write: (text) => {
|
|
20
|
-
stderr += text;
|
|
21
|
-
} } }, servers);
|
|
22
|
-
return { exitCode,
|
|
23
|
-
stdout,
|
|
24
|
-
stderr };
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Serves the `rq` commands as MCP tools over a line-delimited JSON-RPC
|
|
29
|
-
* stream, e.g. standard input and output. Resolves when the input ends,
|
|
30
|
-
* and closes the servers `view` started.
|
|
31
|
-
*/
|
|
32
|
-
export async function runMcpServer(io, input, write) {
|
|
33
|
-
const servers = [];
|
|
34
|
-
try {
|
|
35
|
-
await serveLines(input, write, createTools(io, servers), serverInfo(), { logger: getLogger(io, 'rq.mcp') });
|
|
36
|
-
}
|
|
37
|
-
finally {
|
|
38
|
-
for (const server of servers) {
|
|
39
|
-
await new Promise((resolve) => {
|
|
40
|
-
server.close(resolve);
|
|
41
|
-
server.closeAllConnections();
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* The `rq-mcp` executable: serves the tools with a logger provider made
|
|
48
|
-
* from `--loglevel`, `--logfile`, `--logformat` and the `RQ_LOG_`
|
|
49
|
-
* variables, and disposes it once the input ends. Standard output carries the
|
|
50
|
-
* protocol, so a log level without a log file other than stdout throws here,
|
|
51
|
-
* before the first request is read.
|
|
52
|
-
*/
|
|
53
|
-
export async function main(args, io, input, write) {
|
|
54
|
-
const loggerProvider = createLoggerProvider('RQ_LOG_', readLoggerOptions(args), { allowStdout: false });
|
|
55
|
-
try {
|
|
56
|
-
await runMcpServer({ ...io,
|
|
57
|
-
loggerProvider }, input, write);
|
|
58
|
-
}
|
|
59
|
-
finally {
|
|
60
|
-
await loggerProvider.dispose();
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* The name and the version of the installed package.
|
|
65
|
-
*/
|
|
66
|
-
export function serverInfo() {
|
|
67
|
-
const { version } = createRequire(import.meta.url)('../package.json');
|
|
68
|
-
return { name: SERVER_NAME,
|
|
69
|
-
version };
|
|
70
|
-
}
|
package/dist/view-script.d.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The part of the diagram the window shows, in diagram coordinates: the SVG's
|
|
3
|
-
* `viewBox`.
|
|
4
|
-
*/
|
|
5
|
-
export interface ViewBox {
|
|
6
|
-
x: number;
|
|
7
|
-
y: number;
|
|
8
|
-
width: number;
|
|
9
|
-
height: number;
|
|
10
|
-
}
|
|
11
|
-
export interface Point {
|
|
12
|
-
x: number;
|
|
13
|
-
y: number;
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* The view box zoomed by a wheel turn of `deltaY` around `point`, which stays
|
|
17
|
-
* where it is on the screen: a turn down, a positive `deltaY`, zooms out.
|
|
18
|
-
*
|
|
19
|
-
* Its source is sent to the browser as part of the index page, so it uses
|
|
20
|
-
* nothing but its arguments.
|
|
21
|
-
*/
|
|
22
|
-
export declare function zoomAt(box: ViewBox, point: Point, deltaY: number): ViewBox;
|
|
23
|
-
/**
|
|
24
|
-
* The view box moved so that `grabbed`, the diagram point where a drag
|
|
25
|
-
* started, is under the pointer, now over `point`.
|
|
26
|
-
*
|
|
27
|
-
* Its source is sent to the browser as part of the index page, so it uses
|
|
28
|
-
* nothing but its arguments.
|
|
29
|
-
*/
|
|
30
|
-
export declare function panTo(box: ViewBox, grabbed: Point, point: Point): ViewBox;
|
|
31
|
-
/**
|
|
32
|
-
* Whether pressing `button` on `target` starts a pan: the main button on
|
|
33
|
-
* empty space, not on a node or a link, so that a click on a node still opens
|
|
34
|
-
* its document.
|
|
35
|
-
*
|
|
36
|
-
* Its source is sent to the browser as part of the index page, so it uses
|
|
37
|
-
* nothing but its arguments.
|
|
38
|
-
*/
|
|
39
|
-
export declare function startsPan(button: number, target: {
|
|
40
|
-
closest(selector: string): unknown;
|
|
41
|
-
}): boolean;
|
package/dist/view-script.js
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The view box zoomed by a wheel turn of `deltaY` around `point`, which stays
|
|
3
|
-
* where it is on the screen: a turn down, a positive `deltaY`, zooms out.
|
|
4
|
-
*
|
|
5
|
-
* Its source is sent to the browser as part of the index page, so it uses
|
|
6
|
-
* nothing but its arguments.
|
|
7
|
-
*/
|
|
8
|
-
export function zoomAt(box, point, deltaY) {
|
|
9
|
-
const factor = Math.exp(deltaY * 0.0015);
|
|
10
|
-
return { x: point.x - (point.x - box.x) * factor,
|
|
11
|
-
y: point.y - (point.y - box.y) * factor,
|
|
12
|
-
width: box.width * factor,
|
|
13
|
-
height: box.height * factor };
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* The view box moved so that `grabbed`, the diagram point where a drag
|
|
17
|
-
* started, is under the pointer, now over `point`.
|
|
18
|
-
*
|
|
19
|
-
* Its source is sent to the browser as part of the index page, so it uses
|
|
20
|
-
* nothing but its arguments.
|
|
21
|
-
*/
|
|
22
|
-
export function panTo(box, grabbed, point) {
|
|
23
|
-
return { x: box.x - (point.x - grabbed.x),
|
|
24
|
-
y: box.y - (point.y - grabbed.y),
|
|
25
|
-
width: box.width,
|
|
26
|
-
height: box.height };
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Whether pressing `button` on `target` starts a pan: the main button on
|
|
30
|
-
* empty space, not on a node or a link, so that a click on a node still opens
|
|
31
|
-
* its document.
|
|
32
|
-
*
|
|
33
|
-
* Its source is sent to the browser as part of the index page, so it uses
|
|
34
|
-
* nothing but its arguments.
|
|
35
|
-
*/
|
|
36
|
-
export function startsPan(button, target) {
|
|
37
|
-
return button === 0
|
|
38
|
-
&& !target.closest('.node, a');
|
|
39
|
-
}
|
package/dist/view.d.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { type PostReply } from 'asljs-mdcli';
|
|
2
|
-
import { Io } from 'asljs-rq';
|
|
3
|
-
import { Server } from 'node:http';
|
|
4
|
-
export { DEFAULT_PORT } from 'asljs-mdcli';
|
|
5
|
-
export interface ViewOptions {
|
|
6
|
-
/**
|
|
7
|
-
* The requirement file or folder to view, relative to the working
|
|
8
|
-
* directory, or an id or `.md` name; the requirements folder when absent.
|
|
9
|
-
*/
|
|
10
|
-
target?: string;
|
|
11
|
-
/**
|
|
12
|
-
* The port to listen on, exactly; 0 picks a free one. When absent, the
|
|
13
|
-
* first free port from `DEFAULT_PORT` on.
|
|
14
|
-
*/
|
|
15
|
-
port?: number;
|
|
16
|
-
host?: string;
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* The body of a rendered document: a requirement or test starts with its
|
|
20
|
-
* full path, which a click copies; any other document is its text alone.
|
|
21
|
-
*/
|
|
22
|
-
export declare function renderDocument(file: string): Promise<string>;
|
|
23
|
-
/**
|
|
24
|
-
* Saves what the editor of a requirement or test sends to the document at
|
|
25
|
-
* the URL's path in `folder`: its title and its text, unless the document
|
|
26
|
-
* changed since the editor opened it; the ids of the requirements folder in
|
|
27
|
-
* the text become links. Then the document is post-processed, as after a
|
|
28
|
-
* command.
|
|
29
|
-
*/
|
|
30
|
-
export declare function saveDocument(io: Io, folder: string, url: URL, body: unknown): Promise<PostReply>;
|
|
31
|
-
/**
|
|
32
|
-
* Starts a web server for a requirement file or folder. It serves the
|
|
33
|
-
* documents folder, or the folder of the graph when none is given, which must
|
|
34
|
-
* contain the graph's folder. `/` shows the graph, whose nodes open the
|
|
35
|
-
* documents; a `.md` file of the served folder is shown rendered and any other
|
|
36
|
-
* file as it is, so a document a requirement links to opens wherever it is in
|
|
37
|
-
* the folder. The graph is reloaded on every request for `/`.
|
|
38
|
-
*/
|
|
39
|
-
export declare function execView(io: Io, options: ViewOptions): Promise<Server>;
|
package/dist/view.js
DELETED
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
import { escapeHtml, markdownToHtml, page, postProcess, serverUrl, startServer, takeWritten, writeMarkdown } from 'asljs-mdcli';
|
|
2
|
-
import { findTarget, getLogger, getNodeKind, getRequirementsDir, getResults, getVersion, isFile, isInside, loadGraph, readEditable, readIds, readImplementation, renderImplementationTable, toMermaid, writeEdited } from 'asljs-rq';
|
|
3
|
-
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { panTo, startsPan, zoomAt } from './view-script.js';
|
|
6
|
-
export { DEFAULT_PORT } from 'asljs-mdcli';
|
|
7
|
-
const MERMAID_MODULE = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
8
|
-
/**
|
|
9
|
-
* The full path above a requirement or test, small and grey; a click copies
|
|
10
|
-
* it.
|
|
11
|
-
*/
|
|
12
|
-
const DOCUMENT_STYLE = `.rq-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin: 0 0 0.5rem; }
|
|
13
|
-
.rq-path { margin: 0; font-family: ui-monospace, monospace; font-size: 0.8rem; color: #757575; cursor: copy; overflow-wrap: anywhere; }
|
|
14
|
-
.rq-path.copied { color: #2e7d32; }
|
|
15
|
-
.rq-title { display: flex; align-items: baseline; gap: 0.5rem; margin: 0.67em 0; font-size: 2em; font-weight: bold; }
|
|
16
|
-
.rq-id { color: #757575; }
|
|
17
|
-
#rq-title { flex: 1; min-width: 0; font: inherit; padding: 0 0.25rem; }
|
|
18
|
-
#rq-editor { border: 1px solid #ddd; min-height: 300px; }
|
|
19
|
-
.rq-note, #rq-message { color: #757575; font-size: 0.85rem; }
|
|
20
|
-
.rq-implementation { border-collapse: collapse; }
|
|
21
|
-
.rq-implementation th, .rq-implementation td { border-bottom: 1px solid #ddd; padding: 0.25rem 0.75rem 0.25rem 0; text-align: left; }
|
|
22
|
-
.rq-pass { color: #2e7d32; }
|
|
23
|
-
.rq-fail, .rq-missing { color: #c62828; }
|
|
24
|
-
.rq-not-run { color: #757575; }`;
|
|
25
|
-
/**
|
|
26
|
-
* Stands for a requirement's Implementation list until the rest is rendered.
|
|
27
|
-
*/
|
|
28
|
-
const TABLE_PLACEHOLDER = 'RQIMPLEMENTATIONTABLE';
|
|
29
|
-
/**
|
|
30
|
-
* The Milkdown editor, loaded when Edit is pressed: one module with its
|
|
31
|
-
* dependencies, since separate modules can load ProseMirror twice, and its
|
|
32
|
-
* theme.
|
|
33
|
-
*/
|
|
34
|
-
const CREPE_MODULE = 'https://esm.sh/@milkdown/crepe@7.22.3?bundle';
|
|
35
|
-
const CREPE_THEME = 'https://cdn.jsdelivr.net/npm/@milkdown/crepe@7.22.3/lib/theme';
|
|
36
|
-
/**
|
|
37
|
-
* Edit replaces the rendered document with a field for the title and a
|
|
38
|
-
* Milkdown editor for the text, loaded only then; Save, or Ctrl+S, sends
|
|
39
|
-
* both back with the version the page was made from, and reloads the page
|
|
40
|
-
* once saved.
|
|
41
|
-
*/
|
|
42
|
-
const EDIT_SCRIPT = `<script type="module">
|
|
43
|
-
const data = JSON.parse(document.getElementById('rq-edit-data').textContent);
|
|
44
|
-
const button = document.getElementById('rq-edit');
|
|
45
|
-
const form = document.getElementById('rq-form');
|
|
46
|
-
const title = document.getElementById('rq-title');
|
|
47
|
-
const message = document.getElementById('rq-message');
|
|
48
|
-
let crepe = null;
|
|
49
|
-
button.addEventListener('click', async () => {
|
|
50
|
-
button.disabled = true;
|
|
51
|
-
button.textContent = 'Loading…';
|
|
52
|
-
for (const sheet of [ 'common', 'frame' ]) {
|
|
53
|
-
const link = document.createElement('link');
|
|
54
|
-
link.rel = 'stylesheet';
|
|
55
|
-
link.href = '${CREPE_THEME}/' + sheet + '/style.css';
|
|
56
|
-
document.head.append(link);
|
|
57
|
-
}
|
|
58
|
-
try {
|
|
59
|
-
const { Crepe } = await import('${CREPE_MODULE}');
|
|
60
|
-
crepe = new Crepe({ root: document.getElementById('rq-editor'), defaultValue: data.body });
|
|
61
|
-
await crepe.create();
|
|
62
|
-
} catch (error) {
|
|
63
|
-
button.disabled = false;
|
|
64
|
-
button.textContent = 'Edit';
|
|
65
|
-
message.textContent = 'The editor did not load: ' + error.message;
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
document.getElementById('rq-view').hidden = true;
|
|
69
|
-
button.hidden = true;
|
|
70
|
-
form.hidden = false;
|
|
71
|
-
title.value = data.title;
|
|
72
|
-
title.focus();
|
|
73
|
-
});
|
|
74
|
-
form.addEventListener('submit', async (event) => {
|
|
75
|
-
event.preventDefault();
|
|
76
|
-
message.textContent = 'Saving…';
|
|
77
|
-
const response = await fetch(location.pathname, {
|
|
78
|
-
method: 'POST',
|
|
79
|
-
headers: { 'content-type': 'application/json' },
|
|
80
|
-
body: JSON.stringify({ version: data.version, title: title.value, body: crepe.getMarkdown() })
|
|
81
|
-
});
|
|
82
|
-
const reply = await response.json().catch(() => ({ error: response.statusText }));
|
|
83
|
-
if (response.ok) {
|
|
84
|
-
location.reload();
|
|
85
|
-
} else {
|
|
86
|
-
message.textContent = reply.error;
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
document.addEventListener('keydown', (event) => {
|
|
90
|
-
if (!form.hidden && (event.ctrlKey || event.metaKey) && event.key === 's') {
|
|
91
|
-
event.preventDefault();
|
|
92
|
-
form.requestSubmit();
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
document.getElementById('rq-cancel').addEventListener('click', () => location.reload());
|
|
96
|
-
</script>`;
|
|
97
|
-
/**
|
|
98
|
-
* Copies the path a `.rq-path` holds when it is clicked, and shows it was
|
|
99
|
-
* copied for a moment.
|
|
100
|
-
*/
|
|
101
|
-
const COPY_SCRIPT = `<script>
|
|
102
|
-
document.querySelector('.rq-path').addEventListener('click', async (event) => {
|
|
103
|
-
const element = event.currentTarget;
|
|
104
|
-
await navigator.clipboard.writeText(element.dataset.path);
|
|
105
|
-
element.classList.add('copied');
|
|
106
|
-
setTimeout(() => element.classList.remove('copied'), 1000);
|
|
107
|
-
});
|
|
108
|
-
</script>`;
|
|
109
|
-
/**
|
|
110
|
-
* The body of a rendered document: a requirement or test starts with its
|
|
111
|
-
* full path, which a click copies; any other document is its text alone.
|
|
112
|
-
*/
|
|
113
|
-
export async function renderDocument(file) {
|
|
114
|
-
const text = await readFile(file, 'utf8');
|
|
115
|
-
const kind = getNodeKind(file);
|
|
116
|
-
if (kind === null) {
|
|
117
|
-
return markdownToHtml(text);
|
|
118
|
-
}
|
|
119
|
-
// A requirement's Implementation list is shown as a table of what it
|
|
120
|
-
// links to, with their coverage and result.
|
|
121
|
-
const implementation = kind === 'requirement'
|
|
122
|
-
? await readImplementation(file, text, TABLE_PLACEHOLDER)
|
|
123
|
-
: null;
|
|
124
|
-
const html = implementation === null
|
|
125
|
-
? markdownToHtml(text)
|
|
126
|
-
: markdownToHtml(implementation.text)
|
|
127
|
-
.replace(`<p>${TABLE_PLACEHOLDER}</p>`, renderImplementationTable(implementation.rows));
|
|
128
|
-
const editable = readEditable(file, text);
|
|
129
|
-
// The editor's data, safe inside a script element.
|
|
130
|
-
const data = JSON.stringify(editable)
|
|
131
|
-
.replace(/</g, '\\u003c');
|
|
132
|
-
return `<div class="rq-head">
|
|
133
|
-
<p class="rq-path" data-path="${escapeHtml(file)}" title="Copy the path">${escapeHtml(file)}</p>
|
|
134
|
-
<p><span id="rq-message"></span> <button type="button" id="rq-edit">Edit</button></p>
|
|
135
|
-
</div>
|
|
136
|
-
<div id="rq-view">
|
|
137
|
-
${html}
|
|
138
|
-
</div>
|
|
139
|
-
<form id="rq-form" hidden>
|
|
140
|
-
<p class="rq-title"><span class="rq-id">${escapeHtml(editable.id)}</span><input id="rq-title" aria-label="Title"></p>
|
|
141
|
-
<div id="rq-editor"></div>
|
|
142
|
-
<p class="rq-note">Implementation, Coverage and Status are kept as they are, after the text. Ids such as R2 or T3 become links when saved.</p>
|
|
143
|
-
<p><button type="submit">Save</button> <button type="button" id="rq-cancel">Cancel</button></p>
|
|
144
|
-
</form>
|
|
145
|
-
<script type="application/json" id="rq-edit-data">${data}</script>
|
|
146
|
-
${COPY_SCRIPT}
|
|
147
|
-
${EDIT_SCRIPT}`;
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* Saves what the editor of a requirement or test sends to the document at
|
|
151
|
-
* the URL's path in `folder`: its title and its text, unless the document
|
|
152
|
-
* changed since the editor opened it; the ids of the requirements folder in
|
|
153
|
-
* the text become links. Then the document is post-processed, as after a
|
|
154
|
-
* command.
|
|
155
|
-
*/
|
|
156
|
-
export async function saveDocument(io, folder, url, body) {
|
|
157
|
-
const pathname = decodeURIComponent(url.pathname);
|
|
158
|
-
const file = path.resolve(folder, `.${pathname}`);
|
|
159
|
-
if (!isInside(folder, file)
|
|
160
|
-
|| getNodeKind(file)
|
|
161
|
-
=== null
|
|
162
|
-
|| !await isFile(file)) {
|
|
163
|
-
return { status: 404,
|
|
164
|
-
body: { error: `No requirement or test at ${pathname}.` } };
|
|
165
|
-
}
|
|
166
|
-
if (!isEdit(body)) {
|
|
167
|
-
return { status: 400,
|
|
168
|
-
body: { error: 'Send the version, the title and the text.' } };
|
|
169
|
-
}
|
|
170
|
-
const text = await readFile(file, 'utf8');
|
|
171
|
-
if (getVersion(text)
|
|
172
|
-
!== body.version) {
|
|
173
|
-
return { status: 409,
|
|
174
|
-
body: { error: 'The document changed since the editor opened it; copy your text, reload the page and edit again.' } };
|
|
175
|
-
}
|
|
176
|
-
await writeMarkdown(file, writeEdited(file, text, body, await readIds(getRequirementsDir(io))));
|
|
177
|
-
const processed = await postProcess({ ...io,
|
|
178
|
-
logger: getLogger(io, 'rq.post-process') }, takeWritten(), 'rq.json');
|
|
179
|
-
return { status: 200,
|
|
180
|
-
body: processed === 0
|
|
181
|
-
? { saved: true }
|
|
182
|
-
: { saved: true,
|
|
183
|
-
warning: 'markdownPostProcessing failed; see the server output.' } };
|
|
184
|
-
}
|
|
185
|
-
function isEdit(body) {
|
|
186
|
-
return typeof body === 'object'
|
|
187
|
-
&& body !== null
|
|
188
|
-
&& typeof body.version === 'string'
|
|
189
|
-
&& typeof body.title === 'string'
|
|
190
|
-
&& typeof body.body === 'string';
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* The index is the diagram alone, filling the window.
|
|
194
|
-
*/
|
|
195
|
-
const INDEX_STYLE = `html, body { height: 100%; }
|
|
196
|
-
body { max-width: none; margin: 0; padding: 0; overflow: hidden; }
|
|
197
|
-
#diagram { position: fixed; inset: 0; cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; }
|
|
198
|
-
#diagram.panning { cursor: grabbing; }
|
|
199
|
-
#diagram > pre.mermaid { margin: 0; width: 100%; height: 100%; }
|
|
200
|
-
#diagram svg { display: block; width: 100%; height: 100%; max-width: none !important; }
|
|
201
|
-
#diagram .node { cursor: pointer; }
|
|
202
|
-
#problems { position: fixed; top: 0.5rem; right: 0.5rem; max-width: 40%; max-height: 40%; overflow: auto; background: #fff8e1; border: 1px solid #ef8f00; padding: 0 0.75rem; font-size: 0.875rem; }
|
|
203
|
-
#problems h2 { font-size: 1rem; }`;
|
|
204
|
-
/**
|
|
205
|
-
* Draws the diagram, then zooms it with the mouse wheel around the pointer and
|
|
206
|
-
* pans it by dragging empty space. A click on a node opens its document, through
|
|
207
|
-
* the `click` links of the diagram. The geometry is `zoomAt`, `panTo` and
|
|
208
|
-
* `startsPan`, sent as their own source.
|
|
209
|
-
*/
|
|
210
|
-
const SCRIPT = `import mermaid from '${MERMAID_MODULE}';
|
|
211
|
-
${zoomAt.toString()}
|
|
212
|
-
${panTo.toString()}
|
|
213
|
-
${startsPan.toString()}
|
|
214
|
-
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
|
|
215
|
-
await mermaid.run({ querySelector: '#diagram .mermaid' });
|
|
216
|
-
const box = document.getElementById('diagram');
|
|
217
|
-
const svg = box.querySelector('svg');
|
|
218
|
-
svg.removeAttribute('width');
|
|
219
|
-
svg.removeAttribute('height');
|
|
220
|
-
const { x, y, width, height } = svg.viewBox.baseVal;
|
|
221
|
-
let view = { x, y, width, height };
|
|
222
|
-
const show = () =>
|
|
223
|
-
svg.setAttribute('viewBox', \`\${view.x} \${view.y} \${view.width} \${view.height}\`);
|
|
224
|
-
const toDiagram = (event) =>
|
|
225
|
-
new DOMPoint(event.clientX, event.clientY).matrixTransform(svg.getScreenCTM().inverse());
|
|
226
|
-
box.addEventListener('wheel', (event) => {
|
|
227
|
-
event.preventDefault();
|
|
228
|
-
view = zoomAt(view, toDiagram(event), event.deltaY);
|
|
229
|
-
show();
|
|
230
|
-
}, { passive: false });
|
|
231
|
-
let drag = null;
|
|
232
|
-
box.addEventListener('pointerdown', (event) => {
|
|
233
|
-
if (!startsPan(event.button, event.target)) {
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
// Without this, the browser starts selecting the diagram's text.
|
|
237
|
-
event.preventDefault();
|
|
238
|
-
drag = { pointer: event.pointerId, grabbed: toDiagram(event) };
|
|
239
|
-
box.setPointerCapture(event.pointerId);
|
|
240
|
-
box.classList.add('panning');
|
|
241
|
-
});
|
|
242
|
-
box.addEventListener('pointermove', (event) => {
|
|
243
|
-
if (drag?.pointer !== event.pointerId) {
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
view = panTo(view, drag.grabbed, toDiagram(event));
|
|
247
|
-
show();
|
|
248
|
-
});
|
|
249
|
-
const stop = (event) => {
|
|
250
|
-
if (drag?.pointer === event.pointerId) {
|
|
251
|
-
drag = null;
|
|
252
|
-
box.classList.remove('panning');
|
|
253
|
-
}
|
|
254
|
-
};
|
|
255
|
-
box.addEventListener('pointerup', stop);
|
|
256
|
-
box.addEventListener('pointercancel', stop);`;
|
|
257
|
-
/**
|
|
258
|
-
* Starts a web server for a requirement file or folder. It serves the
|
|
259
|
-
* documents folder, or the folder of the graph when none is given, which must
|
|
260
|
-
* contain the graph's folder. `/` shows the graph, whose nodes open the
|
|
261
|
-
* documents; a `.md` file of the served folder is shown rendered and any other
|
|
262
|
-
* file as it is, so a document a requirement links to opens wherever it is in
|
|
263
|
-
* the folder. The graph is reloaded on every request for `/`.
|
|
264
|
-
*/
|
|
265
|
-
export async function execView(io, options) {
|
|
266
|
-
const target = await findTarget(io, options.target);
|
|
267
|
-
const graphFolder = (await loadGraph(target)).folder;
|
|
268
|
-
const folder = io.documentsDir ?? graphFolder;
|
|
269
|
-
if (!isInside(folder, graphFolder)) {
|
|
270
|
-
throw new Error(`${graphFolder} is not inside the documents folder ${folder}.`);
|
|
271
|
-
}
|
|
272
|
-
const server = await startServer({ folder,
|
|
273
|
-
index: async () => {
|
|
274
|
-
const graph = await loadGraph(target);
|
|
275
|
-
return renderIndex(folder, graph, getResults(graph));
|
|
276
|
-
},
|
|
277
|
-
home: 'Requirements',
|
|
278
|
-
style: DOCUMENT_STYLE,
|
|
279
|
-
render: renderDocument,
|
|
280
|
-
post: async (url, body) => saveDocument(io, folder, url, body),
|
|
281
|
-
port: options.port,
|
|
282
|
-
host: options.host,
|
|
283
|
-
logger: getLogger(io, 'rq.view') });
|
|
284
|
-
io.stdout.write(`Serving ${folder === graphFolder
|
|
285
|
-
? folder
|
|
286
|
-
: `${graphFolder} in ${folder}`} at ${serverUrl(server)}\n`);
|
|
287
|
-
return server;
|
|
288
|
-
}
|
|
289
|
-
function renderIndex(folder, graph, results) {
|
|
290
|
-
const href = (file) => {
|
|
291
|
-
if (!isInside(folder, file)) {
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
294
|
-
return `/${encodeURI(path.relative(folder, file)
|
|
295
|
-
.split(path.sep)
|
|
296
|
-
.join('/'))}`;
|
|
297
|
-
};
|
|
298
|
-
const title = (graph.roots.length === 1
|
|
299
|
-
? graph.nodes.get(graph.roots[0])?.title
|
|
300
|
-
: null)
|
|
301
|
-
?? 'Requirements';
|
|
302
|
-
const problems = graph.errors.length === 0
|
|
303
|
-
? ''
|
|
304
|
-
: `<aside id="problems">
|
|
305
|
-
<h2>Problems</h2>
|
|
306
|
-
<ul>
|
|
307
|
-
${graph.errors
|
|
308
|
-
.map(error => `<li>${escapeHtml(error)}</li>`)
|
|
309
|
-
.join('\n')}
|
|
310
|
-
</ul>
|
|
311
|
-
</aside>
|
|
312
|
-
`;
|
|
313
|
-
return page(title, `<main id="diagram">
|
|
314
|
-
<pre class="mermaid">
|
|
315
|
-
${escapeHtml(toMermaid(graph, href, results))}
|
|
316
|
-
</pre>
|
|
317
|
-
</main>
|
|
318
|
-
${problems}<script type="module">
|
|
319
|
-
${SCRIPT}
|
|
320
|
-
</script>`, INDEX_STYLE);
|
|
321
|
-
}
|
package/docs/rq view.md
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
# rq view
|
|
2
|
-
|
|
3
|
-
Starts a web server for a requirement file or folder, and runs until stopped, by
|
|
4
|
-
SIGINT (Ctrl+C) or SIGTERM.
|
|
5
|
-
|
|
6
|
-
```text
|
|
7
|
-
rq view [path] [--port <port>] [--working-dir <folder>]
|
|
8
|
-
[--requirements-dir <folder>] [--documents-dir <folder>]
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
`[path]` is a requirement file, a folder, a `.md` name or an id; the
|
|
12
|
-
requirements folder by default. The index shows its graph.
|
|
13
|
-
|
|
14
|
-
The server serves the documents folder; see
|
|
15
|
-
[Requirements and documents folders][FD]. When none is set, it serves the folder
|
|
16
|
-
of the graph: `[path]` when it is a folder, the file's folder otherwise. The
|
|
17
|
-
graph's folder must be inside the served folder; `rq view` reports an error
|
|
18
|
-
otherwise. It listens on `127.0.0.1`.
|
|
19
|
-
|
|
20
|
-
```text
|
|
21
|
-
rq view requirements --documents-dir .
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
serves the whole project, so a requirement's link to `docs/design.md` opens
|
|
25
|
-
rendered, and the index still shows the graph of `requirements`.
|
|
26
|
-
|
|
27
|
-
- `/` - the index: the graph as a left-to-right Mermaid diagram of the
|
|
28
|
-
requirements and tests and their `## Implementation` links, filling the
|
|
29
|
-
window, and nothing else; the structure errors, when there are any, are listed
|
|
30
|
-
in a box over its corner. Other documents are not drawn. Each node's border
|
|
31
|
-
shows its state, from its `## Status` section:
|
|
32
|
-
- colour, from `Result`, or for a requirement that records none the result
|
|
33
|
-
that follows from its links - neutral grey for `NOT RUN`, green for `PASS`,
|
|
34
|
-
red for a test that is `FAIL`, amber for a requirement that is `FAIL`
|
|
35
|
-
because of what it links to;
|
|
36
|
-
- style, from `Coverage`, which `rq coverage` writes - solid for a test and
|
|
37
|
-
for a requirement that is `COMPLETE`, dashed for one that is `INCOMPLETE`,
|
|
38
|
-
dotted for one without `Coverage`.
|
|
39
|
-
|
|
40
|
-
The mouse wheel zooms in and out around the pointer, and dragging empty space
|
|
41
|
-
pans. Clicking a node opens its document, at its path in the served folder,
|
|
42
|
-
e.g. `/requirements/R1 Export.md`. All roots are in the one diagram; the page
|
|
43
|
-
is titled with the root's heading when there is one root, `Requirements`
|
|
44
|
-
otherwise. The graph is read again on every request, so a reload shows the
|
|
45
|
-
current files.
|
|
46
|
-
- `/<file>.md` - the document rendered as HTML. Its links to other `.md` files
|
|
47
|
-
open them rendered. A requirement or test starts with its full path, small and
|
|
48
|
-
grey; a click copies it to the clipboard. A requirement's `## Implementation`
|
|
49
|
-
list is shown as a table of what it links to: each one's id and title, linking
|
|
50
|
-
to it, the `Coverage` its `## Status` records, as Complete or Partial, and its
|
|
51
|
-
`Result`, `NOT RUN` when it records none; an item whose document is missing
|
|
52
|
-
says so.
|
|
53
|
-
- any other file of the served folder is served as it is, e.g. an image a
|
|
54
|
-
document shows.
|
|
55
|
-
|
|
56
|
-
The server is for local use: it serves every file inside the served folder,
|
|
57
|
-
including hidden ones, and renders the documents' HTML as it is. It takes a save
|
|
58
|
-
only as JSON, sent to `localhost` or its address from its own pages, so another
|
|
59
|
-
site cannot write through it.
|
|
60
|
-
|
|
61
|
-
Nodes outside the served folder are drawn but do not open. The diagram is drawn
|
|
62
|
-
by Mermaid, loaded from `cdn.jsdelivr.net`; without it the index shows the
|
|
63
|
-
diagram's Mermaid text.
|
|
64
|
-
|
|
65
|
-
## Editing
|
|
66
|
-
|
|
67
|
-
A requirement or test has an Edit button next to its path. It opens:
|
|
68
|
-
|
|
69
|
-
- the title, in a field after the id, which stays as it is; the file keeps its
|
|
70
|
-
name, which [`rq move`][MV] changes;
|
|
71
|
-
- the rest of the text in a [Milkdown][MK] editor, without `## Implementation`,
|
|
72
|
-
`## Coverage` and `## Status`, which the commands own. A link to a requirement
|
|
73
|
-
or test by its id shows as the id alone, e.g. `R2`, and any other reference
|
|
74
|
-
link as an inline link.
|
|
75
|
-
|
|
76
|
-
Save, or Ctrl+S, writes the heading, the id and the title, then the text, then
|
|
77
|
-
the definitions the owned sections use and the owned sections as they were. Each
|
|
78
|
-
id of a requirement or test of the requirements folder in the text, outside
|
|
79
|
-
code, becomes a reference link to it again, e.g. `[R2][]`, with its definition
|
|
80
|
-
after the text. The document is then post-processed with
|
|
81
|
-
`markdownPostProcessing` of `rq.json`, as after a command. A save is refused
|
|
82
|
-
when the file changed since the editor opened it: reload the page and edit
|
|
83
|
-
again. Cancel reloads the page. Milkdown is loaded from `esm.sh`, and its theme
|
|
84
|
-
from `cdn.jsdelivr.net`, when Edit is pressed.
|
|
85
|
-
|
|
86
|
-
## Options
|
|
87
|
-
|
|
88
|
-
- `--port <port>` - the port, exactly: one in use is an error, and 0 picks any
|
|
89
|
-
free one. Without it, the first free port from 3000 on, up to 3099. The
|
|
90
|
-
address is printed when the server starts.
|
|
91
|
-
- `--working-dir <folder>` - the working folder, whose `.rq` folder holds the
|
|
92
|
-
results; see [Working folder][WF]. The results are read again on every request
|
|
93
|
-
for `/`.
|
|
94
|
-
- `--requirements-dir <folder>`, `--documents-dir <folder>` - the requirements
|
|
95
|
-
folder, the default `[path]`, and the documents folder, the served one; see
|
|
96
|
-
[Requirements and documents folders][FD].
|
|
97
|
-
|
|
98
|
-
[FD]: ../../../libs/rq/docs/Requirements.md#requirements-and-documents-folders
|
|
99
|
-
[WF]: ../../../libs/rq/docs/Requirements.md#working-folder
|
|
100
|
-
[MK]: https://milkdown.dev
|
|
101
|
-
[MV]: <Changing the graph.md#rq-move>
|