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