eidosmd 0.2.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 (47) hide show
  1. package/browser/dist/assets/index-Cc3cNWHY.css +1 -0
  2. package/browser/dist/assets/index-DQgCQRa5.js +46 -0
  3. package/browser/dist/index.html +2 -2
  4. package/dist/src/commands/check.js +9 -3
  5. package/dist/src/commands/configure.js +201 -0
  6. package/dist/src/commands/framework.js +15 -4
  7. package/dist/src/commands/init.js +4 -0
  8. package/dist/src/commands/property.js +125 -0
  9. package/dist/src/commands/setup.js +24 -12
  10. package/dist/src/commands/version.js +2 -2
  11. package/dist/src/core/canvas.js +59 -49
  12. package/dist/src/core/check.js +101 -26
  13. package/dist/src/core/edits.js +1381 -0
  14. package/dist/src/core/framework-markdown.js +9 -3
  15. package/dist/src/core/framework-model.js +43 -8
  16. package/dist/src/core/framework-structured.js +104 -28
  17. package/dist/src/core/frontmatter.js +61 -1
  18. package/dist/src/core/git.js +28 -3
  19. package/dist/src/core/links.js +87 -0
  20. package/dist/src/core/markdown.js +16 -9
  21. package/dist/src/core/migrate.js +91 -15
  22. package/dist/src/core/regions.js +117 -0
  23. package/dist/src/core/scaffold.js +15 -9
  24. package/dist/src/core/seed.js +56 -31
  25. package/dist/src/core/server.js +204 -31
  26. package/dist/src/core/settings.js +63 -12
  27. package/dist/src/core/store.js +73 -17
  28. package/dist/src/core/versions.js +10 -5
  29. package/dist/src/program.js +296 -11
  30. package/instructions/authoring.md +4 -2
  31. package/instructions/configuring.md +27 -11
  32. package/instructions/overview.md +6 -4
  33. package/instructions/validating.md +6 -4
  34. package/package.json +1 -1
  35. package/standard/EIDOS.md +135 -193
  36. package/standard/seeds/README.md +12 -16
  37. package/standard/seeds/book/Framework.yaml +30 -50
  38. package/standard/seeds/book/README.md +2 -1
  39. package/standard/seeds/book/_gitignore +7 -1
  40. package/standard/seeds/research/Framework.yaml +30 -50
  41. package/standard/seeds/research/README.md +2 -1
  42. package/standard/seeds/research/_gitignore +7 -1
  43. package/standard/seeds/software/Framework.yaml +31 -51
  44. package/standard/seeds/software/README.md +2 -1
  45. package/standard/seeds/software/_gitignore +7 -1
  46. package/browser/dist/assets/index-C2NMN_D4.css +0 -1
  47. package/browser/dist/assets/index-C65k1ihb.js +0 -46
@@ -12,10 +12,11 @@ import path from 'node:path';
12
12
  import { marked } from 'marked';
13
13
  import { checkRoot } from './check.js';
14
14
  import { buildIndexes } from './index-leaf.js';
15
+ import { applyPlan, editContext, EditError, moveWithLinks, planDocAdd, planDocRemove, renameRoleFiles } from './edits.js';
15
16
  import { embeddedIndexOf, frameworkToDocument, parseFrameworkStructured, serializeDocument, withEmbeddedIndex } from './framework-structured.js';
16
17
  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';
18
+ import { readVersions, recordVersion, tagPrefix, VersionError } from './versions.js';
19
+ import { moveFile, gitHead, gitLog, gitShowFile, gitUser } from './git.js';
19
20
  import { actingUser, matchesIdentity, readSettings, writeSettings } from './settings.js';
20
21
  import { formatFrontmatter } from './frontmatter.js';
21
22
  import { findRole, isRoleName, listRoles, meFile, readActor, renderActor, roleFile, writeActor } from './me.js';
@@ -23,6 +24,7 @@ import { scaffoldBlueprint, ScaffoldError } from './scaffold.js';
23
24
  import { blueprintId, blueprintTitle, propertyString, resolveVariant } from './blueprint.js';
24
25
  import { loadTemplate, stripFrontmatter } from './template.js';
25
26
  import { unitOf } from './framework-model.js';
27
+ import { regionsAsHtml } from './regions.js';
26
28
  import { Store } from './store.js';
27
29
  import { CliError, today } from '../output.js';
28
30
  import { browserDir, packageVersion, readStandard, standardVersion } from '../paths.js';
@@ -91,29 +93,50 @@ function findingsFor(context, blueprint) {
91
93
  return checkRoot(context.framework, context.blueprints, { only: new Set([blueprint.path]), standardVersion: standardVersion() }).findings.filter((finding) => finding.path === blueprint.rel);
92
94
  }
93
95
  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 ?? {} };
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 ?? {} };
95
97
  }
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
+ // 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.
98
107
  function knownValues(context, collectionName) {
99
108
  const out = {};
100
109
  for (const property of propertiesFor(context.framework, collectionName)) {
101
- if (property.type !== 'Text' || ['id', 'title', 'summary'].includes(property.name))
110
+ if (!['Text', 'List'].includes(property.type) || ['id', 'title', 'summary'].includes(property.name))
111
+ continue;
112
+ if (property.type === 'List' && !property.options)
102
113
  continue;
103
114
  const values = new Set();
104
- for (const key of [...Object.keys(property.canvas?.shape ?? {}), ...Object.keys(property.canvas?.color ?? {})])
115
+ for (const key of Object.keys(property.canvas?.styles ?? {}))
105
116
  values.add(key);
106
117
  for (const blueprint of context.blueprints) {
107
118
  if (blueprint.collection.name !== collectionName)
108
119
  continue;
109
- const value = propertyString(blueprint, property.name);
110
- if (value)
120
+ for (const value of heldValues(blueprint, property.name))
111
121
  values.add(value);
112
122
  }
113
- if (values.size > 0)
114
- out[property.name] = values;
123
+ const listed = withOptionsFirst(property, values);
124
+ if (listed.length > 0)
125
+ out[property.name] = listed;
115
126
  }
116
- return Object.fromEntries(Object.entries(out).map(([name, values]) => [name, [...values].sort((a, b) => a.localeCompare(b))]));
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];
117
140
  }
118
141
  function blueprintDetail(context, blueprint) {
119
142
  const raw = readFileSync(blueprint.path, 'utf8');
@@ -128,7 +151,7 @@ function blueprintDetail(context, blueprint) {
128
151
  sections: blueprint.sections.map((section) => section.heading.text),
129
152
  template_sections: template?.sections ?? [],
130
153
  body: blueprint.body,
131
- html: marked.parse(blueprint.body, { async: false }),
154
+ html: renderMarkdown(blueprint.body),
132
155
  raw,
133
156
  findings: findingsFor(context, blueprint),
134
157
  };
@@ -150,14 +173,15 @@ function topLevelFile(context, relPath) {
150
173
  throw new HttpError(404, `no top-level document at ${wanted}`);
151
174
  }
152
175
  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') };
176
+ return { file, rel: rel(context.root, file), title: doc?.title ?? path.basename(file, '.md'), description: doc?.description ?? '', declared: doc !== undefined };
154
177
  }
155
178
  function docView(context, relPath) {
156
- const { file, rel: relative, title } = topLevelFile(context, relPath);
179
+ const { file, rel: relative, title, description, declared } = topLevelFile(context, relPath);
157
180
  if (!existsSync(file))
158
181
  throw new HttpError(404, `${relative} does not exist`);
159
182
  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) : [] };
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) : [] };
161
185
  }
162
186
  function rootSnapshot(context, store) {
163
187
  const actor = readActor(context.root);
@@ -169,16 +193,19 @@ function rootSnapshot(context, store) {
169
193
  cli_version: packageVersion(),
170
194
  standard_version: standardVersion(),
171
195
  search_engine: store.engine,
196
+ // the machine the server runs on, for the page to name its file manager
197
+ platform: process.platform,
172
198
  git_head: git.head,
173
199
  git,
174
200
  settings,
175
201
  framework: { file: rel(context.root, context.framework.file), format: context.framework.format, problems: context.framework.problems, ...frameworkToDocument(context.framework) },
176
202
  blueprints: context.blueprints.map(blueprintSummary),
177
203
  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
204
+ // the health check's totals, for the sidebar's badge, and whether this
205
+ // root counts a warning as a failure
179
206
  check: (() => {
180
207
  const report = checkRoot(context.framework, context.blueprints, { standardVersion: standardVersion() });
181
- return { errors: report.errors, warnings: report.warnings };
208
+ return { errors: report.errors, warnings: report.warnings, strict: settings.shared.check.strict };
182
209
  })(),
183
210
  canvases: listCanvases(context.framework).map((canvas) => ({ id: canvas.id, title: canvas.title, pages: canvas.pages.length })),
184
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)) },
@@ -218,6 +245,8 @@ function collectionTable(context, name) {
218
245
  description: collection.description,
219
246
  unit: unitOf(collection),
220
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),
221
250
  variants: collection.variants.map((variant) => ({ name: variant.name, default: variant.isDefault, description: variant.description })),
222
251
  grouping: collection.grouping ? { label: collection.grouping.label, property: collection.grouping.property, groups: collection.grouping.groups } : null,
223
252
  },
@@ -226,22 +255,24 @@ function collectionTable(context, name) {
226
255
  rows,
227
256
  };
228
257
  }
229
- // The values a Text property takes anywhere in the root, for a sketch's fields.
258
+ // The values a property takes anywhere in the root, for a sketch's fields;
259
+ // declared options first, in order.
230
260
  function knownValuesAll(context, name) {
231
261
  const values = new Set();
232
262
  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 ?? {})])
263
+ for (const key of Object.keys(property?.canvas?.styles ?? {}))
234
264
  values.add(key);
235
265
  for (const blueprint of context.blueprints) {
236
- const value = propertyString(blueprint, name);
237
- if (value !== null && value !== '')
266
+ for (const value of heldValues(blueprint, name))
238
267
  values.add(value);
239
268
  }
240
- return [...values];
269
+ return property ? withOptionsFirst(property, values) : [...values];
241
270
  }
242
271
  function checkSnapshot(context) {
243
272
  const report = checkRoot(context.framework, context.blueprints, { standardVersion: standardVersion() });
244
273
  return {
274
+ // whether this root counts a warning as a failure: the owner's setting
275
+ strict: readSettings(context.root).shared.check.strict,
245
276
  root: report.root,
246
277
  eidos_version: report.eidosVersion,
247
278
  naming: report.naming,
@@ -283,10 +314,62 @@ function frameworkView(context) {
283
314
  // The whole document replaced, the generated index carried over untouched.
284
315
  // A document that cannot be read at all is refused; one with problems is
285
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
+ }
286
369
  function saveFramework(context, body) {
287
370
  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');
371
+ if (!Array.isArray(given['folders'])) {
372
+ throw new HttpError(400, 'expected { document } with a folders list');
290
373
  }
291
374
  const document = { ...given };
292
375
  delete document['index'];
@@ -435,7 +518,12 @@ function pageView(context, canvas, page) {
435
518
  nodes,
436
519
  comments: renderComments(page, nodes),
437
520
  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 })),
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
+ }),
439
527
  // schema properties a sketch may hold: those applying to every collection, with the values the root uses
440
528
  sketch_properties: propertiesFor(context.framework, '')
441
529
  .filter((property) => !property.core && property.appliesTo === 'all')
@@ -1094,7 +1182,7 @@ export function createServer(options) {
1094
1182
  if (resource === 'events' && method === 'GET') {
1095
1183
  response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
1096
1184
  response.write(': connected\n\n');
1097
- const off = store.onChange(() => response.write('data: {"type":"change"}\n\n'));
1185
+ const off = store.onChange((paths) => response.write(`data: ${JSON.stringify({ type: 'change', paths })}\n\n`));
1098
1186
  request.on('close', off);
1099
1187
  return;
1100
1188
  }
@@ -1215,11 +1303,33 @@ export function createServer(options) {
1215
1303
  json(response, 200, docView(store.context(), rest));
1216
1304
  return;
1217
1305
  }
1218
- // POST /api/docs { path, title }: a new markdown file at the root, for ## Top-Level to declare.
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.
1219
1310
  if (resource === 'docs' && rest === '' && method === 'POST') {
1220
1311
  const current = store.context();
1221
1312
  const body = await readBody(request);
1222
- const name = typeof body['path'] === 'string' ? body['path'].trim().replace(/\\/g, '/') : '';
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, '/');
1223
1333
  if (!/^[^/]+\.md$/i.test(name) || name.startsWith('.'))
1224
1334
  throw new HttpError(400, 'a top-level document is one <name>.md straight under the root');
1225
1335
  const file = path.join(current.root, name);
@@ -1231,6 +1341,28 @@ export function createServer(options) {
1231
1341
  json(response, 201, { path: name, title });
1232
1342
  return;
1233
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
+ }
1234
1366
  if (resource === 'docs' && rest !== '' && method === 'PUT') {
1235
1367
  const current = store.context();
1236
1368
  const { file, rel: relative } = topLevelFile(current, rest);
@@ -1243,7 +1375,7 @@ export function createServer(options) {
1243
1375
  return;
1244
1376
  }
1245
1377
  if (resource === 'versions' && method === 'GET') {
1246
- json(response, 200, { versions: readVersions(store.context().root), git_head: gitInfo(store.context().root).head });
1378
+ json(response, 200, { versions: readVersions(store.context().root), git_head: gitInfo(store.context().root).head, tag_prefix: tagPrefix(store.context().root) });
1247
1379
  return;
1248
1380
  }
1249
1381
  // POST /api/versions { version, commit?, tag? }: a snapshot of the root, only because someone asked.
@@ -1268,7 +1400,10 @@ export function createServer(options) {
1268
1400
  return;
1269
1401
  }
1270
1402
  if (resource === 'framework' && method === 'PUT') {
1271
- const saved = saveFramework(store.context(), await readBody(request));
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);
1272
1407
  store.invalidate();
1273
1408
  json(response, 200, { ...saved, ...frameworkView(store.context()) });
1274
1409
  return;
@@ -1311,6 +1446,21 @@ export function createServer(options) {
1311
1446
  }
1312
1447
  if (method === 'PUT') {
1313
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
+ }
1314
1464
  if (typeof body['text'] !== 'string')
1315
1465
  throw new HttpError(400, 'expected { text }');
1316
1466
  mkdirSync(path.dirname(file), { recursive: true });
@@ -1390,6 +1540,29 @@ export function createServer(options) {
1390
1540
  json(response, 200, { launched: command });
1391
1541
  return;
1392
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
+ }
1393
1566
  if (resource === 'canvas') {
1394
1567
  await handleCanvas(store, method, segments.slice(2), request, response);
1395
1568
  return;
@@ -3,21 +3,37 @@
3
3
  // root's and travels with it (whether git is used, the people who work in
4
4
  // the root, what every save does to a blueprint); `local.yaml` is one
5
5
  // person's on one machine (who they are acting as, how their canvas
6
- // behaves, which columns they hide, the diff viewer installed here) and is
7
- // ignored by git through a .gitignore beside it. Nothing the browser
8
- // remembers lives anywhere else.
6
+ // behaves, which columns they hide, the diff viewer installed here), the
7
+ // personal file the standard names since 5.2.0, kept out of git by the one
8
+ // pattern the root's `.eidos/.gitignore` carries for every tool. Nothing
9
+ // the browser remembers lives anywhere else.
9
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
11
  import path from 'node:path';
11
12
  import { parse as parseYaml, stringify } from 'yaml';
12
13
  import { FRAMEWORK_DIR, PLUGINS_DIR, TOOL_KEY } from './framework-model.js';
13
14
  export const SETTINGS_DIR = `${PLUGINS_DIR}/${TOOL_KEY}`;
14
- export const DEFAULT_SHARED = { features: { git: true }, users: [], on_save: [] };
15
+ // The one line in the root's `.eidos/.gitignore` that keeps every tool's
16
+ // personal file out of the repository (Eidos 5.2.0), beside `me.md`.
17
+ export const LOCAL_IGNORE_PATTERN = 'plugins/*/local.yaml';
18
+ // Whether the root's `.eidos/.gitignore` already carries that line.
19
+ export function rootIgnoresLocal(root) {
20
+ const file = path.join(root, FRAMEWORK_DIR, '.gitignore');
21
+ if (!existsSync(file))
22
+ return false;
23
+ return readFileSync(file, 'utf8')
24
+ .split(/\r?\n/)
25
+ .some((line) => line.trim() === LOCAL_IGNORE_PATTERN);
26
+ }
27
+ export const DEFAULT_TAG_PREFIX = 'blueprints/';
28
+ export const DEFAULT_SHARED = { features: { git: true }, users: [], on_save: [], versions: { tag_prefix: DEFAULT_TAG_PREFIX }, check: { strict: false } };
15
29
  export const DEFAULT_LOCAL = {
16
30
  user: null,
17
31
  theme: 'system',
18
32
  canvas: { wheel_zoom: true, right_drag_pan: true, ctrl_click_menu: false, enter_commits: true, context_menu: true, resize_all: false },
19
33
  tables: { page_size: 50, columns: {} },
20
34
  source_mode: false,
35
+ hide_overview: false,
36
+ reader_width: 'fixed',
21
37
  diff_viewer: null,
22
38
  editor: null,
23
39
  recent_searches: [],
@@ -25,10 +41,12 @@ export const DEFAULT_LOCAL = {
25
41
  const SHARED_HEADER = `eidosmd settings shared by everyone in this root. Edit by hand, with eidos setup, or from the browser.
26
42
  features.git: whether the browser reads git (history, authors, versions) when the root is in a repository.
27
43
  users: the people who work here: { name, alias (for @mentions, no spaces), role (from .eidos/roles/), email (ties to git) }.
28
- on_save: rules applied to a blueprint's frontmatter on every save from the browser: { property, set: today | now }.`;
44
+ on_save: rules applied to a blueprint's frontmatter on every save from the browser: { property, set: today | now }.
45
+ versions.tag_prefix: what a recorded version's git tag starts with (eidos version record --tag), blueprints/ by default.
46
+ check.strict: whether a warning fails eidos check as an error does; the framework owner's decision for this root (eidos check --strict / --no-strict override one run).`;
29
47
  const LOCAL_HEADER = `eidosmd settings for this machine, ignored by git. Edit by hand, with eidos setup, or from the browser's Preferences.
30
48
  user: who this machine acts as, by alias or name from users; absent = the git identity, else the role in me.md.
31
- theme: system | light | dark. canvas: scroll-wheel zoom, right-drag pan, ctrl-click menus, whether Enter finishes an in-place edit, whether a right click opens the menu or the details, whether every node resizes. tables: page size and the columns hidden per collection. source_mode: the reader shows files as text.
49
+ theme: system | light | dark. canvas: scroll-wheel zoom, right-drag pan, ctrl-click menus, whether Enter finishes an in-place edit, whether a right click opens the menu or the details, whether every node resizes. tables: page size and the columns hidden per collection. source_mode: the reader shows files as text. hide_overview: the sidebar leaves out Eidos Overview. reader_width: fixed (every page but the canvas held to about 1200px, for a wide screen) | fluid (the whole window).
32
50
  editor: a command with {file} that opens a file in an external editor; blank = the system's default app.
33
51
  diff_viewer: the git plugin's viewer, a command with {a} and {b} for the two files, e.g. "code --diff {a} {b}".`;
34
52
  const isPlain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -71,7 +89,17 @@ function readShared(value, base = DEFAULT_SHARED) {
71
89
  rules.push({ property: entry['property'].trim(), set: entry['set'] });
72
90
  }
73
91
  }
74
- return { features: { git }, users: 'users' in value ? readUsers(value['users']) : base.users.map((user) => ({ ...user })), on_save: 'on_save' in value ? rules : base.on_save.map((rule) => ({ ...rule })) };
92
+ const versions = isPlain(value['versions']) ? value['versions'] : {};
93
+ const prefix = typeof versions['tag_prefix'] === 'string' ? versions['tag_prefix'].trim() : base.versions.tag_prefix;
94
+ const check = isPlain(value['check']) ? value['check'] : {};
95
+ const strict = typeof check['strict'] === 'boolean' ? check['strict'] : base.check.strict;
96
+ return {
97
+ features: { git },
98
+ users: 'users' in value ? readUsers(value['users']) : base.users.map((user) => ({ ...user })),
99
+ on_save: 'on_save' in value ? rules : base.on_save.map((rule) => ({ ...rule })),
100
+ versions: { tag_prefix: prefix },
101
+ check: { strict },
102
+ };
75
103
  }
76
104
  // A user by alias (case-insensitive) or by name.
77
105
  export function findUser(users, key) {
@@ -103,6 +131,9 @@ function readLocal(value, base = DEFAULT_LOCAL) {
103
131
  if (typeof tables['page_size'] === 'number' && tables['page_size'] >= 10)
104
132
  out.tables.page_size = Math.round(tables['page_size']);
105
133
  if (isPlain(tables['columns'])) {
134
+ // a columns map given is the whole map (a write merges per collection
135
+ // before it gets here), so an entry it lacks is gone
136
+ out.tables.columns = {};
106
137
  for (const [collection, hidden] of Object.entries(tables['columns'])) {
107
138
  if (Array.isArray(hidden))
108
139
  out.tables.columns[collection] = hidden.filter((name) => typeof name === 'string');
@@ -110,6 +141,10 @@ function readLocal(value, base = DEFAULT_LOCAL) {
110
141
  }
111
142
  if (typeof value['source_mode'] === 'boolean')
112
143
  out.source_mode = value['source_mode'];
144
+ if (typeof value['hide_overview'] === 'boolean')
145
+ out.hide_overview = value['hide_overview'];
146
+ if (value['reader_width'] === 'fixed' || value['reader_width'] === 'fluid')
147
+ out.reader_width = value['reader_width'];
113
148
  if (value['diff_viewer'] === null)
114
149
  out.diff_viewer = null;
115
150
  else if (typeof value['diff_viewer'] === 'string')
@@ -142,8 +177,11 @@ function writeYaml(file, header, value) {
142
177
  writeFileSync(file, `${comment}\n${stringify(value)}`, 'utf8');
143
178
  }
144
179
  // Each file is written whole from the merged value: a partial patch keeps
145
- // what it does not name. The local file's .gitignore is written beside it
146
- // the first time, so a personal setting never reaches the repository.
180
+ // what it does not name. On a root whose `.eidos/.gitignore` does not yet
181
+ // carry the standard's pattern (one written before 5.2.0 and not migrated),
182
+ // a .gitignore of this tool's own is written beside the local file, so a
183
+ // personal setting never reaches the repository; `eidos migrate` adds the
184
+ // pattern and removes that file.
147
185
  export function writeSettings(root, patch) {
148
186
  const current = readSettings(root);
149
187
  const dir = settingsDir(root);
@@ -153,6 +191,10 @@ export function writeSettings(root, patch) {
153
191
  const merged = { ...base, ...given };
154
192
  if (isPlain(given['features']))
155
193
  merged['features'] = { ...base['features'], ...given['features'] };
194
+ if (isPlain(given['versions']))
195
+ merged['versions'] = { ...base['versions'], ...given['versions'] };
196
+ if (isPlain(given['check']))
197
+ merged['check'] = { ...base['check'], ...given['check'] };
156
198
  writeYaml(path.join(dir, 'settings.yaml'), SHARED_HEADER, readShared(merged, current.shared));
157
199
  }
158
200
  if (isPlain(patch.local)) {
@@ -164,12 +206,21 @@ export function writeSettings(root, patch) {
164
206
  if (isPlain(given['tables'])) {
165
207
  const tables = base['tables'];
166
208
  merged['tables'] = { ...tables, ...given['tables'] };
167
- if (isPlain(given['tables']['columns']))
168
- merged['tables']['columns'] = { ...tables['columns'], ...given['tables']['columns'] };
209
+ if (isPlain(given['tables']['columns'])) {
210
+ // a collection given as null drops its entry, for a collection renamed away
211
+ const columns = { ...tables['columns'] };
212
+ for (const [collection, hidden] of Object.entries(given['tables']['columns'])) {
213
+ if (hidden === null)
214
+ delete columns[collection];
215
+ else
216
+ columns[collection] = hidden;
217
+ }
218
+ merged['tables']['columns'] = columns;
219
+ }
169
220
  }
170
221
  writeYaml(path.join(dir, 'local.yaml'), LOCAL_HEADER, readLocal(merged, current.local));
171
222
  const ignore = path.join(dir, '.gitignore');
172
- if (!existsSync(ignore))
223
+ if (!rootIgnoresLocal(root) && !existsSync(ignore))
173
224
  writeFileSync(ignore, 'local.yaml\n', 'utf8');
174
225
  }
175
226
  return readSettings(root);