eidosmd 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/README.md +50 -29
- package/browser/dist/assets/index-C2NMN_D4.css +1 -0
- package/browser/dist/assets/index-C65k1ihb.js +46 -0
- package/browser/dist/favicon.svg +5 -0
- package/browser/dist/index.html +15 -0
- package/browser/dist/mark.svg +4 -0
- package/dist/src/cli.js +7 -0
- package/dist/src/commands/agents.js +1 -1
- package/dist/src/commands/canvas.js +77 -0
- package/dist/src/commands/check.js +1 -1
- package/dist/src/commands/framework.js +24 -5
- package/dist/src/commands/index.js +4 -4
- package/dist/src/commands/init.js +1 -0
- package/dist/src/commands/instructions.js +1 -1
- package/dist/src/commands/list.js +8 -8
- package/dist/src/commands/migrate.js +32 -0
- package/dist/src/commands/new.js +3 -3
- package/dist/src/commands/seeds.js +5 -5
- package/dist/src/commands/setup.js +119 -0
- package/dist/src/commands/version.js +44 -0
- package/dist/src/commands/whoami.js +4 -4
- package/dist/src/context.js +5 -5
- package/dist/src/core/blueprint.js +16 -11
- package/dist/src/core/canvas-schema.js +148 -0
- package/dist/src/core/canvas.js +722 -0
- package/dist/src/core/check.js +127 -51
- package/dist/src/core/convert.js +7 -6
- package/dist/src/core/framework-markdown.js +113 -34
- package/dist/src/core/framework-model.js +27 -10
- package/dist/src/core/framework-structured.js +132 -33
- package/dist/src/core/framework.js +13 -13
- package/dist/src/core/git.js +59 -0
- package/dist/src/core/index-leaf.js +2 -2
- package/dist/src/core/me.js +16 -8
- package/dist/src/core/migrate.js +243 -0
- package/dist/src/core/naming.js +1 -1
- package/dist/src/core/root.js +2 -2
- package/dist/src/core/scaffold.js +19 -19
- package/dist/src/core/seed.js +151 -60
- package/dist/src/core/server.js +1236 -52
- package/dist/src/core/settings.js +181 -0
- package/dist/src/core/store.js +259 -0
- package/dist/src/core/template.js +32 -0
- package/dist/src/core/versions.js +79 -0
- package/dist/src/output.js +4 -1
- package/dist/src/program.js +134 -39
- package/instructions/authoring.md +13 -12
- package/instructions/configuring.md +65 -34
- package/instructions/init-required.md +4 -4
- package/instructions/overview.md +17 -7
- package/instructions/validating.md +5 -4
- package/package.json +21 -12
- package/standard/EIDOS.md +129 -188
- package/standard/seeds/README.md +6 -6
- package/standard/seeds/book/Framework.yaml +81 -0
- package/standard/seeds/book/README.md +9 -5
- package/standard/seeds/book/_gitignore +3 -3
- package/standard/seeds/book/me.md +1 -1
- package/standard/seeds/book/roles/README.md +3 -3
- package/standard/seeds/book/roles/framework-owner.md +2 -2
- package/standard/seeds/book/{shapes → templates}/chapter.full.md +0 -8
- package/standard/seeds/book/{shapes → templates}/chapter.sketch.md +0 -7
- package/standard/seeds/book/{shapes → templates}/frame.market.md +0 -6
- package/standard/seeds/book/templates/frame.premise.md +17 -0
- package/standard/seeds/book/{shapes → templates}/frame.reader.md +0 -6
- package/standard/seeds/book/{shapes → templates}/frame.voice.md +0 -7
- package/standard/seeds/research/Framework.yaml +81 -0
- package/standard/seeds/research/README.md +9 -5
- package/standard/seeds/research/_gitignore +3 -3
- package/standard/seeds/research/me.md +1 -1
- package/standard/seeds/research/roles/README.md +3 -3
- package/standard/seeds/research/roles/framework-owner.md +2 -2
- package/standard/seeds/research/{shapes → templates}/frame.ethics.md +0 -6
- package/standard/seeds/research/{shapes → templates}/frame.method.md +0 -7
- package/standard/seeds/research/{shapes → templates}/frame.prior-work.md +0 -6
- package/standard/seeds/research/{shapes → templates}/frame.question.md +0 -7
- package/standard/seeds/research/{shapes → templates}/investigation.full.md +0 -8
- package/standard/seeds/research/{shapes → templates}/investigation.note.md +0 -7
- package/standard/seeds/software/Framework.yaml +82 -0
- package/standard/seeds/software/README.md +5 -5
- package/standard/seeds/software/_gitignore +3 -3
- package/standard/seeds/software/me.md +1 -1
- package/standard/seeds/software/roles/README.md +3 -3
- package/standard/seeds/software/roles/framework-owner.md +2 -2
- package/standard/seeds/software/roles/project-manager.md +2 -2
- package/standard/seeds/software/roles/stakeholder.md +1 -1
- package/standard/seeds/software/{shapes → templates}/frame.architecture.md +0 -7
- package/standard/seeds/software/{shapes → templates}/frame.audience.md +1 -8
- package/standard/seeds/software/{shapes → templates}/frame.criteria.md +0 -8
- package/standard/seeds/software/{shapes → templates}/frame.market.md +0 -8
- package/standard/seeds/software/{shapes → templates}/spec.full.md +0 -8
- package/standard/seeds/software/{shapes → templates}/spec.micro.md +0 -9
- package/browser/index.html +0 -268
- package/dist/src/commands/convert.js +0 -30
- package/dist/src/core/shape.js +0 -26
- package/standard/seeds/book/Framework.md +0 -87
- package/standard/seeds/book/shapes/frame.premise.md +0 -24
- package/standard/seeds/research/Framework.md +0 -88
- package/standard/seeds/software/Framework.md +0 -88
- /package/standard/seeds/software/{shapes → templates}/.gitkeep +0 -0
package/dist/src/core/server.js
CHANGED
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
// The local browser's server: a plain Node http server on 127.0.0.1 serving
|
|
2
|
-
//
|
|
3
|
-
// through the same core function the matching command uses (`new`
|
|
4
|
-
// `index` rebuilds, `check` validates), so nothing is possible
|
|
5
|
-
// not possible from the shell.
|
|
6
|
-
//
|
|
2
|
+
// the built page (browser/dist) and a JSON API. Every write the page can make
|
|
3
|
+
// goes through the same core function the matching command uses (`new`
|
|
4
|
+
// scaffolds, `index` rebuilds, `check` validates), so nothing is possible
|
|
5
|
+
// here that is not possible from the shell. Reads come from the store, which
|
|
6
|
+
// keeps the parsed root until a file changes and indexes it for search.
|
|
7
7
|
import { createServer as createHttpServer } from 'node:http';
|
|
8
|
-
import {
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
9
11
|
import path from 'node:path';
|
|
10
12
|
import { marked } from 'marked';
|
|
11
|
-
import { loadRoot } from '../context.js';
|
|
12
13
|
import { checkRoot } from './check.js';
|
|
13
14
|
import { buildIndexes } from './index-leaf.js';
|
|
14
|
-
import { frameworkToDocument } from './framework-structured.js';
|
|
15
|
-
import {
|
|
15
|
+
import { embeddedIndexOf, frameworkToDocument, parseFrameworkStructured, serializeDocument, withEmbeddedIndex } from './framework-structured.js';
|
|
16
|
+
import { FRAMEWORK_DIR, FrameworkError, propertiesFor } from './framework.js';
|
|
17
|
+
import { readVersions, recordVersion, VersionError } from './versions.js';
|
|
18
|
+
import { gitHead, gitLog, gitShowFile, gitUser } from './git.js';
|
|
19
|
+
import { actingUser, matchesIdentity, readSettings, writeSettings } from './settings.js';
|
|
20
|
+
import { formatFrontmatter } from './frontmatter.js';
|
|
21
|
+
import { findRole, isRoleName, listRoles, meFile, readActor, renderActor, roleFile, writeActor } from './me.js';
|
|
16
22
|
import { scaffoldBlueprint, ScaffoldError } from './scaffold.js';
|
|
17
|
-
import { blueprintId, blueprintTitle, propertyString,
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
23
|
+
import { blueprintId, blueprintTitle, propertyString, resolveVariant } from './blueprint.js';
|
|
24
|
+
import { loadTemplate, stripFrontmatter } from './template.js';
|
|
25
|
+
import { unitOf } from './framework-model.js';
|
|
26
|
+
import { Store } from './store.js';
|
|
27
|
+
import { CliError, today } from '../output.js';
|
|
20
28
|
import { browserDir, packageVersion, readStandard, standardVersion } from '../paths.js';
|
|
21
29
|
import { guideText, GUIDES } from '../commands/instructions.js';
|
|
22
|
-
import {
|
|
30
|
+
import { ANCHORS, EDGE_CAPS, CanvasError, createCanvas, findEndpoint, findNode, FRAME_REF, findPage, listCanvases, loadCanvas, nextCommentId, nextNodeId, renderComments, renderPage, replacePageContents, saveCanvas, toJsonCanvas, canvasProperties, readBox, readStyle, sketchValues } from './canvas.js';
|
|
23
31
|
class HttpError extends Error {
|
|
24
32
|
status;
|
|
25
33
|
constructor(status, message) {
|
|
@@ -34,9 +42,11 @@ function json(response, status, body) {
|
|
|
34
42
|
}
|
|
35
43
|
async function readBody(request) {
|
|
36
44
|
const chunks = [];
|
|
45
|
+
let size = 0;
|
|
37
46
|
for await (const chunk of request) {
|
|
38
47
|
chunks.push(chunk);
|
|
39
|
-
|
|
48
|
+
size += chunk.length;
|
|
49
|
+
if (size > 8 * 1024 * 1024) {
|
|
40
50
|
throw new HttpError(413, 'request body too large');
|
|
41
51
|
}
|
|
42
52
|
}
|
|
@@ -55,15 +65,16 @@ async function readBody(request) {
|
|
|
55
65
|
function rel(root, file) {
|
|
56
66
|
return path.relative(root, file).split(path.sep).join('/');
|
|
57
67
|
}
|
|
68
|
+
const str = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null);
|
|
58
69
|
function blueprintSummary(blueprint) {
|
|
59
|
-
const {
|
|
70
|
+
const { variant, declared } = resolveVariant(blueprint);
|
|
60
71
|
return {
|
|
61
72
|
id: blueprintId(blueprint),
|
|
62
73
|
title: blueprintTitle(blueprint),
|
|
63
74
|
summary: propertyString(blueprint, 'summary'),
|
|
64
75
|
collection: blueprint.collection.name,
|
|
65
76
|
group: blueprint.group,
|
|
66
|
-
|
|
77
|
+
variant: variant?.name ?? declared,
|
|
67
78
|
path: blueprint.rel,
|
|
68
79
|
};
|
|
69
80
|
}
|
|
@@ -79,35 +90,155 @@ function findByPath(context, relPath) {
|
|
|
79
90
|
function findingsFor(context, blueprint) {
|
|
80
91
|
return checkRoot(context.framework, context.blueprints, { only: new Set([blueprint.path]), standardVersion: standardVersion() }).findings.filter((finding) => finding.path === blueprint.rel);
|
|
81
92
|
}
|
|
93
|
+
function propertyView(property) {
|
|
94
|
+
return { name: property.name, type: property.type, core: property.core, owner: property.owner, meaning: property.meaning, applies_to: property.appliesTo, canvas: property.canvas ?? null, tools: property.tools ?? {} };
|
|
95
|
+
}
|
|
96
|
+
// The values a Text property already takes across a collection, for the
|
|
97
|
+
// editor to offer; a canvas hint's keys count as values too.
|
|
98
|
+
function knownValues(context, collectionName) {
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const property of propertiesFor(context.framework, collectionName)) {
|
|
101
|
+
if (property.type !== 'Text' || ['id', 'title', 'summary'].includes(property.name))
|
|
102
|
+
continue;
|
|
103
|
+
const values = new Set();
|
|
104
|
+
for (const key of [...Object.keys(property.canvas?.shape ?? {}), ...Object.keys(property.canvas?.color ?? {})])
|
|
105
|
+
values.add(key);
|
|
106
|
+
for (const blueprint of context.blueprints) {
|
|
107
|
+
if (blueprint.collection.name !== collectionName)
|
|
108
|
+
continue;
|
|
109
|
+
const value = propertyString(blueprint, property.name);
|
|
110
|
+
if (value)
|
|
111
|
+
values.add(value);
|
|
112
|
+
}
|
|
113
|
+
if (values.size > 0)
|
|
114
|
+
out[property.name] = values;
|
|
115
|
+
}
|
|
116
|
+
return Object.fromEntries(Object.entries(out).map(([name, values]) => [name, [...values].sort((a, b) => a.localeCompare(b))]));
|
|
117
|
+
}
|
|
82
118
|
function blueprintDetail(context, blueprint) {
|
|
83
119
|
const raw = readFileSync(blueprint.path, 'utf8');
|
|
84
|
-
const {
|
|
85
|
-
const
|
|
120
|
+
const { variant } = resolveVariant(blueprint);
|
|
121
|
+
const template = variant ? loadTemplate(context.framework, variant) : null;
|
|
86
122
|
return {
|
|
87
123
|
...blueprintSummary(blueprint),
|
|
88
124
|
properties: blueprint.properties ?? {},
|
|
89
125
|
frontmatter_error: blueprint.frontmatterError,
|
|
126
|
+
schema: propertiesFor(context.framework, blueprint.collection.name).map(propertyView),
|
|
127
|
+
known_values: knownValues(context, blueprint.collection.name),
|
|
90
128
|
sections: blueprint.sections.map((section) => section.heading.text),
|
|
91
|
-
|
|
129
|
+
template_sections: template?.sections ?? [],
|
|
130
|
+
body: blueprint.body,
|
|
92
131
|
html: marked.parse(blueprint.body, { async: false }),
|
|
93
132
|
raw,
|
|
94
133
|
findings: findingsFor(context, blueprint),
|
|
95
134
|
};
|
|
96
135
|
}
|
|
97
|
-
|
|
136
|
+
// Whether git is read at all here: the root's switch, and then whether the
|
|
137
|
+
// root sits in a repository and who git says is here.
|
|
138
|
+
function gitInfo(root, shared = readSettings(root).shared) {
|
|
139
|
+
const enabled = shared.features.git;
|
|
140
|
+
const head = enabled ? gitHead(root) : null;
|
|
141
|
+
return { enabled, available: head !== null, head, user: head === null ? null : gitUser(root) };
|
|
142
|
+
}
|
|
143
|
+
// A top-level document: README.md or one the framework declares, addressed
|
|
144
|
+
// by its path from the root. Nothing else at the root is served as a doc.
|
|
145
|
+
function topLevelFile(context, relPath) {
|
|
146
|
+
const wanted = decodeURIComponent(relPath).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
147
|
+
const file = path.resolve(context.root, wanted);
|
|
148
|
+
const declared = new Set([path.resolve(context.root, 'README.md'), ...context.framework.topLevel.map((doc) => path.resolve(path.dirname(context.framework.file), doc.path))]);
|
|
149
|
+
if (!file.startsWith(context.root + path.sep) || !file.endsWith('.md') || !declared.has(file)) {
|
|
150
|
+
throw new HttpError(404, `no top-level document at ${wanted}`);
|
|
151
|
+
}
|
|
152
|
+
const doc = context.framework.topLevel.find((entry) => path.resolve(path.dirname(context.framework.file), entry.path) === file);
|
|
153
|
+
return { file, rel: rel(context.root, file), title: doc?.title ?? path.basename(file, '.md') };
|
|
154
|
+
}
|
|
155
|
+
function docView(context, relPath) {
|
|
156
|
+
const { file, rel: relative, title } = topLevelFile(context, relPath);
|
|
157
|
+
if (!existsSync(file))
|
|
158
|
+
throw new HttpError(404, `${relative} does not exist`);
|
|
159
|
+
const raw = readFileSync(file, 'utf8');
|
|
160
|
+
return { path: relative, title, html: marked.parse(raw, { async: false }), raw, history: gitInfo(context.root).available ? gitLog(context.root, relative, 20) : [] };
|
|
161
|
+
}
|
|
162
|
+
function rootSnapshot(context, store) {
|
|
98
163
|
const actor = readActor(context.root);
|
|
164
|
+
const settings = readSettings(context.root);
|
|
165
|
+
const git = gitInfo(context.root, settings.shared);
|
|
99
166
|
return {
|
|
100
167
|
root: context.root,
|
|
101
168
|
name: path.basename(context.root),
|
|
102
169
|
cli_version: packageVersion(),
|
|
103
170
|
standard_version: standardVersion(),
|
|
171
|
+
search_engine: store.engine,
|
|
172
|
+
git_head: git.head,
|
|
173
|
+
git,
|
|
174
|
+
settings,
|
|
104
175
|
framework: { file: rel(context.root, context.framework.file), format: context.framework.format, problems: context.framework.problems, ...frameworkToDocument(context.framework) },
|
|
105
176
|
blueprints: context.blueprints.map(blueprintSummary),
|
|
106
177
|
roles: listRoles(context.root).map((role) => ({ name: role.name, title: role.title, summary: role.summary })),
|
|
178
|
+
// the health check's totals, for the sidebar's badge
|
|
179
|
+
check: (() => {
|
|
180
|
+
const report = checkRoot(context.framework, context.blueprints, { standardVersion: standardVersion() });
|
|
181
|
+
return { errors: report.errors, warnings: report.warnings };
|
|
182
|
+
})(),
|
|
183
|
+
canvases: listCanvases(context.framework).map((canvas) => ({ id: canvas.id, title: canvas.title, pages: canvas.pages.length })),
|
|
107
184
|
actor: { exists: actor.exists, role: actor.actor.role, ownership: actor.actor.ownership, experience: actor.actor.experience, capacity: actor.actor.capacity, path: rel(context.root, meFile(context.root)) },
|
|
108
185
|
guides: GUIDES,
|
|
109
186
|
};
|
|
110
187
|
}
|
|
188
|
+
// One collection as a table: the schema's properties for this collection as
|
|
189
|
+
// columns, one row per blueprint with everything its frontmatter holds.
|
|
190
|
+
function collectionTable(context, name) {
|
|
191
|
+
const collection = context.framework.collections.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase());
|
|
192
|
+
if (!collection) {
|
|
193
|
+
throw new HttpError(404, `no collection '${name}'`);
|
|
194
|
+
}
|
|
195
|
+
const report = checkRoot(context.framework, context.blueprints, { standardVersion: standardVersion() });
|
|
196
|
+
const counts = new Map();
|
|
197
|
+
for (const finding of report.findings) {
|
|
198
|
+
if (!finding.path)
|
|
199
|
+
continue;
|
|
200
|
+
const entry = counts.get(finding.path) ?? { errors: 0, warnings: 0 };
|
|
201
|
+
if (finding.level === 'error')
|
|
202
|
+
entry.errors += 1;
|
|
203
|
+
else
|
|
204
|
+
entry.warnings += 1;
|
|
205
|
+
counts.set(finding.path, entry);
|
|
206
|
+
}
|
|
207
|
+
const rows = context.blueprints
|
|
208
|
+
.filter((blueprint) => blueprint.collection === collection)
|
|
209
|
+
.map((blueprint) => ({
|
|
210
|
+
...blueprintSummary(blueprint),
|
|
211
|
+
properties: blueprint.properties ?? {},
|
|
212
|
+
frontmatter_error: blueprint.frontmatterError,
|
|
213
|
+
findings: counts.get(blueprint.rel) ?? { errors: 0, warnings: 0 },
|
|
214
|
+
}));
|
|
215
|
+
return {
|
|
216
|
+
collection: {
|
|
217
|
+
name: collection.name,
|
|
218
|
+
description: collection.description,
|
|
219
|
+
unit: unitOf(collection),
|
|
220
|
+
framing: collection.framing,
|
|
221
|
+
variants: collection.variants.map((variant) => ({ name: variant.name, default: variant.isDefault, description: variant.description })),
|
|
222
|
+
grouping: collection.grouping ? { label: collection.grouping.label, property: collection.grouping.property, groups: collection.grouping.groups } : null,
|
|
223
|
+
},
|
|
224
|
+
columns: propertiesFor(context.framework, collection.name).map(propertyView),
|
|
225
|
+
known_values: knownValues(context, collection.name),
|
|
226
|
+
rows,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// The values a Text property takes anywhere in the root, for a sketch's fields.
|
|
230
|
+
function knownValuesAll(context, name) {
|
|
231
|
+
const values = new Set();
|
|
232
|
+
const property = context.framework.schema.custom.find((candidate) => candidate.name === name);
|
|
233
|
+
for (const key of [...Object.keys(property?.canvas?.shape ?? {}), ...Object.keys(property?.canvas?.color ?? {})])
|
|
234
|
+
values.add(key);
|
|
235
|
+
for (const blueprint of context.blueprints) {
|
|
236
|
+
const value = propertyString(blueprint, name);
|
|
237
|
+
if (value !== null && value !== '')
|
|
238
|
+
values.add(value);
|
|
239
|
+
}
|
|
240
|
+
return [...values];
|
|
241
|
+
}
|
|
111
242
|
function checkSnapshot(context) {
|
|
112
243
|
const report = checkRoot(context.framework, context.blueprints, { standardVersion: standardVersion() });
|
|
113
244
|
return {
|
|
@@ -128,6 +259,66 @@ function indexRebuild(context) {
|
|
|
128
259
|
missing_summary: results.flatMap((result) => result.missingSummary),
|
|
129
260
|
};
|
|
130
261
|
}
|
|
262
|
+
// --- the framework, templates, and me.md, for the settings panel -----------
|
|
263
|
+
function templatesView(context) {
|
|
264
|
+
const out = [];
|
|
265
|
+
for (const collection of context.framework.collections) {
|
|
266
|
+
for (const variant of collection.variants) {
|
|
267
|
+
const template = loadTemplate(context.framework, variant);
|
|
268
|
+
out.push({ collection: collection.name, variant: variant.name, path: variant.template, exists: template !== null, sections: template?.sections ?? [] });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
function frameworkView(context) {
|
|
274
|
+
const { framework } = context;
|
|
275
|
+
return {
|
|
276
|
+
file: rel(context.root, framework.file),
|
|
277
|
+
format: framework.format,
|
|
278
|
+
problems: framework.problems,
|
|
279
|
+
document: frameworkToDocument(framework),
|
|
280
|
+
templates: templatesView(context),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// The whole document replaced, the generated index carried over untouched.
|
|
284
|
+
// A document that cannot be read at all is refused; one with problems is
|
|
285
|
+
// written and the problems returned, as `check` would report them.
|
|
286
|
+
function saveFramework(context, body) {
|
|
287
|
+
const given = (typeof body['document'] === 'object' && body['document'] !== null ? body['document'] : body);
|
|
288
|
+
if (!Array.isArray(given['collections'])) {
|
|
289
|
+
throw new HttpError(400, 'expected { document } with a collections list');
|
|
290
|
+
}
|
|
291
|
+
const document = { ...given };
|
|
292
|
+
delete document['index'];
|
|
293
|
+
let text = serializeDocument(document);
|
|
294
|
+
const current = readFileSync(context.framework.file, 'utf8');
|
|
295
|
+
const index = embeddedIndexOf(current);
|
|
296
|
+
if (index) {
|
|
297
|
+
text = withEmbeddedIndex(text, index);
|
|
298
|
+
}
|
|
299
|
+
let parsed;
|
|
300
|
+
try {
|
|
301
|
+
parsed = parseFrameworkStructured(text, context.root, context.framework.file);
|
|
302
|
+
}
|
|
303
|
+
catch (cause) {
|
|
304
|
+
if (cause instanceof FrameworkError)
|
|
305
|
+
throw new HttpError(400, cause.message);
|
|
306
|
+
throw cause;
|
|
307
|
+
}
|
|
308
|
+
writeFileSync(context.framework.file, text, 'utf8');
|
|
309
|
+
return { file: rel(context.root, context.framework.file), problems: parsed.problems };
|
|
310
|
+
}
|
|
311
|
+
// A template file, addressed relative to `.eidos/` as the framework declares it.
|
|
312
|
+
function templateFile(context, relPath) {
|
|
313
|
+
const wanted = decodeURIComponent(relPath).replace(/\\/g, '/');
|
|
314
|
+
const base = path.join(context.root, FRAMEWORK_DIR);
|
|
315
|
+
const file = path.resolve(base, wanted);
|
|
316
|
+
if (!file.startsWith(base + path.sep) || !file.endsWith('.md')) {
|
|
317
|
+
throw new HttpError(400, `a template lives under ${FRAMEWORK_DIR}/ and ends in .md`);
|
|
318
|
+
}
|
|
319
|
+
return file;
|
|
320
|
+
}
|
|
321
|
+
// --- blueprint writes -----------------------------------------------------------
|
|
131
322
|
function createBlueprint(context, body) {
|
|
132
323
|
const text = (value) => (typeof value === 'string' && value.trim() !== '' ? value : null);
|
|
133
324
|
const set = {};
|
|
@@ -143,7 +334,7 @@ function createBlueprint(context, body) {
|
|
|
143
334
|
result = scaffoldBlueprint(context.framework, context.blueprints, {
|
|
144
335
|
collection: text(body['collection']) ?? '',
|
|
145
336
|
title: text(body['title']) ?? '',
|
|
146
|
-
|
|
337
|
+
variant: text(body['variant']),
|
|
147
338
|
group: text(body['group']),
|
|
148
339
|
id: text(body['id']),
|
|
149
340
|
summary: text(body['summary']),
|
|
@@ -159,67 +350,1058 @@ function createBlueprint(context, body) {
|
|
|
159
350
|
}
|
|
160
351
|
mkdirSync(path.dirname(result.path), { recursive: true });
|
|
161
352
|
writeFileSync(result.path, result.content, 'utf8');
|
|
162
|
-
return { path: result.rel, id: result.id, collection: result.collection.name, group: result.group,
|
|
353
|
+
return { path: result.rel, id: result.id, collection: result.collection.name, group: result.group, variant: result.variant.name, notes: result.notes };
|
|
354
|
+
}
|
|
355
|
+
// `{ text }` replaces the file; `{ properties, body }` writes the frontmatter
|
|
356
|
+
// in the house style from an ordered list of [key, value] pairs (or an object,
|
|
357
|
+
// in its key order) above the given body.
|
|
358
|
+
function blueprintText(body, rules = []) {
|
|
359
|
+
if (typeof body['text'] === 'string') {
|
|
360
|
+
return body['text'].replace(/\r\n?/g, '\n');
|
|
361
|
+
}
|
|
362
|
+
const properties = body['properties'];
|
|
363
|
+
const bodyText = typeof body['body'] === 'string' ? body['body'].replace(/\r\n?/g, '\n') : null;
|
|
364
|
+
if (properties === undefined || bodyText === null) {
|
|
365
|
+
throw new HttpError(400, 'expected { text } or { properties, body }');
|
|
366
|
+
}
|
|
367
|
+
let entries;
|
|
368
|
+
if (Array.isArray(properties)) {
|
|
369
|
+
entries = properties
|
|
370
|
+
.filter((pair) => Array.isArray(pair) && typeof pair[0] === 'string' && pair[0].trim() !== '')
|
|
371
|
+
.map((pair) => [String(pair[0]).trim(), pair[1]]);
|
|
372
|
+
}
|
|
373
|
+
else if (typeof properties === 'object' && properties !== null) {
|
|
374
|
+
entries = Object.entries(properties);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
throw new HttpError(400, 'properties must be a list of [key, value] pairs or an object');
|
|
378
|
+
}
|
|
379
|
+
// The root's on-save rules: a named property takes the date or the time,
|
|
380
|
+
// added at the end when the file lacked it.
|
|
381
|
+
for (const rule of rules) {
|
|
382
|
+
const value = rule.set === 'today' ? today() : localMinute();
|
|
383
|
+
const at = entries.findIndex(([key]) => key === rule.property);
|
|
384
|
+
if (at === -1)
|
|
385
|
+
entries.push([rule.property, value]);
|
|
386
|
+
else
|
|
387
|
+
entries[at] = [rule.property, value];
|
|
388
|
+
}
|
|
389
|
+
const frontmatter = formatFrontmatter(entries);
|
|
390
|
+
return frontmatter + (bodyText.startsWith('\n') ? bodyText : '\n' + bodyText);
|
|
391
|
+
}
|
|
392
|
+
// `YYYY-MM-DDTHH:mm` in local time, the Date & time form the standard names.
|
|
393
|
+
function localMinute() {
|
|
394
|
+
const now = new Date();
|
|
395
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
396
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
397
|
+
}
|
|
398
|
+
// --- canvas maps ---------------------------------------------------------------
|
|
399
|
+
function canvasSummary(context, canvas) {
|
|
400
|
+
return {
|
|
401
|
+
id: canvas.id,
|
|
402
|
+
title: canvas.title,
|
|
403
|
+
path: rel(context.root, canvas.file),
|
|
404
|
+
pages: canvas.pages.map((page) => ({ id: page.id, title: page.title, nodes: page.nodes.length, edges: page.edges.length })),
|
|
405
|
+
problems: canvas.problems,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
// The comment author: the user this machine acts as, else the user the git
|
|
409
|
+
// identity matches (by email, then name), else the git name with the me.md
|
|
410
|
+
// role beside it, else that role alone, else "me". Yes, it can be faked;
|
|
411
|
+
// it is attribution, not authentication.
|
|
412
|
+
function commentAuthor(context) {
|
|
413
|
+
const settings = readSettings(context.root);
|
|
414
|
+
const meRole = readActor(context.root).actor.role;
|
|
415
|
+
const acting = actingUser(settings);
|
|
416
|
+
if (acting)
|
|
417
|
+
return { name: acting.name, role: acting.role ?? meRole, user: acting.alias ?? acting.name };
|
|
418
|
+
const git = gitInfo(context.root, settings.shared);
|
|
419
|
+
if (git.user) {
|
|
420
|
+
const identity = git.user;
|
|
421
|
+
const match = settings.shared.users.find((user) => matchesIdentity(user, identity));
|
|
422
|
+
if (match)
|
|
423
|
+
return { name: match.name, role: match.role ?? meRole, user: match.alias ?? match.name };
|
|
424
|
+
return { name: git.user.name, role: meRole, user: null };
|
|
425
|
+
}
|
|
426
|
+
return { name: meRole ?? 'me', role: null, user: null };
|
|
427
|
+
}
|
|
428
|
+
const signed = (author) => ({ author: author.name, ...(author.role ? { role: author.role } : {}), ...(author.user ? { user: author.user } : {}) });
|
|
429
|
+
function pageView(context, canvas, page) {
|
|
430
|
+
const nodes = renderPage(context.framework, context.blueprints, page);
|
|
431
|
+
return {
|
|
432
|
+
canvas: canvasSummary(context, canvas),
|
|
433
|
+
// `raw` is the page as the file holds it: what the browser's undo puts back
|
|
434
|
+
page: { id: page.id, title: page.title, frames: page.frames, edges: page.edges, raw: { frames: page.frames, nodes: page.nodes, edges: page.edges, layout: page.layout, comments: page.comments } },
|
|
435
|
+
nodes,
|
|
436
|
+
comments: renderComments(page, nodes),
|
|
437
|
+
author: commentAuthor(context),
|
|
438
|
+
palette: canvasProperties(context.framework).map((property) => ({ property: property.name, type: property.type, applies_to: property.appliesTo, shape: property.canvas.shape ?? null, color: property.canvas.color ?? null, show: property.canvas.show === true })),
|
|
439
|
+
// schema properties a sketch may hold: those applying to every collection, with the values the root uses
|
|
440
|
+
sketch_properties: propertiesFor(context.framework, '')
|
|
441
|
+
.filter((property) => !property.core && property.appliesTo === 'all')
|
|
442
|
+
.map((property) => ({ ...propertyView(property), values: knownValuesAll(context, property.name) })),
|
|
443
|
+
collections: context.framework.collections.map((collection) => ({
|
|
444
|
+
name: collection.name,
|
|
445
|
+
variants: collection.variants.map((variant) => ({ name: variant.name, default: variant.isDefault })),
|
|
446
|
+
groups: collection.grouping?.groups.map((group) => group.name) ?? [],
|
|
447
|
+
})),
|
|
448
|
+
git: gitInfo(context.root).available,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function mutateCanvas(context, canvasId, change) {
|
|
452
|
+
const canvas = loadCanvas(context.framework, canvasId);
|
|
453
|
+
change(canvas);
|
|
454
|
+
saveCanvas(canvas);
|
|
455
|
+
return loadCanvas(context.framework, canvasId);
|
|
456
|
+
}
|
|
457
|
+
const slug = (title, fallback) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || fallback;
|
|
458
|
+
// Every canvas route, dispatched on the rest of the path after /api/canvas.
|
|
459
|
+
async function handleCanvas(store, method, segments, request, response) {
|
|
460
|
+
const [canvasId, ...rest] = segments;
|
|
461
|
+
if (canvasId === undefined || canvasId === '') {
|
|
462
|
+
if (method === 'GET') {
|
|
463
|
+
const current = store.context();
|
|
464
|
+
json(response, 200, listCanvases(current.framework).map((canvas) => canvasSummary(current, canvas)));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (method === 'POST') {
|
|
468
|
+
const body = await readBody(request);
|
|
469
|
+
const current = store.context();
|
|
470
|
+
json(response, 201, canvasSummary(current, createCanvas(current.framework, str(body['title']) ?? '')));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
throw new HttpError(404, 'not found');
|
|
474
|
+
}
|
|
475
|
+
let current = store.context();
|
|
476
|
+
// GET /api/canvas/<id> the canvas and its first page
|
|
477
|
+
// PUT /api/canvas/<id> { title }
|
|
478
|
+
// DELETE /api/canvas/<id>
|
|
479
|
+
if (rest.length === 0) {
|
|
480
|
+
if (method === 'GET') {
|
|
481
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
482
|
+
const page = canvas.pages[0];
|
|
483
|
+
json(response, 200, page ? pageView(current, canvas, page) : { canvas: canvasSummary(current, canvas), page: null, nodes: [] });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (method === 'PUT') {
|
|
487
|
+
const body = await readBody(request);
|
|
488
|
+
const canvas = mutateCanvas(current, canvasId, (draft) => {
|
|
489
|
+
const title = str(body['title']);
|
|
490
|
+
if (!title)
|
|
491
|
+
throw new CanvasError('a canvas needs a title');
|
|
492
|
+
draft.title = title;
|
|
493
|
+
});
|
|
494
|
+
json(response, 200, canvasSummary(current, canvas));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (method === 'DELETE') {
|
|
498
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
499
|
+
rmSync(canvas.file);
|
|
500
|
+
json(response, 200, { deleted: canvas.id });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
throw new HttpError(404, 'not found');
|
|
504
|
+
}
|
|
505
|
+
if (rest[0] === 'export' && method === 'GET') {
|
|
506
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
507
|
+
const page = findPage(canvas, rest[1] ?? canvas.pages[0]?.id ?? '');
|
|
508
|
+
const text = JSON.stringify(toJsonCanvas(current.framework, current.blueprints, page), null, 2);
|
|
509
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-disposition': `attachment; filename="${canvas.id}-${page.id}.canvas"` });
|
|
510
|
+
response.end(text);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (rest[0] === 'pages' && rest.length === 1 && method === 'POST') {
|
|
514
|
+
const body = await readBody(request);
|
|
515
|
+
const title = str(body['title']) ?? 'Untitled';
|
|
516
|
+
const canvas = mutateCanvas(current, canvasId, (draft) => {
|
|
517
|
+
const id = str(body['id']) ?? slug(title, `page-${draft.pages.length + 1}`);
|
|
518
|
+
if (draft.pages.some((page) => page.id === id))
|
|
519
|
+
throw new CanvasError(`page '${id}' already exists`, 409);
|
|
520
|
+
draft.pages.push({ id, title, frames: [], nodes: [], edges: [], layout: {}, comments: [] });
|
|
521
|
+
});
|
|
522
|
+
json(response, 201, canvasSummary(current, canvas));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (rest[0] !== 'pages' || rest[1] === undefined) {
|
|
526
|
+
throw new HttpError(404, 'not found');
|
|
527
|
+
}
|
|
528
|
+
const pageId = rest[1];
|
|
529
|
+
const tail = rest.slice(2);
|
|
530
|
+
if (tail.length === 0 && method === 'GET') {
|
|
531
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
532
|
+
json(response, 200, pageView(current, canvas, findPage(canvas, pageId)));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const body = method === 'GET' ? {} : await readBody(request);
|
|
536
|
+
// PUT pages/<p> { title } renames; { page: { frames, nodes, edges, layout, comments } } replaces the contents whole (undo, multi-select edits)
|
|
537
|
+
if (tail.length === 0 && method === 'PUT') {
|
|
538
|
+
const canvas = mutateCanvas(current, canvasId, (draft) => {
|
|
539
|
+
const page = findPage(draft, pageId);
|
|
540
|
+
const title = str(body['title']);
|
|
541
|
+
if (title)
|
|
542
|
+
page.title = title;
|
|
543
|
+
if (typeof body['page'] === 'object' && body['page'] !== null)
|
|
544
|
+
replacePageContents(page, body['page']);
|
|
545
|
+
});
|
|
546
|
+
json(response, 200, pageView(current, canvas, findPage(canvas, pageId)));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (tail.length === 0 && method === 'DELETE') {
|
|
550
|
+
const canvas = mutateCanvas(current, canvasId, (draft) => {
|
|
551
|
+
findPage(draft, pageId);
|
|
552
|
+
if (draft.pages.length === 1)
|
|
553
|
+
throw new CanvasError('a canvas keeps at least one page');
|
|
554
|
+
draft.pages = draft.pages.filter((page) => page.id !== pageId);
|
|
555
|
+
});
|
|
556
|
+
json(response, 200, canvasSummary(current, canvas));
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
let changed = null;
|
|
560
|
+
// POST nodes one of { sketch: { label, note?, properties? } } | { sticky: { text, color? } } | { text: { text, size?, font? } } | { annotation: { text, color? } } | { blueprint } + frame?, link?, locked?, style?, box?, x?, y?
|
|
561
|
+
// PUT nodes/<n> the same fields, replacing what is given; { x, y } stores a layout override; { x: null } clears it
|
|
562
|
+
// DELETE nodes/<n> removes the node, its edges, and its comments
|
|
563
|
+
// POST nodes/<n>/promote { collection, title?, variant?, group?, summary? }
|
|
564
|
+
// POST edges { from, to, from_anchor?, to_anchor? }; PUT edges { from, to, label?, label_at?, bend?, anchors, color?, style?, start?, end? }; DELETE edges { from, to }
|
|
565
|
+
// POST frames { title, id?, x?, y?, w?, h?, color?, members? }; PUT frames/<f> { title?, x?, y?, w?, h?, color?, locked?, frame? }; DELETE frames/<f>?contents=delete
|
|
566
|
+
// PUT layout { positions: { <node>: { x, y } } } pins several at once; DELETE layout clears every override
|
|
567
|
+
// POST comments { x, y, node?, text }; POST comments/<c>/replies { text }; PUT comments/<c> { resolved?, x?, y?, node? }; DELETE comments/<c>
|
|
568
|
+
// PUT comments/<c>/messages/<i> { text }; DELETE comments/<c>/messages/<i>
|
|
569
|
+
if (tail[0] === 'nodes' && tail.length === 1 && method === 'POST') {
|
|
570
|
+
let created = '';
|
|
571
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
572
|
+
const page = findPage(draft, pageId);
|
|
573
|
+
const node = { id: str(body['id']) ?? nextNodeId(page) };
|
|
574
|
+
if (page.nodes.some((candidate) => candidate.id === node.id))
|
|
575
|
+
throw new CanvasError(`node '${node.id}' already exists`, 409);
|
|
576
|
+
applyNodeFields(node, body);
|
|
577
|
+
if (!node.blueprint && !node.sketch && !node.sticky && !node.text && !node.annotation)
|
|
578
|
+
throw new CanvasError('a node is { blueprint }, { sketch: { label } }, { sticky: { text } }, { text: { text } }, or { annotation: { text } }');
|
|
579
|
+
page.nodes.push(node);
|
|
580
|
+
if (typeof body['x'] === 'number' && typeof body['y'] === 'number')
|
|
581
|
+
page.layout[node.id] = { x: body['x'], y: body['y'] };
|
|
582
|
+
created = node.id;
|
|
583
|
+
});
|
|
584
|
+
const page = findPage(changed, pageId);
|
|
585
|
+
json(response, 201, { node: renderPage(current.framework, current.blueprints, page).find((node) => node.id === created) ?? null, ...pageView(current, changed, page) });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (tail[0] === 'nodes' && tail[1] !== undefined && tail.length === 2 && method === 'PUT') {
|
|
589
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
590
|
+
const page = findPage(draft, pageId);
|
|
591
|
+
const node = findNode(page, tail[1] ?? '');
|
|
592
|
+
applyNodeFields(node, body);
|
|
593
|
+
if (body['x'] === null)
|
|
594
|
+
delete page.layout[node.id];
|
|
595
|
+
else if (typeof body['x'] === 'number' && typeof body['y'] === 'number')
|
|
596
|
+
page.layout[node.id] = { x: body['x'], y: body['y'] };
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
else if (tail[0] === 'nodes' && tail[1] !== undefined && tail.length === 2 && method === 'DELETE') {
|
|
600
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
601
|
+
const page = findPage(draft, pageId);
|
|
602
|
+
findNode(page, tail[1] ?? '');
|
|
603
|
+
removeNodes(page, [tail[1] ?? '']);
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
else if (tail[0] === 'nodes' && tail[1] !== undefined && tail[2] === 'promote' && method === 'POST') {
|
|
607
|
+
const canvas = loadCanvas(current.framework, canvasId);
|
|
608
|
+
const node = findNode(findPage(canvas, pageId), tail[1]);
|
|
609
|
+
if (!node.sketch)
|
|
610
|
+
throw new HttpError(400, `node '${node.id}' is already a blueprint`);
|
|
611
|
+
const sketch = node.sketch;
|
|
612
|
+
const set = sketchValues(current.framework, sketch);
|
|
613
|
+
const created = createBlueprint(current, {
|
|
614
|
+
collection: str(body['collection']) ?? '',
|
|
615
|
+
title: str(body['title']) ?? (sketch.label.trim() === '' ? '' : sketch.label),
|
|
616
|
+
variant: str(body['variant']) ?? undefined,
|
|
617
|
+
group: str(body['group']) ?? undefined,
|
|
618
|
+
summary: str(body['summary']) ?? sketch.note ?? undefined,
|
|
619
|
+
set,
|
|
620
|
+
});
|
|
621
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
622
|
+
const target = findNode(findPage(draft, pageId), tail[1] ?? '');
|
|
623
|
+
target.blueprint = String(created['id']);
|
|
624
|
+
delete target.sketch;
|
|
625
|
+
});
|
|
626
|
+
store.invalidate();
|
|
627
|
+
current = store.context();
|
|
628
|
+
}
|
|
629
|
+
else if (tail[0] === 'edges' && (method === 'POST' || method === 'PUT')) {
|
|
630
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
631
|
+
const page = findPage(draft, pageId);
|
|
632
|
+
const from = str(body['from']);
|
|
633
|
+
const to = str(body['to']);
|
|
634
|
+
if (!from || !to)
|
|
635
|
+
throw new CanvasError('an edge needs from and to');
|
|
636
|
+
if (from === to)
|
|
637
|
+
throw new CanvasError('an edge needs two different ends');
|
|
638
|
+
findEndpoint(page, from);
|
|
639
|
+
findEndpoint(page, to);
|
|
640
|
+
const existing = page.edges.find((edge) => edge.from === from && edge.to === to);
|
|
641
|
+
const label = str(body['label']);
|
|
642
|
+
const edge = existing ?? { from, to };
|
|
643
|
+
if (label)
|
|
644
|
+
edge.label = label;
|
|
645
|
+
else if (body['label'] === null || body['label'] === '')
|
|
646
|
+
delete edge.label;
|
|
647
|
+
if (typeof body['bend'] === 'number')
|
|
648
|
+
edge.bend = Math.round(body['bend']);
|
|
649
|
+
else if (body['bend'] === null)
|
|
650
|
+
delete edge.bend;
|
|
651
|
+
for (const key of ['from_anchor', 'to_anchor']) {
|
|
652
|
+
const given = body[key];
|
|
653
|
+
if (given === null)
|
|
654
|
+
delete edge[key];
|
|
655
|
+
else if (typeof given === 'string' && ANCHORS.includes(given))
|
|
656
|
+
edge[key] = given;
|
|
657
|
+
}
|
|
658
|
+
// the look: a color, dashed or solid, a cap at either end, the label's place along the line
|
|
659
|
+
if (typeof body['label_at'] === 'number')
|
|
660
|
+
edge.label_at = Math.round(Math.min(1, Math.max(0, body['label_at'])) * 100) / 100;
|
|
661
|
+
else if (body['label_at'] === null)
|
|
662
|
+
delete edge.label_at;
|
|
663
|
+
const color = str(body['color']);
|
|
664
|
+
if (color)
|
|
665
|
+
edge.color = color;
|
|
666
|
+
else if (body['color'] === null || body['color'] === '')
|
|
667
|
+
delete edge.color;
|
|
668
|
+
if (body['style'] === 'dashed')
|
|
669
|
+
edge.style = 'dashed';
|
|
670
|
+
else if (body['style'] === 'solid' || body['style'] === null)
|
|
671
|
+
delete edge.style;
|
|
672
|
+
for (const key of ['start', 'end']) {
|
|
673
|
+
const given = body[key];
|
|
674
|
+
if (given === null)
|
|
675
|
+
delete edge[key];
|
|
676
|
+
else if (typeof given === 'string' && EDGE_CAPS.includes(given))
|
|
677
|
+
edge[key] = given;
|
|
678
|
+
}
|
|
679
|
+
if (!existing)
|
|
680
|
+
page.edges.push(edge);
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
else if (tail[0] === 'edges' && method === 'DELETE') {
|
|
684
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
685
|
+
const page = findPage(draft, pageId);
|
|
686
|
+
page.edges = page.edges.filter((edge) => !(edge.from === body['from'] && edge.to === body['to']));
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
else if (tail[0] === 'frames' && tail.length === 1 && method === 'POST') {
|
|
690
|
+
let created = '';
|
|
691
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
692
|
+
const page = findPage(draft, pageId);
|
|
693
|
+
const title = str(body['title']) ?? `Frame ${page.frames.length + 1}`;
|
|
694
|
+
let id = str(body['id']) ?? slug(title, `frame-${page.frames.length + 1}`);
|
|
695
|
+
let n = 1;
|
|
696
|
+
while (page.frames.some((frame) => frame.id === id))
|
|
697
|
+
id = `${slug(title, 'frame')}-${(n += 1)}`;
|
|
698
|
+
const frame = { id, title };
|
|
699
|
+
applyFrameFields(frame, body);
|
|
700
|
+
page.frames.push(frame);
|
|
701
|
+
// Nodes inside a drawn frame belong to it.
|
|
702
|
+
if (frame.x !== undefined) {
|
|
703
|
+
const members = Array.isArray(body['members']) ? new Set(body['members'].map(String)) : null;
|
|
704
|
+
for (const node of page.nodes) {
|
|
705
|
+
if (members?.has(node.id))
|
|
706
|
+
node.frame = id;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
created = id;
|
|
710
|
+
});
|
|
711
|
+
const page = findPage(changed, pageId);
|
|
712
|
+
json(response, 200, { frame: page.frames.find((frame) => frame.id === created) ?? null, ...pageView(current, changed, page) });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
else if (tail[0] === 'frames' && tail[1] !== undefined && method === 'PUT') {
|
|
716
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
717
|
+
const page = findPage(draft, pageId);
|
|
718
|
+
const frame = page.frames.find((candidate) => candidate.id === tail[1]);
|
|
719
|
+
if (!frame)
|
|
720
|
+
throw new CanvasError(`no frame '${tail[1]}'`, 404);
|
|
721
|
+
applyFrameFields(frame, body);
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
else if (tail[0] === 'frames' && tail[1] !== undefined && method === 'DELETE') {
|
|
725
|
+
// ?contents=delete takes the attached nodes with it; otherwise they stay, unattached.
|
|
726
|
+
const withContents = new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('contents') === 'delete';
|
|
727
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
728
|
+
const page = findPage(draft, pageId);
|
|
729
|
+
const id = tail[1] ?? '';
|
|
730
|
+
if (!page.frames.some((frame) => frame.id === id))
|
|
731
|
+
throw new CanvasError(`no frame '${id}' on page '${page.id}'`, 404);
|
|
732
|
+
if (withContents)
|
|
733
|
+
removeNodes(page, page.nodes.filter((node) => node.frame === id).map((node) => node.id));
|
|
734
|
+
page.frames = page.frames.filter((frame) => frame.id !== id);
|
|
735
|
+
for (const node of page.nodes)
|
|
736
|
+
if (node.frame === id)
|
|
737
|
+
delete node.frame;
|
|
738
|
+
for (const frame of page.frames)
|
|
739
|
+
if (frame.frame === id)
|
|
740
|
+
delete frame.frame;
|
|
741
|
+
page.edges = page.edges.filter((edge) => edge.from !== `${FRAME_REF}${id}` && edge.to !== `${FRAME_REF}${id}`);
|
|
742
|
+
dropUnpointedAnnotations(page);
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
else if (tail[0] === 'layout' && method === 'PUT') {
|
|
746
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
747
|
+
const page = findPage(draft, pageId);
|
|
748
|
+
const positions = typeof body['positions'] === 'object' && body['positions'] !== null ? body['positions'] : {};
|
|
749
|
+
for (const [id, at] of Object.entries(positions)) {
|
|
750
|
+
findNode(page, id);
|
|
751
|
+
if (at === null)
|
|
752
|
+
delete page.layout[id];
|
|
753
|
+
else if (typeof at === 'object' && at !== null && typeof at['x'] === 'number' && typeof at['y'] === 'number') {
|
|
754
|
+
page.layout[id] = { x: Math.round(at.x), y: Math.round(at.y) };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
else if (tail[0] === 'layout' && method === 'DELETE') {
|
|
760
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
761
|
+
findPage(draft, pageId).layout = {};
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
else if (tail[0] === 'comments' && tail.length === 1 && method === 'POST') {
|
|
765
|
+
const text = str(body['text']);
|
|
766
|
+
if (!text)
|
|
767
|
+
throw new HttpError(400, 'a comment needs text');
|
|
768
|
+
if (typeof body['x'] !== 'number' || typeof body['y'] !== 'number')
|
|
769
|
+
throw new HttpError(400, 'a comment needs a point { x, y }');
|
|
770
|
+
let created = '';
|
|
771
|
+
const author = commentAuthor(current);
|
|
772
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
773
|
+
const page = findPage(draft, pageId);
|
|
774
|
+
const comment = { id: nextCommentId(page), x: Math.round(body['x']), y: Math.round(body['y']), messages: [{ ...signed(author), text, at: new Date().toISOString() }] };
|
|
775
|
+
const node = str(body['node']);
|
|
776
|
+
if (node) {
|
|
777
|
+
findNode(page, node);
|
|
778
|
+
comment.node = node;
|
|
779
|
+
}
|
|
780
|
+
page.comments.push(comment);
|
|
781
|
+
created = comment.id;
|
|
782
|
+
});
|
|
783
|
+
const page = findPage(changed, pageId);
|
|
784
|
+
json(response, 201, { comment: page.comments.find((comment) => comment.id === created) ?? null, ...pageView(current, changed, page) });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
else if (tail[0] === 'comments' && tail[1] !== undefined && tail[2] === 'replies' && method === 'POST') {
|
|
788
|
+
const text = str(body['text']);
|
|
789
|
+
if (!text)
|
|
790
|
+
throw new HttpError(400, 'a reply needs text');
|
|
791
|
+
const author = commentAuthor(current);
|
|
792
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
793
|
+
const comment = findComment(findPage(draft, pageId), tail[1] ?? '');
|
|
794
|
+
comment.messages.push({ ...signed(author), text, at: new Date().toISOString() });
|
|
795
|
+
delete comment.resolved;
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
else if (tail[0] === 'comments' && tail[1] !== undefined && tail[2] === 'messages' && tail[3] !== undefined && (method === 'PUT' || method === 'DELETE')) {
|
|
799
|
+
// One message of a thread, rewritten or removed; a thread whose last
|
|
800
|
+
// message goes is gone itself.
|
|
801
|
+
const index = Number.parseInt(tail[3], 10);
|
|
802
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
803
|
+
const page = findPage(draft, pageId);
|
|
804
|
+
const comment = findComment(page, tail[1] ?? '');
|
|
805
|
+
const message = comment.messages[index];
|
|
806
|
+
if (!Number.isInteger(index) || !message)
|
|
807
|
+
throw new CanvasError(`comment ${comment.id} has no message ${tail[3]}`, 404);
|
|
808
|
+
if (method === 'PUT') {
|
|
809
|
+
const text = str(body['text']);
|
|
810
|
+
if (!text)
|
|
811
|
+
throw new HttpError(400, 'a message needs text');
|
|
812
|
+
message.text = text;
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
comment.messages.splice(index, 1);
|
|
816
|
+
if (comment.messages.length === 0)
|
|
817
|
+
page.comments = page.comments.filter((candidate) => candidate.id !== comment.id);
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
else if (tail[0] === 'comments' && tail[1] !== undefined && tail.length === 2 && method === 'PUT') {
|
|
821
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
822
|
+
const page = findPage(draft, pageId);
|
|
823
|
+
const comment = findComment(page, tail[1] ?? '');
|
|
824
|
+
if (body['resolved'] === true)
|
|
825
|
+
comment.resolved = true;
|
|
826
|
+
else if (body['resolved'] === false)
|
|
827
|
+
delete comment.resolved;
|
|
828
|
+
if (typeof body['x'] === 'number' && typeof body['y'] === 'number') {
|
|
829
|
+
comment.x = Math.round(body['x']);
|
|
830
|
+
comment.y = Math.round(body['y']);
|
|
831
|
+
}
|
|
832
|
+
if (body['node'] === null)
|
|
833
|
+
delete comment.node;
|
|
834
|
+
else if (typeof body['node'] === 'string' && body['node'] !== '') {
|
|
835
|
+
findNode(page, body['node']);
|
|
836
|
+
comment.node = body['node'];
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
else if (tail[0] === 'comments' && tail[1] !== undefined && tail.length === 2 && method === 'DELETE') {
|
|
841
|
+
changed = mutateCanvas(current, canvasId, (draft) => {
|
|
842
|
+
const page = findPage(draft, pageId);
|
|
843
|
+
findComment(page, tail[1] ?? '');
|
|
844
|
+
page.comments = page.comments.filter((comment) => comment.id !== tail[1]);
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
throw new HttpError(404, 'not found');
|
|
849
|
+
}
|
|
850
|
+
json(response, 200, pageView(current, changed, findPage(changed, pageId)));
|
|
851
|
+
}
|
|
852
|
+
function findComment(page, id) {
|
|
853
|
+
const comment = page.comments.find((candidate) => candidate.id === id);
|
|
854
|
+
if (!comment)
|
|
855
|
+
throw new CanvasError(`no comment '${id}' on page '${page.id}'`, 404);
|
|
856
|
+
return comment;
|
|
163
857
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
858
|
+
// Nodes gone from a page take their place, their edges, and the comments
|
|
859
|
+
// pinned to them; an annotation left pointing at nothing goes too.
|
|
860
|
+
function removeNodes(page, ids) {
|
|
861
|
+
const gone = new Set(ids);
|
|
862
|
+
page.nodes = page.nodes.filter((node) => !gone.has(node.id));
|
|
863
|
+
page.edges = page.edges.filter((edge) => !gone.has(edge.from) && !gone.has(edge.to));
|
|
864
|
+
page.comments = page.comments.filter((comment) => !comment.node || !gone.has(comment.node));
|
|
865
|
+
for (const id of gone)
|
|
866
|
+
delete page.layout[id];
|
|
867
|
+
dropUnpointedAnnotations(page);
|
|
868
|
+
}
|
|
869
|
+
function dropUnpointedAnnotations(page) {
|
|
870
|
+
const unpointed = page.nodes.filter((node) => node.annotation && !page.edges.some((edge) => edge.from === node.id)).map((node) => node.id);
|
|
871
|
+
if (unpointed.length > 0)
|
|
872
|
+
removeNodes(page, unpointed);
|
|
873
|
+
}
|
|
874
|
+
function applyFrameFields(frame, body) {
|
|
875
|
+
const title = str(body['title']);
|
|
876
|
+
if (title)
|
|
877
|
+
frame.title = title;
|
|
878
|
+
if (typeof body['x'] === 'number' && typeof body['y'] === 'number' && typeof body['w'] === 'number' && typeof body['h'] === 'number') {
|
|
879
|
+
frame.x = Math.round(body['x']);
|
|
880
|
+
frame.y = Math.round(body['y']);
|
|
881
|
+
frame.w = Math.max(40, Math.round(body['w']));
|
|
882
|
+
frame.h = Math.max(40, Math.round(body['h']));
|
|
883
|
+
}
|
|
884
|
+
else if (body['x'] === null) {
|
|
885
|
+
delete frame.x;
|
|
886
|
+
delete frame.y;
|
|
887
|
+
delete frame.w;
|
|
888
|
+
delete frame.h;
|
|
168
889
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
890
|
+
if (body['color'] === null || body['color'] === '')
|
|
891
|
+
delete frame.color;
|
|
892
|
+
else if (typeof body['color'] === 'string')
|
|
893
|
+
frame.color = body['color'];
|
|
894
|
+
if (body['frame'] === null || body['frame'] === '')
|
|
895
|
+
delete frame.frame;
|
|
896
|
+
else if (typeof body['frame'] === 'string' && body['frame'] !== frame.id)
|
|
897
|
+
frame.frame = body['frame'];
|
|
898
|
+
if (body['locked'] === true)
|
|
899
|
+
frame.locked = true;
|
|
900
|
+
else if (body['locked'] === false)
|
|
901
|
+
delete frame.locked;
|
|
902
|
+
}
|
|
903
|
+
function applyNodeFields(node, body) {
|
|
904
|
+
if (typeof body['blueprint'] === 'string' && body['blueprint'].trim() !== '') {
|
|
905
|
+
node.blueprint = body['blueprint'].trim();
|
|
906
|
+
delete node.sketch;
|
|
907
|
+
delete node.sticky;
|
|
908
|
+
delete node.text;
|
|
909
|
+
delete node.annotation;
|
|
910
|
+
}
|
|
911
|
+
if (typeof body['sticky'] === 'object' && body['sticky'] !== null) {
|
|
912
|
+
const given = body['sticky'];
|
|
913
|
+
const sticky = node.sticky ?? { text: '' };
|
|
914
|
+
if (typeof given['text'] === 'string')
|
|
915
|
+
sticky.text = given['text'];
|
|
916
|
+
if (given['color'] === null || given['color'] === '')
|
|
917
|
+
delete sticky.color;
|
|
918
|
+
else if (typeof given['color'] === 'string')
|
|
919
|
+
sticky.color = given['color'];
|
|
920
|
+
node.sticky = sticky;
|
|
921
|
+
delete node.blueprint;
|
|
922
|
+
delete node.sketch;
|
|
923
|
+
delete node.text;
|
|
924
|
+
delete node.annotation;
|
|
925
|
+
}
|
|
926
|
+
if (typeof body['text'] === 'object' && body['text'] !== null) {
|
|
927
|
+
const given = body['text'];
|
|
928
|
+
const item = node.text ?? { text: '' };
|
|
929
|
+
if (typeof given['text'] === 'string')
|
|
930
|
+
item.text = given['text'];
|
|
931
|
+
if (given['size'] === 'small' || given['size'] === 'medium' || given['size'] === 'large')
|
|
932
|
+
item.size = given['size'];
|
|
933
|
+
else if (given['size'] === null)
|
|
934
|
+
delete item.size;
|
|
935
|
+
if (typeof given['font'] === 'number' && given['font'] >= 8 && given['font'] <= 64)
|
|
936
|
+
item.font = Math.round(given['font']);
|
|
937
|
+
else if (given['font'] === null)
|
|
938
|
+
delete item.font;
|
|
939
|
+
node.text = item;
|
|
940
|
+
delete node.blueprint;
|
|
941
|
+
delete node.sketch;
|
|
942
|
+
delete node.sticky;
|
|
943
|
+
delete node.annotation;
|
|
944
|
+
}
|
|
945
|
+
if (typeof body['annotation'] === 'object' && body['annotation'] !== null) {
|
|
946
|
+
const given = body['annotation'];
|
|
947
|
+
const item = node.annotation ?? { text: '' };
|
|
948
|
+
if (typeof given['text'] === 'string')
|
|
949
|
+
item.text = given['text'];
|
|
950
|
+
if (given['color'] === null || given['color'] === '')
|
|
951
|
+
delete item.color;
|
|
952
|
+
else if (typeof given['color'] === 'string')
|
|
953
|
+
item.color = given['color'];
|
|
954
|
+
node.annotation = item;
|
|
955
|
+
delete node.blueprint;
|
|
956
|
+
delete node.sketch;
|
|
957
|
+
delete node.sticky;
|
|
958
|
+
delete node.text;
|
|
959
|
+
}
|
|
960
|
+
if (body['locked'] === true)
|
|
961
|
+
node.locked = true;
|
|
962
|
+
else if (body['locked'] === false)
|
|
963
|
+
delete node.locked;
|
|
964
|
+
if (typeof body['sketch'] === 'object' && body['sketch'] !== null) {
|
|
965
|
+
const given = body['sketch'];
|
|
966
|
+
const sketch = node.sketch ?? { label: '' };
|
|
967
|
+
if (typeof given['label'] === 'string')
|
|
968
|
+
sketch.label = given['label'];
|
|
969
|
+
for (const key of ['kind', 'status', 'note']) {
|
|
970
|
+
if (given[key] === null || given[key] === '')
|
|
971
|
+
delete sketch[key];
|
|
972
|
+
else if (typeof given[key] === 'string')
|
|
973
|
+
sketch[key] = given[key];
|
|
974
|
+
}
|
|
975
|
+
if (typeof given['properties'] === 'object' && given['properties'] !== null) {
|
|
976
|
+
const properties = { ...(sketch.properties ?? {}) };
|
|
977
|
+
for (const [key, value] of Object.entries(given['properties'])) {
|
|
978
|
+
if (value === null || value === '')
|
|
979
|
+
delete properties[key];
|
|
980
|
+
else if (typeof value === 'string')
|
|
981
|
+
properties[key] = value;
|
|
982
|
+
}
|
|
983
|
+
// the legacy pair is superseded by a value held by name
|
|
984
|
+
if (sketch.kind !== undefined && Object.keys(properties).length > 0)
|
|
985
|
+
delete sketch.kind;
|
|
986
|
+
if (sketch.status !== undefined && Object.keys(properties).length > 0)
|
|
987
|
+
delete sketch.status;
|
|
988
|
+
if (Object.keys(properties).length > 0)
|
|
989
|
+
sketch.properties = properties;
|
|
990
|
+
else
|
|
991
|
+
delete sketch.properties;
|
|
992
|
+
}
|
|
993
|
+
node.sketch = sketch;
|
|
994
|
+
delete node.blueprint;
|
|
995
|
+
delete node.sticky;
|
|
996
|
+
delete node.text;
|
|
997
|
+
delete node.annotation;
|
|
998
|
+
}
|
|
999
|
+
if (body['style'] === null) {
|
|
1000
|
+
delete node.style;
|
|
1001
|
+
delete node.styled;
|
|
1002
|
+
}
|
|
1003
|
+
else if (typeof body['style'] === 'object' && body['style'] !== null) {
|
|
1004
|
+
const given = body['style'];
|
|
1005
|
+
const style = { ...(node.style ?? {}) };
|
|
1006
|
+
for (const key of ['shape', 'color', 'fill', 'fill_color', 'size']) {
|
|
1007
|
+
if (given[key] === null || given[key] === '')
|
|
1008
|
+
delete style[key];
|
|
1009
|
+
else if (given[key] !== undefined)
|
|
1010
|
+
style[key] = given[key];
|
|
1011
|
+
}
|
|
1012
|
+
const read = readStyle(style);
|
|
1013
|
+
if (read)
|
|
1014
|
+
node.style = read;
|
|
1015
|
+
else
|
|
1016
|
+
delete node.style;
|
|
1017
|
+
}
|
|
1018
|
+
if (body['styled'] === true)
|
|
1019
|
+
node.styled = true;
|
|
1020
|
+
else if (body['styled'] === false)
|
|
1021
|
+
delete node.styled;
|
|
1022
|
+
if (body['box'] === null)
|
|
1023
|
+
delete node.box;
|
|
1024
|
+
else if (typeof body['box'] === 'object' && body['box'] !== null) {
|
|
1025
|
+
const box = readBox(body['box']);
|
|
1026
|
+
if (box)
|
|
1027
|
+
node.box = box;
|
|
1028
|
+
}
|
|
1029
|
+
if (body['frame'] === null || body['frame'] === '')
|
|
1030
|
+
delete node.frame;
|
|
1031
|
+
else if (typeof body['frame'] === 'string')
|
|
1032
|
+
node.frame = body['frame'];
|
|
1033
|
+
if (body['link'] === null)
|
|
1034
|
+
delete node.link;
|
|
1035
|
+
else if (typeof body['link'] === 'object' && body['link'] !== null) {
|
|
1036
|
+
const given = body['link'];
|
|
1037
|
+
const link = {};
|
|
1038
|
+
if (typeof given['page'] === 'string' && given['page'] !== '')
|
|
1039
|
+
link.page = given['page'];
|
|
1040
|
+
if (typeof given['canvas'] === 'string' && given['canvas'] !== '')
|
|
1041
|
+
link.canvas = given['canvas'];
|
|
1042
|
+
if (Object.keys(link).length > 0)
|
|
1043
|
+
node.link = link;
|
|
1044
|
+
else
|
|
1045
|
+
delete node.link;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
// --- the page itself ----------------------------------------------------------
|
|
1049
|
+
const distDir = path.join(browserDir, 'dist');
|
|
1050
|
+
const MIME = {
|
|
1051
|
+
'.html': 'text/html; charset=utf-8',
|
|
1052
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
1053
|
+
'.css': 'text/css; charset=utf-8',
|
|
1054
|
+
'.svg': 'image/svg+xml',
|
|
1055
|
+
'.png': 'image/png',
|
|
1056
|
+
'.ico': 'image/x-icon',
|
|
1057
|
+
'.json': 'application/json; charset=utf-8',
|
|
1058
|
+
'.woff2': 'font/woff2',
|
|
1059
|
+
'.woff': 'font/woff',
|
|
1060
|
+
'.map': 'application/json',
|
|
1061
|
+
};
|
|
1062
|
+
// A file from the built bundle, or the page for any route the app owns.
|
|
1063
|
+
function serveStatic(pathname, response) {
|
|
1064
|
+
const wanted = path.normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
|
|
1065
|
+
let file = path.join(distDir, wanted);
|
|
1066
|
+
if (!file.startsWith(distDir) || !existsSync(file) || statSync(file).isDirectory()) {
|
|
1067
|
+
file = path.join(distDir, 'index.html');
|
|
1068
|
+
}
|
|
1069
|
+
if (!existsSync(file)) {
|
|
1070
|
+
throw new HttpError(500, `the browser page is missing at ${file}; in a checkout, build it with \`pnpm build\``);
|
|
1071
|
+
}
|
|
1072
|
+
const body = readFileSync(file);
|
|
1073
|
+
const type = MIME[path.extname(file)] ?? 'application/octet-stream';
|
|
1074
|
+
const cache = file.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable';
|
|
1075
|
+
response.writeHead(200, { 'content-type': type, 'content-length': body.length, 'cache-control': cache });
|
|
1076
|
+
response.end(body);
|
|
173
1077
|
}
|
|
174
1078
|
export function createServer(options) {
|
|
175
|
-
const
|
|
176
|
-
const context = () => loadRoot({ root: options.root }, options.cwd);
|
|
1079
|
+
const store = new Store({ root: options.root, cwd: options.cwd, watch: options.watch ?? true });
|
|
177
1080
|
const handle = async (request, response) => {
|
|
178
1081
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
179
1082
|
const method = request.method ?? 'GET';
|
|
180
1083
|
const segments = url.pathname.split('/').filter((segment) => segment !== '');
|
|
181
|
-
if (segments.length === 0 || (segments[0] !== 'api' && !url.pathname.includes('.'))) {
|
|
182
|
-
if (!existsSync(page)) {
|
|
183
|
-
throw new HttpError(500, `the browser page is missing at ${page}`);
|
|
184
|
-
}
|
|
185
|
-
const html = readFileSync(page);
|
|
186
|
-
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': html.length });
|
|
187
|
-
response.end(html);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
1084
|
if (segments[0] !== 'api') {
|
|
191
|
-
|
|
1085
|
+
serveStatic(url.pathname, response);
|
|
1086
|
+
return;
|
|
192
1087
|
}
|
|
193
1088
|
const resource = segments[1] ?? '';
|
|
194
1089
|
const rest = segments.slice(2).join('/');
|
|
195
1090
|
if (resource === 'root' && method === 'GET') {
|
|
196
|
-
json(response, 200, rootSnapshot(context()));
|
|
1091
|
+
json(response, 200, rootSnapshot(store.context(), store));
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (resource === 'events' && method === 'GET') {
|
|
1095
|
+
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
1096
|
+
response.write(': connected\n\n');
|
|
1097
|
+
const off = store.onChange(() => response.write('data: {"type":"change"}\n\n'));
|
|
1098
|
+
request.on('close', off);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (resource === 'search' && method === 'GET') {
|
|
1102
|
+
const query = url.searchParams.get('q') ?? '';
|
|
1103
|
+
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 20) || 20));
|
|
1104
|
+
json(response, 200, { query, hits: query.trim() === '' ? [] : store.search(query, limit) });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (resource === 'collections' && rest !== '' && method === 'GET') {
|
|
1108
|
+
json(response, 200, collectionTable(store.context(), decodeURIComponent(rest)));
|
|
197
1109
|
return;
|
|
198
1110
|
}
|
|
199
1111
|
if (resource === 'blueprints' && rest === '' && method === 'GET') {
|
|
200
|
-
json(response, 200, context().blueprints.map(blueprintSummary));
|
|
1112
|
+
json(response, 200, store.context().blueprints.map(blueprintSummary));
|
|
201
1113
|
return;
|
|
202
1114
|
}
|
|
203
1115
|
if (resource === 'blueprints' && rest === '' && method === 'POST') {
|
|
204
|
-
|
|
1116
|
+
const created = createBlueprint(store.context(), await readBody(request));
|
|
1117
|
+
store.invalidate();
|
|
1118
|
+
json(response, 201, created);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
// GET /api/blueprints/<path>/history: the commits that touched the file, newest first; empty without git.
|
|
1122
|
+
if (resource === 'blueprints' && rest.endsWith('/history') && method === 'GET') {
|
|
1123
|
+
const current = store.context();
|
|
1124
|
+
const blueprint = findByPath(current, rest.slice(0, -'/history'.length));
|
|
1125
|
+
const git = gitInfo(current.root);
|
|
1126
|
+
json(response, 200, { path: blueprint.rel, git: git.available, history: git.available ? gitLog(current.root, blueprint.rel) : [] });
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
// POST /api/blueprints/<path>/diff { sha }: open the configured diff viewer on the file then and now.
|
|
1130
|
+
if (resource === 'blueprints' && rest.endsWith('/diff') && method === 'POST') {
|
|
1131
|
+
const current = store.context();
|
|
1132
|
+
const blueprint = findByPath(current, rest.slice(0, -'/diff'.length));
|
|
1133
|
+
const body = await readBody(request);
|
|
1134
|
+
const sha = typeof body['sha'] === 'string' ? body['sha'].trim() : '';
|
|
1135
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha))
|
|
1136
|
+
throw new HttpError(400, 'expected { sha }');
|
|
1137
|
+
const git = gitInfo(current.root);
|
|
1138
|
+
if (!git.enabled)
|
|
1139
|
+
throw new HttpError(400, 'git features are off for this root (Plugins → Git)');
|
|
1140
|
+
if (!git.available)
|
|
1141
|
+
throw new HttpError(400, 'this root is not in a git repository');
|
|
1142
|
+
const viewer = readSettings(current.root).local.diff_viewer;
|
|
1143
|
+
if (!viewer)
|
|
1144
|
+
throw new HttpError(400, 'no diff viewer set; name one under Plugins (a command with {a} and {b})');
|
|
1145
|
+
const then = gitShowFile(current.root, sha, blueprint.rel);
|
|
1146
|
+
if (then === null)
|
|
1147
|
+
throw new HttpError(404, `${sha.slice(0, 7)} has no ${blueprint.rel}`);
|
|
1148
|
+
const temp = path.join(tmpdir(), `eidos-${sha.slice(0, 7)}-${path.basename(blueprint.path)}`);
|
|
1149
|
+
writeFileSync(temp, then, 'utf8');
|
|
1150
|
+
const quote = (file) => `"${file.replace(/"/g, '\\"')}"`;
|
|
1151
|
+
const command = viewer.replace(/\{a\}/g, quote(temp)).replace(/\{b\}/g, quote(blueprint.path));
|
|
1152
|
+
const child = spawn(command, { cwd: current.root, shell: true, detached: true, stdio: 'ignore' });
|
|
1153
|
+
child.on('error', () => undefined);
|
|
1154
|
+
child.unref();
|
|
1155
|
+
json(response, 200, { launched: command, then: temp });
|
|
205
1156
|
return;
|
|
206
1157
|
}
|
|
207
1158
|
if (resource === 'blueprints' && rest !== '' && method === 'GET') {
|
|
208
|
-
const current = context();
|
|
1159
|
+
const current = store.context();
|
|
209
1160
|
json(response, 200, blueprintDetail(current, findByPath(current, rest)));
|
|
210
1161
|
return;
|
|
211
1162
|
}
|
|
212
1163
|
if (resource === 'blueprints' && rest !== '' && method === 'PUT') {
|
|
213
|
-
const current = context();
|
|
214
|
-
|
|
1164
|
+
const current = store.context();
|
|
1165
|
+
const blueprint = findByPath(current, rest);
|
|
1166
|
+
writeFileSync(blueprint.path, blueprintText(await readBody(request), readSettings(current.root).shared.on_save), 'utf8');
|
|
1167
|
+
store.invalidate();
|
|
1168
|
+
const fresh = store.context();
|
|
1169
|
+
const reloaded = fresh.blueprints.find((candidate) => candidate.rel === blueprint.rel);
|
|
1170
|
+
json(response, 200, { path: blueprint.rel, findings: reloaded ? findingsFor(fresh, reloaded) : [] });
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (resource === 'blueprints' && rest !== '' && method === 'DELETE') {
|
|
1174
|
+
const current = store.context();
|
|
1175
|
+
const blueprint = findByPath(current, rest);
|
|
1176
|
+
rmSync(blueprint.path);
|
|
1177
|
+
store.invalidate();
|
|
1178
|
+
json(response, 200, { deleted: blueprint.rel });
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
// GET /api/files?dir=<root-relative>&ext=md: the folders and files under one folder of the root, for a picker.
|
|
1182
|
+
if (resource === 'files' && method === 'GET') {
|
|
1183
|
+
const current = store.context();
|
|
1184
|
+
const wanted = (url.searchParams.get('dir') ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
1185
|
+
const dir = path.resolve(current.root, wanted);
|
|
1186
|
+
if (dir !== current.root && !dir.startsWith(current.root + path.sep))
|
|
1187
|
+
throw new HttpError(400, 'a folder inside the root');
|
|
1188
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
1189
|
+
throw new HttpError(404, `no folder ${wanted || '/'}`);
|
|
1190
|
+
const ext = url.searchParams.get('ext');
|
|
1191
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
1192
|
+
.filter((entry) => !entry.name.startsWith('.') && entry.name !== 'node_modules')
|
|
1193
|
+
.filter((entry) => entry.isDirectory() || !ext || entry.name.toLowerCase().endsWith(`.${ext.toLowerCase()}`))
|
|
1194
|
+
.map((entry) => ({ name: entry.name, path: rel(current.root, path.join(dir, entry.name)), kind: entry.isDirectory() ? 'dir' : 'file' }))
|
|
1195
|
+
.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1));
|
|
1196
|
+
json(response, 200, { dir: wanted, entries });
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
// The CLI's own settings: two YAML files under .eidos/plugins/eidosmd/.
|
|
1200
|
+
if (resource === 'settings' && method === 'GET') {
|
|
1201
|
+
json(response, 200, readSettings(store.context().root));
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (resource === 'settings' && method === 'PUT') {
|
|
1205
|
+
const body = await readBody(request);
|
|
1206
|
+
json(response, 200, writeSettings(store.context().root, { shared: body['shared'], local: body['local'] }));
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (resource === 'git' && method === 'GET') {
|
|
1210
|
+
json(response, 200, gitInfo(store.context().root));
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
// Top-level documents: README.md and what ## Top-Level declares, read and written as text.
|
|
1214
|
+
if (resource === 'docs' && rest !== '' && method === 'GET') {
|
|
1215
|
+
json(response, 200, docView(store.context(), rest));
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
// POST /api/docs { path, title }: a new markdown file at the root, for ## Top-Level to declare.
|
|
1219
|
+
if (resource === 'docs' && rest === '' && method === 'POST') {
|
|
1220
|
+
const current = store.context();
|
|
1221
|
+
const body = await readBody(request);
|
|
1222
|
+
const name = typeof body['path'] === 'string' ? body['path'].trim().replace(/\\/g, '/') : '';
|
|
1223
|
+
if (!/^[^/]+\.md$/i.test(name) || name.startsWith('.'))
|
|
1224
|
+
throw new HttpError(400, 'a top-level document is one <name>.md straight under the root');
|
|
1225
|
+
const file = path.join(current.root, name);
|
|
1226
|
+
if (existsSync(file))
|
|
1227
|
+
throw new HttpError(409, `${name} already exists`);
|
|
1228
|
+
const title = typeof body['title'] === 'string' && body['title'].trim() !== '' ? body['title'].trim() : path.basename(name, '.md');
|
|
1229
|
+
writeFileSync(file, `# ${title}\n\n`, 'utf8');
|
|
1230
|
+
store.invalidate();
|
|
1231
|
+
json(response, 201, { path: name, title });
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (resource === 'docs' && rest !== '' && method === 'PUT') {
|
|
1235
|
+
const current = store.context();
|
|
1236
|
+
const { file, rel: relative } = topLevelFile(current, rest);
|
|
1237
|
+
const body = await readBody(request);
|
|
1238
|
+
if (typeof body['text'] !== 'string')
|
|
1239
|
+
throw new HttpError(400, 'expected { text }');
|
|
1240
|
+
writeFileSync(file, body['text'].replace(/\r\n?/g, '\n'), 'utf8');
|
|
1241
|
+
store.invalidate();
|
|
1242
|
+
json(response, 200, docView(store.context(), relative));
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (resource === 'versions' && method === 'GET') {
|
|
1246
|
+
json(response, 200, { versions: readVersions(store.context().root), git_head: gitInfo(store.context().root).head });
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
// POST /api/versions { version, commit?, tag? }: a snapshot of the root, only because someone asked.
|
|
1250
|
+
if (resource === 'versions' && method === 'POST') {
|
|
1251
|
+
const current = store.context();
|
|
1252
|
+
const body = await readBody(request);
|
|
1253
|
+
let recorded;
|
|
1254
|
+
try {
|
|
1255
|
+
recorded = recordVersion(current.root, { version: str(body['version']) ?? '', commit: str(body['commit']), tag: body['tag'] === true });
|
|
1256
|
+
}
|
|
1257
|
+
catch (cause) {
|
|
1258
|
+
if (cause instanceof VersionError)
|
|
1259
|
+
throw new HttpError(400, cause.message);
|
|
1260
|
+
throw cause;
|
|
1261
|
+
}
|
|
1262
|
+
store.invalidate();
|
|
1263
|
+
json(response, 201, { ...recorded.version, tagged: recorded.tagged, versions: readVersions(current.root) });
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
if (resource === 'framework' && method === 'GET') {
|
|
1267
|
+
json(response, 200, frameworkView(store.context()));
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (resource === 'framework' && method === 'PUT') {
|
|
1271
|
+
const saved = saveFramework(store.context(), await readBody(request));
|
|
1272
|
+
store.invalidate();
|
|
1273
|
+
json(response, 200, { ...saved, ...frameworkView(store.context()) });
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
// A finding's fix, by code: the one so far strips the frontmatter block a template must not carry.
|
|
1277
|
+
if (resource === 'fix' && method === 'POST') {
|
|
1278
|
+
const current = store.context();
|
|
1279
|
+
const body = await readBody(request);
|
|
1280
|
+
const code = body['code'];
|
|
1281
|
+
const target = body['path'];
|
|
1282
|
+
if (typeof code !== 'string' || typeof target !== 'string')
|
|
1283
|
+
throw new HttpError(400, 'expected { code, path }');
|
|
1284
|
+
if (code !== 'template-frontmatter')
|
|
1285
|
+
throw new HttpError(400, `no fix for ${code}`);
|
|
1286
|
+
const templatesDir = path.join(current.root, FRAMEWORK_DIR, 'templates');
|
|
1287
|
+
const file = path.resolve(current.root, target);
|
|
1288
|
+
if (!file.startsWith(templatesDir + path.sep) || !existsSync(file))
|
|
1289
|
+
throw new HttpError(400, 'not a template of this root');
|
|
1290
|
+
writeFileSync(file, stripFrontmatter(readFileSync(file, 'utf8')), 'utf8');
|
|
1291
|
+
store.invalidate();
|
|
1292
|
+
json(response, 200, { fixed: target });
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
// The roles: one response contract per file under .eidos/roles/, the framework's own,
|
|
1296
|
+
// read, written, and removed by name (its kebab-case file name).
|
|
1297
|
+
if (resource === 'roles' && rest === '' && method === 'GET') {
|
|
1298
|
+
const current = store.context();
|
|
1299
|
+
json(response, 200, listRoles(current.root).map((role) => ({ name: role.name, title: role.title, summary: role.summary, path: rel(current.root, role.file) })));
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (resource === 'roles' && rest !== '') {
|
|
1303
|
+
const current = store.context();
|
|
1304
|
+
const name = decodeURIComponent(rest);
|
|
1305
|
+
if (!isRoleName(name))
|
|
1306
|
+
throw new HttpError(400, 'a role name is kebab-case: letters, digits, and dashes');
|
|
1307
|
+
const file = roleFile(current.root, name);
|
|
1308
|
+
if (method === 'GET') {
|
|
1309
|
+
json(response, 200, { name, path: rel(current.root, file), exists: existsSync(file), text: existsSync(file) ? readFileSync(file, 'utf8') : '' });
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (method === 'PUT') {
|
|
1313
|
+
const body = await readBody(request);
|
|
1314
|
+
if (typeof body['text'] !== 'string')
|
|
1315
|
+
throw new HttpError(400, 'expected { text }');
|
|
1316
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1317
|
+
writeFileSync(file, body['text'].replace(/\r\n?/g, '\n'), 'utf8');
|
|
1318
|
+
store.invalidate();
|
|
1319
|
+
json(response, 200, { name, path: rel(current.root, file) });
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (method === 'DELETE') {
|
|
1323
|
+
if (!existsSync(file))
|
|
1324
|
+
throw new HttpError(404, `no role '${name}'`);
|
|
1325
|
+
unlinkSync(file);
|
|
1326
|
+
store.invalidate();
|
|
1327
|
+
json(response, 200, { deleted: rel(current.root, file) });
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (resource === 'templates' && rest === '' && method === 'GET') {
|
|
1332
|
+
json(response, 200, templatesView(store.context()));
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (resource === 'templates' && rest !== '' && method === 'GET') {
|
|
1336
|
+
const current = store.context();
|
|
1337
|
+
const file = templateFile(current, rest);
|
|
1338
|
+
json(response, 200, { path: rel(path.join(current.root, FRAMEWORK_DIR), file), exists: existsSync(file), text: existsSync(file) ? readFileSync(file, 'utf8') : '' });
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (resource === 'templates' && rest !== '' && method === 'PUT') {
|
|
1342
|
+
const current = store.context();
|
|
1343
|
+
const file = templateFile(current, rest);
|
|
1344
|
+
const body = await readBody(request);
|
|
1345
|
+
if (typeof body['text'] !== 'string')
|
|
1346
|
+
throw new HttpError(400, 'expected { text }');
|
|
1347
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1348
|
+
writeFileSync(file, body['text'].replace(/\r\n?/g, '\n'), 'utf8');
|
|
1349
|
+
store.invalidate();
|
|
1350
|
+
json(response, 200, { path: rel(path.join(current.root, FRAMEWORK_DIR), file) });
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
if (resource === 'me' && method === 'GET') {
|
|
1354
|
+
const current = store.context();
|
|
1355
|
+
const actor = readActor(current.root);
|
|
1356
|
+
json(response, 200, { exists: actor.exists, text: actor.text, actor: actor.actor, path: rel(current.root, meFile(current.root)), roles: listRoles(current.root).map((role) => ({ name: role.name, title: role.title, summary: role.summary })) });
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
// me.md as text, or built from its parts: a role (by name, else a custom
|
|
1360
|
+
// one as written), a note, and the three calibration axes.
|
|
1361
|
+
if (resource === 'me' && method === 'PUT') {
|
|
1362
|
+
const current = store.context();
|
|
1363
|
+
const body = await readBody(request);
|
|
1364
|
+
if (typeof body['text'] === 'string') {
|
|
1365
|
+
writeActor(current.root, body['text'].replace(/\r\n?/g, '\n'));
|
|
1366
|
+
}
|
|
1367
|
+
else {
|
|
1368
|
+
const field = (name) => (typeof body[name] === 'string' && body[name].trim() !== '' ? body[name].trim() : null);
|
|
1369
|
+
const wanted = field('role');
|
|
1370
|
+
const role = wanted ? findRole(current.root, wanted) : null;
|
|
1371
|
+
writeActor(current.root, renderActor(role, role ? null : wanted, { ownership: field('ownership'), experience: field('experience'), capacity: field('capacity') }, field('note')));
|
|
1372
|
+
}
|
|
1373
|
+
json(response, 200, { path: rel(current.root, meFile(current.root)) });
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
// A file of the root opened in the external editor from Preferences, else the system's default app.
|
|
1377
|
+
if (resource === 'open' && method === 'POST') {
|
|
1378
|
+
const current = store.context();
|
|
1379
|
+
const body = await readBody(request);
|
|
1380
|
+
const target = typeof body['path'] === 'string' ? body['path'] : '';
|
|
1381
|
+
const file = path.resolve(current.root, target);
|
|
1382
|
+
if (target === '' || !file.startsWith(current.root + path.sep) || !existsSync(file) || !statSync(file).isFile())
|
|
1383
|
+
throw new HttpError(400, 'not a file of this root');
|
|
1384
|
+
const editor = readSettings(current.root).local.editor;
|
|
1385
|
+
const quote = (given) => `"${given.replace(/"/g, '\\"')}"`;
|
|
1386
|
+
const command = editor ? editor.replace(/\{file\}/g, quote(file)) : process.platform === 'darwin' ? `open ${quote(file)}` : process.platform === 'win32' ? `start "" ${quote(file)}` : `xdg-open ${quote(file)}`;
|
|
1387
|
+
const child = spawn(command, { cwd: current.root, shell: true, detached: true, stdio: 'ignore' });
|
|
1388
|
+
child.on('error', () => undefined);
|
|
1389
|
+
child.unref();
|
|
1390
|
+
json(response, 200, { launched: command });
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
if (resource === 'canvas') {
|
|
1394
|
+
await handleCanvas(store, method, segments.slice(2), request, response);
|
|
215
1395
|
return;
|
|
216
1396
|
}
|
|
217
1397
|
if (resource === 'check' && method === 'GET') {
|
|
218
|
-
json(response, 200, checkSnapshot(context()));
|
|
1398
|
+
json(response, 200, checkSnapshot(store.context()));
|
|
219
1399
|
return;
|
|
220
1400
|
}
|
|
221
1401
|
if (resource === 'index' && method === 'POST') {
|
|
222
|
-
|
|
1402
|
+
const result = indexRebuild(store.context());
|
|
1403
|
+
store.invalidate();
|
|
1404
|
+
json(response, 200, result);
|
|
223
1405
|
return;
|
|
224
1406
|
}
|
|
225
1407
|
if (resource === 'standard' && method === 'GET') {
|
|
@@ -236,9 +1418,9 @@ export function createServer(options) {
|
|
|
236
1418
|
}
|
|
237
1419
|
throw new HttpError(404, 'not found');
|
|
238
1420
|
};
|
|
239
|
-
|
|
1421
|
+
const server = createHttpServer((request, response) => {
|
|
240
1422
|
handle(request, response).catch((cause) => {
|
|
241
|
-
if (cause instanceof HttpError) {
|
|
1423
|
+
if (cause instanceof HttpError || cause instanceof CanvasError) {
|
|
242
1424
|
json(response, cause.status, { error: cause.message });
|
|
243
1425
|
}
|
|
244
1426
|
else if (cause instanceof CliError) {
|
|
@@ -249,6 +1431,8 @@ export function createServer(options) {
|
|
|
249
1431
|
}
|
|
250
1432
|
});
|
|
251
1433
|
});
|
|
1434
|
+
server.on('close', () => store.close());
|
|
1435
|
+
return server;
|
|
252
1436
|
}
|
|
253
1437
|
// The first free port at or after `preferred`, on the loopback interface.
|
|
254
1438
|
export function listen(server, preferred) {
|