eidosmd 0.1.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 (95) hide show
  1. package/LICENSE +133 -0
  2. package/NOTICE +15 -0
  3. package/README.md +146 -0
  4. package/browser/index.html +268 -0
  5. package/dist/src/cli.js +5 -0
  6. package/dist/src/commands/agents.js +47 -0
  7. package/dist/src/commands/browser.js +40 -0
  8. package/dist/src/commands/check.js +61 -0
  9. package/dist/src/commands/convert.js +30 -0
  10. package/dist/src/commands/framework.js +76 -0
  11. package/dist/src/commands/index.js +64 -0
  12. package/dist/src/commands/init.js +66 -0
  13. package/dist/src/commands/instructions.js +40 -0
  14. package/dist/src/commands/list.js +73 -0
  15. package/dist/src/commands/new.js +68 -0
  16. package/dist/src/commands/seeds.js +40 -0
  17. package/dist/src/commands/show.js +24 -0
  18. package/dist/src/commands/whoami.js +84 -0
  19. package/dist/src/context.js +35 -0
  20. package/dist/src/core/blueprint.js +129 -0
  21. package/dist/src/core/check.js +407 -0
  22. package/dist/src/core/convert.js +37 -0
  23. package/dist/src/core/framework-markdown.js +233 -0
  24. package/dist/src/core/framework-model.js +48 -0
  25. package/dist/src/core/framework-structured.js +334 -0
  26. package/dist/src/core/framework.js +56 -0
  27. package/dist/src/core/frontmatter.js +116 -0
  28. package/dist/src/core/index-leaf.js +83 -0
  29. package/dist/src/core/markdown.js +100 -0
  30. package/dist/src/core/me.js +89 -0
  31. package/dist/src/core/naming.js +46 -0
  32. package/dist/src/core/root.js +88 -0
  33. package/dist/src/core/scaffold.js +118 -0
  34. package/dist/src/core/seed.js +223 -0
  35. package/dist/src/core/server.js +275 -0
  36. package/dist/src/core/shape.js +26 -0
  37. package/dist/src/output.js +38 -0
  38. package/dist/src/paths.js +28 -0
  39. package/dist/src/program.js +242 -0
  40. package/instructions/authoring.md +50 -0
  41. package/instructions/configuring.md +65 -0
  42. package/instructions/init-required.md +32 -0
  43. package/instructions/overview.md +37 -0
  44. package/instructions/validating.md +33 -0
  45. package/package.json +53 -0
  46. package/standard/EIDOS.md +324 -0
  47. package/standard/seeds/README.md +25 -0
  48. package/standard/seeds/book/Framework.md +87 -0
  49. package/standard/seeds/book/README.md +22 -0
  50. package/standard/seeds/book/_gitignore +4 -0
  51. package/standard/seeds/book/me.md +18 -0
  52. package/standard/seeds/book/roles/README.md +12 -0
  53. package/standard/seeds/book/roles/collaborator.md +16 -0
  54. package/standard/seeds/book/roles/editor.md +16 -0
  55. package/standard/seeds/book/roles/framework-owner.md +16 -0
  56. package/standard/seeds/book/roles/reader.md +16 -0
  57. package/standard/seeds/book/shapes/chapter.full.md +45 -0
  58. package/standard/seeds/book/shapes/chapter.sketch.md +26 -0
  59. package/standard/seeds/book/shapes/frame.market.md +23 -0
  60. package/standard/seeds/book/shapes/frame.premise.md +24 -0
  61. package/standard/seeds/book/shapes/frame.reader.md +23 -0
  62. package/standard/seeds/book/shapes/frame.voice.md +24 -0
  63. package/standard/seeds/research/Framework.md +88 -0
  64. package/standard/seeds/research/README.md +23 -0
  65. package/standard/seeds/research/_gitignore +4 -0
  66. package/standard/seeds/research/me.md +18 -0
  67. package/standard/seeds/research/roles/README.md +12 -0
  68. package/standard/seeds/research/roles/framework-owner.md +16 -0
  69. package/standard/seeds/research/roles/researcher.md +16 -0
  70. package/standard/seeds/research/roles/reviewer.md +16 -0
  71. package/standard/seeds/research/roles/sponsor.md +16 -0
  72. package/standard/seeds/research/shapes/frame.ethics.md +23 -0
  73. package/standard/seeds/research/shapes/frame.method.md +24 -0
  74. package/standard/seeds/research/shapes/frame.prior-work.md +23 -0
  75. package/standard/seeds/research/shapes/frame.question.md +24 -0
  76. package/standard/seeds/research/shapes/investigation.full.md +49 -0
  77. package/standard/seeds/research/shapes/investigation.note.md +26 -0
  78. package/standard/seeds/software/Framework.md +88 -0
  79. package/standard/seeds/software/README.md +24 -0
  80. package/standard/seeds/software/_gitignore +4 -0
  81. package/standard/seeds/software/me.md +18 -0
  82. package/standard/seeds/software/roles/.gitkeep +0 -0
  83. package/standard/seeds/software/roles/README.md +13 -0
  84. package/standard/seeds/software/roles/designer.md +16 -0
  85. package/standard/seeds/software/roles/developer.md +16 -0
  86. package/standard/seeds/software/roles/framework-owner.md +16 -0
  87. package/standard/seeds/software/roles/project-manager.md +18 -0
  88. package/standard/seeds/software/roles/stakeholder.md +16 -0
  89. package/standard/seeds/software/shapes/.gitkeep +0 -0
  90. package/standard/seeds/software/shapes/frame.architecture.md +26 -0
  91. package/standard/seeds/software/shapes/frame.audience.md +26 -0
  92. package/standard/seeds/software/shapes/frame.criteria.md +27 -0
  93. package/standard/seeds/software/shapes/frame.market.md +31 -0
  94. package/standard/seeds/software/shapes/spec.full.md +67 -0
  95. package/standard/seeds/software/shapes/spec.micro.md +32 -0
@@ -0,0 +1,118 @@
1
+ // Scaffolding one blueprint: frontmatter generated from the properties that
2
+ // apply to its collection, body rendered from its flavor's shape, filename in
3
+ // the framework's convention with a permanent kebab-case id inside. Born
4
+ // conforming; the prose is still the owner's.
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { blueprintId } from './blueprint.js';
8
+ import { defaultFlavor, findCollection, findFlavor, propertiesFor } from './framework.js';
9
+ import { convert, isKebab, kebab } from './naming.js';
10
+ import { loadShape } from './shape.js';
11
+ import { blankBlueprint } from './seed.js';
12
+ export class ScaffoldError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'ScaffoldError';
16
+ }
17
+ }
18
+ function coerce(type, raw) {
19
+ switch (type.toLowerCase()) {
20
+ case 'list':
21
+ return raw
22
+ .split(',')
23
+ .map((item) => item.trim())
24
+ .filter((item) => item !== '');
25
+ case 'number': {
26
+ const parsed = Number(raw);
27
+ return Number.isNaN(parsed) ? raw : parsed;
28
+ }
29
+ case 'checkbox':
30
+ return /^(true|yes|on|1)$/i.test(raw.trim());
31
+ default:
32
+ return raw;
33
+ }
34
+ }
35
+ export function scaffoldBlueprint(framework, existing, options) {
36
+ const collection = findCollection(framework, options.collection);
37
+ if (!collection) {
38
+ const known = framework.collections.map((candidate) => candidate.name).join(', ');
39
+ throw new ScaffoldError(`no collection '${options.collection}' in this framework (declared: ${known})`);
40
+ }
41
+ const flavor = options.flavor === null ? defaultFlavor(collection) : findFlavor(collection, options.flavor);
42
+ if (!flavor) {
43
+ const known = collection.flavors.map((candidate) => candidate.name).join(', ');
44
+ throw new ScaffoldError(options.flavor === null
45
+ ? `collection '${collection.name}' declares no flavors`
46
+ : `no flavor '${options.flavor}' in '${collection.name}' (declared: ${known})`);
47
+ }
48
+ const shape = loadShape(framework, flavor);
49
+ if (!shape) {
50
+ throw new ScaffoldError(`flavor '${flavor.name}' points at a shape that does not exist: _eidos/${flavor.shape}`);
51
+ }
52
+ const title = options.title.trim();
53
+ if (title === '') {
54
+ throw new ScaffoldError('a blueprint needs a title');
55
+ }
56
+ const id = options.id ?? kebab(title);
57
+ if (!isKebab(id)) {
58
+ throw new ScaffoldError(`id '${id}' is not kebab-case`);
59
+ }
60
+ const clash = existing.find((blueprint) => blueprintId(blueprint) === id);
61
+ if (clash) {
62
+ throw new ScaffoldError(`id '${id}' is already taken by ${clash.rel}`);
63
+ }
64
+ const notes = [];
65
+ let group = null;
66
+ if (options.group !== null) {
67
+ const declared = collection.grouping?.groups.find((candidate) => candidate.name.toLowerCase() === options.group?.toLowerCase() || kebab(candidate.name) === kebab(options.group ?? ''));
68
+ group = declared ? declared.name : convert(options.group, framework.naming);
69
+ if (!declared && collection.grouping && collection.grouping.groups.length > 0) {
70
+ notes.push(`group '${group}' is not declared under ${collection.grouping.label} in Framework.md; add a bullet for it`);
71
+ }
72
+ if (!collection.grouping) {
73
+ notes.push(`collection '${collection.name}' declares no grouping; the sub-folder alone groups this blueprint`);
74
+ }
75
+ }
76
+ const fileName = framework.naming === 'kebab-case' ? `${id}.md` : `${convert(title, framework.naming)}.md`;
77
+ const dir = group === null ? path.join(framework.root, collection.name) : path.join(framework.root, collection.name, group);
78
+ const file = path.join(dir, fileName);
79
+ if (existsSync(file)) {
80
+ throw new ScaffoldError(`${path.relative(framework.root, file)} already exists`);
81
+ }
82
+ const values = {
83
+ id,
84
+ title,
85
+ summary: options.summary ?? '',
86
+ flavor: flavor.name,
87
+ status: 'Draft',
88
+ date_created: options.date,
89
+ date_modified: options.date,
90
+ };
91
+ if (group !== null && collection.grouping?.property) {
92
+ values[collection.grouping.property] = group;
93
+ }
94
+ const types = new Map(propertiesFor(framework, collection.name).map((property) => [property.name, property.type]));
95
+ for (const [key, raw] of Object.entries(options.set)) {
96
+ const type = types.get(key);
97
+ if (type === undefined) {
98
+ notes.push(`property '${key}' is not in the Schema for ${collection.name}; written anyway`);
99
+ }
100
+ values[key] = coerce(type ?? 'Text', raw);
101
+ }
102
+ let content = blankBlueprint(framework, collection.name, values, shape.template, title);
103
+ const extra = Object.keys(options.set).filter((key) => !types.has(key));
104
+ if (extra.length > 0) {
105
+ const lines = extra.map((key) => `${key}: ${String(values[key])}`).join('\n');
106
+ content = content.replace(/\n---\n/, `\n${lines}\n---\n`);
107
+ }
108
+ return {
109
+ path: file,
110
+ rel: path.relative(framework.root, file).split(path.sep).join('/'),
111
+ content,
112
+ collection,
113
+ flavor,
114
+ group,
115
+ id,
116
+ notes,
117
+ };
118
+ }
@@ -0,0 +1,223 @@
1
+ // Installing a seed: copy a starting framework into a new root's `_eidos/`,
2
+ // write it as Framework.yaml with its naming convention and starting groups,
3
+ // scaffold a folder per collection, and drop one blank blueprint per framing
4
+ // flavor.
5
+ // It writes no prose: the README's one-liner, each group's description, and
6
+ // every scaffolded blueprint's summary and body stay the owner's.
7
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { formatFrontmatter } from './frontmatter.js';
10
+ import { parseFramework, propertiesFor } from './framework.js';
11
+ import { frameworkToDocument, serializeDocument } from './framework-structured.js';
12
+ import { buildIndexes } from './index-leaf.js';
13
+ import { listBlueprints } from './blueprint.js';
14
+ import { convert, kebab, titleize } from './naming.js';
15
+ import { renderShape } from './shape.js';
16
+ const GROUP_PROMPT = '_(one line on what belongs here)_';
17
+ const GITIGNORE_SHIPPED = '_gitignore';
18
+ export function listSeeds(seedsDir) {
19
+ if (!existsSync(seedsDir)) {
20
+ return [];
21
+ }
22
+ return readdirSync(seedsDir)
23
+ .sort()
24
+ .map((name) => path.join(seedsDir, name))
25
+ .filter((dir) => existsSync(path.join(dir, 'Framework.md')))
26
+ .map((dir) => ({ name: path.basename(dir), dir, framework: readSeedFramework(dir) }));
27
+ }
28
+ function readSeedFramework(dir) {
29
+ const file = path.join(dir, 'Framework.md');
30
+ return parseFramework(readFileSync(file, 'utf8'), 'md', dir, file);
31
+ }
32
+ export class InstallError extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = 'InstallError';
36
+ }
37
+ }
38
+ class Writer {
39
+ dryRun;
40
+ actions = [];
41
+ constructor(dryRun) {
42
+ this.dryRun = dryRun;
43
+ }
44
+ copyTree(src, dest) {
45
+ this.actions.push(`copy ${src}/ → ${dest}/`);
46
+ if (!this.dryRun) {
47
+ cpSync(src, dest, { recursive: true });
48
+ }
49
+ }
50
+ remove(file) {
51
+ this.actions.push(`rm ${file}`);
52
+ if (!this.dryRun) {
53
+ rmSync(file, { force: true });
54
+ }
55
+ }
56
+ rename(from, to) {
57
+ this.actions.push(`mv ${from} → ${to}`);
58
+ if (!this.dryRun) {
59
+ renameSync(from, to);
60
+ }
61
+ }
62
+ mkdir(dir) {
63
+ this.actions.push(`mkdir ${dir}/`);
64
+ if (!this.dryRun) {
65
+ mkdirSync(dir, { recursive: true });
66
+ }
67
+ }
68
+ write(file, text) {
69
+ this.actions.push(`write ${file}`);
70
+ if (!this.dryRun) {
71
+ mkdirSync(path.dirname(file), { recursive: true });
72
+ writeFileSync(file, text, 'utf8');
73
+ }
74
+ }
75
+ }
76
+ function setNaming(text, naming) {
77
+ if (/^naming:.*$/m.test(text)) {
78
+ return text.replace(/^naming:.*$/m, `naming: ${naming}`);
79
+ }
80
+ return text.replace(/^---\n/, `---\nnaming: ${naming}\n`);
81
+ }
82
+ // The grouping bullet's placeholder becomes one bullet per starting group, each
83
+ // in the framework's convention so a grouping property's value can match its folder.
84
+ function setGroups(text, label, groups) {
85
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
+ const pattern = new RegExp(`^-\\s+\\*\\*${escaped}:\\*\\*.*$`, 'm');
87
+ const bullets = groups.map((group) => ` - **${group}** — ${GROUP_PROMPT}`).join('\n');
88
+ return text.replace(pattern, `- **${label}:**\n${bullets}`);
89
+ }
90
+ function renameCollection(text, from, to) {
91
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
+ return text.replace(new RegExp(`(?<![\\w-])${escaped}(?!\\w)`, 'g'), to);
93
+ }
94
+ // A blank blueprint: every property that applies to the collection, with the
95
+ // values given and `[]` for an empty list, then the flavor's shape as the body.
96
+ export function blankBlueprint(framework, collectionName, values, shapeTemplate, title) {
97
+ const entries = propertiesFor(framework, collectionName).map((property) => {
98
+ const value = values[property.name];
99
+ if ((value === undefined || value === null || value === '') && property.type.toLowerCase() === 'list') {
100
+ return [property.name, []];
101
+ }
102
+ return [property.name, value ?? ''];
103
+ });
104
+ return formatFrontmatter(entries) + '\n' + renderShape(shapeTemplate, title);
105
+ }
106
+ export function installSeed(options) {
107
+ const seedDir = path.join(options.seedsDir, options.seed);
108
+ const frameworkSrc = path.join(seedDir, 'Framework.md');
109
+ if (!existsSync(frameworkSrc)) {
110
+ throw new InstallError(`no seed '${options.seed}' under ${options.seedsDir} (needs a Framework.md)`);
111
+ }
112
+ const root = path.resolve(options.root);
113
+ if (existsSync(path.join(root, '_eidos'))) {
114
+ throw new InstallError(`${root}/_eidos already exists: that is a root; use migrate`);
115
+ }
116
+ let text = readFileSync(frameworkSrc, 'utf8');
117
+ const seedFramework = parseFramework(text, 'md', seedDir, frameworkSrc);
118
+ if (seedFramework.collections.length === 0) {
119
+ throw new InstallError(`${frameworkSrc} declares no collections`);
120
+ }
121
+ const writer = new Writer(options.dryRun);
122
+ const naming = options.naming;
123
+ // 1. the framework itself. The seed's README is the root's visible door, so it
124
+ // moves to the root; roles/, shapes/, me.md, and .gitignore travel as they are.
125
+ writer.copyTree(seedDir, path.join(root, '_eidos'));
126
+ writer.remove(path.join(root, '_eidos', 'README.md'));
127
+ // npm never packs a file named .gitignore, so a seed ships it as _gitignore.
128
+ if (existsSync(path.join(seedDir, GITIGNORE_SHIPPED))) {
129
+ writer.rename(path.join(root, '_eidos', GITIGNORE_SHIPPED), path.join(root, '_eidos', '.gitignore'));
130
+ }
131
+ // 2. the Framework: naming, starting groups, and each collection in convention.
132
+ text = setNaming(text, naming);
133
+ const grouped = seedFramework.collections.find((collection) => collection.grouping && !collection.framing) ?? null;
134
+ if (options.groups.length > 0) {
135
+ if (!grouped || !grouped.grouping) {
136
+ throw new InstallError(`no collection in '${options.seed}' declares a grouping`);
137
+ }
138
+ text = setGroups(text, grouped.grouping.label, options.groups.map((group) => convert(group, naming)));
139
+ }
140
+ const readmeSrc = path.join(seedDir, 'README.md');
141
+ let readme = existsSync(readmeSrc) ? readFileSync(readmeSrc, 'utf8') : null;
142
+ if (readme !== null && options.product !== null) {
143
+ readme = readme.replace(/\{\{Product\}\}/g, options.product);
144
+ }
145
+ if (readme !== null) {
146
+ readme = readme.replace(/Framework\.md/g, 'Framework.yaml');
147
+ }
148
+ const folders = new Map();
149
+ for (const collection of seedFramework.collections) {
150
+ const folder = convert(collection.name, naming);
151
+ folders.set(collection.name, folder);
152
+ if (folder !== collection.name) {
153
+ text = renameCollection(text, collection.name, folder);
154
+ if (readme !== null) {
155
+ readme = renameCollection(readme, collection.name, folder);
156
+ }
157
+ }
158
+ }
159
+ // The seed's markdown is the source; the root gets the same framework as
160
+ // YAML, with the guidance as comments. The seed's prose (section intros,
161
+ // HTML comments) has no field to land in and stays in the seed.
162
+ const fromSeed = parseFramework(text, 'md', root, path.join(root, '_eidos', 'Framework.md'));
163
+ writer.remove(path.join(root, '_eidos', 'Framework.md'));
164
+ const frameworkFile = path.join(root, '_eidos', 'Framework.yaml');
165
+ const serialized = serializeDocument(frameworkToDocument(fromSeed));
166
+ writer.write(frameworkFile, serialized);
167
+ const installed = parseFramework(serialized, 'yaml', root, frameworkFile);
168
+ if (readme !== null) {
169
+ writer.write(path.join(root, 'README.md'), readme);
170
+ }
171
+ // 3. a folder per collection; blank blueprints for the framing one.
172
+ const scaffolded = [];
173
+ const groupsInConvention = options.groups.map((group) => convert(group, naming));
174
+ for (const collection of seedFramework.collections) {
175
+ const folderName = folders.get(collection.name) ?? collection.name;
176
+ const folder = path.join(root, folderName);
177
+ writer.mkdir(folder);
178
+ if (collection === grouped) {
179
+ for (const group of groupsInConvention) {
180
+ writer.mkdir(path.join(folder, group));
181
+ }
182
+ }
183
+ if (!collection.framing) {
184
+ continue;
185
+ }
186
+ for (const flavor of collection.flavors) {
187
+ const shapeFile = path.join(seedDir, flavor.shape);
188
+ if (!existsSync(shapeFile)) {
189
+ writer.actions.push(`skip ${collection.name}/${flavor.name}: no shape at ${flavor.shape}`);
190
+ continue;
191
+ }
192
+ const title = titleize(flavor.name);
193
+ const values = {
194
+ id: kebab(flavor.name),
195
+ title,
196
+ flavor: flavor.name,
197
+ status: 'Draft',
198
+ date_created: options.date,
199
+ date_modified: options.date,
200
+ };
201
+ const body = blankBlueprint(installed, folderName, values, readFileSync(shapeFile, 'utf8'), title);
202
+ const file = path.join(folder, `${convert(title, naming)}.md`);
203
+ writer.write(file, body);
204
+ scaffolded.push(`${folderName}/${convert(title, naming)}.md`);
205
+ }
206
+ }
207
+ // The framing collection has blueprints now, so the index lists them at once.
208
+ if (!options.dryRun) {
209
+ buildIndexes(installed, listBlueprints(installed), { check: false });
210
+ }
211
+ return {
212
+ root,
213
+ seed: options.seed,
214
+ eidosVersion: installed.eidosVersion,
215
+ naming,
216
+ frameworkFile,
217
+ collections: seedFramework.collections.map((collection) => folders.get(collection.name) ?? collection.name),
218
+ groupedCollection: grouped ? (folders.get(grouped.name) ?? grouped.name) : null,
219
+ groups: groupsInConvention,
220
+ scaffolded,
221
+ actions: writer.actions,
222
+ };
223
+ }
@@ -0,0 +1,275 @@
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.
7
+ import { createServer as createHttpServer } from 'node:http';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { marked } from 'marked';
11
+ import { loadRoot } from '../context.js';
12
+ import { checkRoot } from './check.js';
13
+ import { buildIndexes } from './index-leaf.js';
14
+ import { frameworkToDocument } from './framework-structured.js';
15
+ import { listRoles, meFile, readActor } from './me.js';
16
+ 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';
20
+ import { browserDir, packageVersion, readStandard, standardVersion } from '../paths.js';
21
+ import { guideText, GUIDES } from '../commands/instructions.js';
22
+ import { today } from '../output.js';
23
+ class HttpError extends Error {
24
+ status;
25
+ constructor(status, message) {
26
+ super(message);
27
+ this.status = status;
28
+ }
29
+ }
30
+ function json(response, status, body) {
31
+ const text = JSON.stringify(body);
32
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
33
+ response.end(text);
34
+ }
35
+ async function readBody(request) {
36
+ const chunks = [];
37
+ for await (const chunk of request) {
38
+ chunks.push(chunk);
39
+ if (chunks.reduce((size, part) => size + part.length, 0) > 8 * 1024 * 1024) {
40
+ throw new HttpError(413, 'request body too large');
41
+ }
42
+ }
43
+ const text = Buffer.concat(chunks).toString('utf8');
44
+ if (text.trim() === '') {
45
+ return {};
46
+ }
47
+ try {
48
+ const parsed = JSON.parse(text);
49
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
50
+ }
51
+ catch {
52
+ throw new HttpError(400, 'request body is not JSON');
53
+ }
54
+ }
55
+ function rel(root, file) {
56
+ return path.relative(root, file).split(path.sep).join('/');
57
+ }
58
+ function blueprintSummary(blueprint) {
59
+ const { flavor, declared } = resolveFlavor(blueprint);
60
+ return {
61
+ id: blueprintId(blueprint),
62
+ title: blueprintTitle(blueprint),
63
+ summary: propertyString(blueprint, 'summary'),
64
+ collection: blueprint.collection.name,
65
+ group: blueprint.group,
66
+ flavor: flavor?.name ?? declared,
67
+ path: blueprint.rel,
68
+ };
69
+ }
70
+ function findByPath(context, relPath) {
71
+ const wanted = decodeURIComponent(relPath).replace(/\\/g, '/');
72
+ const found = context.blueprints.find((blueprint) => blueprint.rel === wanted);
73
+ if (!found) {
74
+ throw new HttpError(404, `no blueprint at ${wanted}`);
75
+ }
76
+ return found;
77
+ }
78
+ // Only this file's findings; the framework-level ones belong to the check view.
79
+ function findingsFor(context, blueprint) {
80
+ return checkRoot(context.framework, context.blueprints, { only: new Set([blueprint.path]), standardVersion: standardVersion() }).findings.filter((finding) => finding.path === blueprint.rel);
81
+ }
82
+ function blueprintDetail(context, blueprint) {
83
+ const raw = readFileSync(blueprint.path, 'utf8');
84
+ const { flavor } = resolveFlavor(blueprint);
85
+ const shape = flavor ? loadShape(context.framework, flavor) : null;
86
+ return {
87
+ ...blueprintSummary(blueprint),
88
+ properties: blueprint.properties ?? {},
89
+ frontmatter_error: blueprint.frontmatterError,
90
+ sections: blueprint.sections.map((section) => section.heading.text),
91
+ shape_sections: shape?.sections ?? [],
92
+ html: marked.parse(blueprint.body, { async: false }),
93
+ raw,
94
+ findings: findingsFor(context, blueprint),
95
+ };
96
+ }
97
+ function rootSnapshot(context) {
98
+ const actor = readActor(context.root);
99
+ return {
100
+ root: context.root,
101
+ name: path.basename(context.root),
102
+ cli_version: packageVersion(),
103
+ standard_version: standardVersion(),
104
+ framework: { file: rel(context.root, context.framework.file), format: context.framework.format, problems: context.framework.problems, ...frameworkToDocument(context.framework) },
105
+ blueprints: context.blueprints.map(blueprintSummary),
106
+ roles: listRoles(context.root).map((role) => ({ name: role.name, title: role.title, summary: role.summary })),
107
+ 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
+ guides: GUIDES,
109
+ };
110
+ }
111
+ function checkSnapshot(context) {
112
+ const report = checkRoot(context.framework, context.blueprints, { standardVersion: standardVersion() });
113
+ return {
114
+ root: report.root,
115
+ eidos_version: report.eidosVersion,
116
+ naming: report.naming,
117
+ blueprints: report.blueprints,
118
+ collections: report.collections,
119
+ errors: report.errors,
120
+ warnings: report.warnings,
121
+ findings: report.findings,
122
+ };
123
+ }
124
+ function indexRebuild(context) {
125
+ const results = buildIndexes(context.framework, context.blueprints, { check: false });
126
+ return {
127
+ indexes: results.map((result) => ({ collection: result.collection.name, path: rel(context.root, result.file), status: result.status, blueprints: result.count })),
128
+ missing_summary: results.flatMap((result) => result.missingSummary),
129
+ };
130
+ }
131
+ function createBlueprint(context, body) {
132
+ const text = (value) => (typeof value === 'string' && value.trim() !== '' ? value : null);
133
+ const set = {};
134
+ if (typeof body['set'] === 'object' && body['set'] !== null) {
135
+ for (const [key, value] of Object.entries(body['set'])) {
136
+ if (typeof value === 'string' && value.trim() !== '') {
137
+ set[key] = value;
138
+ }
139
+ }
140
+ }
141
+ let result;
142
+ try {
143
+ result = scaffoldBlueprint(context.framework, context.blueprints, {
144
+ collection: text(body['collection']) ?? '',
145
+ title: text(body['title']) ?? '',
146
+ flavor: text(body['flavor']),
147
+ group: text(body['group']),
148
+ id: text(body['id']),
149
+ summary: text(body['summary']),
150
+ set,
151
+ date: today(),
152
+ });
153
+ }
154
+ catch (cause) {
155
+ if (cause instanceof ScaffoldError) {
156
+ throw new HttpError(400, cause.message);
157
+ }
158
+ throw cause;
159
+ }
160
+ mkdirSync(path.dirname(result.path), { recursive: true });
161
+ 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 };
163
+ }
164
+ function saveBlueprint(context, blueprint, body) {
165
+ const text = body['text'];
166
+ if (typeof text !== 'string') {
167
+ throw new HttpError(400, 'expected { text }');
168
+ }
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) : [] };
173
+ }
174
+ export function createServer(options) {
175
+ const page = path.join(browserDir, 'index.html');
176
+ const context = () => loadRoot({ root: options.root }, options.cwd);
177
+ const handle = async (request, response) => {
178
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
179
+ const method = request.method ?? 'GET';
180
+ 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
+ if (segments[0] !== 'api') {
191
+ throw new HttpError(404, 'not found');
192
+ }
193
+ const resource = segments[1] ?? '';
194
+ const rest = segments.slice(2).join('/');
195
+ if (resource === 'root' && method === 'GET') {
196
+ json(response, 200, rootSnapshot(context()));
197
+ return;
198
+ }
199
+ if (resource === 'blueprints' && rest === '' && method === 'GET') {
200
+ json(response, 200, context().blueprints.map(blueprintSummary));
201
+ return;
202
+ }
203
+ if (resource === 'blueprints' && rest === '' && method === 'POST') {
204
+ json(response, 201, createBlueprint(context(), await readBody(request)));
205
+ return;
206
+ }
207
+ if (resource === 'blueprints' && rest !== '' && method === 'GET') {
208
+ const current = context();
209
+ json(response, 200, blueprintDetail(current, findByPath(current, rest)));
210
+ return;
211
+ }
212
+ if (resource === 'blueprints' && rest !== '' && method === 'PUT') {
213
+ const current = context();
214
+ json(response, 200, saveBlueprint(current, findByPath(current, rest), await readBody(request)));
215
+ return;
216
+ }
217
+ if (resource === 'check' && method === 'GET') {
218
+ json(response, 200, checkSnapshot(context()));
219
+ return;
220
+ }
221
+ if (resource === 'index' && method === 'POST') {
222
+ json(response, 200, indexRebuild(context()));
223
+ return;
224
+ }
225
+ if (resource === 'standard' && method === 'GET') {
226
+ json(response, 200, { version: standardVersion(), html: marked.parse(readStandard(), { async: false }) });
227
+ return;
228
+ }
229
+ if (resource === 'instructions' && method === 'GET') {
230
+ const guide = rest === '' ? 'overview' : rest;
231
+ if (!GUIDES.some((entry) => entry.name === guide)) {
232
+ throw new HttpError(404, `no guide '${guide}'`);
233
+ }
234
+ json(response, 200, { guide, html: marked.parse(guideText(guide), { async: false }) });
235
+ return;
236
+ }
237
+ throw new HttpError(404, 'not found');
238
+ };
239
+ return createHttpServer((request, response) => {
240
+ handle(request, response).catch((cause) => {
241
+ if (cause instanceof HttpError) {
242
+ json(response, cause.status, { error: cause.message });
243
+ }
244
+ else if (cause instanceof CliError) {
245
+ json(response, 400, { error: cause.message });
246
+ }
247
+ else {
248
+ json(response, 500, { error: cause instanceof Error ? cause.message : String(cause) });
249
+ }
250
+ });
251
+ });
252
+ }
253
+ // The first free port at or after `preferred`, on the loopback interface.
254
+ export function listen(server, preferred) {
255
+ return new Promise((resolve, reject) => {
256
+ const tryPort = (port) => {
257
+ const onError = (error) => {
258
+ server.removeListener('error', onError);
259
+ if (error.code === 'EADDRINUSE' && port < preferred + 50) {
260
+ tryPort(port + 1);
261
+ }
262
+ else {
263
+ reject(error);
264
+ }
265
+ };
266
+ server.once('error', onError);
267
+ server.listen(port, '127.0.0.1', () => {
268
+ server.removeListener('error', onError);
269
+ const address = server.address();
270
+ resolve(typeof address === 'object' && address !== null ? address.port : port);
271
+ });
272
+ };
273
+ tryPort(preferred);
274
+ });
275
+ }
@@ -0,0 +1,26 @@
1
+ // A shape file: one body template in `_eidos/shapes/`, read for the sections
2
+ // it declares (to validate a blueprint against) and as the template a new
3
+ // blueprint's body is rendered from.
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { headings } from './markdown.js';
6
+ import { shapePath } from './framework.js';
7
+ export function loadShape(framework, flavor) {
8
+ const file = shapePath(framework, flavor);
9
+ if (!existsSync(file)) {
10
+ return null;
11
+ }
12
+ const template = readFileSync(file, 'utf8');
13
+ const all = headings(template);
14
+ return {
15
+ path: file,
16
+ flavor,
17
+ template,
18
+ sections: all.filter((heading) => heading.level === 2).map((heading) => heading.text),
19
+ headings: all,
20
+ };
21
+ }
22
+ // The body a scaffolded blueprint starts from: the template with its title
23
+ // filled in and any leading blank lines dropped.
24
+ export function renderShape(template, title) {
25
+ return template.replace(/\r\n?/g, '\n').replace(/\{\{title\}\}/g, title).replace(/^\n+/, '');
26
+ }
@@ -0,0 +1,38 @@
1
+ // Shared output and exit conventions: 0 done, 1 the check found problems (or
2
+ // an index is stale), 2 the command could not run.
3
+ export const EXIT_OK = 0;
4
+ export const EXIT_FINDINGS = 1;
5
+ export const EXIT_USAGE = 2;
6
+ export class CliError extends Error {
7
+ exitCode;
8
+ constructor(message, exitCode = EXIT_USAGE) {
9
+ super(message);
10
+ this.name = 'CliError';
11
+ this.exitCode = exitCode;
12
+ }
13
+ }
14
+ export function printJson(value) {
15
+ process.stdout.write(JSON.stringify(value, null, 2) + '\n');
16
+ }
17
+ export function print(...lines) {
18
+ process.stdout.write(lines.join('\n') + '\n');
19
+ }
20
+ export function printError(message) {
21
+ process.stderr.write(message.endsWith('\n') ? message : message + '\n');
22
+ }
23
+ export function today() {
24
+ return new Date().toISOString().slice(0, 10);
25
+ }
26
+ // Left-aligned columns for a plain table; widths from the widest cell.
27
+ export function table(rows, gap = 2) {
28
+ const widths = [];
29
+ for (const row of rows) {
30
+ row.forEach((cell, index) => {
31
+ widths[index] = Math.max(widths[index] ?? 0, cell.length);
32
+ });
33
+ }
34
+ return rows.map((row) => row
35
+ .map((cell, index) => (index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)))
36
+ .join(' '.repeat(gap))
37
+ .trimEnd());
38
+ }