asljs-rq-srv 0.1.1 → 0.1.2
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/README.md +28 -10
- package/bin/rq-srv.js +12 -0
- package/dist/browser/data.d.ts +54 -0
- package/dist/browser/data.js +1 -0
- package/dist/browser/document.d.ts +20 -0
- package/dist/browser/document.js +96 -0
- package/dist/browser/icons.d.ts +6 -0
- package/dist/browser/icons.js +4 -0
- package/dist/{index-script.d.ts → browser/outline.d.ts} +0 -3
- package/dist/{index-script.js → browser/outline.js} +0 -3
- package/dist/browser/plugin.d.ts +21 -0
- package/dist/browser/plugin.js +278 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/main.d.ts +11 -0
- package/dist/main.js +40 -0
- package/dist/view.d.ts +18 -33
- package/dist/view.js +152 -425
- package/docs/rq-srv.md +190 -0
- package/package.json +13 -6
- package/docs/rq view.md +0 -132
package/README.md
CHANGED
|
@@ -5,33 +5,50 @@
|
|
|
5
5
|
|
|
6
6
|
## Overview
|
|
7
7
|
|
|
8
|
-
`asljs-rq-srv` is the `rq
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
`asljs-rq-srv` is the `rq-srv` web server: an editor with the requirements graph
|
|
9
|
+
as a diagram and the documents, rendered and edited in the browser from the data
|
|
10
|
+
the server sends.
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
npm install asljs-rq-srv
|
|
15
|
+
npm install --global asljs-rq-srv
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
19
19
|
|
|
20
|
+
```bash
|
|
21
|
+
rq-srv
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
serves every project of the current folder, each folder with an `rq.json`, in
|
|
25
|
+
one editor with a list box to switch between them, until stopped with Ctrl+C.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
rq-srv --working-dir ~/work --port 8080
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
serves the projects of `~/work` on port 8080. Each project's folders come from
|
|
32
|
+
its own `rq.json`; `rq-srv` takes no folder option of `rq`.
|
|
33
|
+
|
|
20
34
|
```ts
|
|
21
|
-
import {
|
|
35
|
+
import { createApp } from 'asljs-rq-srv';
|
|
22
36
|
```
|
|
23
37
|
|
|
24
|
-
`
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
`createApp(context, options?)` takes an `EditorAppContext` of `asljs-appbase`,
|
|
39
|
+
`{ cwd, base, env, loggerProvider, files? }`, whose `cwd` the projects are
|
|
40
|
+
looked for in, and `{ stderr }`, where a failed post-processing is reported, and
|
|
41
|
+
returns the `EditorApp` whose `handler` serves the editor below `base`, for a
|
|
42
|
+
server of its own or one that serves several applications.
|
|
27
43
|
|
|
28
44
|
## Further reading
|
|
29
45
|
|
|
30
|
-
- [rq
|
|
46
|
+
- [rq-srv][DOC] - the command, what the server serves, and editing.
|
|
31
47
|
|
|
32
48
|
## Related packages
|
|
33
49
|
|
|
34
50
|
- [asljs-rq-cli][CLI] - the `rq` command line.
|
|
51
|
+
- [asljs-rq-mcp][MCP] - the `rq-mcp` MCP server.
|
|
35
52
|
- [asljs-rq][RQL] - the requirements model and the commands as functions.
|
|
36
53
|
|
|
37
54
|
## License
|
|
@@ -40,6 +57,7 @@ See [LICENSE][LIC] for details.
|
|
|
40
57
|
|
|
41
58
|
[#1]: https://github.com/AlexandriteSoftware/asljs
|
|
42
59
|
[CLI]: ../rq-cli/README.md
|
|
43
|
-
[DOC]:
|
|
60
|
+
[DOC]: docs/rq-srv.md
|
|
44
61
|
[LIC]: LICENSE.md
|
|
62
|
+
[MCP]: ../rq-mcp/README.md
|
|
45
63
|
[RQL]: ../rq/README.md
|
package/bin/rq-srv.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type GraphDiagram, type ImplementationRow } from 'asljs-rq';
|
|
2
|
+
/**
|
|
3
|
+
* What `_/data.json` of a project answers: the title of the graph, its
|
|
4
|
+
* structure errors, and the diagram, whose nodes link to their documents by
|
|
5
|
+
* paths relative to the project's base.
|
|
6
|
+
*/
|
|
7
|
+
export interface GraphReply {
|
|
8
|
+
title: string;
|
|
9
|
+
problems: string[];
|
|
10
|
+
diagram: GraphDiagram;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* What the editor of a requirement or test edits: the id, which stays, the
|
|
14
|
+
* title after it, and the text without the sections the commands own; a save
|
|
15
|
+
* sends `version` back.
|
|
16
|
+
*/
|
|
17
|
+
export interface EditableReply {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
body: string;
|
|
21
|
+
version: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A requirement's `## Implementation` list: the rows of the documents it
|
|
25
|
+
* links to, shown as a table where the paragraph `placeholder` stands in the
|
|
26
|
+
* text.
|
|
27
|
+
*/
|
|
28
|
+
export interface ImplementationReply {
|
|
29
|
+
placeholder: string;
|
|
30
|
+
rows: ImplementationRow[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* What `_/document.json?path=<path>` answers for a markdown document of the
|
|
34
|
+
* served folder: its path as asked, its full path, and its text. A
|
|
35
|
+
* requirement or test also has `editable`; a requirement with an
|
|
36
|
+
* `## Implementation` list has `implementation`, its list replaced in `text`
|
|
37
|
+
* by the placeholder.
|
|
38
|
+
*/
|
|
39
|
+
export interface DocumentReply {
|
|
40
|
+
path: string;
|
|
41
|
+
file: string;
|
|
42
|
+
text: string;
|
|
43
|
+
editable?: EditableReply;
|
|
44
|
+
implementation?: ImplementationReply;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* What a `POST` of `{ path, version, title, body }` to `_/document.json`
|
|
48
|
+
* answers when it saved the document: a warning when the post-processing
|
|
49
|
+
* failed.
|
|
50
|
+
*/
|
|
51
|
+
export interface SaveReply {
|
|
52
|
+
saved: true;
|
|
53
|
+
warning?: string;
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ImplementationRow } from 'asljs-rq';
|
|
2
|
+
import { type DocumentReply } from './data.js';
|
|
3
|
+
/**
|
|
4
|
+
* The rendered text of a document of `_/document.json`: its markdown, with
|
|
5
|
+
* the paragraph that stands for a requirement's Implementation list replaced
|
|
6
|
+
* by the table of its rows.
|
|
7
|
+
*/
|
|
8
|
+
export declare function renderText(reply: DocumentReply): DocumentFragment;
|
|
9
|
+
/**
|
|
10
|
+
* The table of a requirement's Implementation list: each linked document's id
|
|
11
|
+
* and title, linking to it by its path from the requirement, the coverage and
|
|
12
|
+
* the result it records; a missing document is marked missing, and an item
|
|
13
|
+
* that links to no requirement or test shows its text.
|
|
14
|
+
*/
|
|
15
|
+
export declare function implementationTable(rows: readonly ImplementationRow[]): HTMLTableElement;
|
|
16
|
+
/**
|
|
17
|
+
* The full path of a requirement or test, which a click copies, showing it
|
|
18
|
+
* was copied for a moment.
|
|
19
|
+
*/
|
|
20
|
+
export declare function pathLine(file: string): HTMLElement;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { element } from 'asljs-ui-editor';
|
|
2
|
+
import { markdownToHtml } from 'asljs-ui-markdown';
|
|
3
|
+
/**
|
|
4
|
+
* How long the path line shows that it was copied, in milliseconds.
|
|
5
|
+
*/
|
|
6
|
+
const COPIED_MS = 1000;
|
|
7
|
+
/**
|
|
8
|
+
* The rendered text of a document of `_/document.json`: its markdown, with
|
|
9
|
+
* the paragraph that stands for a requirement's Implementation list replaced
|
|
10
|
+
* by the table of its rows.
|
|
11
|
+
*/
|
|
12
|
+
export function renderText(reply) {
|
|
13
|
+
const template = element('template');
|
|
14
|
+
template.innerHTML =
|
|
15
|
+
markdownToHtml(reply.text);
|
|
16
|
+
const content = template.content;
|
|
17
|
+
if (reply.implementation
|
|
18
|
+
!== undefined) {
|
|
19
|
+
const { placeholder, rows } = reply.implementation;
|
|
20
|
+
for (const paragraph of content.querySelectorAll('p')) {
|
|
21
|
+
if (paragraph.textContent
|
|
22
|
+
=== placeholder) {
|
|
23
|
+
paragraph.replaceWith(implementationTable(rows));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return content;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The table of a requirement's Implementation list: each linked document's id
|
|
31
|
+
* and title, linking to it by its path from the requirement, the coverage and
|
|
32
|
+
* the result it records; a missing document is marked missing, and an item
|
|
33
|
+
* that links to no requirement or test shows its text.
|
|
34
|
+
*/
|
|
35
|
+
export function implementationTable(rows) {
|
|
36
|
+
const table = element('table', 'rq-implementation');
|
|
37
|
+
const headings = element('tr');
|
|
38
|
+
for (const heading of ['ID',
|
|
39
|
+
'Title',
|
|
40
|
+
'Coverage',
|
|
41
|
+
'Result']) {
|
|
42
|
+
headings.append(element('th', undefined, heading));
|
|
43
|
+
}
|
|
44
|
+
const head = element('thead');
|
|
45
|
+
head.append(headings);
|
|
46
|
+
const body = element('tbody');
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
body.append(implementationRow(row));
|
|
49
|
+
}
|
|
50
|
+
table.append(head, body);
|
|
51
|
+
return table;
|
|
52
|
+
}
|
|
53
|
+
function implementationRow(row) {
|
|
54
|
+
const cell = (content, className) => {
|
|
55
|
+
const made = element('td', className);
|
|
56
|
+
made.append(content);
|
|
57
|
+
return made;
|
|
58
|
+
};
|
|
59
|
+
const link = (text) => {
|
|
60
|
+
if (row.href === null
|
|
61
|
+
|| text === '') {
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
const anchor = element('a', undefined, text);
|
|
65
|
+
anchor.setAttribute('href', encodeURI(row.href));
|
|
66
|
+
return anchor;
|
|
67
|
+
};
|
|
68
|
+
const tr = element('tr', row.missing
|
|
69
|
+
? 'rq-missing'
|
|
70
|
+
: undefined);
|
|
71
|
+
tr.append(cell(link(row.id)), cell(row.missing
|
|
72
|
+
? 'missing'
|
|
73
|
+
: link(row.title)), cell(row.coverage), cell(row.result, row.result === ''
|
|
74
|
+
? undefined
|
|
75
|
+
: `rq-${row.result
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(' ', '-')}`));
|
|
78
|
+
return tr;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The full path of a requirement or test, which a click copies, showing it
|
|
82
|
+
* was copied for a moment.
|
|
83
|
+
*/
|
|
84
|
+
export function pathLine(file) {
|
|
85
|
+
const line = element('p', 'rq-path', file);
|
|
86
|
+
line.title = 'Copy the path';
|
|
87
|
+
line.addEventListener('click', () => {
|
|
88
|
+
void navigator.clipboard
|
|
89
|
+
.writeText(file)
|
|
90
|
+
.then(() => {
|
|
91
|
+
line.classList.add('copied');
|
|
92
|
+
setTimeout(() => line.classList.remove('copied'), COPIED_MS);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
return line;
|
|
96
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The icon of the application, drawn in the text colour.
|
|
3
|
+
*/
|
|
4
|
+
export const ICONS = { graph: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="m8.2 10.9 7.6-3.8M8.2 13.1l7.6 3.8"/></svg>' };
|
|
@@ -9,9 +9,6 @@ export interface OutlineItem {
|
|
|
9
9
|
* each followed by what it links to, in link order. A node linked from several,
|
|
10
10
|
* such as a test, is listed under the first that reaches it; a node no root
|
|
11
11
|
* reaches, as in a cycle, is listed at the top.
|
|
12
|
-
*
|
|
13
|
-
* Its source is sent to the browser as part of the index page, so it uses
|
|
14
|
-
* nothing but its arguments.
|
|
15
12
|
*/
|
|
16
13
|
export declare function buildOutline(nodes: readonly {
|
|
17
14
|
id: string;
|
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
* each followed by what it links to, in link order. A node linked from several,
|
|
4
4
|
* such as a test, is listed under the first that reaches it; a node no root
|
|
5
5
|
* reaches, as in a cycle, is listed at the top.
|
|
6
|
-
*
|
|
7
|
-
* Its source is sent to the browser as part of the index page, so it uses
|
|
8
|
-
* nothing but its arguments.
|
|
9
6
|
*/
|
|
10
7
|
export function buildOutline(nodes, links) {
|
|
11
8
|
const labels = new Map(nodes.map(node => [node.id,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type EditorPluginFactory } from 'asljs-ui-editor';
|
|
2
|
+
/**
|
|
3
|
+
* The plugin of a project, the browser module of `asljs-ui-editor` the
|
|
4
|
+
* server serves. It loads the graph from `_/data.json` of the project's
|
|
5
|
+
* `base`. The graph is the first document, `rq:graph`, and stays open; a
|
|
6
|
+
* double click on a node opens its document as another tab,
|
|
7
|
+
* `rq:doc:<path>`. The diagram's selection is the editor's: a click on a node
|
|
8
|
+
* selects it, and so does choosing it in the Nodes list, which also moves the
|
|
9
|
+
* view to it. The graph's preview, on its right, views or edits the selected
|
|
10
|
+
* node, as the View and Edit tabs of its action bar choose. The diagram is
|
|
11
|
+
* read-only: nodes move, but delete and resize do nothing. The search finds
|
|
12
|
+
* the nodes by their titles, and opens their documents.
|
|
13
|
+
*
|
|
14
|
+
* A document is rendered here from `_/document.json`: a requirement's
|
|
15
|
+
* Implementation list as a table, a requirement or test as a tab with its
|
|
16
|
+
* full path, which a click copies, above it. A link to a markdown document of
|
|
17
|
+
* the project opens it as another tab. Edit edits the title and the text of a
|
|
18
|
+
* requirement or test, saved with a `POST` to `_/document.json`.
|
|
19
|
+
*/
|
|
20
|
+
declare const createPlugin: EditorPluginFactory;
|
|
21
|
+
export default createPlugin;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createModelFromDocument, createPreviewModel, searchElements } from 'asljs-ui-diagram';
|
|
2
|
+
import { element, fetchJson, ICONS as EDITOR_ICONS, note, pathHref, projectPath, searchMatcher, trackActive } from 'asljs-ui-editor';
|
|
3
|
+
import { markdownDocument } from 'asljs-ui-markdown';
|
|
4
|
+
import { pathLine, renderText } from './document.js';
|
|
5
|
+
import { ICONS } from './icons.js';
|
|
6
|
+
import { buildOutline } from './outline.js';
|
|
7
|
+
/**
|
|
8
|
+
* Where the browser keeps the state of the graph's preview.
|
|
9
|
+
*/
|
|
10
|
+
const LAYOUT_KEY = 'rq-view-layout';
|
|
11
|
+
/**
|
|
12
|
+
* What the editor says under the text of a requirement or test.
|
|
13
|
+
*/
|
|
14
|
+
const EDIT_NOTE = 'Implementation, Coverage and Status are kept as they are, after the text. Ids such as R2 or T3 become links when saved.';
|
|
15
|
+
/**
|
|
16
|
+
* The plugin of a project, the browser module of `asljs-ui-editor` the
|
|
17
|
+
* server serves. It loads the graph from `_/data.json` of the project's
|
|
18
|
+
* `base`. The graph is the first document, `rq:graph`, and stays open; a
|
|
19
|
+
* double click on a node opens its document as another tab,
|
|
20
|
+
* `rq:doc:<path>`. The diagram's selection is the editor's: a click on a node
|
|
21
|
+
* selects it, and so does choosing it in the Nodes list, which also moves the
|
|
22
|
+
* view to it. The graph's preview, on its right, views or edits the selected
|
|
23
|
+
* node, as the View and Edit tabs of its action bar choose. The diagram is
|
|
24
|
+
* read-only: nodes move, but delete and resize do nothing. The search finds
|
|
25
|
+
* the nodes by their titles, and opens their documents.
|
|
26
|
+
*
|
|
27
|
+
* A document is rendered here from `_/document.json`: a requirement's
|
|
28
|
+
* Implementation list as a table, a requirement or test as a tab with its
|
|
29
|
+
* full path, which a click copies, above it. A link to a markdown document of
|
|
30
|
+
* the project opens it as another tab. Edit edits the title and the text of a
|
|
31
|
+
* requirement or test, saved with a `POST` to `_/document.json`.
|
|
32
|
+
*/
|
|
33
|
+
const createPlugin = async ({ id, base, model }) => {
|
|
34
|
+
const { title, problems, diagram: { document: data, hrefs } } = await fetchJson(base, '_/data.json');
|
|
35
|
+
const graphId = `${id}:graph`;
|
|
36
|
+
const labels = new Map(data.nodes.map(node => [node.id,
|
|
37
|
+
node.label]));
|
|
38
|
+
// The path of each node's document, relative to the project; none for a
|
|
39
|
+
// node outside the served folder.
|
|
40
|
+
const paths = new Map(Object.entries(hrefs)
|
|
41
|
+
.map(([node, href]) => [node,
|
|
42
|
+
decodeURI(href)]));
|
|
43
|
+
const titles = new Map([...paths]
|
|
44
|
+
.map(([node, file]) => [file,
|
|
45
|
+
labels.get(node) ?? file]));
|
|
46
|
+
const graph = createModelFromDocument(data);
|
|
47
|
+
graph.deleteElements = () => { };
|
|
48
|
+
graph.resizeElement = () => false;
|
|
49
|
+
const toDocument = (file) => ({ id: `${id}:doc:${file}`,
|
|
50
|
+
title: titles.get(file)
|
|
51
|
+
?? file
|
|
52
|
+
.split('/')
|
|
53
|
+
.at(-1)
|
|
54
|
+
.replace(/\.md$/i, ''),
|
|
55
|
+
data: { path: file } });
|
|
56
|
+
const nodeDocument = (node) => {
|
|
57
|
+
const file = paths.get(node);
|
|
58
|
+
return file === undefined
|
|
59
|
+
? null
|
|
60
|
+
: toDocument(file);
|
|
61
|
+
};
|
|
62
|
+
const selectNode = (node) => {
|
|
63
|
+
model.activate(graphId);
|
|
64
|
+
graph.select([node]);
|
|
65
|
+
requestAnimationFrame(() => graph.reveal(node));
|
|
66
|
+
};
|
|
67
|
+
const openNode = (node) => {
|
|
68
|
+
const opened = nodeDocument(node);
|
|
69
|
+
if (opened !== null) {
|
|
70
|
+
model.open(opened);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
// The editor's selection is the graph's, which the Nodes list marks.
|
|
74
|
+
graph.on('change', () => {
|
|
75
|
+
const ids = graph.selection.filter(node => labels.has(node));
|
|
76
|
+
const node = ids.length === 1
|
|
77
|
+
? ids[0]
|
|
78
|
+
: null;
|
|
79
|
+
if ((model.selection?.id
|
|
80
|
+
?? null)
|
|
81
|
+
!== node) {
|
|
82
|
+
model.select(node === null
|
|
83
|
+
? null
|
|
84
|
+
: { id: node,
|
|
85
|
+
title: labels.get(node),
|
|
86
|
+
data: { path: paths.get(node) ?? null } });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
// A document of the project: rendered from what `_/document.json`
|
|
90
|
+
// answers, its links to markdown documents opened as tabs.
|
|
91
|
+
const documentView = (file, options) => {
|
|
92
|
+
let reply = null;
|
|
93
|
+
const head = element('div');
|
|
94
|
+
return markdownDocument({ url: pathHref(base, file),
|
|
95
|
+
load: async () => {
|
|
96
|
+
const loaded = await fetchJson(base, `_/document.json?path=${encodeURIComponent(file)}`);
|
|
97
|
+
reply = loaded;
|
|
98
|
+
head.replaceChildren(...loaded.editable === undefined
|
|
99
|
+
? []
|
|
100
|
+
: [pathLine(loaded.file)]);
|
|
101
|
+
return loaded.editable === undefined
|
|
102
|
+
? { body: loaded.text }
|
|
103
|
+
: { body: loaded.editable.body,
|
|
104
|
+
version: loaded.editable.version,
|
|
105
|
+
title: { label: loaded.editable.id,
|
|
106
|
+
value: loaded.editable.title } };
|
|
107
|
+
},
|
|
108
|
+
render: () => renderText(reply),
|
|
109
|
+
save: options.save === true
|
|
110
|
+
? async (edit) => {
|
|
111
|
+
const saved = await fetchJson(base, '_/document.json', { path: file,
|
|
112
|
+
version: edit.version,
|
|
113
|
+
title: edit.title ?? '',
|
|
114
|
+
body: edit.body });
|
|
115
|
+
return saved.warning;
|
|
116
|
+
}
|
|
117
|
+
: undefined,
|
|
118
|
+
head: options.head === true
|
|
119
|
+
? () => head
|
|
120
|
+
: undefined,
|
|
121
|
+
note: EDIT_NOTE,
|
|
122
|
+
editing: options.editing,
|
|
123
|
+
openLink: (url) => {
|
|
124
|
+
const target = projectPath(base, url);
|
|
125
|
+
if (target === null
|
|
126
|
+
|| !/\.md$/i.test(target)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
model.open(toDocument(target));
|
|
130
|
+
return true;
|
|
131
|
+
},
|
|
132
|
+
onInput: options.keep === undefined
|
|
133
|
+
? undefined
|
|
134
|
+
: () => model.keep(options.keep) });
|
|
135
|
+
};
|
|
136
|
+
const nodeButton = (node, label) => {
|
|
137
|
+
const button = element('button', 'asljs-plugin-item', label);
|
|
138
|
+
button.type = 'button';
|
|
139
|
+
button.dataset.node = node;
|
|
140
|
+
button.title = label;
|
|
141
|
+
button.addEventListener('click', () => selectNode(node));
|
|
142
|
+
button.addEventListener('dblclick', () => openNode(node));
|
|
143
|
+
return button;
|
|
144
|
+
};
|
|
145
|
+
// The graph's preview: View shows the selected node's document, Edit
|
|
146
|
+
// edits it; its state is kept in this browser.
|
|
147
|
+
let stored = {};
|
|
148
|
+
try {
|
|
149
|
+
stored =
|
|
150
|
+
JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}');
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Without storage, the preview starts open, viewing.
|
|
154
|
+
}
|
|
155
|
+
const preview = createPreviewModel(stored.preview
|
|
156
|
+
?? { open: true,
|
|
157
|
+
mode: 'view' });
|
|
158
|
+
preview.on('change', () => {
|
|
159
|
+
try {
|
|
160
|
+
localStorage.setItem(LAYOUT_KEY, JSON.stringify({ preview: { open: preview.open,
|
|
161
|
+
mode: preview.mode,
|
|
162
|
+
width: preview.width } }));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Without storage, the preview lasts until the page is reloaded.
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
const previewed = (selection) => selection.length === 1
|
|
169
|
+
? paths.get(selection[0]) ?? null
|
|
170
|
+
: null;
|
|
171
|
+
const previewModes = [{ id: 'view',
|
|
172
|
+
title: 'View',
|
|
173
|
+
isEnabled: selection => previewed(selection) !== null,
|
|
174
|
+
render: selection => documentView(previewed(selection), {}) },
|
|
175
|
+
{ id: 'edit',
|
|
176
|
+
title: 'Edit',
|
|
177
|
+
isEnabled: selection => previewed(selection) !== null,
|
|
178
|
+
render: selection => documentView(previewed(selection), { editing: true,
|
|
179
|
+
save: true }) }];
|
|
180
|
+
const graphView = () => {
|
|
181
|
+
const view = element('div', 'rq-graph');
|
|
182
|
+
const diagram = document.createElement('asljs-diagram');
|
|
183
|
+
diagram.tabIndex = 0;
|
|
184
|
+
diagram.model = graph;
|
|
185
|
+
diagram.addEventListener('dblclick', (event) => {
|
|
186
|
+
const node = event.target
|
|
187
|
+
?.closest?.('g[data-id]')
|
|
188
|
+
?.getAttribute('data-id');
|
|
189
|
+
if (node) {
|
|
190
|
+
openNode(node);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
const panel = document.createElement('asljs-diagram-preview');
|
|
194
|
+
panel.placeholder =
|
|
195
|
+
'Select a requirement or test to preview it.';
|
|
196
|
+
panel.modes = previewModes;
|
|
197
|
+
panel.model = graph;
|
|
198
|
+
panel.preview = preview;
|
|
199
|
+
const stage = element('div', 'rq-stage');
|
|
200
|
+
stage.append(diagram, panel);
|
|
201
|
+
const zoom = document.createElement('asljs-diagram-zoom');
|
|
202
|
+
zoom.viewport = graph.viewport;
|
|
203
|
+
const tabs = document.createElement('asljs-diagram-preview-tabs');
|
|
204
|
+
tabs.modes = previewModes;
|
|
205
|
+
tabs.preview = preview;
|
|
206
|
+
const bar = element('div', 'rq-actionbar');
|
|
207
|
+
bar.append(zoom, tabs);
|
|
208
|
+
view.append(stage, bar);
|
|
209
|
+
requestAnimationFrame(() => graph.viewport.fit());
|
|
210
|
+
return view;
|
|
211
|
+
};
|
|
212
|
+
const outline = (items) => {
|
|
213
|
+
const list = element('ul', 'asljs-plugin-list');
|
|
214
|
+
for (const item of items) {
|
|
215
|
+
const entry = element('li');
|
|
216
|
+
entry.append(nodeButton(item.id, item.label));
|
|
217
|
+
if (item.children.length > 0) {
|
|
218
|
+
entry.append(outline(item.children));
|
|
219
|
+
}
|
|
220
|
+
list.append(entry);
|
|
221
|
+
}
|
|
222
|
+
return list;
|
|
223
|
+
};
|
|
224
|
+
// The nodes whose title matches: as searchElements finds them, or, with a
|
|
225
|
+
// regular expression or the case matched, as searchMatcher does.
|
|
226
|
+
const matching = (query) => {
|
|
227
|
+
if (!query.regex
|
|
228
|
+
&& !query.matchCase) {
|
|
229
|
+
return searchElements(graph.diagram.elements, query.text)
|
|
230
|
+
.map(item => String(item.value));
|
|
231
|
+
}
|
|
232
|
+
const matches = searchMatcher(query);
|
|
233
|
+
return data.nodes
|
|
234
|
+
.filter(node => matches(node.label))
|
|
235
|
+
.map(node => node.id);
|
|
236
|
+
};
|
|
237
|
+
return { id,
|
|
238
|
+
title: 'Requirements',
|
|
239
|
+
icon: ICONS.graph,
|
|
240
|
+
documents: [{ id: graphId,
|
|
241
|
+
title,
|
|
242
|
+
closable: false }],
|
|
243
|
+
operations: [{ id: 'nodes',
|
|
244
|
+
title: 'Nodes',
|
|
245
|
+
icon: EDITOR_ICONS.list,
|
|
246
|
+
render: () => {
|
|
247
|
+
const list = outline(buildOutline(data.nodes, data.links));
|
|
248
|
+
trackActive(model, list, { attribute: 'node',
|
|
249
|
+
current: () => model.selection?.id ?? null });
|
|
250
|
+
return list;
|
|
251
|
+
} },
|
|
252
|
+
...problems.length === 0
|
|
253
|
+
? []
|
|
254
|
+
: [{ id: 'problems',
|
|
255
|
+
title: `Problems (${problems.length})`,
|
|
256
|
+
icon: EDITOR_ICONS.warning,
|
|
257
|
+
render: () => {
|
|
258
|
+
const list = element('div');
|
|
259
|
+
list.append(...problems.map(note));
|
|
260
|
+
return list;
|
|
261
|
+
} }]],
|
|
262
|
+
renderDocument: (opened) => opened.id === graphId
|
|
263
|
+
? graphView()
|
|
264
|
+
: documentView(opened.data.path, { head: true,
|
|
265
|
+
save: true,
|
|
266
|
+
keep: opened.id }),
|
|
267
|
+
search: async (query) => matching(query)
|
|
268
|
+
.filter(node => labels.has(node))
|
|
269
|
+
.flatMap((node) => {
|
|
270
|
+
const found = nodeDocument(node);
|
|
271
|
+
return found === null
|
|
272
|
+
? []
|
|
273
|
+
: [{ document: found,
|
|
274
|
+
title: labels.get(node) }];
|
|
275
|
+
}),
|
|
276
|
+
dispose: () => model.select(null) };
|
|
277
|
+
};
|
|
278
|
+
export default createPlugin;
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { main } from './main.js';
|
|
2
|
+
export { createApp, type RqAppOptions } from './view.js';
|
package/dist/index.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { main } from './main.js';
|
|
2
|
+
export { createApp } from './view.js';
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Io } from 'asljs-rq';
|
|
2
|
+
/**
|
|
3
|
+
* The `rq-srv` executable: serves the projects of the working folder, on the
|
|
4
|
+
* port of `--port`, else of the nearest `rq.json`, until SIGINT or SIGTERM,
|
|
5
|
+
* then closes the application and returns the exit code. Its logger provider
|
|
6
|
+
* is made from `--loglevel`, `--logfile`, `--logformat`, the `RQ_LOG_`
|
|
7
|
+
* variables and `rq.json` (`runMain`); help and the version answer 0, a
|
|
8
|
+
* wrong argument or a server that cannot start 1, with the reason on
|
|
9
|
+
* `io.stderr`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function main(args: string[], io: Io): Promise<number>;
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { commandLine, configPort, loadConfig, packageVersion, parsePort, runMain, serveApp } from 'asljs-appbase';
|
|
2
|
+
import { CONFIG_FILE } from 'asljs-rq';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createApp } from './view.js';
|
|
6
|
+
const WORKING_DIR = 'The folder the projects are looked for in: its rq.json, else each rq.json of its subfolders; the current directory by default. The port and the logging come from the nearest rq.json of it or a parent';
|
|
7
|
+
/**
|
|
8
|
+
* The `rq-srv` executable: serves the projects of the working folder, on the
|
|
9
|
+
* port of `--port`, else of the nearest `rq.json`, until SIGINT or SIGTERM,
|
|
10
|
+
* then closes the application and returns the exit code. Its logger provider
|
|
11
|
+
* is made from `--loglevel`, `--logfile`, `--logformat`, the `RQ_LOG_`
|
|
12
|
+
* variables and `rq.json` (`runMain`); help and the version answer 0, a
|
|
13
|
+
* wrong argument or a server that cannot start 1, with the reason on
|
|
14
|
+
* `io.stderr`.
|
|
15
|
+
*/
|
|
16
|
+
export async function main(args, io) {
|
|
17
|
+
return await runMain(args, io, { prefix: 'RQ_LOG_',
|
|
18
|
+
configFile: CONFIG_FILE }, async (loggerProvider) => {
|
|
19
|
+
const cli = commandLine(new Command('rq-srv'), io, { workingDir: WORKING_DIR });
|
|
20
|
+
cli
|
|
21
|
+
.description('Serve a requirements graph and its documents over HTTP: the graph in an editor, and the documents rendered and editable.')
|
|
22
|
+
.version(packageVersion(import.meta.url))
|
|
23
|
+
.helpCommand(false)
|
|
24
|
+
.option('--port <port>', 'Port to listen on, exactly; 0 picks a free one. By default port of rq.json, else the first free port from 3000 on')
|
|
25
|
+
.action(async (options) => {
|
|
26
|
+
const cwd = path.resolve(io.cwd, options.workingDir ?? '.');
|
|
27
|
+
const port = parsePort(options.port)
|
|
28
|
+
?? configPort(await loadConfig(cwd, CONFIG_FILE));
|
|
29
|
+
await serveApp(await createApp({ cwd,
|
|
30
|
+
base: '/',
|
|
31
|
+
env: io.env,
|
|
32
|
+
loggerProvider }, { stderr: io.stderr }), { io,
|
|
33
|
+
cwd,
|
|
34
|
+
port,
|
|
35
|
+
loggerProvider });
|
|
36
|
+
});
|
|
37
|
+
await cli.parseAsync(args, { from: 'user' });
|
|
38
|
+
return 0;
|
|
39
|
+
});
|
|
40
|
+
}
|