@hydranium/cli 1.0.0-next.307 → 1.0.0-next.309

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 (38) hide show
  1. package/README.md +11 -9
  2. package/lib/commands/headless-harness.js +1 -1
  3. package/lib/commands/headless-harness.js.map +1 -1
  4. package/lib/commands/init-args.d.ts.map +1 -1
  5. package/lib/commands/init-args.js +21 -16
  6. package/lib/commands/init-args.js.map +1 -1
  7. package/lib/commands/init-templates.d.ts +12 -6
  8. package/lib/commands/init-templates.d.ts.map +1 -1
  9. package/lib/commands/init-templates.js +394 -127
  10. package/lib/commands/init-templates.js.map +1 -1
  11. package/lib/commands/init-wizard.d.ts.map +1 -1
  12. package/lib/commands/init-wizard.js +8 -7
  13. package/lib/commands/init-wizard.js.map +1 -1
  14. package/lib/commands/init-workspace.d.ts +0 -8
  15. package/lib/commands/init-workspace.d.ts.map +1 -1
  16. package/lib/commands/init-workspace.js +0 -1
  17. package/lib/commands/init-workspace.js.map +1 -1
  18. package/lib/commands/init.d.ts +26 -1
  19. package/lib/commands/init.d.ts.map +1 -1
  20. package/lib/commands/init.js +64 -20
  21. package/lib/commands/init.js.map +1 -1
  22. package/package.json +11 -10
  23. package/src/commands/headless-harness.ts +1 -1
  24. package/src/commands/init-args.ts +21 -16
  25. package/src/commands/init-templates.ts +424 -133
  26. package/src/commands/init-wizard.ts +8 -7
  27. package/src/commands/init-workspace.ts +0 -9
  28. package/src/commands/init.ts +90 -20
  29. package/templates/gitignore +1 -0
  30. package/templates/protocol/gitignore +3 -0
  31. package/templates/protocol/src/__GRAMMAR_ID__-diagram-types.ts +41 -0
  32. package/templates/server/src/glsp/__GRAMMAR_ID__/create-node-operation-handler.ts +5 -4
  33. package/templates/server/src/glsp/__GRAMMAR_ID__/gmodel-factory.ts +6 -3
  34. package/templates/server/src/{services.ts → node/services.ts} +6 -2
  35. package/templates/server/test/__GRAMMAR_ID__-diagram.test.ts +28 -7
  36. package/templates/workspace/nodes.__EXTENSION__ +5 -0
  37. package/templates/workspace/references.__EXTENSION__ +2 -0
  38. package/templates/server/src/glsp/__GRAMMAR_ID__/types.ts +0 -26
@@ -22,6 +22,9 @@
22
22
  * `bin` key, the DI module filename, and the data-server port command.
23
23
  * - `__CONFIG_ROOT_CONST__` — SCREAMING_SNAKE project id, naming the
24
24
  * `lsp.configurationRoot` constant a multi-grammar project binds.
25
+ * - `__PROTOCOL_PACKAGE__` — the protocol member's package name, scope
26
+ * included, which the server and every client import ids and wire types
27
+ * from.
25
28
  *
26
29
  * GRAMMAR tokens, substituted once per grammar:
27
30
  *
@@ -51,9 +54,16 @@
51
54
  * generator emits `interface <projectName>Element` as the base type every rule
52
55
  * extends, which a rule of that name would collide with.
53
56
  *
57
+ * The project is two packages and a starter workspace, each in its own
58
+ * directory: `server/` (the grammar, the language and its heads), `protocol/`
59
+ * (what the server and its clients both import, so a client never installs
60
+ * Langium), and `workspace/` (starter model files every host and the CLI
61
+ * open). A standalone project adds a root manifest naming them as npm
62
+ * workspaces; a workspace member joins the surrounding root instead.
63
+ *
54
64
  * A file that is fixed text with tokens lives in the `templates/` tree the
55
65
  * package ships beside `lib`, at the path it is emitted at, tokens included
56
- * (the ignore file without its dot); one computed from the composition is
66
+ * (each ignore file without its dot); one computed from the composition is
57
67
  * built here. The tree is read synchronously, so `runInit` stays synchronous,
58
68
  * and it is neither formatted with the repo's settings nor given a license
59
69
  * header, since either would change what `init` emits.
@@ -78,7 +88,7 @@
78
88
 
79
89
  import { readFileSync } from 'node:fs';
80
90
  import { readCliVersion } from '../cli-version.js';
81
- import type { InitComposition, InitFile, InitGrammarNames, InitHead } from './init.js';
91
+ import type { InitComposition, InitFile, InitGrammarNames, InitHead, InitMember } from './init.js';
82
92
  import type { JsonValue } from './init-workspace.js';
83
93
 
84
94
  /** One template file: its path (tokens allowed) and its content (tokens allowed). */
@@ -117,12 +127,49 @@ function screamingSnake(projectId: string): string {
117
127
  return projectId.replace(/-/g, '_').toUpperCase();
118
128
  }
119
129
 
130
+ /**
131
+ * A member's package name: `<id>-<member>`, under the scope when there is one.
132
+ *
133
+ * Suffixed even for the server, because a project of several packages has no
134
+ * member entitled to the bare id, and a later client or host would otherwise
135
+ * read as a part of the server.
136
+ */
137
+ export function memberPackageName(composition: InitComposition, member: InitMember): string {
138
+ const { scope } = composition.packaging;
139
+ const name = `${composition.projectId}-${member}`;
140
+ return scope === undefined ? name : `${scope}/${name}`;
141
+ }
142
+
143
+ /**
144
+ * The composition as one member sees it: inside a workspace, its placement
145
+ * sits one directory below the project's, so its `extends` and its lint
146
+ * config path reach one level further up.
147
+ */
148
+ function forMember(composition: InitComposition, member: InitMember): InitComposition {
149
+ const workspace = composition.packaging.workspace;
150
+ if (workspace === undefined) {
151
+ return composition;
152
+ }
153
+ return {
154
+ ...composition,
155
+ packaging: {
156
+ ...composition.packaging,
157
+ workspace: {
158
+ ...workspace,
159
+ targetPath: `${workspace.targetPath}/${member}`,
160
+ baseTsconfig: workspace.baseTsconfig === undefined ? undefined : `../${workspace.baseTsconfig}`
161
+ }
162
+ }
163
+ };
164
+ }
165
+
120
166
  /** Substitute the project-tier tokens. */
121
167
  function project(text: string, composition: InitComposition): string {
122
168
  return text
123
169
  .replace(/__NAME__/g, composition.name)
124
170
  .replace(/__PROJECT_ID__/g, composition.projectId)
125
- .replace(/__CONFIG_ROOT_CONST__/g, screamingSnake(composition.projectId));
171
+ .replace(/__CONFIG_ROOT_CONST__/g, screamingSnake(composition.projectId))
172
+ .replace(/__PROTOCOL_PACKAGE__/g, memberPackageName(composition, 'protocol'));
126
173
  }
127
174
 
128
175
  /**
@@ -157,14 +204,20 @@ function template(path: string): string {
157
204
  return readFileSync(new URL(path, TEMPLATES), 'utf-8');
158
205
  }
159
206
 
160
- /** A `server/` template emitted once per project, rendered with the project tokens. */
207
+ /** A template emitted once per project at its own path, rendered with the project tokens. */
161
208
  function projectTemplate(path: string, composition: InitComposition): InitFile {
162
- return { path, content: project(template(`server/${path}`), composition) };
209
+ return { path, content: project(template(path), composition) };
163
210
  }
164
211
 
165
- /** A `server/` template emitted once per grammar, its path rendered like its content. */
212
+ /** A template emitted once per grammar, its path rendered like its content. */
166
213
  function grammarTemplate(path: string, composition: InitComposition, grammar: InitGrammarNames): InitFile {
167
- return { path: render(path, composition, grammar), content: render(template(`server/${path}`), composition, grammar) };
214
+ return { path: render(path, composition, grammar), content: render(template(path), composition, grammar) };
215
+ }
216
+
217
+ /** An ignore file, stored without its dot because `npm pack` drops a nested `.gitignore` and applies it as an ignore file. */
218
+ function ignoreFile(directory: string): InitFile {
219
+ const prefix = directory === '' ? '' : `${directory}/`;
220
+ return { path: `${prefix}.gitignore`, content: template(`${prefix}gitignore`) };
168
221
  }
169
222
 
170
223
  /** A comma-separated list of single-quoted TypeScript/JS string literals. */
@@ -260,6 +313,17 @@ const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, st
260
313
  }
261
314
  };
262
315
 
316
+ /**
317
+ * What each head adds to the protocol member, which carries only what a
318
+ * client may install. Keyed by head because the `glsp` head implies a diagram,
319
+ * whose element ids extend GLSP's default type names.
320
+ */
321
+ const PROTOCOL_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, string>>> = {
322
+ lsp: {},
323
+ data: {},
324
+ glsp: { '@eclipse-glsp/protocol': '2.7.0' }
325
+ };
326
+
263
327
  /**
264
328
  * The exact pins of `heads`, for a workspace root to declare too. npm installs a
265
329
  * root's own dependencies at the top of the tree, so another member's version of
@@ -268,7 +332,7 @@ const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, st
268
332
  */
269
333
  export function workspaceRootPins(heads: readonly InitHead[]): ReadonlyArray<readonly [string, string]> {
270
334
  return heads
271
- .flatMap(head => Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES[head]))
335
+ .flatMap(head => [...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES[head]), ...Object.entries(PROTOCOL_THIRD_PARTY_DEPENDENCIES[head])])
272
336
  .filter(([, version]) => /^\d+\.\d+\.\d+$/.test(version))
273
337
  .sort(([left], [right]) => left.localeCompare(right));
274
338
  }
@@ -291,19 +355,32 @@ function pinsVitest(composition: InitComposition): boolean {
291
355
  return !(installsVitest41(workspace?.packageManagerNpm) && installsVitest41(workspace?.npmVersion));
292
356
  }
293
357
 
294
- /** The dependency block for a head set, merged and sorted as npm writes it. */
358
+ /**
359
+ * The version a member pins a sibling member at: exact, at the version every
360
+ * member starts with. npm links a sibling only when the range admits its local
361
+ * version, and otherwise looks for it in the registry, where it was never
362
+ * published.
363
+ */
364
+ const MEMBER_PIN = '0.0.0';
365
+
366
+ /** A dependency block, sorted as npm writes it. */
367
+ function sortedBlock(dependencies: Readonly<Record<string, string>>, indent: string): string {
368
+ return Object.keys(dependencies)
369
+ .sort()
370
+ .map(name => `${indent}"${name}": "${dependencies[name]}"`)
371
+ .join(',\n');
372
+ }
373
+
374
+ /** The server's dependency block: the head set's packages and the protocol member. */
295
375
  function dependencyBlock(composition: InitComposition, indent: string): string {
296
- const merged: Record<string, string> = {};
376
+ const merged: Record<string, string> = { [memberPackageName(composition, 'protocol')]: MEMBER_PIN };
297
377
  for (const head of composition.heads) {
298
378
  Object.assign(merged, HEAD_THIRD_PARTY_DEPENDENCIES[head]);
299
379
  for (const framework of HEAD_FRAMEWORK_DEPENDENCIES[head]) {
300
380
  merged[framework] = frameworkPin(composition);
301
381
  }
302
382
  }
303
- return Object.keys(merged)
304
- .sort()
305
- .map(name => `${indent}"${name}": "${merged[name]}"`)
306
- .join(',\n');
383
+ return sortedBlock(merged, indent);
307
384
  }
308
385
 
309
386
  // JSON at two-space indent, not the three the `.ts` templates use: that is the
@@ -330,6 +407,11 @@ function dependencyBlock(composition: InitComposition, indent: string): string {
330
407
  // from this package's own grammar and can reach a consumer from nowhere else; a
331
408
  // listed path that does not exist yet is inert.
332
409
  //
410
+ // `exports` puts `createServices`, the one module reading the real filesystem,
411
+ // behind `./node`, so the root loads in a browser worker that has no `node:fs`.
412
+ // `./lib/*` and `./package.json` stay reachable for a host that resolves
413
+ // `<server>/lib/main.js` to spawn the server.
414
+ //
333
415
  // `repository` is deliberately NOT emitted while `author` is emitted empty. An
334
416
  // empty `author` is the form `npm init` itself writes and asserts nothing, but
335
417
  // `repository` is CONSUMED — the registry page, `npm repo` and publish
@@ -354,17 +436,29 @@ __BIN__ },
354
436
  "type": "module",
355
437
  "main": "lib/index.js",
356
438
  "types": "lib/index.d.ts",
439
+ "exports": {
440
+ ".": {
441
+ "types": "./lib/index.d.ts",
442
+ "default": "./lib/index.js"
443
+ },
444
+ "./node": {
445
+ "types": "./lib/node/services.d.ts",
446
+ "default": "./lib/node/services.js"
447
+ },
448
+ "./lib/*": "./lib/*",
449
+ "./package.json": "./package.json"
450
+ },
357
451
  "scripts": {
358
- "build": "npm run generate && tsc",
452
+ "build": "npm run generate && tsc -b",
359
453
  "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-hydranium tsconfig.tsbuildinfo",
360
454
  "generate": "npm run langium:generate && npm run generate:transfer-model",
361
- "generate:transfer-model": "hydranium-cli generate-transfer-model --ast-file src/language-server/generated/ast.ts --augmentation-file src/language-server/ast.ts --out-file src/language-server/generated-hydranium/transfer-model.ts --ast-builder-file src/language-server/generated-hydranium/ast-builder.ts --element-type-name __NAME__Element --terminals-name __NAME__Terminals --regen-command \\"Run: __NPM_RUN__ generate:transfer-model\\"",
455
+ "generate:transfer-model": "hydranium-cli generate-transfer-model --ast-file src/language-server/generated/ast.ts --augmentation-file src/language-server/ast.ts --out-file ../protocol/src/generated-hydranium/transfer-model.ts --ast-builder-file src/language-server/generated-hydranium/ast-builder.ts --languages-file ../protocol/src/generated-hydranium/languages.ts --element-type-name __NAME__Element --terminals-name __NAME__Terminals --regen-command \\"Run: __NPM_RUN__ generate:transfer-model\\"",
362
456
  "langium:generate": "langium generate",
363
457
  "langium:watch": "langium generate --watch",
364
458
  __LINT__ "start": "node lib/main.js --stdio",
365
459
  "test": "npm run typecheck:test && vitest run",
366
460
  "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
367
- "watch": "tsc --watch"
461
+ "watch": "tsc -b --watch"
368
462
  },
369
463
  "dependencies": {
370
464
  __DEPENDENCIES__
@@ -384,16 +478,17 @@ __DEPENDENCIES__
384
478
  `;
385
479
 
386
480
  /**
387
- * How the scaffold names `npm run` when talking about itself.
481
+ * How the scaffold names `npm run` for one member's script.
388
482
  *
389
- * A workspace member's scripts are routinely invoked from the repo root, where
390
- * a bare `npm run` reaches the ROOT manifest and fails — so the regen hint a
391
- * generated file prints has to name the package, or it sends every reader who
392
- * follows it to the wrong place.
483
+ * Always with `--prefix`, from the directory commands are run in: the
484
+ * workspace root for a member, the project root for a standalone project. A
485
+ * bare `npm run` there reaches the ROOT manifest and fails, and the regen hint
486
+ * a generated file prints is read in `protocol/` as well, where the script it
487
+ * names does not exist.
393
488
  */
394
- function npmRun(composition: InitComposition): string {
489
+ function npmRun(composition: InitComposition, member: InitMember): string {
395
490
  const workspace = composition.packaging.workspace;
396
- return workspace === undefined ? 'npm run' : `npm --prefix ${workspace.targetPath} run`;
491
+ return `npm --prefix ${workspace === undefined ? member : `${workspace.targetPath}/${member}`} run`;
397
492
  }
398
493
 
399
494
  /**
@@ -416,38 +511,38 @@ function binBlock(composition: InitComposition): string {
416
511
  return entries.map(([name, target]) => ` "${name}": "${target}"`).join(',\n') + '\n';
417
512
  }
418
513
 
419
- /** `package.json` with the name, the private flag and the dependency block all derived. */
514
+ /**
515
+ * A member's `lint` script line, over `directories`, or nothing.
516
+ *
517
+ * Emitted only where a root lint config says the repo lints. The absence is
518
+ * the dangerous direction: a task runner runs a script only where one is
519
+ * declared, so a package with no `lint` is SKIPPED rather than reported, and
520
+ * that reads as a clean lint. A wrong invocation is one visible line to edit.
521
+ */
522
+ function lintScript(member: InitComposition, directories: string): string {
523
+ const workspace = member.packaging.workspace;
524
+ if (workspace?.oxlintConfig !== undefined) {
525
+ const config = `${'../'.repeat(workspace.targetPath.split('/').length)}${workspace.oxlintConfig}`;
526
+ return ` "lint": "oxlint --report-unused-disable-directives --format stylish --config ${config} ${directories} --max-warnings 0",\n`;
527
+ }
528
+ return workspace?.eslintConfig !== undefined ? ` "lint": "eslint ${directories} --max-warnings 0",\n` : '';
529
+ }
530
+
531
+ /** The server's `package.json`, with the name, the private flag and the dependency block all derived. */
420
532
  function packageJson(composition: InitComposition): string {
421
- const { scope } = composition.packaging;
422
- const packageName = scope === undefined ? composition.projectId : `${scope}/${composition.projectId}`;
423
533
  const keywords =
424
534
  ['hydranium', 'langium', 'language-server', composition.projectId]
425
535
  .sort()
426
536
  .map(keyword => ` ${JSON.stringify(keyword)}`)
427
537
  .join(',\n') + '\n';
428
- // Emitted only where a root lint config says the repo lints. The absence is
429
- // the dangerous direction: a task runner runs a script only where one is
430
- // declared, so a package with no `lint` is SKIPPED rather than reported, and
431
- // that reads as a clean lint. A wrong invocation is one visible line to edit.
432
- const workspace = composition.packaging.workspace;
433
- const oxlintConfig =
434
- workspace?.oxlintConfig === undefined
435
- ? undefined
436
- : `${'../'.repeat(workspace.targetPath.split('/').length)}${workspace.oxlintConfig}`;
437
- const lint =
438
- workspace?.oxlintConfig !== undefined
439
- ? ` "lint": "oxlint --report-unused-disable-directives --format stylish --config ${oxlintConfig} src test --max-warnings 0",\n`
440
- : workspace?.eslintConfig !== undefined
441
- ? ' "lint": "eslint src test --max-warnings 0",\n'
442
- : '';
443
538
  return project(PACKAGE_JSON, composition)
444
- .replace('__PACKAGE_NAME__', packageName)
539
+ .replace('__PACKAGE_NAME__', memberPackageName(composition, 'server'))
445
540
  .replace('__KEYWORDS__', keywords)
446
541
  .replace('__BIN__', binBlock(composition))
447
- .replace('__LINT__', lint)
542
+ .replace('__LINT__', lintScript(forMember(composition, 'server'), 'src test'))
448
543
  .replace('__VITEST__', pinsVitest(composition) ? '~4.0.18' : '^4.0.0')
449
544
  .replace('__PRIVATE__', composition.packaging.private ? ' "private": true,\n' : '')
450
- .replace('__NPM_RUN__', npmRun(composition))
545
+ .replace('__NPM_RUN__', npmRun(composition, 'server'))
451
546
  .replace('__FRAMEWORK_PIN__', frameworkPin(composition))
452
547
  .replace('__DEPENDENCIES__', dependencyBlock(composition, ' '));
453
548
  }
@@ -515,6 +610,24 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
515
610
  ['types', ['node']]
516
611
  ];
517
612
 
613
+ /** The protocol member's compiler options, pruned against a workspace base like the server's. */
614
+ const PROTOCOL_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
615
+ ['target', 'ES2022'],
616
+ ['lib', ['ES2022']],
617
+ ['module', 'NodeNext'],
618
+ ['moduleResolution', 'NodeNext'],
619
+ ['rootDir', 'src'],
620
+ ['outDir', 'lib'],
621
+ ['strict', true],
622
+ ['skipLibCheck', true],
623
+ ['declaration', true],
624
+ // The server's `tsc -b` builds this project through a reference, which
625
+ // `tsc` refuses unless the referenced project is composite.
626
+ ['composite', true],
627
+ // No ambient Node types: a browser client compiles against this package.
628
+ ['types', []]
629
+ ];
630
+
518
631
  /**
519
632
  * The options that describe where THIS package's own files live. Never pruned,
520
633
  * and emitted first in the workspace case: a base config that supplied them
@@ -546,22 +659,42 @@ function optionEquals(key: string, left: JsonValue | undefined, right: JsonValue
546
659
  * the same key: inheriting `"module": "CommonJS"` where the scaffold needs
547
660
  * `NodeNext` would break every emitted import, so a differing base value is a
548
661
  * reason to keep the override rather than to trust the parent.
662
+ *
663
+ * `member` is the composition as that member sees it, so `extends` reaches the
664
+ * workspace base from the member's own directory.
549
665
  */
550
- function tsconfigJson(composition: InitComposition): string {
551
- const workspace = composition.packaging.workspace;
666
+ function tsconfigJson(
667
+ member: InitComposition,
668
+ compilerOptions: ReadonlyArray<readonly [string, JsonValue]>,
669
+ include: string,
670
+ references = ''
671
+ ): string {
672
+ const workspace = member.packaging.workspace;
552
673
  const base = workspace?.baseTsconfig === undefined ? undefined : (workspace.baseCompilerOptions ?? {});
553
674
  const options =
554
675
  base === undefined
555
- ? TSCONFIG_COMPILER_OPTIONS
676
+ ? compilerOptions
556
677
  : [
557
- ...TSCONFIG_COMPILER_OPTIONS.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
558
- ...TSCONFIG_COMPILER_OPTIONS.filter(
559
- ([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !optionEquals(key, base[key], value)
560
- )
678
+ ...compilerOptions.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
679
+ ...compilerOptions.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !optionEquals(key, base[key], value))
561
680
  ];
562
681
  const extendsLine = workspace?.baseTsconfig === undefined ? '' : ` "extends": "${workspace.baseTsconfig}",\n`;
563
682
  const body = options.map(([key, value]) => ` "${key}": ${JSON.stringify(value)}`).join(',\n');
564
- return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src", "src/**/*.json"]\n}\n`;
683
+ const referencesLine = references === '' ? '' : `,\n "references": ${references}`;
684
+ return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ${include}${referencesLine}\n}\n`;
685
+ }
686
+
687
+ /**
688
+ * The server's `tsconfig.json`. It references the protocol member, so `tsc -b`
689
+ * compiles protocol first, after `generate` has written its generated modules.
690
+ */
691
+ function serverTsconfigJson(composition: InitComposition): string {
692
+ return tsconfigJson(
693
+ forMember(composition, 'server'),
694
+ TSCONFIG_COMPILER_OPTIONS,
695
+ '["src", "src/**/*.json"]',
696
+ '[{ "path": "../protocol" }]'
697
+ );
565
698
  }
566
699
 
567
700
  /**
@@ -778,14 +911,13 @@ function portCommandNames(composition: InitComposition): string[] {
778
911
  }
779
912
 
780
913
  /**
781
- * The socket heads' discovery commands, as an importable module.
914
+ * The socket heads' discovery commands, in the protocol member.
782
915
  *
783
- * Deliberately NOT declared in `main.ts`. A host shell has to name the same
784
- * string to reach the head, and `main.ts` opens a connection at module scope,
785
- * so importing a constant from it would start a server. A host that retypes the
786
- * literal instead gets no error when it drifts: the framework's port poll
787
- * defaults to `findPortAttempts = -1`, so a wrong command retries forever
788
- * rather than failing.
916
+ * A host shell has to name the same string the server publishes, and reaches
917
+ * it here without depending on the server. A host that retypes the literal
918
+ * instead gets no error when it drifts: the framework's port poll defaults to
919
+ * `findPortAttempts = -1`, so a wrong command retries forever rather than
920
+ * failing.
789
921
  */
790
922
  function headPortsFile(composition: InitComposition): string {
791
923
  const upper = screamingSnake(composition.projectId);
@@ -809,37 +941,35 @@ function headPortsFile(composition: InitComposition): string {
809
941
  // process serves every registered grammar, so a language-derived name would tie
810
942
  // a project-level endpoint to whichever grammar was scaffolded first.
811
943
  //
812
- // They live here rather than in \`main.ts\` because a host shell has to name the
813
- // same string to reach the head, and \`main.ts\` is an executable entry — nothing
814
- // can import from it. A shell that retypes the literal gets no error when it
815
- // drifts: the framework's port poll retries indefinitely by default. Import
816
- // these instead, and assert any host-side copy against them.
944
+ // The server publishes them and a host shell has to name the same string to
945
+ // reach the head; both import them from here. A shell that retypes the literal
946
+ // gets no error when it drifts: the framework's port poll retries indefinitely
947
+ // by default.
817
948
 
818
949
  ${blocks.join('\n\n')}
819
950
  `;
820
951
  }
821
952
 
822
- const INDEX = `// Public surface of the __NAME__ language server: the DI factory, the generated
823
- // AST, and the headless \`createServices\` entry.
953
+ /**
954
+ * The server's root entry: the DI factory, the generated AST and each diagram
955
+ * module, all loadable in a browser worker.
956
+ */
957
+ function indexFile(composition: InitComposition): string {
958
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
959
+ const diagramPhrase = diagrams.length === 0 ? '' : diagrams.length === 1 ? ', plus the diagram module' : ', plus the diagram modules';
960
+ const text = `// Public surface of the __NAME__ language server: the DI factory and the
961
+ // generated AST${diagramPhrase}.
824
962
  //
825
- // The entry points under \`src/\` are deliberately NOT re-exported. Each opens a
826
- // transport at module scope, so importing one starts a server as a side effect
827
- // — which is why they are \`bin\` targets and this file is \`main\`.
963
+ // Everything here loads in a browser worker as well as in Node, so a host can
964
+ // compose the language wherever it runs it. \`createServices\`, which reads the
965
+ // real filesystem, is the package's \`./node\` entry instead. The entry points
966
+ // under \`src/\` are not re-exported either: each opens a transport at module
967
+ // scope, so importing one starts a server, which is why they are \`bin\` targets.
828
968
 
829
969
  export * from './language-server/__PROJECT_ID__-module.js';
830
970
  export * from './language-server/ast.js';
831
- export { createServices } from './services.js';
832
- `;
833
-
834
- /**
835
- * The public surface, plus the head-port commands when there is a socket head.
836
- *
837
- * The commands belong here rather than only in `head-ports.ts` so a host shell
838
- * reaches them from the package root, the same way it reaches `createServices`.
839
- */
840
- function indexFile(composition: InitComposition): string {
841
- const base = project(INDEX, composition);
842
- return portCommandNames(composition).length ? `${base}export * from './head-ports.js';\n` : base;
971
+ ${diagrams.map(grammar => `export * from './glsp/${grammar.grammarId}/diagram-module.js';\n`).join('')}`;
972
+ return project(text, composition);
843
973
  }
844
974
 
845
975
  /**
@@ -903,15 +1033,15 @@ import { startGlspServer } from '@hydranium/glsp-server/node';
903
1033
  // \`TransferElement\` structurally, so naming the AST type here compiles fine and
904
1034
  // silently tells every typed client that a reference is a resolvable object
905
1035
  // rather than a name.
906
- ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1036
+ ${importList(roots, memberPackageName(composition, 'protocol'), columns, true)}
907
1037
  `
908
1038
  : '';
909
1039
 
910
- // The commands live in `head-ports.ts`, not here: a host shell has to name
911
- // the same string to reach the head, and this file is an executable entry, so
912
- // nothing can import from it.
1040
+ // The commands live in the protocol member, not here: a host shell has to
1041
+ // name the same string to reach the head, and this file is an executable
1042
+ // entry, so nothing can import from it.
913
1043
  const portCommands = portCommandNames(composition).length
914
- ? importList(portCommandNames(composition), './head-ports.js', columns) + '\n'
1044
+ ? importList(portCommandNames(composition), memberPackageName(composition, 'protocol'), columns) + '\n'
915
1045
  : '';
916
1046
 
917
1047
  const dataBlock = data
@@ -1048,7 +1178,7 @@ function dataServerMainFile(composition: InitComposition): string {
1048
1178
  ${importList(['NodeFileSystem', 'startStdioServer'], '@hydranium/core/node', columns)}
1049
1179
  import { DataServer } from '@hydranium/data-server';
1050
1180
  // The TRANSFER root${plural}, not the AST one${plural} — same reasoning as \`main.ts\`.
1051
- ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1181
+ ${importList(roots, memberPackageName(composition, 'protocol'), columns, true)}
1052
1182
  import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1053
1183
 
1054
1184
  const { shared } = create__NAME__Services({ ...NodeFileSystem });
@@ -1097,7 +1227,7 @@ function servicesTest(composition: InitComposition): string {
1097
1227
  // is missing from the composition.
1098
1228
 
1099
1229
  import { describe, expect, it } from 'vitest';
1100
- import { createServices } from '../src/services.js';
1230
+ import { createServices } from '../src/node/services.js';
1101
1231
 
1102
1232
  describe('__NAME__ services', () => {
1103
1233
  it('composes the DI tree and registers ${title}', () => {
@@ -1178,8 +1308,8 @@ function serializationTest(composition: InitComposition): string {
1178
1308
  import { parseHelper } from '@hydranium/core/testing';
1179
1309
  import { describe, expect, it } from 'vitest';
1180
1310
  ${importList(astTypes, '../src/language-server/ast.js', columns, true)}
1181
- ${importList(transferTypes, '../src/language-server/generated-hydranium/transfer-model.js', columns, true)}
1182
- import { createServices } from '../src/services.js';
1311
+ ${importList(transferTypes, memberPackageName(composition, 'protocol'), columns, true)}
1312
+ import { createServices } from '../src/node/services.js';
1183
1313
 
1184
1314
  ${composition.grammars.map(grammar => render(SERIALIZATION_SUITE, composition, grammar)).join('\n\n')}
1185
1315
  `;
@@ -1306,7 +1436,7 @@ function grammarTest(composition: InitComposition, header: string, imports: read
1306
1436
  import { parseHelper } from '@hydranium/core/testing';
1307
1437
  import { describe, expect, it } from 'vitest';
1308
1438
  ${imports.join('\n')}${imports.length ? '\n' : ''}${importList(astTypes, '../src/language-server/ast.js', columns, true)}
1309
- import { createServices } from '../src/services.js';
1439
+ import { createServices } from '../src/node/services.js';
1310
1440
 
1311
1441
  ${composition.grammars.map(grammar => render(suite, composition, grammar)).join('\n\n')}
1312
1442
  `;
@@ -1337,6 +1467,7 @@ function readme(composition: InitComposition): string {
1337
1467
  const multi = composition.grammars.length > 1;
1338
1468
  const data = composition.heads.includes('data');
1339
1469
  const primaryExtension = composition.grammars[0].extensions[0];
1470
+ const diagramCount = composition.grammars.filter(grammar => grammar.diagram).length;
1340
1471
  const grammarBullets = composition.grammars.map(
1341
1472
  grammar => `- \`src/grammar/${grammar.grammarId}.langium\` — the \`${grammar.grammar}\` grammar (.${grammar.extensions.join(', .')})`
1342
1473
  );
@@ -1361,13 +1492,13 @@ function readme(composition: InitComposition): string {
1361
1492
  // nowhere else.
1362
1493
  '```bash',
1363
1494
  '# Grammar / AST reflection',
1364
- 'npx hydranium-cli reflect --services ./lib/services.js',
1495
+ 'npx hydranium-cli reflect --services ./lib/node/services.js',
1365
1496
  '',
1366
1497
  '# Grammar-convention lint (CI gate)',
1367
- 'npx hydranium-cli lint-grammar --services ./lib/services.js',
1498
+ 'npx hydranium-cli lint-grammar --services ./lib/node/services.js',
1368
1499
  '',
1369
1500
  '# Validate a workspace of model files (non-zero exit on errors)',
1370
- 'npx hydranium-cli validate --services ./lib/services.js <workspace-dir>',
1501
+ 'npx hydranium-cli validate --services ./lib/node/services.js ../workspace',
1371
1502
  ...(data
1372
1503
  ? [
1373
1504
  '',
@@ -1378,8 +1509,8 @@ function readme(composition: InitComposition): string {
1378
1509
  '#',
1379
1510
  '# The workspace goes to the ENTRY, not to `--cwd`: `--cwd` re-roots the child,',
1380
1511
  '# so a relative entry path is refused by name (an absolute one is fine).',
1381
- 'npx hydranium-cli projects --server "node ./lib/data-server-main.js <workspace-dir>"',
1382
- `npx hydranium-cli query --server "node ./lib/data-server-main.js <workspace-dir>" --uri <file:// URI of a .${primaryExtension} file>`
1512
+ 'npx hydranium-cli projects --server "node ./lib/data-server-main.js ../workspace"',
1513
+ `npx hydranium-cli query --server "node ./lib/data-server-main.js ../workspace" --uri <file:// URI of ../workspace/nodes.${primaryExtension}>`
1383
1514
  ]
1384
1515
  : []),
1385
1516
  '```',
@@ -1393,8 +1524,8 @@ function readme(composition: InitComposition): string {
1393
1524
  grammar =>
1394
1525
  `- \`src/language-server/${grammar.grammarId}-serializer.ts\` — emits \`${grammar.grammar}\` back to text (the framework defaults this to a throw)`
1395
1526
  ),
1396
- '- `src/services.ts` — zero-arg `createServices()` for the headless CLI',
1397
- '- `src/index.ts` — the package entry (`main`): DI factory + generated AST',
1527
+ "- `src/node/services.ts` — zero-arg `createServices()` for the headless CLI, the package's `./node` entry",
1528
+ `- \`src/index.ts\` — the package root, loadable in a browser worker: DI factory + generated AST${diagramCount === 0 ? '' : diagramCount === 1 ? ' + diagram module' : ' + diagram modules'}`,
1398
1529
  `- \`src/main.ts\` — starts ${composition.heads.join(' + ')}, the \`__PROJECT_ID__\` bin entry`,
1399
1530
  ...(data
1400
1531
  ? [
@@ -1406,8 +1537,9 @@ function readme(composition: InitComposition): string {
1406
1537
  .filter(grammar => grammar.diagram)
1407
1538
  .map(
1408
1539
  grammar =>
1409
- `- \`src/glsp/${grammar.grammarId}/\` — the \`${grammar.grammar}\` diagram: type ids, state, storage, submission handler, GModel factory, configuration, create-node handler, DI module`
1540
+ `- \`src/glsp/${grammar.grammarId}/\` — the \`${grammar.grammar}\` diagram: state, storage, submission handler, GModel factory, configuration, create-node handler, DI module`
1410
1541
  ),
1542
+ `- \`../protocol/\` — the \`${memberPackageName(composition, 'protocol')}\` package: what this server and its clients both import (language ids, ${portCommandNames(composition).length ? 'port commands, ' : ''}${composition.grammars.some(grammar => grammar.diagram) ? 'diagram ids, ' : ''}the transfer model), written partly by \`npm run generate\``,
1411
1543
  '- `test/services.test.ts` — the DI tree composes; grows as your language does',
1412
1544
  '- `test/parsing.test.ts` / `linking.test.ts` / `validating.test.ts` — the three tiers a new language breaks first',
1413
1545
  '- `test/serialization.test.ts` — each grammar round-trips through its serializer',
@@ -1513,9 +1645,9 @@ function readme(composition: InitComposition): string {
1513
1645
  */
1514
1646
  function glspFiles(composition: InitComposition, grammar: InitGrammarNames): InitFile[] {
1515
1647
  const columns = columnsFor(composition);
1516
- const fixed = (file: string): InitFile => grammarTemplate(`src/glsp/__GRAMMAR_ID__/${file}`, composition, grammar);
1648
+ const fixed = (file: string): InitFile => grammarTemplate(`server/src/glsp/__GRAMMAR_ID__/${file}`, composition, grammar);
1517
1649
  const computed = (file: string, text: string): InitFile => ({
1518
- path: `src/glsp/${grammar.grammarId}/${file}`,
1650
+ path: `server/src/glsp/${grammar.grammarId}/${file}`,
1519
1651
  content: render(text, composition, grammar)
1520
1652
  });
1521
1653
 
@@ -1542,7 +1674,7 @@ ${importList(
1542
1674
  columns
1543
1675
  )}
1544
1676
  import { injectable } from 'inversify';
1545
- import { __GRAMMAR_ID_CONST___EDGE_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from './types.js';
1677
+ import { __GRAMMAR_ID_CONST___EDGE_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from '__PROTOCOL_PACKAGE__';
1546
1678
 
1547
1679
  @injectable()
1548
1680
  export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
@@ -1614,7 +1746,7 @@ import { __GRAMMAR__GModelFactory } from './gmodel-factory.js';
1614
1746
  import { __GRAMMAR__GlspState } from './state.js';
1615
1747
  import { __GRAMMAR__GlspStorage } from './storage.js';
1616
1748
  import { __GRAMMAR__SubmissionHandler } from './submission-handler.js';
1617
- import { __GRAMMAR_ID_CONST___DIAGRAM_TYPE } from './types.js';
1749
+ import { __GRAMMAR_ID_CONST___DIAGRAM_TYPE } from '__PROTOCOL_PACKAGE__';
1618
1750
 
1619
1751
  export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule {
1620
1752
  readonly diagramType = __GRAMMAR_ID_CONST___DIAGRAM_TYPE;
@@ -1657,7 +1789,6 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1657
1789
  `;
1658
1790
 
1659
1791
  return [
1660
- fixed('types.ts'),
1661
1792
  fixed('state.ts'),
1662
1793
  fixed('storage.ts'),
1663
1794
  fixed('submission-handler.ts'),
@@ -1665,35 +1796,35 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1665
1796
  computed('diagram-configuration.ts', configuration),
1666
1797
  fixed('create-node-operation-handler.ts'),
1667
1798
  computed('diagram-module.ts', module),
1668
- grammarTemplate('test/__GRAMMAR_ID__-diagram.test.ts', composition, grammar)
1799
+ grammarTemplate('server/test/__GRAMMAR_ID__-diagram.test.ts', composition, grammar)
1669
1800
  ];
1670
1801
  }
1671
1802
 
1672
1803
  /**
1673
- * Every file the scaffold emits, fully rendered.
1804
+ * The server member: the grammar, the language and its heads.
1674
1805
  *
1675
1806
  * Grammar-count-dependent emissions live here rather than in a template string:
1676
1807
  * one `.langium` file per grammar, and the shared `common.langium` fragment only
1677
1808
  * when there is more than one grammar to share it.
1678
1809
  */
1679
- export function buildInitTemplates(composition: InitComposition): InitFile[] {
1810
+ function serverFiles(composition: InitComposition): InitFile[] {
1680
1811
  const multi = composition.grammars.length > 1;
1681
1812
  const grammarFiles: InitFile[] = composition.grammars.map(grammar => ({
1682
- path: `src/grammar/${grammar.grammarId}.langium`,
1813
+ path: `server/src/grammar/${grammar.grammarId}.langium`,
1683
1814
  content: render(grammarFile(multi), composition, grammar)
1684
1815
  }));
1685
1816
  // The shared lexical fragment, only for several grammars: each declaring its
1686
1817
  // own `WS` / `ID` lets the token sets drift apart.
1687
1818
  if (multi) {
1688
- grammarFiles.unshift(projectTemplate('src/grammar/common.langium', composition));
1819
+ grammarFiles.unshift(projectTemplate('server/src/grammar/common.langium', composition));
1689
1820
  }
1690
1821
 
1691
1822
  const diagramFiles = composition.grammars.filter(grammar => grammar.diagram).flatMap(grammar => glspFiles(composition, grammar));
1692
1823
 
1693
1824
  return [
1694
- { path: 'package.json', content: packageJson(composition) },
1695
- { path: 'langium-config.json', content: langiumConfig(composition) },
1696
- { path: 'tsconfig.json', content: tsconfigJson(composition) },
1825
+ { path: 'server/package.json', content: packageJson(composition) },
1826
+ { path: 'server/langium-config.json', content: langiumConfig(composition) },
1827
+ { path: 'server/tsconfig.json', content: serverTsconfigJson(composition) },
1697
1828
  // `isolatedModules` is what makes this check agree with the transform that
1698
1829
  // actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
1699
1830
  // vitest compiles each file on its own — a transform with no type
@@ -1702,40 +1833,39 @@ export function buildInitTemplates(composition: InitComposition): InitFile[] {
1702
1833
  // transform then emits as a real import of a symbol that does not exist at
1703
1834
  // runtime, so the typecheck passes and the suite fails with a confusing
1704
1835
  // missing-export.
1705
- projectTemplate('tsconfig.test.json', composition),
1836
+ projectTemplate('server/tsconfig.test.json', composition),
1706
1837
  // Found by name, unlike `tsconfig.test.json`. Under vite 8, vitest's transform
1707
1838
  // applies a tsconfig only to the files its own `include` lists, so without this
1708
1839
  // a test declaring a decorated class fails to load.
1709
- projectTemplate('test/tsconfig.json', composition),
1710
- projectTemplate('vitest.config.ts', composition),
1840
+ projectTemplate('server/test/tsconfig.json', composition),
1841
+ projectTemplate('server/vitest.config.ts', composition),
1711
1842
  // Written for a workspace member too: its root may not ignore `lib/` (a `yo
1712
1843
  // langium` root ignores `out/`), and a rule the root repeats costs nothing.
1713
- // Stored without its dot, because `npm pack` drops a nested `.gitignore` from
1714
- // the tarball and applies it as an ignore file.
1715
- { path: '.gitignore', content: template('server/gitignore') },
1716
- { path: 'README.md', content: readme(composition) },
1844
+ ignoreFile('server'),
1845
+ { path: 'server/README.md', content: readme(composition) },
1717
1846
  ...grammarFiles,
1718
- { path: `src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },
1719
- grammarTemplate('src/language-server/ast.ts', composition, composition.grammars[0]),
1847
+ { path: `server/src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },
1848
+ grammarTemplate('server/src/language-server/ast.ts', composition, composition.grammars[0]),
1720
1849
  // Per grammar, because `services.serializer.Serializer` is a per-language slot
1721
1850
  // and a serializer is grammar-shaped by definition. That is also why the
1722
1851
  // framework refuses to default it: its `UnboundSerializer` throws, naming the
1723
1852
  // binding to add, rather than guessing a syntax.
1724
- ...composition.grammars.map(grammar => grammarTemplate('src/language-server/__GRAMMAR_ID__-serializer.ts', composition, grammar)),
1725
- { path: 'src/index.ts', content: indexFile(composition) },
1726
- projectTemplate('src/services.ts', composition),
1727
- { path: 'src/main.ts', content: mainFile(composition) },
1728
- ...(composition.heads.includes('data') ? [{ path: 'src/data-server-main.ts', content: dataServerMainFile(composition) }] : []),
1729
- ...(portCommandNames(composition).length ? [{ path: 'src/head-ports.ts', content: headPortsFile(composition) }] : []),
1853
+ ...composition.grammars.map(grammar =>
1854
+ grammarTemplate('server/src/language-server/__GRAMMAR_ID__-serializer.ts', composition, grammar)
1855
+ ),
1856
+ { path: 'server/src/index.ts', content: indexFile(composition) },
1857
+ projectTemplate('server/src/node/services.ts', composition),
1858
+ { path: 'server/src/main.ts', content: mainFile(composition) },
1859
+ ...(composition.heads.includes('data') ? [{ path: 'server/src/data-server-main.ts', content: dataServerMainFile(composition) }] : []),
1730
1860
  ...diagramFiles,
1731
- { path: 'test/services.test.ts', content: servicesTest(composition) },
1732
- { path: 'test/parsing.test.ts', content: grammarTest(composition, PARSING_HEADER, [], PARSING_SUITE) },
1861
+ { path: 'server/test/services.test.ts', content: servicesTest(composition) },
1862
+ { path: 'server/test/parsing.test.ts', content: grammarTest(composition, PARSING_HEADER, [], PARSING_SUITE) },
1733
1863
  {
1734
- path: 'test/linking.test.ts',
1864
+ path: 'server/test/linking.test.ts',
1735
1865
  content: grammarTest(composition, LINKING_HEADER, ["import { AstUtils } from '@hydranium/langium';"], LINKING_SUITE)
1736
1866
  },
1737
1867
  {
1738
- path: 'test/validating.test.ts',
1868
+ path: 'server/test/validating.test.ts',
1739
1869
  content: grammarTest(
1740
1870
  composition,
1741
1871
  VALIDATING_HEADER,
@@ -1743,6 +1873,167 @@ export function buildInitTemplates(composition: InitComposition): InitFile[] {
1743
1873
  VALIDATING_SUITE
1744
1874
  )
1745
1875
  },
1746
- { path: 'test/serialization.test.ts', content: serializationTest(composition) }
1876
+ { path: 'server/test/serialization.test.ts', content: serializationTest(composition) }
1877
+ ];
1878
+ }
1879
+
1880
+ /**
1881
+ * The protocol member's `package.json`.
1882
+ *
1883
+ * Its `build` runs the server's `generate` first, because the server writes
1884
+ * this package's `generated-hydranium/` modules from the grammar: a runner that
1885
+ * builds in dependency order reaches this package before the server, and would
1886
+ * otherwise compile modules that do not exist yet. A full build therefore
1887
+ * generates twice; the second pass rewrites nothing.
1888
+ */
1889
+ function protocolPackageJson(composition: InitComposition): string {
1890
+ const member = forMember(composition, 'protocol');
1891
+ const dependencies: Record<string, string> = {};
1892
+ for (const head of composition.heads) {
1893
+ Object.assign(dependencies, PROTOCOL_THIRD_PARTY_DEPENDENCIES[head]);
1894
+ }
1895
+ const dependencyLines =
1896
+ Object.keys(dependencies).length === 0 ? '' : ` "dependencies": {\n${sortedBlock(dependencies, ' ')}\n },\n`;
1897
+ return `{
1898
+ "name": "${memberPackageName(composition, 'protocol')}",
1899
+ "version": "0.0.0",
1900
+ ${composition.packaging.private ? ' "private": true,\n' : ''} "description": "What the ${composition.name} language server and its clients share: language ids, wire types and diagram ids.",
1901
+ "license": "UNLICENSED",
1902
+ "author": "",
1903
+ "files": [
1904
+ "lib",
1905
+ "src"
1906
+ ],
1907
+ "type": "module",
1908
+ "main": "lib/index.js",
1909
+ "types": "lib/index.d.ts",
1910
+ "scripts": {
1911
+ "build": "npm --prefix ../server run generate && tsc -b",
1912
+ "clean": "rimraf lib tsconfig.tsbuildinfo",
1913
+ ${lintScript(member, 'src')} "watch": "tsc -b --watch"
1914
+ },
1915
+ ${dependencyLines} "devDependencies": {
1916
+ "rimraf": "^5.0.0",
1917
+ "typescript": "^7.0.2"
1918
+ },
1919
+ "engines": {
1920
+ "node": ">=22.13"
1921
+ }
1922
+ }
1923
+ `;
1924
+ }
1925
+
1926
+ /** The protocol member's root entry, one re-export per module. */
1927
+ function protocolIndexFile(composition: InitComposition): string {
1928
+ const modules = [
1929
+ './generated-hydranium/languages.js',
1930
+ './generated-hydranium/transfer-model.js',
1931
+ ...(portCommandNames(composition).length ? ['./head-ports.js'] : []),
1932
+ ...composition.grammars.filter(grammar => grammar.diagram).map(grammar => `./${grammar.grammarId}-diagram-types.js`)
1933
+ ];
1934
+ const text = `// What the __NAME__ server and its clients both import. A client depends on
1935
+ // this package instead of the server, so it never installs Langium to reach a
1936
+ // constant or a wire type.
1937
+ //
1938
+ // The server's \`generate\` writes \`generated-hydranium/\` from the grammar;
1939
+ // commit those modules with the grammar change that produced them.
1940
+
1941
+ ${modules.map(module => `export * from '${module}';`).join('\n')}
1942
+ `;
1943
+ return project(text, composition);
1944
+ }
1945
+
1946
+ /** The protocol member's README. */
1947
+ function protocolReadme(composition: InitComposition): string {
1948
+ const lines = [
1949
+ `# ${memberPackageName(composition, 'protocol')}`,
1950
+ '',
1951
+ 'What the __NAME__ language server and its clients both import, so a client',
1952
+ 'never depends on the server or installs Langium.',
1953
+ '',
1954
+ '- `src/generated-hydranium/languages.ts` — each language id and its file extensions',
1955
+ '- `src/generated-hydranium/transfer-model.ts` — the wire types of the model, references as plain strings',
1956
+ ...(portCommandNames(composition).length ? ['- `src/head-ports.ts` — the LSP requests a host sends to find the socket heads'] : []),
1957
+ ...composition.grammars
1958
+ .filter(grammar => grammar.diagram)
1959
+ .map(grammar => `- \`src/${grammar.grammarId}-diagram-types.ts\` — the \`${grammar.grammar}\` diagram's type ids`),
1960
+ '',
1961
+ "The server's `npm run generate` writes `src/generated-hydranium/` from the",
1962
+ 'grammar, and `npm run build` here runs it first. Commit the generated',
1963
+ 'modules with the grammar change that produced them.',
1964
+ ''
1965
+ ];
1966
+ return project(lines.join('\n'), composition);
1967
+ }
1968
+
1969
+ /** The protocol member: everything the server and its clients both import. */
1970
+ function protocolFiles(composition: InitComposition): InitFile[] {
1971
+ return [
1972
+ { path: 'protocol/package.json', content: protocolPackageJson(composition) },
1973
+ { path: 'protocol/tsconfig.json', content: tsconfigJson(forMember(composition, 'protocol'), PROTOCOL_COMPILER_OPTIONS, '["src"]') },
1974
+ ignoreFile('protocol'),
1975
+ { path: 'protocol/README.md', content: protocolReadme(composition) },
1976
+ { path: 'protocol/src/index.ts', content: protocolIndexFile(composition) },
1977
+ ...(portCommandNames(composition).length ? [{ path: 'protocol/src/head-ports.ts', content: headPortsFile(composition) }] : []),
1978
+ ...composition.grammars
1979
+ .filter(grammar => grammar.diagram)
1980
+ .map(grammar => grammarTemplate('protocol/src/__GRAMMAR_ID__-diagram-types.ts', composition, grammar))
1981
+ ];
1982
+ }
1983
+
1984
+ /**
1985
+ * The starter model files every host and the CLI open, two per language: one
1986
+ * declaring nodes, one referencing a node in the other file.
1987
+ *
1988
+ * Named by extension alone, since `init` refuses two grammars sharing one.
1989
+ */
1990
+ function workspaceFiles(composition: InitComposition): InitFile[] {
1991
+ return composition.grammars.flatMap(grammar => [
1992
+ grammarTemplate('workspace/nodes.__EXTENSION__', composition, grammar),
1993
+ grammarTemplate('workspace/references.__EXTENSION__', composition, grammar)
1994
+ ]);
1995
+ }
1996
+
1997
+ /**
1998
+ * A standalone project's root: the members as npm workspaces.
1999
+ *
2000
+ * Always private: it is never published, whatever `--public` makes of the
2001
+ * members. npm runs a script across the members in the order listed here, not
2002
+ * by their dependencies; listing protocol first is safe because its `build`
2003
+ * runs the server's `generate` before compiling.
2004
+ */
2005
+ function rootFiles(composition: InitComposition): InitFile[] {
2006
+ const content = `{
2007
+ "name": "${composition.projectId}-root",
2008
+ "version": "0.0.0",
2009
+ "private": true,
2010
+ "workspaces": [
2011
+ "protocol",
2012
+ "server"
2013
+ ],
2014
+ "scripts": {
2015
+ "build": "npm run build --workspaces",
2016
+ "clean": "npm run clean --workspaces --if-present",
2017
+ "test": "npm test --workspaces --if-present"
2018
+ },
2019
+ "engines": {
2020
+ "node": ">=22.13"
2021
+ }
2022
+ }
2023
+ `;
2024
+ return [{ path: 'package.json', content }, ignoreFile('')];
2025
+ }
2026
+
2027
+ /**
2028
+ * Every file the scaffold emits, fully rendered, at its path in the project
2029
+ * directory. A workspace member's project gets no root of its own: it joins the
2030
+ * surrounding workspace, whose manifest `init` prints the changes for.
2031
+ */
2032
+ export function buildInitTemplates(composition: InitComposition): InitFile[] {
2033
+ return [
2034
+ ...(composition.packaging.workspace === undefined ? rootFiles(composition) : []),
2035
+ ...protocolFiles(composition),
2036
+ ...serverFiles(composition),
2037
+ ...workspaceFiles(composition)
1747
2038
  ];
1748
2039
  }