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.
Files changed (100) hide show
  1. package/README.md +50 -29
  2. package/browser/dist/assets/index-C2NMN_D4.css +1 -0
  3. package/browser/dist/assets/index-C65k1ihb.js +46 -0
  4. package/browser/dist/favicon.svg +5 -0
  5. package/browser/dist/index.html +15 -0
  6. package/browser/dist/mark.svg +4 -0
  7. package/dist/src/cli.js +7 -0
  8. package/dist/src/commands/agents.js +1 -1
  9. package/dist/src/commands/canvas.js +77 -0
  10. package/dist/src/commands/check.js +1 -1
  11. package/dist/src/commands/framework.js +24 -5
  12. package/dist/src/commands/index.js +4 -4
  13. package/dist/src/commands/init.js +1 -0
  14. package/dist/src/commands/instructions.js +1 -1
  15. package/dist/src/commands/list.js +8 -8
  16. package/dist/src/commands/migrate.js +32 -0
  17. package/dist/src/commands/new.js +3 -3
  18. package/dist/src/commands/seeds.js +5 -5
  19. package/dist/src/commands/setup.js +119 -0
  20. package/dist/src/commands/version.js +44 -0
  21. package/dist/src/commands/whoami.js +4 -4
  22. package/dist/src/context.js +5 -5
  23. package/dist/src/core/blueprint.js +16 -11
  24. package/dist/src/core/canvas-schema.js +148 -0
  25. package/dist/src/core/canvas.js +722 -0
  26. package/dist/src/core/check.js +127 -51
  27. package/dist/src/core/convert.js +7 -6
  28. package/dist/src/core/framework-markdown.js +113 -34
  29. package/dist/src/core/framework-model.js +27 -10
  30. package/dist/src/core/framework-structured.js +132 -33
  31. package/dist/src/core/framework.js +13 -13
  32. package/dist/src/core/git.js +59 -0
  33. package/dist/src/core/index-leaf.js +2 -2
  34. package/dist/src/core/me.js +16 -8
  35. package/dist/src/core/migrate.js +243 -0
  36. package/dist/src/core/naming.js +1 -1
  37. package/dist/src/core/root.js +2 -2
  38. package/dist/src/core/scaffold.js +19 -19
  39. package/dist/src/core/seed.js +151 -60
  40. package/dist/src/core/server.js +1236 -52
  41. package/dist/src/core/settings.js +181 -0
  42. package/dist/src/core/store.js +259 -0
  43. package/dist/src/core/template.js +32 -0
  44. package/dist/src/core/versions.js +79 -0
  45. package/dist/src/output.js +4 -1
  46. package/dist/src/program.js +134 -39
  47. package/instructions/authoring.md +13 -12
  48. package/instructions/configuring.md +65 -34
  49. package/instructions/init-required.md +4 -4
  50. package/instructions/overview.md +17 -7
  51. package/instructions/validating.md +5 -4
  52. package/package.json +21 -12
  53. package/standard/EIDOS.md +129 -188
  54. package/standard/seeds/README.md +6 -6
  55. package/standard/seeds/book/Framework.yaml +81 -0
  56. package/standard/seeds/book/README.md +9 -5
  57. package/standard/seeds/book/_gitignore +3 -3
  58. package/standard/seeds/book/me.md +1 -1
  59. package/standard/seeds/book/roles/README.md +3 -3
  60. package/standard/seeds/book/roles/framework-owner.md +2 -2
  61. package/standard/seeds/book/{shapes → templates}/chapter.full.md +0 -8
  62. package/standard/seeds/book/{shapes → templates}/chapter.sketch.md +0 -7
  63. package/standard/seeds/book/{shapes → templates}/frame.market.md +0 -6
  64. package/standard/seeds/book/templates/frame.premise.md +17 -0
  65. package/standard/seeds/book/{shapes → templates}/frame.reader.md +0 -6
  66. package/standard/seeds/book/{shapes → templates}/frame.voice.md +0 -7
  67. package/standard/seeds/research/Framework.yaml +81 -0
  68. package/standard/seeds/research/README.md +9 -5
  69. package/standard/seeds/research/_gitignore +3 -3
  70. package/standard/seeds/research/me.md +1 -1
  71. package/standard/seeds/research/roles/README.md +3 -3
  72. package/standard/seeds/research/roles/framework-owner.md +2 -2
  73. package/standard/seeds/research/{shapes → templates}/frame.ethics.md +0 -6
  74. package/standard/seeds/research/{shapes → templates}/frame.method.md +0 -7
  75. package/standard/seeds/research/{shapes → templates}/frame.prior-work.md +0 -6
  76. package/standard/seeds/research/{shapes → templates}/frame.question.md +0 -7
  77. package/standard/seeds/research/{shapes → templates}/investigation.full.md +0 -8
  78. package/standard/seeds/research/{shapes → templates}/investigation.note.md +0 -7
  79. package/standard/seeds/software/Framework.yaml +82 -0
  80. package/standard/seeds/software/README.md +5 -5
  81. package/standard/seeds/software/_gitignore +3 -3
  82. package/standard/seeds/software/me.md +1 -1
  83. package/standard/seeds/software/roles/README.md +3 -3
  84. package/standard/seeds/software/roles/framework-owner.md +2 -2
  85. package/standard/seeds/software/roles/project-manager.md +2 -2
  86. package/standard/seeds/software/roles/stakeholder.md +1 -1
  87. package/standard/seeds/software/{shapes → templates}/frame.architecture.md +0 -7
  88. package/standard/seeds/software/{shapes → templates}/frame.audience.md +1 -8
  89. package/standard/seeds/software/{shapes → templates}/frame.criteria.md +0 -8
  90. package/standard/seeds/software/{shapes → templates}/frame.market.md +0 -8
  91. package/standard/seeds/software/{shapes → templates}/spec.full.md +0 -8
  92. package/standard/seeds/software/{shapes → templates}/spec.micro.md +0 -9
  93. package/browser/index.html +0 -268
  94. package/dist/src/commands/convert.js +0 -30
  95. package/dist/src/core/shape.js +0 -26
  96. package/standard/seeds/book/Framework.md +0 -87
  97. package/standard/seeds/book/shapes/frame.premise.md +0 -24
  98. package/standard/seeds/research/Framework.md +0 -88
  99. package/standard/seeds/software/Framework.md +0 -88
  100. /package/standard/seeds/software/{shapes → templates}/.gitkeep +0 -0
@@ -1,25 +1,33 @@
1
1
  // The local browser's server: a plain Node http server on 127.0.0.1 serving
2
- // one HTML page and a small JSON API. Every write the page can make goes
3
- // through the same core function the matching command uses (`new` scaffolds,
4
- // `index` rebuilds, `check` validates), so nothing is possible here that is
5
- // not possible from the shell. The root is re-read on every request; files
6
- // change on disk while the page is open.
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 { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
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 { listRoles, meFile, readActor } from './me.js';
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, resolveFlavor } from './blueprint.js';
18
- import { loadShape } from './shape.js';
19
- import { CliError } from '../output.js';
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 { today } from '../output.js';
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
- if (chunks.reduce((size, part) => size + part.length, 0) > 8 * 1024 * 1024) {
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 { flavor, declared } = resolveFlavor(blueprint);
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
- flavor: flavor?.name ?? declared,
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 { flavor } = resolveFlavor(blueprint);
85
- const shape = flavor ? loadShape(context.framework, flavor) : null;
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
- shape_sections: shape?.sections ?? [],
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
- function rootSnapshot(context) {
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
- flavor: text(body['flavor']),
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, flavor: result.flavor.name, notes: result.notes };
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
- function saveBlueprint(context, blueprint, body) {
165
- const text = body['text'];
166
- if (typeof text !== 'string') {
167
- throw new HttpError(400, 'expected { text }');
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
- writeFileSync(blueprint.path, text.replace(/\r\n?/g, '\n'), 'utf8');
170
- const fresh = loadRoot({ root: context.root }, context.cwd);
171
- const reloaded = fresh.blueprints.find((candidate) => candidate.rel === blueprint.rel);
172
- return { path: blueprint.rel, findings: reloaded ? findingsFor(fresh, reloaded) : [] };
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 page = path.join(browserDir, 'index.html');
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
- throw new HttpError(404, 'not found');
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
- json(response, 201, createBlueprint(context(), await readBody(request)));
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
- json(response, 200, saveBlueprint(current, findByPath(current, rest), await readBody(request)));
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
- json(response, 200, indexRebuild(context()));
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
- return createHttpServer((request, response) => {
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) {