@hydranium/cli 1.0.0-next.31 → 1.0.0-next.312

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 (252) hide show
  1. package/README.md +79 -205
  2. package/bin/hydranium-cli.js +13 -0
  3. package/heap-analysis/heap/core/load.mjs +2 -2
  4. package/lib/cli-version.js.map +1 -1
  5. package/lib/cli.js +7 -2
  6. package/lib/cli.js.map +1 -1
  7. package/lib/commands/analyze-heap-args.js.map +1 -1
  8. package/lib/commands/analyze-heap.d.ts +3 -0
  9. package/lib/commands/analyze-heap.d.ts.map +1 -1
  10. package/lib/commands/analyze-heap.js +2 -1
  11. package/lib/commands/analyze-heap.js.map +1 -1
  12. package/lib/commands/ast-ground-truth-args.d.ts +3 -2
  13. package/lib/commands/ast-ground-truth-args.d.ts.map +1 -1
  14. package/lib/commands/ast-ground-truth-args.js +11 -9
  15. package/lib/commands/ast-ground-truth-args.js.map +1 -1
  16. package/lib/commands/ast-ground-truth-driver.js +2 -1
  17. package/lib/commands/ast-ground-truth-driver.js.map +1 -1
  18. package/lib/commands/ast-ground-truth.js +1 -1
  19. package/lib/commands/ast-ground-truth.js.map +1 -1
  20. package/lib/commands/client-session.d.ts +17 -0
  21. package/lib/commands/client-session.d.ts.map +1 -0
  22. package/lib/commands/client-session.js +27 -0
  23. package/lib/commands/client-session.js.map +1 -0
  24. package/lib/commands/create-args.d.ts +17 -0
  25. package/lib/commands/create-args.d.ts.map +1 -0
  26. package/lib/commands/create-args.js +53 -0
  27. package/lib/commands/create-args.js.map +1 -0
  28. package/lib/commands/create.d.ts +38 -0
  29. package/lib/commands/create.d.ts.map +1 -0
  30. package/lib/commands/create.js +43 -0
  31. package/lib/commands/create.js.map +1 -0
  32. package/lib/commands/delete-args.d.ts +17 -0
  33. package/lib/commands/delete-args.d.ts.map +1 -0
  34. package/lib/commands/delete-args.js +49 -0
  35. package/lib/commands/delete-args.js.map +1 -0
  36. package/lib/commands/delete.d.ts +30 -0
  37. package/lib/commands/delete.d.ts.map +1 -0
  38. package/lib/commands/delete.js +32 -0
  39. package/lib/commands/delete.js.map +1 -0
  40. package/lib/commands/generate-languages.d.ts +35 -0
  41. package/lib/commands/generate-languages.d.ts.map +1 -0
  42. package/lib/commands/generate-languages.js +105 -0
  43. package/lib/commands/generate-languages.js.map +1 -0
  44. package/lib/commands/generate-transfer-model-args.d.ts.map +1 -1
  45. package/lib/commands/generate-transfer-model-args.js +7 -0
  46. package/lib/commands/generate-transfer-model-args.js.map +1 -1
  47. package/lib/commands/generate-transfer-model-config.d.ts.map +1 -1
  48. package/lib/commands/generate-transfer-model-config.js +6 -3
  49. package/lib/commands/generate-transfer-model-config.js.map +1 -1
  50. package/lib/commands/generate-transfer-model.d.ts +15 -1
  51. package/lib/commands/generate-transfer-model.d.ts.map +1 -1
  52. package/lib/commands/generate-transfer-model.js +46 -8
  53. package/lib/commands/generate-transfer-model.js.map +1 -1
  54. package/lib/commands/harness-args.d.ts +44 -42
  55. package/lib/commands/harness-args.d.ts.map +1 -1
  56. package/lib/commands/harness-args.js +109 -27
  57. package/lib/commands/harness-args.js.map +1 -1
  58. package/lib/commands/headless-harness.d.ts +37 -20
  59. package/lib/commands/headless-harness.d.ts.map +1 -1
  60. package/lib/commands/headless-harness.js +68 -17
  61. package/lib/commands/headless-harness.js.map +1 -1
  62. package/lib/commands/init-args.d.ts.map +1 -1
  63. package/lib/commands/init-args.js +32 -27
  64. package/lib/commands/init-args.js.map +1 -1
  65. package/lib/commands/init-prompt.d.ts +3 -3
  66. package/lib/commands/init-prompt.d.ts.map +1 -1
  67. package/lib/commands/init-prompt.js +4 -5
  68. package/lib/commands/init-prompt.js.map +1 -1
  69. package/lib/commands/init-templates.d.ts +18 -14
  70. package/lib/commands/init-templates.d.ts.map +1 -1
  71. package/lib/commands/init-templates.js +732 -677
  72. package/lib/commands/init-templates.js.map +1 -1
  73. package/lib/commands/init-wizard.d.ts.map +1 -1
  74. package/lib/commands/init-wizard.js +17 -27
  75. package/lib/commands/init-wizard.js.map +1 -1
  76. package/lib/commands/init-workspace.d.ts +13 -11
  77. package/lib/commands/init-workspace.d.ts.map +1 -1
  78. package/lib/commands/init-workspace.js +21 -7
  79. package/lib/commands/init-workspace.js.map +1 -1
  80. package/lib/commands/init.d.ts +45 -19
  81. package/lib/commands/init.d.ts.map +1 -1
  82. package/lib/commands/init.js +80 -43
  83. package/lib/commands/init.js.map +1 -1
  84. package/lib/commands/kebab.d.ts +15 -0
  85. package/lib/commands/kebab.d.ts.map +1 -0
  86. package/lib/commands/kebab.js +17 -0
  87. package/lib/commands/kebab.js.map +1 -0
  88. package/lib/commands/lint-grammar-args.d.ts +3 -2
  89. package/lib/commands/lint-grammar-args.d.ts.map +1 -1
  90. package/lib/commands/lint-grammar-args.js +13 -10
  91. package/lib/commands/lint-grammar-args.js.map +1 -1
  92. package/lib/commands/lint-grammar-driver.js +2 -1
  93. package/lib/commands/lint-grammar-driver.js.map +1 -1
  94. package/lib/commands/lint-grammar-report.js.map +1 -1
  95. package/lib/commands/lint-grammar.js +1 -1
  96. package/lib/commands/lint-grammar.js.map +1 -1
  97. package/lib/commands/measure-memory-args.d.ts +3 -2
  98. package/lib/commands/measure-memory-args.d.ts.map +1 -1
  99. package/lib/commands/measure-memory-args.js +22 -11
  100. package/lib/commands/measure-memory-args.js.map +1 -1
  101. package/lib/commands/measure-memory-driver.js +5 -3
  102. package/lib/commands/measure-memory-driver.js.map +1 -1
  103. package/lib/commands/measure-memory.js +1 -1
  104. package/lib/commands/measure-memory.js.map +1 -1
  105. package/lib/commands/model-docs-args.d.ts +4 -3
  106. package/lib/commands/model-docs-args.d.ts.map +1 -1
  107. package/lib/commands/model-docs-args.js +12 -10
  108. package/lib/commands/model-docs-args.js.map +1 -1
  109. package/lib/commands/model-docs-driver.js +2 -1
  110. package/lib/commands/model-docs-driver.js.map +1 -1
  111. package/lib/commands/model-docs.js +1 -1
  112. package/lib/commands/model-docs.js.map +1 -1
  113. package/lib/commands/projects-args.d.ts.map +1 -1
  114. package/lib/commands/projects-args.js +5 -5
  115. package/lib/commands/projects-args.js.map +1 -1
  116. package/lib/commands/projects.d.ts +3 -4
  117. package/lib/commands/projects.d.ts.map +1 -1
  118. package/lib/commands/projects.js +2 -2
  119. package/lib/commands/projects.js.map +1 -1
  120. package/lib/commands/query-args.d.ts.map +1 -1
  121. package/lib/commands/query-args.js +5 -5
  122. package/lib/commands/query-args.js.map +1 -1
  123. package/lib/commands/query.d.ts +3 -4
  124. package/lib/commands/query.d.ts.map +1 -1
  125. package/lib/commands/query.js +2 -2
  126. package/lib/commands/query.js.map +1 -1
  127. package/lib/commands/reflect-args.d.ts +3 -2
  128. package/lib/commands/reflect-args.d.ts.map +1 -1
  129. package/lib/commands/reflect-args.js +13 -10
  130. package/lib/commands/reflect-args.js.map +1 -1
  131. package/lib/commands/reflect-driver.js +2 -1
  132. package/lib/commands/reflect-driver.js.map +1 -1
  133. package/lib/commands/reflect-report.js.map +1 -1
  134. package/lib/commands/reflect.js +1 -1
  135. package/lib/commands/reflect.js.map +1 -1
  136. package/lib/commands/save-args.d.ts.map +1 -1
  137. package/lib/commands/save-args.js +7 -4
  138. package/lib/commands/save-args.js.map +1 -1
  139. package/lib/commands/save.d.ts +8 -4
  140. package/lib/commands/save.d.ts.map +1 -1
  141. package/lib/commands/save.js +12 -23
  142. package/lib/commands/save.js.map +1 -1
  143. package/lib/commands/validate-args.d.ts +3 -2
  144. package/lib/commands/validate-args.d.ts.map +1 -1
  145. package/lib/commands/validate-args.js +13 -10
  146. package/lib/commands/validate-args.js.map +1 -1
  147. package/lib/commands/validate-driver.js +2 -1
  148. package/lib/commands/validate-driver.js.map +1 -1
  149. package/lib/commands/validate-report.js.map +1 -1
  150. package/lib/commands/validate.js +1 -1
  151. package/lib/commands/validate.js.map +1 -1
  152. package/lib/commands/watch-args.d.ts.map +1 -1
  153. package/lib/commands/watch-args.js +6 -4
  154. package/lib/commands/watch-args.js.map +1 -1
  155. package/lib/commands/watch.d.ts +3 -4
  156. package/lib/commands/watch.d.ts.map +1 -1
  157. package/lib/commands/watch.js +20 -2
  158. package/lib/commands/watch.js.map +1 -1
  159. package/lib/driver-heap.d.ts +52 -0
  160. package/lib/driver-heap.d.ts.map +1 -0
  161. package/lib/driver-heap.js +51 -0
  162. package/lib/driver-heap.js.map +1 -0
  163. package/lib/index.d.ts +2 -0
  164. package/lib/index.d.ts.map +1 -1
  165. package/lib/index.js +2 -0
  166. package/lib/index.js.map +1 -1
  167. package/lib/log-level.d.ts +21 -11
  168. package/lib/log-level.d.ts.map +1 -1
  169. package/lib/log-level.js +25 -14
  170. package/lib/log-level.js.map +1 -1
  171. package/lib/spawn-data-server.d.ts +9 -4
  172. package/lib/spawn-data-server.d.ts.map +1 -1
  173. package/lib/spawn-data-server.js +5 -4
  174. package/lib/spawn-data-server.js.map +1 -1
  175. package/lib/stderr-connection-logger.d.ts +12 -0
  176. package/lib/stderr-connection-logger.d.ts.map +1 -0
  177. package/lib/stderr-connection-logger.js +16 -0
  178. package/lib/stderr-connection-logger.js.map +1 -0
  179. package/lib/testing/echo-server.js +9 -1
  180. package/lib/testing/echo-server.js.map +1 -1
  181. package/package.json +52 -39
  182. package/src/cli.ts +7 -2
  183. package/src/commands/analyze-heap.ts +4 -1
  184. package/src/commands/ast-ground-truth-args.ts +15 -12
  185. package/src/commands/ast-ground-truth-driver.ts +2 -1
  186. package/src/commands/ast-ground-truth.ts +1 -1
  187. package/src/commands/client-session.ts +33 -0
  188. package/src/commands/create-args.ts +68 -0
  189. package/src/commands/create.ts +83 -0
  190. package/src/commands/delete-args.ts +64 -0
  191. package/src/commands/delete.ts +61 -0
  192. package/src/commands/generate-languages.ts +132 -0
  193. package/src/commands/generate-transfer-model-args.ts +7 -0
  194. package/src/commands/generate-transfer-model-config.ts +6 -3
  195. package/src/commands/generate-transfer-model.ts +56 -8
  196. package/src/commands/harness-args.ts +99 -31
  197. package/src/commands/headless-harness.ts +86 -26
  198. package/src/commands/init-args.ts +32 -27
  199. package/src/commands/init-prompt.ts +5 -6
  200. package/src/commands/init-templates.ts +790 -696
  201. package/src/commands/init-wizard.ts +17 -27
  202. package/src/commands/init-workspace.ts +31 -16
  203. package/src/commands/init.ts +126 -58
  204. package/src/commands/kebab.ts +17 -0
  205. package/src/commands/lint-grammar-args.ts +17 -13
  206. package/src/commands/lint-grammar-driver.ts +2 -1
  207. package/src/commands/lint-grammar.ts +1 -1
  208. package/src/commands/measure-memory-args.ts +27 -14
  209. package/src/commands/measure-memory-driver.ts +5 -3
  210. package/src/commands/measure-memory.ts +1 -1
  211. package/src/commands/model-docs-args.ts +16 -13
  212. package/src/commands/model-docs-driver.ts +2 -1
  213. package/src/commands/model-docs.ts +1 -1
  214. package/src/commands/projects-args.ts +5 -4
  215. package/src/commands/projects.ts +4 -6
  216. package/src/commands/query-args.ts +5 -4
  217. package/src/commands/query.ts +4 -6
  218. package/src/commands/reflect-args.ts +17 -13
  219. package/src/commands/reflect-driver.ts +2 -1
  220. package/src/commands/reflect.ts +1 -1
  221. package/src/commands/save-args.ts +7 -3
  222. package/src/commands/save.ts +24 -27
  223. package/src/commands/validate-args.ts +17 -13
  224. package/src/commands/validate-driver.ts +2 -1
  225. package/src/commands/validate.ts +1 -1
  226. package/src/commands/watch-args.ts +6 -3
  227. package/src/commands/watch.ts +28 -7
  228. package/src/driver-heap.ts +65 -0
  229. package/src/index.ts +2 -0
  230. package/src/log-level.ts +42 -14
  231. package/src/spawn-data-server.ts +17 -5
  232. package/src/stderr-connection-logger.ts +18 -0
  233. package/src/testing/echo-server.ts +11 -2
  234. package/templates/gitignore +1 -0
  235. package/templates/protocol/gitignore +3 -0
  236. package/templates/protocol/src/__GRAMMAR_ID__-diagram-types.ts +41 -0
  237. package/templates/server/gitignore +6 -0
  238. package/templates/server/src/glsp/__GRAMMAR_ID__/create-node-operation-handler.ts +62 -0
  239. package/templates/server/src/glsp/__GRAMMAR_ID__/gmodel-factory.ts +60 -0
  240. package/templates/server/src/glsp/__GRAMMAR_ID__/state.ts +18 -0
  241. package/templates/server/src/glsp/__GRAMMAR_ID__/storage.ts +16 -0
  242. package/templates/server/src/glsp/__GRAMMAR_ID__/submission-handler.ts +24 -0
  243. package/templates/server/src/grammar/common.langium +12 -0
  244. package/templates/server/src/language-server/__GRAMMAR_ID__-serializer.ts +53 -0
  245. package/templates/server/src/language-server/ast.ts +23 -0
  246. package/templates/server/src/node/services.ts +16 -0
  247. package/templates/server/test/__GRAMMAR_ID__-diagram.test.ts +112 -0
  248. package/templates/server/test/tsconfig.json +4 -0
  249. package/templates/server/tsconfig.test.json +10 -0
  250. package/templates/server/vitest.config.ts +14 -0
  251. package/templates/workspace/nodes.__EXTENSION__ +5 -0
  252. package/templates/workspace/references.__EXTENSION__ +2 -0
@@ -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
  *
@@ -29,6 +32,8 @@
29
32
  * Drives the PER-LANGUAGE generated symbols (`<grammar>GeneratedModule`,
30
33
  * `<grammar>LanguageMetaData`).
31
34
  * - `__GRAMMAR_ID__` — kebab grammar id: the `.langium` filename.
35
+ * - `__GRAMMAR_ID_CONST__` — SCREAMING_SNAKE grammar id, prefixing the
36
+ * diagram's type-id constants.
32
37
  * - `__LANGUAGE_ID__` — kebab routing key: the `langium-config` entry id and
33
38
  * its TextMate output.
34
39
  * - `__EXTENSION__` — the grammar's FIRST file extension, without the dot.
@@ -49,10 +54,19 @@
49
54
  * generator emits `interface <projectName>Element` as the base type every rule
50
55
  * extends, which a rule of that name would collide with.
51
56
  *
52
- * Kept as in-source strings (not on-disk assets) so no build-time copy step
53
- * is needed and the substitution is unit-testable. The `.ts` / `.langium` / JSON
54
- * templates are derived from the in-repo reference example so the generated project
55
- * compiles against the real `@hydranium/*` API once `langium generate` has run.
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
+ *
64
+ * A file that is fixed text with tokens lives in the `templates/` tree the
65
+ * package ships beside `lib`, at the path it is emitted at, tokens included
66
+ * (each ignore file without its dot); one computed from the composition is
67
+ * built here. The tree is read synchronously, so `runInit` stays synchronous,
68
+ * and it is neither formatted with the repo's settings nor given a license
69
+ * header, since either would change what `init` emits.
56
70
  *
57
71
  * Two conventions the templates follow deliberately:
58
72
  *
@@ -72,8 +86,9 @@
72
86
  * over; the adopter's own formatter settles it.
73
87
  */
74
88
 
89
+ import { readFileSync } from 'node:fs';
75
90
  import { readCliVersion } from '../cli-version.js';
76
- import type { InitComposition, InitFile, InitGrammarNames, InitHead } from './init.js';
91
+ import type { InitComposition, InitFile, InitGrammarNames, InitHead, InitMember } from './init.js';
77
92
  import type { JsonValue } from './init-workspace.js';
78
93
 
79
94
  /** One template file: its path (tokens allowed) and its content (tokens allowed). */
@@ -82,16 +97,6 @@ export interface InitTemplate {
82
97
  content: string;
83
98
  }
84
99
 
85
- /**
86
- * The version every framework package carries before the first release.
87
- *
88
- * A scaffold made by a CLI still at this version pins a range the registry
89
- * cannot serve, so the emitted README keeps its yalc note and `init` keeps its
90
- * install warning while — and only while — it holds: from a published CLI the
91
- * derived pins resolve and either note would be false as printed.
92
- */
93
- export const UNPUBLISHED_FRAMEWORK_VERSION = '0.0.0';
94
-
95
100
  /**
96
101
  * The version `init` pins every `@hydranium/*` dependency at: the scaffolding
97
102
  * CLI's own.
@@ -122,12 +127,49 @@ function screamingSnake(projectId: string): string {
122
127
  return projectId.replace(/-/g, '_').toUpperCase();
123
128
  }
124
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
+
125
166
  /** Substitute the project-tier tokens. */
126
167
  function project(text: string, composition: InitComposition): string {
127
168
  return text
128
169
  .replace(/__NAME__/g, composition.name)
129
170
  .replace(/__PROJECT_ID__/g, composition.projectId)
130
- .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'));
131
173
  }
132
174
 
133
175
  /**
@@ -140,6 +182,7 @@ function project(text: string, composition: InitComposition): string {
140
182
  */
141
183
  function grammarTier(text: string, grammar: InitGrammarNames): string {
142
184
  return text
185
+ .replace(/__GRAMMAR_ID_CONST__/g, screamingSnake(grammar.grammarId))
143
186
  .replace(/__GRAMMAR_ID__/g, grammar.grammarId)
144
187
  .replace(/__GRAMMAR__/g, grammar.grammar)
145
188
  .replace(/__LANGUAGE_ID__/g, grammar.languageId)
@@ -153,6 +196,30 @@ function render(text: string, composition: InitComposition, grammar: InitGrammar
153
196
  return grammarTier(project(text, composition), grammar);
154
197
  }
155
198
 
199
+ /** The template tree, the same two levels up from `src/commands` and `lib/commands`. */
200
+ const TEMPLATES = new URL('../../templates/', import.meta.url);
201
+
202
+ /** One template file's text. */
203
+ function template(path: string): string {
204
+ return readFileSync(new URL(path, TEMPLATES), 'utf-8');
205
+ }
206
+
207
+ /** A template emitted once per project at its own path, rendered with the project tokens. */
208
+ function projectTemplate(path: string, composition: InitComposition): InitFile {
209
+ return { path, content: project(template(path), composition) };
210
+ }
211
+
212
+ /** A template emitted once per grammar, its path rendered like its content. */
213
+ function grammarTemplate(path: string, composition: InitComposition, grammar: InitGrammarNames): InitFile {
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`) };
221
+ }
222
+
156
223
  /** A comma-separated list of single-quoted TypeScript/JS string literals. */
157
224
  function quotedList(values: readonly string[]): string {
158
225
  return values.map(value => `'${value}'`).join(', ');
@@ -177,7 +244,7 @@ function columnsFor(composition: InitComposition): number {
177
244
 
178
245
  /**
179
246
  * A named import on one line while it fits the column budget, else one symbol
180
- * per line. Emitted files are supposed to pass a prettier check as-is, and the
247
+ * per line. Emitted files are supposed to pass a format check as-is, and the
181
248
  * generated-module import grows by one symbol per grammar.
182
249
  */
183
250
  function importList(symbols: readonly string[], from: string, columns: number, typeOnly = false): string {
@@ -198,9 +265,13 @@ function arrayLiteral(elements: readonly string[], indent: string, columns: numb
198
265
  * The FRAMEWORK packages each head adds, carrying no version: every
199
266
  * `@hydranium/*` pin is derived at scaffold time from {@link frameworkPin}, so
200
267
  * none of them can name a version its siblings were not published at.
268
+ *
269
+ * `base` is every head set's: the services every head serves from are composed
270
+ * the same with or without an LSP head.
201
271
  */
202
- const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead, readonly string[]> = {
203
- lsp: ['@hydranium/core', '@hydranium/langium', '@hydranium/protocol'],
272
+ const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead | 'base', readonly string[]> = {
273
+ base: ['@hydranium/core', '@hydranium/langium', '@hydranium/protocol'],
274
+ lsp: [],
204
275
  data: ['@hydranium/data-server'],
205
276
  glsp: ['@hydranium/glsp-server']
206
277
  };
@@ -221,15 +292,22 @@ const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead, readonly string[]> = {
221
292
  * `overrides` block exists to prevent — so a repo gate holds each entry here
222
293
  * against the manifest that declares it.
223
294
  *
224
- * `langium` is required by `lsp` even though hand-written code goes through the
295
+ * `langium` is required even though hand-written code goes through the
225
296
  * `@hydranium/langium` chokepoint, because `langium-cli` emits direct imports
226
- * into the generated files and regenerates them on every build.
297
+ * into the generated files and regenerates them on every build. The `vscode-*`
298
+ * packages are required peers of `@hydranium/core`, so they are `base` too.
227
299
  */
228
- const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, string>>> = {
229
- lsp: {
300
+ const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead | 'base', Readonly<Record<string, string>>> = {
301
+ base: {
230
302
  langium: '4.3.1',
231
- 'vscode-languageserver': '~10.0.1'
303
+ // Exact, as are the protocol and transport it pins, so Langium and the
304
+ // framework share their copies; across copies, an error a handler throws
305
+ // or returns loses its code, and a later patch can pin another protocol.
306
+ 'vscode-languageserver': '10.0.1',
307
+ 'vscode-jsonrpc': '9.0.0',
308
+ 'vscode-languageserver-protocol': '3.18.1'
232
309
  },
310
+ lsp: {},
233
311
  data: {},
234
312
  glsp: {
235
313
  '@eclipse-glsp/graph': '2.7.0',
@@ -241,26 +319,86 @@ const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, st
241
319
  }
242
320
  };
243
321
 
244
- /** The dependency block for a head set, merged and sorted as npm writes it. */
322
+ /**
323
+ * What each head adds to the protocol member, which carries only what a
324
+ * client may install. Keyed by head because the `glsp` head implies a diagram,
325
+ * whose element ids extend GLSP's default type names.
326
+ */
327
+ const PROTOCOL_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, string>>> = {
328
+ lsp: {},
329
+ data: {},
330
+ glsp: { '@eclipse-glsp/protocol': '2.7.0' }
331
+ };
332
+
333
+ /**
334
+ * The exact pins of `heads`, for a workspace root to declare too. npm installs a
335
+ * root's own dependencies at the top of the tree, so another member's version of
336
+ * one nests beneath that member instead of taking the place of the copy this
337
+ * package shares with the framework.
338
+ */
339
+ export function workspaceRootPins(heads: readonly InitHead[]): ReadonlyArray<readonly [string, string]> {
340
+ return [
341
+ ...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES.base),
342
+ ...heads.flatMap(head => [
343
+ ...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES[head]),
344
+ ...Object.entries(PROTOCOL_THIRD_PARTY_DEPENDENCIES[head])
345
+ ])
346
+ ]
347
+ .filter(([, version]) => /^\d+\.\d+\.\d+$/.test(version))
348
+ .sort(([left], [right]) => left.localeCompare(right));
349
+ }
350
+
351
+ /** Whether an npm version is 11.6.0 or later, the first that installs `vitest` 4.1's peer set; `11.6.0-rc.1` is earlier. */
352
+ function installsVitest41(npmVersion: string | undefined): boolean {
353
+ const [release, prerelease] = (npmVersion ?? '0.0.0').split('-', 2);
354
+ const [major, minor, patch] = release.split('.').map(Number);
355
+ return major > 11 || (major === 11 && (minor > 6 || (minor === 6 && (patch > 0 || prerelease === undefined))));
356
+ }
357
+
358
+ /**
359
+ * Whether the scaffold holds `vitest` below 4.1, whose peer set crashes npm
360
+ * before 11.6 (every npm a Node 22 release bundles) on the first install. Only a
361
+ * workspace root declaring a newer npm lifts it, and only when `init` runs under
362
+ * an npm known to be newer: npm does not enforce `packageManager`.
363
+ */
364
+ function pinsVitest(composition: InitComposition): boolean {
365
+ const workspace = composition.packaging.workspace;
366
+ return !(installsVitest41(workspace?.packageManagerNpm) && installsVitest41(workspace?.npmVersion));
367
+ }
368
+
369
+ /**
370
+ * The version a member pins a sibling member at: exact, at the version every
371
+ * member starts with. npm links a sibling only when the range admits its local
372
+ * version, and otherwise looks for it in the registry, where it was never
373
+ * published.
374
+ */
375
+ const MEMBER_PIN = '0.0.0';
376
+
377
+ /** A dependency block, sorted as npm writes it. */
378
+ function sortedBlock(dependencies: Readonly<Record<string, string>>, indent: string): string {
379
+ return Object.keys(dependencies)
380
+ .sort()
381
+ .map(name => `${indent}"${name}": "${dependencies[name]}"`)
382
+ .join(',\n');
383
+ }
384
+
385
+ /** The server's dependency block: the base and head set's packages and the protocol member. */
245
386
  function dependencyBlock(composition: InitComposition, indent: string): string {
246
- const merged: Record<string, string> = {};
247
- for (const head of composition.heads) {
387
+ const merged: Record<string, string> = { [memberPackageName(composition, 'protocol')]: MEMBER_PIN };
388
+ for (const head of ['base', ...composition.heads] as const) {
248
389
  Object.assign(merged, HEAD_THIRD_PARTY_DEPENDENCIES[head]);
249
390
  for (const framework of HEAD_FRAMEWORK_DEPENDENCIES[head]) {
250
391
  merged[framework] = frameworkPin(composition);
251
392
  }
252
393
  }
253
- return Object.keys(merged)
254
- .sort()
255
- .map(name => `${indent}"${name}": "${merged[name]}"`)
256
- .join(',\n');
394
+ return sortedBlock(merged, indent);
257
395
  }
258
396
 
259
397
  // JSON at two-space indent, not the three the `.ts` templates use: that is the
260
398
  // npm convention, and `npm install` rewrites `package.json` at two spaces
261
399
  // regardless — so three would not survive the adopter's first install.
262
400
  //
263
- // The KEY ORDER is `prettier-plugin-packagejson`'s canonical one, `private`
401
+ // The KEY ORDER is the one Oxfmt's `sortPackageJson` canonicalises to, `private`
264
402
  // before `license` included. Any other order is rewritten by the scaffolded
265
403
  // project's own first `format` run — a diff on a file the adopter never touched
266
404
  // — and it makes a byte-compare against a formatted copy of this emission
@@ -280,6 +418,11 @@ function dependencyBlock(composition: InitComposition, indent: string): string {
280
418
  // from this package's own grammar and can reach a consumer from nowhere else; a
281
419
  // listed path that does not exist yet is inert.
282
420
  //
421
+ // `exports` puts `createServices`, the one module reading the real filesystem,
422
+ // behind `./node`, so the root loads in a browser worker that has no `node:fs`.
423
+ // `./lib/*` and `./package.json` stay reachable for a host that resolves
424
+ // `<server>/lib/main.js` to spawn the server.
425
+ //
283
426
  // `repository` is deliberately NOT emitted while `author` is emitted empty. An
284
427
  // empty `author` is the form `npm init` itself writes and asserts nothing, but
285
428
  // `repository` is CONSUMED — the registry page, `npm repo` and publish
@@ -291,16 +434,9 @@ const PACKAGE_JSON = `{
291
434
  "version": "0.0.0",
292
435
  __PRIVATE__ "description": "__NAME__ language server, built with the Hydranium framework.",
293
436
  "keywords": [
294
- "hydranium",
295
- "langium",
296
- "language-server",
297
- "__PROJECT_ID__"
298
- ],
437
+ __KEYWORDS__ ],
299
438
  "license": "UNLICENSED",
300
439
  "author": "",
301
- "type": "module",
302
- "main": "lib/index.js",
303
- "types": "lib/index.d.ts",
304
440
  "bin": {
305
441
  __BIN__ },
306
442
  "files": [
@@ -308,17 +444,32 @@ __BIN__ },
308
444
  "src",
309
445
  "syntaxes"
310
446
  ],
447
+ "type": "module",
448
+ "main": "lib/index.js",
449
+ "types": "lib/index.d.ts",
450
+ "exports": {
451
+ ".": {
452
+ "types": "./lib/index.d.ts",
453
+ "default": "./lib/index.js"
454
+ },
455
+ "./node": {
456
+ "types": "./lib/node/services.d.ts",
457
+ "default": "./lib/node/services.js"
458
+ },
459
+ "./lib/*": "./lib/*",
460
+ "./package.json": "./package.json"
461
+ },
311
462
  "scripts": {
312
- "build": "npm run generate && tsc",
463
+ "build": "npm run generate && tsc -b",
313
464
  "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-hydranium tsconfig.tsbuildinfo",
314
465
  "generate": "npm run langium:generate && npm run generate:transfer-model",
315
- "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\\"",
466
+ "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\\"",
316
467
  "langium:generate": "langium generate",
317
468
  "langium:watch": "langium generate --watch",
318
- __LINT__ "start": "node lib/main.js --stdio",
469
+ __LINT__ "start": "node lib/main.js__START_ARGS__",
319
470
  "test": "npm run typecheck:test && vitest run",
320
471
  "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
321
- "watch": "tsc --watch"
472
+ "watch": "tsc -b --watch"
322
473
  },
323
474
  "dependencies": {
324
475
  __DEPENDENCIES__
@@ -328,8 +479,8 @@ __DEPENDENCIES__
328
479
  "@types/node": "^22.0.0",
329
480
  "langium-cli": "4.3.0",
330
481
  "rimraf": "^5.0.0",
331
- "typescript": "^5.8.0",
332
- "vitest": "^4.0.0"
482
+ "typescript": "^7.0.2",
483
+ "vitest": "__VITEST__"
333
484
  },
334
485
  "engines": {
335
486
  "node": ">=22.13"
@@ -338,16 +489,17 @@ __DEPENDENCIES__
338
489
  `;
339
490
 
340
491
  /**
341
- * How the scaffold names `npm run` when talking about itself.
492
+ * How the scaffold names `npm run` for one member's script.
342
493
  *
343
- * A workspace member's scripts are routinely invoked from the repo root, where
344
- * a bare `npm run` reaches the ROOT manifest and fails — so the regen hint a
345
- * generated file prints has to name the package, or it sends every reader who
346
- * follows it to the wrong place.
494
+ * Always with `--prefix`, from the directory commands are run in: the
495
+ * workspace root for a member, the project root for a standalone project. A
496
+ * bare `npm run` there reaches the ROOT manifest and fails, and the regen hint
497
+ * a generated file prints is read in `protocol/` as well, where the script it
498
+ * names does not exist.
347
499
  */
348
- function npmRun(composition: InitComposition): string {
500
+ function npmRun(composition: InitComposition, member: InitMember): string {
349
501
  const workspace = composition.packaging.workspace;
350
- return workspace === undefined ? 'npm run' : `npm --prefix ${workspace.targetPath} run`;
502
+ return `npm --prefix ${workspace === undefined ? member : `${workspace.targetPath}/${member}`} run`;
351
503
  }
352
504
 
353
505
  /**
@@ -359,7 +511,7 @@ function npmRun(composition: InitComposition): string {
359
511
  * name. Without the second key a scaffolded project has no command line that
360
512
  * reaches its own data head at all.
361
513
  *
362
- * Alphabetical, because `prettier-plugin-packagejson` sorts `bin` and any other
514
+ * Alphabetical, because Oxfmt's `sortPackageJson` sorts `bin` and any other
363
515
  * order is rewritten by the scaffolded project's first `format` run.
364
516
  */
365
517
  function binBlock(composition: InitComposition): string {
@@ -370,21 +522,39 @@ function binBlock(composition: InitComposition): string {
370
522
  return entries.map(([name, target]) => ` "${name}": "${target}"`).join(',\n') + '\n';
371
523
  }
372
524
 
373
- /** `package.json` with the name, the private flag and the dependency block all derived. */
525
+ /**
526
+ * A member's `lint` script line, over `directories`, or nothing.
527
+ *
528
+ * Emitted only where a root lint config says the repo lints. The absence is
529
+ * the dangerous direction: a task runner runs a script only where one is
530
+ * declared, so a package with no `lint` is SKIPPED rather than reported, and
531
+ * that reads as a clean lint. A wrong invocation is one visible line to edit.
532
+ */
533
+ function lintScript(member: InitComposition, directories: string): string {
534
+ const workspace = member.packaging.workspace;
535
+ if (workspace?.oxlintConfig !== undefined) {
536
+ const config = `${'../'.repeat(workspace.targetPath.split('/').length)}${workspace.oxlintConfig}`;
537
+ return ` "lint": "oxlint --report-unused-disable-directives --format stylish --config ${config} ${directories} --max-warnings 0",\n`;
538
+ }
539
+ return workspace?.eslintConfig !== undefined ? ` "lint": "eslint ${directories} --max-warnings 0",\n` : '';
540
+ }
541
+
542
+ /** The server's `package.json`, with the name, the private flag and the dependency block all derived. */
374
543
  function packageJson(composition: InitComposition): string {
375
- const { scope } = composition.packaging;
376
- const packageName = scope === undefined ? composition.projectId : `${scope}/${composition.projectId}`;
377
- // Emitted only where a root eslint config says the repo lints. The absence is
378
- // the dangerous direction: a task runner runs a script only where one is
379
- // declared, so a package with no `lint` is SKIPPED rather than reported, and
380
- // that reads as a clean lint. A wrong invocation is one visible line to edit.
381
- const lint = composition.packaging.workspace?.eslintConfig === undefined ? '' : ' "lint": "eslint src test --max-warnings 0",\n';
544
+ const keywords =
545
+ ['hydranium', 'langium', 'language-server', composition.projectId]
546
+ .sort()
547
+ .map(keyword => ` ${JSON.stringify(keyword)}`)
548
+ .join(',\n') + '\n';
382
549
  return project(PACKAGE_JSON, composition)
383
- .replace('__PACKAGE_NAME__', packageName)
550
+ .replace('__PACKAGE_NAME__', memberPackageName(composition, 'server'))
551
+ .replace('__KEYWORDS__', keywords)
384
552
  .replace('__BIN__', binBlock(composition))
385
- .replace('__LINT__', lint)
553
+ .replace('__LINT__', lintScript(forMember(composition, 'server'), 'src test'))
554
+ .replace('__START_ARGS__', composition.heads.includes('lsp') ? ' --stdio' : ' ../workspace')
555
+ .replace('__VITEST__', pinsVitest(composition) ? '~4.0.18' : '^4.0.0')
386
556
  .replace('__PRIVATE__', composition.packaging.private ? ' "private": true,\n' : '')
387
- .replace('__NPM_RUN__', npmRun(composition))
557
+ .replace('__NPM_RUN__', npmRun(composition, 'server'))
388
558
  .replace('__FRAMEWORK_PIN__', frameworkPin(composition))
389
559
  .replace('__DEPENDENCIES__', dependencyBlock(composition, ' '));
390
560
  }
@@ -439,6 +609,11 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
439
609
  ['outDir', 'lib'],
440
610
  ['strict', true],
441
611
  ['esModuleInterop', true],
612
+ // A server that renders its own user-facing messages loads a catalogue, and
613
+ // a catalogue is JSON. Without this the import does not resolve at all, and
614
+ // without the `include` glob below a composite project rejects it with
615
+ // TS6307 naming neither cause.
616
+ ['resolveJsonModule', true],
442
617
  ['skipLibCheck', true],
443
618
  ['declaration', true],
444
619
  ['experimentalDecorators', true],
@@ -447,6 +622,24 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
447
622
  ['types', ['node']]
448
623
  ];
449
624
 
625
+ /** The protocol member's compiler options, pruned against a workspace base like the server's. */
626
+ const PROTOCOL_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
627
+ ['target', 'ES2022'],
628
+ ['lib', ['ES2022']],
629
+ ['module', 'NodeNext'],
630
+ ['moduleResolution', 'NodeNext'],
631
+ ['rootDir', 'src'],
632
+ ['outDir', 'lib'],
633
+ ['strict', true],
634
+ ['skipLibCheck', true],
635
+ ['declaration', true],
636
+ // The server's `tsc -b` builds this project through a reference, which
637
+ // `tsc` refuses unless the referenced project is composite.
638
+ ['composite', true],
639
+ // No ambient Node types: a browser client compiles against this package.
640
+ ['types', []]
641
+ ];
642
+
450
643
  /**
451
644
  * The options that describe where THIS package's own files live. Never pruned,
452
645
  * and emitted first in the workspace case: a base config that supplied them
@@ -455,9 +648,20 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
455
648
  */
456
649
  const PACKAGE_LOCAL_OPTIONS: readonly string[] = ['rootDir', 'outDir'];
457
650
 
458
- /** Compare two JSON option values. Sound here because compiler options are scalars and flat arrays. */
459
- function jsonEquals(left: JsonValue | undefined, right: JsonValue): boolean {
460
- return JSON.stringify(left) === JSON.stringify(right);
651
+ /**
652
+ * The enum-valued options of {@link TSCONFIG_COMPILER_OPTIONS}, whose values
653
+ * `tsc` reads case-insensitively, so `nodenext` and `NodeNext` are one value.
654
+ * An enum-valued option added there belongs here too.
655
+ */
656
+ const CASE_INSENSITIVE_OPTIONS: readonly string[] = ['target', 'lib', 'module', 'moduleResolution'];
657
+
658
+ /** Compare two option values. Sound here because compiler options are scalars and flat arrays. */
659
+ function optionEquals(key: string, left: JsonValue | undefined, right: JsonValue): boolean {
660
+ const normalise = (value: JsonValue | undefined) => {
661
+ const text = JSON.stringify(value);
662
+ return CASE_INSENSITIVE_OPTIONS.includes(key) ? text?.toLowerCase() : text;
663
+ };
664
+ return normalise(left) === normalise(right);
461
665
  }
462
666
 
463
667
  /**
@@ -467,112 +671,55 @@ function jsonEquals(left: JsonValue | undefined, right: JsonValue): boolean {
467
671
  * the same key: inheriting `"module": "CommonJS"` where the scaffold needs
468
672
  * `NodeNext` would break every emitted import, so a differing base value is a
469
673
  * reason to keep the override rather than to trust the parent.
674
+ *
675
+ * `member` is the composition as that member sees it, so `extends` reaches the
676
+ * workspace base from the member's own directory.
470
677
  */
471
- function tsconfigJson(composition: InitComposition): string {
472
- const workspace = composition.packaging.workspace;
678
+ function tsconfigJson(
679
+ member: InitComposition,
680
+ compilerOptions: ReadonlyArray<readonly [string, JsonValue]>,
681
+ include: string,
682
+ references = ''
683
+ ): string {
684
+ const workspace = member.packaging.workspace;
473
685
  const base = workspace?.baseTsconfig === undefined ? undefined : (workspace.baseCompilerOptions ?? {});
474
686
  const options =
475
687
  base === undefined
476
- ? TSCONFIG_COMPILER_OPTIONS
688
+ ? compilerOptions
477
689
  : [
478
- ...TSCONFIG_COMPILER_OPTIONS.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
479
- ...TSCONFIG_COMPILER_OPTIONS.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !jsonEquals(base[key], value))
690
+ ...compilerOptions.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
691
+ ...compilerOptions.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !optionEquals(key, base[key], value))
480
692
  ];
481
693
  const extendsLine = workspace?.baseTsconfig === undefined ? '' : ` "extends": "${workspace.baseTsconfig}",\n`;
482
694
  const body = options.map(([key, value]) => ` "${key}": ${JSON.stringify(value)}`).join(',\n');
483
- return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src"]\n}\n`;
484
- }
485
-
486
- // `isolatedModules` is what makes this check agree with the transform that
487
- // actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
488
- // vitest compiles through esbuild — a per-file transform with no type
489
- // information, which cannot tell a re-exported TYPE from a re-exported value.
490
- // Without the flag `tsc` accepts `export { SomeType } from './x'` that esbuild
491
- // then emits as a real import of a symbol that does not exist at runtime, so
492
- // the typecheck passes and the suite fails with a confusing missing-export.
493
- const TSCONFIG_TEST = `{
494
- "extends": "./tsconfig.json",
495
- "compilerOptions": {
496
- "rootDir": ".",
497
- "noEmit": true,
498
- "isolatedModules": true,
499
- "types": ["node"]
500
- },
501
- "include": ["src", "test"]
695
+ const referencesLine = references === '' ? '' : `,\n "references": ${references}`;
696
+ return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ${include}${referencesLine}\n}\n`;
502
697
  }
503
- `;
504
-
505
- const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
506
-
507
- // Vitest transforms TypeScript itself and resolves \`.js\` specifiers to their
508
- // \`.ts\` source, so tests import from \`../src/\` with the same specifiers the
509
- // compiled output uses. \`include\` is scoped to \`test/\` so \`lib/\` is never
510
- // scanned; \`npm test\` runs \`typecheck:test\` first, because the esbuild
511
- // transform strips types without checking them.
512
-
513
- export default defineConfig({
514
- test: {
515
- environment: 'node',
516
- include: ['test/**/*.{test,spec}.ts']
517
- }
518
- });
519
- `;
520
-
521
- const GITIGNORE = `node_modules/
522
- lib/
523
- *.tsbuildinfo
524
-
525
- # Langium-generated TextMate grammar (rewritten by every \`langium generate\`).
526
- syntaxes/
527
- `;
528
698
 
529
699
  /**
530
- * The workspace-member `.gitignore` — one entry, and the one entry a root
531
- * cannot be assumed to have.
532
- *
533
- * A member inherits the root's rules, and `node_modules/`, `lib/` and
534
- * `*.tsbuildinfo` are in every monorepo root already, so repeating them here
535
- * would be three lines to keep in step for no coverage. `syntaxes/` is not like
536
- * them: it is a Langium artefact, so a root that has never held a Langium
537
- * package has no rule for it, and the first `langium generate` then offers
538
- * generated output up for commit with nothing to warn the adopter. Dropping the
539
- * whole file rather than this subset is the mistake this template exists to
540
- * undo.
700
+ * The server's `tsconfig.json`. It references the protocol member, so `tsc -b`
701
+ * compiles protocol first, after `generate` has written its generated modules.
541
702
  */
542
- const GITIGNORE_WORKSPACE_MEMBER = `# The workspace root already covers \`node_modules/\`, \`lib/\` and \`*.tsbuildinfo\`.
543
- # It has no reason to know about this one: the TextMate grammar is a Langium
544
- # artefact, rewritten by every \`langium generate\`, so a root that has never held
545
- # a Langium package ignores nothing here and the generated file is offered for
546
- # commit.
547
- syntaxes/
548
- `;
549
-
550
- /** The terminals every grammar needs, shared verbatim by the fragment and the lone-grammar case. */
551
- const TERMINALS = `hidden terminal WS: /\\s+/;
552
- terminal ID: /[_a-zA-Z][\\w_]*/;
553
- hidden terminal SL_COMMENT: /\\/\\/[^\\n\\r]*/;
554
- hidden terminal ML_COMMENT: /\\/\\*[\\s\\S]*?\\*\\//;
555
- `;
703
+ function serverTsconfigJson(composition: InitComposition): string {
704
+ return tsconfigJson(
705
+ forMember(composition, 'server'),
706
+ TSCONFIG_COMPILER_OPTIONS,
707
+ '["src", "src/**/*.json"]',
708
+ '[{ "path": "../protocol" }]'
709
+ );
710
+ }
556
711
 
557
712
  /**
558
- * The shared lexical fragment, emitted only for a multi-grammar project.
559
- *
560
- * The alternative — every grammar declaring its own `WS` / `ID` — lets the token
561
- * sets drift apart.
713
+ * The terminals every grammar needs: the shared fragment's text after its
714
+ * leading comment run, so a lone grammar declares the same set.
562
715
  */
563
- const COMMON_GRAMMAR = `// Shared lexical basis for this project's grammars. Imported, never registered:
564
- // it has no entry rule and no \`langium-config.json\` entry, so it is a fragment
565
- // rather than a language.
566
- //
567
- // Keeping the tokens here rather than in one of the languages means a grammar
568
- // imports \`common\` for its TOKENS and another language only when it needs that
569
- // language's TYPES — separate reasons that are worth keeping separate.
570
-
571
- ${TERMINALS}`;
716
+ function terminals(): string {
717
+ return template('server/src/grammar/common.langium').replace(/^(\/\/.*\n)*\n/, '');
718
+ }
572
719
 
573
720
  /** One starter grammar. `shared` decides whether it imports the fragment or declares its own terminals. */
574
721
  function grammarFile(shared: boolean): string {
575
- const lexis = shared ? "import './common'\n" : `\n${TERMINALS}`;
722
+ const lexis = shared ? "import './common'\n" : `\n${terminals()}`;
576
723
  return `grammar __GRAMMAR__
577
724
  ${shared ? '\n' + lexis : ''}
578
725
  // A minimal starter grammar — replace with your own language. \`__NODE_RULE__\` is
@@ -761,185 +908,100 @@ ${returnType}
761
908
  return project(text, composition);
762
909
  }
763
910
 
764
- const SERVICES = `// Zero-arg service factory for the headless \`hydranium-cli\` tooling: the
765
- // \`reflect\` / \`lint-grammar\` / \`validate\` subcommands import this via
766
- // \`--services ./lib/services.js\`. The head wires its own filesystem here.
767
- //
768
- // The contract is language-count-agnostic — a second grammar needs no edit here.
769
-
770
- import { NodeFileSystem } from '@hydranium/core/node';
771
- import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
772
-
773
- export function createServices(): ReturnType<typeof create__NAME__Services> {
774
- return create__NAME__Services({ ...NodeFileSystem });
911
+ /** One exported constant of the protocol member's `head-ports.ts`. */
912
+ interface HeadPortConstant {
913
+ readonly name: string;
914
+ readonly value: string;
915
+ readonly doc: string;
775
916
  }
776
- `;
777
-
778
- const AST = `// The language's AST entry point: a re-export of what \`langium generate\`
779
- // emits, plus anywhere you augment those types.
780
- //
781
- // Import the AST from HERE rather than from \`./generated/ast.js\`, so any
782
- // augmentation below travels with every import. One \`langium-cli\` run over N
783
- // grammars emits ONE combined AST module sharing one reflection, so a further
784
- // grammar needs no change here.
785
- //
786
- // \`generate:transfer-model\` reads this file as its \`--augmentation-file\`: the
787
- // generated wire types are derived from the AST *as augmented*, not from the
788
- // raw generated module. Augment a type here and the transfer model follows.
789
- //
790
- // A \`@derived\` property is computed at build time rather than parsed, so it is
791
- // declared here and populated by an AST-extension contribution:
792
- //
793
- // declare module './generated/ast.js' {
794
- // interface __ENTRY_RULE__ {
795
- // /** @derived Populated by an \`ast.extensions.computedProperties\` contribution. */
796
- // readonly _summary?: string;
797
- // }
798
- // }
799
-
800
- export * from './generated/ast.js';
801
- `;
802
917
 
803
918
  /**
804
- * The starter serializer — the concrete-syntax emitter for ONE grammar.
919
+ * How a host finds each socket head's port, in emission order: an LSP request
920
+ * when the LSP head publishes the ports, else the environment variable
921
+ * `main.ts` reads a port from.
805
922
  *
806
- * Emitted per grammar, not per project, because `services.serializer.Serializer`
807
- * is a per-language slot and a serializer is grammar-shaped by definition. That
808
- * is also why the framework refuses to default it: its `UnboundSerializer`
809
- * throws, naming the binding to add, rather than guessing a syntax.
810
- */
811
- const SERIALIZER = `// Concrete-syntax emitter for __GRAMMAR__ — the parser's inverse, turning a
812
- // model back into text this grammar accepts. Derived from the starter grammar
813
- // exactly as \`generated/ast.ts\` is, so replacing the grammar replaces this too.
814
- //
815
- // Without it every structured write fails: the framework's default binding at
816
- // \`services.serializer.Serializer\` THROWS, because a concrete syntax is
817
- // language knowledge no framework can derive. \`ModelService.update\` / \`save\`,
818
- // the data head's \`saveModelDocument\` and a GLSP \`SaveModelAction\` all reach
819
- // it.
820
- //
821
- // \`AbstractSerializer\`'s generic property walk lays out FORMAT-structured
822
- // output — its YAML and JSON subclasses are what it exists for — and cannot
823
- // produce a keyword-delimited syntax like \`node a -> b\`. So \`serializeNode\` is
824
- // a per-\`$type\` emitter here and the two array hooks are unreachable.
825
-
826
- import { AbstractSerializer } from '@hydranium/core';
827
- import type { AstNode } from '@hydranium/langium';
828
- import { type __ENTRY_RULE__, type __NODE_RULE__, is__ENTRY_RULE__, is__NODE_RULE__ } from './ast.js';
829
-
830
- export class __GRAMMAR__Serializer extends AbstractSerializer<__ENTRY_RULE__> {
831
- protected override serializeNode(node: AstNode | Record<string, unknown>): string {
832
- if (is__ENTRY_RULE__(node)) {
833
- return node.nodes.map(child => this.emitNode(child)).join('\\n');
834
- }
835
- if (is__NODE_RULE__(node)) {
836
- return this.emitNode(node);
837
- }
838
- // Defensive: a rule added to the grammar with no emitter added here.
839
- throw new Error(\`__GRAMMAR__Serializer: no emitter for $type \${(node as AstNode).$type}\`);
840
- }
841
-
842
- /** Unreachable — this grammar's one list is emitted by its \`__ENTRY_RULE__\` parent. */
843
- protected override serializeArray(): string {
844
- throw new Error('__GRAMMAR__Serializer: arrays are emitted by the per-$type parent, not the generic dispatch.');
845
- }
846
-
847
- /** Unreachable — same reasoning as {@link serializeArray}. */
848
- protected override serializeReferenceArray(): string {
849
- throw new Error('__GRAMMAR__Serializer: reference arrays are emitted by the per-$type parent, not the generic dispatch.');
850
- }
851
-
852
- /**
853
- * \`serializeReferenceText\` rather than \`node.target?.$refText\`: it is the one
854
- * read that spans BOTH input shapes. A transfer model reaching
855
- * \`serializeTransfer\` carries \`target\` as a plain string rather than a
856
- * \`Reference\`, and a serializer that reaches for \`$refText\` directly emits
857
- * the AST correctly and drops every reference on the transfer path.
858
- */
859
- private emitNode(node: __NODE_RULE__): string {
860
- const target = this.serializeReferenceText(node.target);
861
- return target === undefined ? \`node \${node.name}\` : \`node \${node.name} -> \${target}\`;
862
- }
863
- }
864
- `;
865
-
866
- /**
867
- * The exported constant names for the head set, in emission order. Both are
868
- * keyed by the PROJECT rather than by a language: one server of each kind per
923
+ * Keyed by the PROJECT rather than by a language: one server of each kind per
869
924
  * process serves every registered grammar, so a language-derived name would tie
870
925
  * a project-level endpoint to whichever grammar was scaffolded first.
871
926
  */
872
- function portCommandNames(composition: InitComposition): string[] {
927
+ function headPortConstants(composition: InitComposition): HeadPortConstant[] {
873
928
  const upper = screamingSnake(composition.projectId);
874
- return [
875
- ...(composition.heads.includes('data') ? [`${upper}_DATA_SERVER_PORT_COMMAND`] : []),
876
- ...(composition.heads.includes('glsp') ? [`${upper}_GLSP_PORT_COMMAND`] : [])
929
+ const lsp = composition.heads.includes('lsp');
930
+ const heads = [
931
+ ...(composition.heads.includes('data') ? [{ key: 'DATA_SERVER', path: 'data-server', label: 'data-server' }] : []),
932
+ ...(composition.heads.includes('glsp') ? [{ key: 'GLSP_SERVER', path: 'glsp-server', label: 'GLSP' }] : [])
877
933
  ];
934
+ return heads.map(head =>
935
+ lsp
936
+ ? {
937
+ name: `${upper}_${head.key}_PORT_COMMAND`,
938
+ value: `${composition.projectId}/${head.path}/port`,
939
+ doc: `LSP request the host queries to discover the ${head.label} socket port.`
940
+ }
941
+ : {
942
+ name: `${upper}_${head.key}_PORT_ENV`,
943
+ value: `${upper}_${head.key}_PORT`,
944
+ doc: `Environment variable naming the ${head.label} socket port when no \`--${head.path}-port\` flag does.`
945
+ }
946
+ );
878
947
  }
879
948
 
880
949
  /**
881
- * The socket heads' discovery commands, as an importable module.
950
+ * The socket heads' port commands or variables, in the protocol member.
882
951
  *
883
- * Deliberately NOT declared in `main.ts`. A host shell has to name the same
884
- * string to reach the head, and `main.ts` opens a connection at module scope,
885
- * so importing a constant from it would start a server. A host that retypes the
886
- * literal instead gets no error when it drifts: the framework's port poll
887
- * defaults to `findPortAttempts = -1`, so a wrong command retries forever
888
- * rather than failing.
952
+ * A host shell has to name the same string the server reads, and reaches it
953
+ * here without depending on the server. A host that retypes the literal
954
+ * instead gets no error when it drifts: the framework's port poll defaults to
955
+ * `findPortAttempts = -1`, so a wrong command retries forever rather than
956
+ * failing, and a wrong variable is never read.
889
957
  */
890
958
  function headPortsFile(composition: InitComposition): string {
891
- const upper = screamingSnake(composition.projectId);
892
- const data = composition.heads.includes('data');
893
- const glsp = composition.heads.includes('glsp');
894
- const blocks = [
895
- ...(data
896
- ? [
897
- `/** LSP request the host queries to discover the data-server socket port. */\nexport const ${upper}_DATA_SERVER_PORT_COMMAND = '${composition.projectId}/data-server/port';`
898
- ]
899
- : []),
900
- ...(glsp
901
- ? [
902
- `/** LSP request the host queries to discover the GLSP socket port. */\nexport const ${upper}_GLSP_PORT_COMMAND = '${composition.projectId}/glsp/port';`
903
- ]
904
- : [])
905
- ];
906
- return `// The LSP requests a host queries to discover this server's socket head${blocks.length > 1 ? 's' : ''}.
959
+ const constants = headPortConstants(composition);
960
+ const blocks = constants.map(constant => `/** ${constant.doc} */\nexport const ${constant.name} = '${constant.value}';`);
961
+ const heads = `socket head${blocks.length > 1 ? 's' : ''}`;
962
+ const opening = composition.heads.includes('lsp')
963
+ ? `// The LSP requests a host queries to discover this server's ${heads}.`
964
+ : `// The environment variables that name the ports of this server's ${heads}.`;
965
+ const usage = composition.heads.includes('lsp')
966
+ ? `// The server publishes them and a host shell has to name the same string to
967
+ // reach the head; both import them from here. A shell that retypes the literal
968
+ // gets no error when it drifts: the framework's port poll retries indefinitely
969
+ // by default.`
970
+ : `// The server reads them and a process that starts it sets them; both import
971
+ // them from here. A variable retyped with a typo is never read, and the head
972
+ // then listens on whichever port is free.`;
973
+ return `${opening}
907
974
  //
908
- // Both are keyed by the PROJECT, not the language: one server of each kind per
975
+ // ${blocks.length > 1 ? 'Both are' : 'It is'} keyed by the PROJECT, not the language: one server of each kind per
909
976
  // process serves every registered grammar, so a language-derived name would tie
910
977
  // a project-level endpoint to whichever grammar was scaffolded first.
911
978
  //
912
- // They live here rather than in \`main.ts\` because a host shell has to name the
913
- // same string to reach the head, and \`main.ts\` is an executable entry — nothing
914
- // can import from it. A shell that retypes the literal gets no error when it
915
- // drifts: the framework's port poll retries indefinitely by default. Import
916
- // these instead, and assert any host-side copy against them.
979
+ ${usage}
917
980
 
918
981
  ${blocks.join('\n\n')}
919
982
  `;
920
983
  }
921
984
 
922
- const INDEX = `// Public surface of the __NAME__ language server: the DI factory, the generated
923
- // AST, and the headless \`createServices\` entry.
985
+ /**
986
+ * The server's root entry: the DI factory, the generated AST and each diagram
987
+ * module, all loadable in a browser worker.
988
+ */
989
+ function indexFile(composition: InitComposition): string {
990
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
991
+ const diagramPhrase = diagrams.length === 0 ? '' : diagrams.length === 1 ? ', plus the diagram module' : ', plus the diagram modules';
992
+ const text = `// Public surface of the __NAME__ language server: the DI factory and the
993
+ // generated AST${diagramPhrase}.
924
994
  //
925
- // The entry points under \`src/\` are deliberately NOT re-exported. Each opens a
926
- // transport at module scope, so importing one starts a server as a side effect
927
- // — which is why they are \`bin\` targets and this file is \`main\`.
995
+ // Everything here loads in a browser worker as well as in Node, so a host can
996
+ // compose the language wherever it runs it. \`createServices\`, which reads the
997
+ // real filesystem, is the package's \`./node\` entry instead. The entry points
998
+ // under \`src/\` are not re-exported either: each opens a transport at module
999
+ // scope, so importing one starts a server, which is why they are \`bin\` targets.
928
1000
 
929
1001
  export * from './language-server/__PROJECT_ID__-module.js';
930
1002
  export * from './language-server/ast.js';
931
- export { createServices } from './services.js';
932
- `;
933
-
934
- /**
935
- * The public surface, plus the head-port commands when there is a socket head.
936
- *
937
- * The commands belong here rather than only in `head-ports.ts` so a host shell
938
- * reaches them from the package root, the same way it reaches `createServices`.
939
- */
940
- function indexFile(composition: InitComposition): string {
941
- const base = project(INDEX, composition);
942
- return portCommandNames(composition).length ? `${base}export * from './head-ports.js';\n` : base;
1003
+ ${diagrams.map(grammar => `export * from './glsp/${grammar.grammarId}/diagram-module.js';\n`).join('')}`;
1004
+ return project(text, composition);
943
1005
  }
944
1006
 
945
1007
  /**
@@ -969,10 +1031,12 @@ function mainFile(composition: InitComposition): string {
969
1031
  const columns = columnsFor(composition);
970
1032
  const roots = [...composition.grammars.map(grammar => grammar.entryRule)].sort();
971
1033
  const upper = screamingSnake(composition.projectId);
1034
+ const lsp = composition.heads.includes('lsp');
972
1035
  const data = composition.heads.includes('data');
973
1036
  const glsp = composition.heads.includes('glsp');
974
1037
  const diagrams = composition.grammars.filter(grammar => grammar.diagram);
975
1038
  const plural = roots.length > 1 ? 's' : '';
1039
+ const portFlags = [...(data ? ['--data-server-port'] : []), ...(glsp ? ['--glsp-server-port'] : [])];
976
1040
 
977
1041
  const headNames = composition.heads.map(head =>
978
1042
  head === 'lsp' ? 'the Langium LSP head' : head === 'data' ? 'a socket data-server (model server) head' : 'a GLSP head'
@@ -986,7 +1050,7 @@ function mainFile(composition: InitComposition): string {
986
1050
  // `reflect-metadata` must be imported before anything that reads a
987
1051
  // decorator's emitted metadata, which inversify does at module scope.
988
1052
  const reflectImport = glsp ? "import 'reflect-metadata';\n" : '';
989
- const coreNodeSymbols = ['NodeFileSystem', ...(data ? ['publishPortOnLspConnection', 'startSocketServer'] : [])];
1053
+ const coreNodeSymbols = ['NodeFileSystem', ...(data ? [...(lsp ? ['publishPortOnLspConnection'] : []), 'startSocketServer'] : [])];
990
1054
  const glspImports = glsp
991
1055
  ? `import { ServerModule } from '@eclipse-glsp/server/node.js';
992
1056
  import { GlspClientLogger, HydraniumGlspAppModule } from '@hydranium/glsp-server';
@@ -1003,46 +1067,59 @@ import { startGlspServer } from '@hydranium/glsp-server/node';
1003
1067
  // \`TransferElement\` structurally, so naming the AST type here compiles fine and
1004
1068
  // silently tells every typed client that a reference is a resolvable object
1005
1069
  // rather than a name.
1006
- ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1070
+ ${importList(roots, memberPackageName(composition, 'protocol'), columns, true)}
1007
1071
  `
1008
1072
  : '';
1009
1073
 
1010
- // The commands live in `head-ports.ts`, not here: a host shell has to name
1011
- // the same string to reach the head, and this file is an executable entry, so
1012
- // nothing can import from it.
1013
- const portCommands = portCommandNames(composition).length
1014
- ? importList(portCommandNames(composition), './head-ports.js', columns) + '\n'
1015
- : '';
1016
-
1017
- const dataBlock = data
1018
- ? `
1074
+ // The names live in the protocol member, not here: a host shell has to name
1075
+ // the same string to reach the head, and this file is an executable entry,
1076
+ // so nothing can import from it.
1077
+ const portNames = headPortConstants(composition).map(constant => constant.name);
1078
+ const portImports = portNames.length ? importList(portNames, memberPackageName(composition, 'protocol'), columns) + '\n' : '';
1079
+
1080
+ const dataBlock = !data
1081
+ ? ''
1082
+ : !lsp
1083
+ ? `
1084
+ // Data-server head: each accepted client gets its own DataServer.
1085
+ const dataServer = startSocketServer(
1086
+ { shared, workspace, port: portFrom('data-server-port', ${upper}_DATA_SERVER_PORT_ENV), logTag: 'ModelServer', logger: shared.Logger },
1087
+ dataConnection => {
1088
+ new DataServer<${roots.join(' | ')}>(dataConnection, shared);
1089
+ return { dispose: () => undefined };
1090
+ }
1091
+ );
1092
+ `
1093
+ : `
1019
1094
  // Data-server head alongside LSP: binds an ephemeral port, published over the LSP
1020
1095
  // connection for the host to discover. Each accepted client gets its own DataServer.
1021
1096
  //
1022
1097
  // Neither the bind nor the publish may be swallowed: either failure leaves the LSP
1023
1098
  // head serving text edits while every data client waits on a port command that was
1024
1099
  // never registered, and the launcher reports a bind failure only if given a logger.
1025
- const dataServer = startSocketServer({ port: 0, logTag: 'ModelServer', logger: shared.Logger }, dataConnection => {
1100
+ const dataServer = startSocketServer({ shared, port: 0, logTag: 'ModelServer', logger: shared.Logger }, dataConnection => {
1026
1101
  new DataServer<${roots.join(' | ')}>(dataConnection, shared);
1027
1102
  return { dispose: () => undefined };
1028
1103
  });
1029
1104
  dataServer.started
1030
- .then(() => {
1031
- const { port } = dataServer;
1032
- if (port === undefined) {
1033
- // \`started\` resolves only once the address is resolved, so this is
1034
- // unreachable; a non-null assertion in its place would publish
1035
- // \`undefined\`, which the host cannot tell from an unreachable port.
1036
- throw new Error('the data head started without a resolved port');
1037
- }
1038
- publishPortOnLspConnection(shared.lsp.Connection, ${upper}_DATA_SERVER_PORT_COMMAND, port);
1039
- })
1105
+ .then(
1106
+ () => {
1107
+ const { port } = dataServer;
1108
+ if (port === undefined) {
1109
+ // \`started\` resolves only once the address is resolved, so this is
1110
+ // unreachable; a non-null assertion in its place would publish
1111
+ // \`undefined\`, which the host cannot tell from an unreachable port.
1112
+ throw new Error('the data head started without a resolved port');
1113
+ }
1114
+ publishPortOnLspConnection(shared.lsp.Connection, ${upper}_DATA_SERVER_PORT_COMMAND, port);
1115
+ },
1116
+ // The launcher logged its own bind failure; the rejection is control flow.
1117
+ () => undefined
1118
+ )
1040
1119
  .catch((error: unknown) => {
1041
- const reason = error instanceof Error ? error.message : String(error);
1042
- shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${reason}\`);
1120
+ shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${describeErrorWithStack(error)}\`);
1043
1121
  });
1044
- `
1045
- : '';
1122
+ `;
1046
1123
 
1047
1124
  const glspBlock = glsp
1048
1125
  ? `
@@ -1051,49 +1128,115 @@ dataServer.started
1051
1128
  // grammar a request concerns, so nothing per-language belongs there — each
1052
1129
  // diagram module declares its own language instead.
1053
1130
  //
1054
- // GLSP framework logs route through the LSP connection rather than stdout, which
1055
- // IS the LSP transport in stdio mode; writing there corrupts the protocol stream.
1131
+ ${
1132
+ lsp
1133
+ ? `// GLSP framework logs route through the LSP connection rather than stdout, which
1134
+ // IS the LSP transport in stdio mode; writing there corrupts the protocol stream.`
1135
+ : `// GLSP framework logs route through the framework's logger to stderr. GLSP's
1136
+ // own startup line goes to stdout, beside the ports line below.`
1137
+ }
1056
1138
  const glspServer = startGlspServer({
1057
1139
  // The GLSP log threshold lives on the logger, not beside it: the framework
1058
1140
  // replaces GLSP's own \`Logger\` binding, so a launcher-level \`logLevel\` would
1059
1141
  // be discarded. The logger tracks the framework's process-wide threshold, so
1060
- // \`HYDRANIUM_LOG_LEVEL\` and the LSP log-level setting govern GLSP output too;
1142
+ // \`HYDRANIUM_LOG_LEVEL\` ${lsp ? 'and the LSP log-level setting govern' : 'governs'} GLSP output too;
1061
1143
  // pass \`logLevel\` only to make GLSP quieter than the rest of the server.
1062
1144
  createLogger: caller => new GlspClientLogger(shared, { component: caller }),
1063
1145
  serverModule: new ServerModule()${diagrams.map(grammar => `.configureDiagramModule(new ${grammar.grammar}DiagramModule())`).join('')},
1064
1146
  appModules: [new HydraniumGlspAppModule({ shared })],
1065
- lspConnection: shared.lsp.Connection,
1066
- portCommand: ${upper}_GLSP_PORT_COMMAND
1147
+ ${
1148
+ lsp
1149
+ ? ` lspConnection: shared.lsp.Connection,
1150
+ portCommand: ${upper}_GLSP_SERVER_PORT_COMMAND`
1151
+ : ` workspace,
1152
+ port: portFrom('glsp-server-port', ${upper}_GLSP_SERVER_PORT_ENV),
1153
+ logger: shared.Logger`
1154
+ }
1067
1155
  });
1068
- void glspServer;
1069
- `
1156
+ ${lsp ? 'void glspServer;\n' : ''}`
1070
1157
  : '';
1071
1158
 
1159
+ const lspImports = `// The framework's entry point, NOT Langium's \`@hydranium/langium/lsp\` one. It is
1160
+ // signature-compatible and delegates straight through; what it adds first is
1161
+ // \`assertLspHeadComposed\`, which fails the start if the LSP head's SHARED module
1162
+ // was never composed. Reaching for Langium's is the natural mistake and it is
1163
+ // silent: the server boots, links and completes, while echo suppression, the
1164
+ // didChangeContent debounce and the last-client-close rebuild are all inert.
1165
+ import { startLanguageServer, withHydraniumLspFeatures } from '@hydranium/core/lsp';
1166
+ import { ProposedFeatures, createConnection } from 'vscode-languageserver/node';
1167
+ `;
1168
+
1169
+ const lspStart = `
1170
+ const connection = createConnection(withHydraniumLspFeatures(ProposedFeatures.all));
1171
+ const { shared } = create__NAME__Services({ connection, ...NodeFileSystem });
1172
+ startLanguageServer(shared);
1173
+ `;
1174
+
1175
+ // No LSP client names the workspace or asks for the ports here, so the
1176
+ // command line does, and the ports go to stdout once every head listens.
1177
+ const servers = [...(data ? ['dataServer'] : []), ...(glsp ? ['glspServer'] : [])];
1178
+ const socketStart = `
1179
+ const { values, positionals } = parseArgs({
1180
+ options: { ${portFlags.map(flag => `'${flag.slice(2)}': { type: 'string' }`).join(', ')} },
1181
+ allowPositionals: true
1182
+ });
1183
+ // The workspace root: the first argument, else the process cwd.
1184
+ const workspace = positionals[0] ?? process.cwd();
1185
+
1186
+ /** A head's port: its flag, else its environment variable, else 0 for any free port. */
1187
+ function portFrom(flag: keyof typeof values, variable: string): number {
1188
+ const text = values[flag] ?? process.env[variable] ?? '0';
1189
+ const port = Number(text);
1190
+ // Decimal digits only: \`Number\` also reads '' as 0 and takes '1e3' or '0x10'.
1191
+ if (!/^\\d+$/.test(text) || port > 65_535) {
1192
+ throw new Error(\`--\${flag} or \${variable} is '\${text}', not a port\`);
1193
+ }
1194
+ return port;
1195
+ }
1196
+
1197
+ const { shared } = create__NAME__Services({ ...NodeFileSystem });
1198
+ `;
1199
+ const portsLine = `
1200
+ // One JSON line on stdout once every head listens, for the process that started
1201
+ // this one to read the ports from: the one line that starts with \`{\`. Each
1202
+ // head brings the workspace up before it listens; a head that fails to start
1203
+ // has logged why and ends the process, which would otherwise serve without it.
1204
+ Promise.all([${servers.map(server => `${server}.started`).join(', ')}]).then(
1205
+ () => process.stdout.write(\`\${JSON.stringify({ ${servers.map(server => `${server}: ${server}.port`).join(', ')} })}\\n\`),
1206
+ () => process.exit(1)
1207
+ );
1208
+ `;
1209
+
1210
+ const invocation = lsp
1211
+ ? `// Invocation: \`node lib/main.js --stdio\`, or the package's \`__PROJECT_ID__\` bin
1212
+ // script.`
1213
+ : `// Invocation: \`node lib/main.js [<workspace-path>] ${portFlags.map(flag => `[${flag} <port>]`).join(' ')}\`,
1214
+ // or the package's \`__PROJECT_ID__\` bin script. Each port falls back to its
1215
+ // environment variable, then to any free port.`;
1216
+ const dataNote = !data
1217
+ ? ''
1218
+ : lsp
1219
+ ? `
1220
+ //
1221
+ // NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: stdio
1222
+ // here carries LSP, and the data head is a socket whose port is published over
1223
+ // the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
1224
+ : `
1225
+ //
1226
+ // NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: they
1227
+ // drive the data protocol over a child's stdio, and the data head here is a
1228
+ // socket. Those subcommands spawn \`data-server-main.js\`.`;
1229
+
1072
1230
  const text = `${SHEBANG}// Standalone entry point: starts ${headSummary} in the same
1073
1231
  // process, so every head shares one model store.
1074
- // Invocation: \`node lib/main.js --stdio\`, or the package's \`__PROJECT_ID__\` bin
1075
- // script.${
1076
- data
1077
- ? `
1078
- //
1079
- // NOT the entry \`hydranium-cli query\` / \`save\` / \`projects\` / \`watch\` speak to:
1080
- // stdio here carries LSP, and the data head is a socket whose port is published
1081
- // over the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
1082
- : ''
1083
- }
1232
+ ${invocation}${dataNote}
1084
1233
  //
1085
1234
  // Everything here runs at module scope, so this file is an executable rather
1086
1235
  // than a library entry — import \`./index.js\` instead to compose the language.
1087
1236
 
1088
1237
  ${reflectImport}${glspImports}${importList(coreNodeSymbols, '@hydranium/core/node', columns)}
1089
- ${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}import { startLanguageServer } from '@hydranium/langium/lsp';
1090
- import { ProposedFeatures, createConnection } from 'vscode-languageserver/node';
1091
- ${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1092
- ${portCommands === '' ? '' : '\n' + portCommands}
1093
- const connection = createConnection(ProposedFeatures.all);
1094
- const { shared } = create__NAME__Services({ connection, ...NodeFileSystem });
1095
- startLanguageServer(shared);
1096
- ${dataBlock}${glspBlock}`;
1238
+ ${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}${data && lsp ? "import { describeErrorWithStack } from '@hydranium/protocol';\n" : ''}${lsp ? lspImports : "import { parseArgs } from 'node:util';\n"}${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1239
+ ${portImports === '' ? '' : '\n' + portImports}${lsp ? lspStart : socketStart}${dataBlock}${glspBlock}${lsp ? '' : portsLine}`;
1097
1240
  return project(text, composition);
1098
1241
  }
1099
1242
 
@@ -1101,13 +1244,12 @@ ${dataBlock}${glspBlock}`;
1101
1244
  * The second executable entry: the data head alone, on stdio.
1102
1245
  *
1103
1246
  * Emitted with the `data` head because it is the ONLY command line that reaches
1104
- * that head from outside the editor. `main.ts` gives stdio to LSP and publishes
1105
- * the data head's socket port over the LSP connection, so a client that is not
1106
- * an LSP client cannot discover it — which is every one of `hydranium-cli`'s
1107
- * `query` / `save` / `projects` / `watch`, all of which drive the data protocol
1108
- * over a spawned child's stdin/stdout. Without this file a scaffolded project
1109
- * has no `--server` value, and the closest guess — the LSP entry — fails as an
1110
- * unrecognised method rather than as a missing head.
1247
+ * that head from outside the editor. `main.ts` serves the data head on a
1248
+ * socket, so a client driving the data protocol over a spawned child's
1249
+ * stdin/stdout cannot reach it — which is every data-server subcommand of
1250
+ * `hydranium-cli`. Without this file a scaffolded project has no `--server`
1251
+ * value, and the closest guess, `main.ts`, fails as an unrecognised method or
1252
+ * an unknown flag rather than as a missing head.
1111
1253
  *
1112
1254
  * A separate process rather than a flag on `main.ts`: only one protocol can own
1113
1255
  * stdio, so the two entries are mutually exclusive by construction.
@@ -1118,21 +1260,27 @@ function dataServerMainFile(composition: InitComposition): string {
1118
1260
  const plural = roots.length > 1 ? 's' : '';
1119
1261
 
1120
1262
  const text = `${SHEBANG}// Standalone entry point: a data-server head on **stdio**, with no LSP head in
1121
- // the process. This is the transport \`hydranium-cli\` speaks — \`query\`, \`save\`,
1122
- // \`projects\` and \`watch\` all spawn a server command and drive JSON-RPC over its
1123
- // stdin/stdout — so it is what makes those subcommands usable against this
1124
- // language.
1263
+ // the process. This is the transport \`hydranium-cli\` speaks — its data-server
1264
+ // subcommands spawn a server command and drive JSON-RPC over its stdin/stdout —
1265
+ // so it is what makes those subcommands usable against this language.
1125
1266
  //
1126
1267
  // Invocation: \`node lib/data-server-main.js [<workspace-path>]\`, or the
1127
1268
  // package's \`__PROJECT_ID__-data-server\` bin script. The workspace path defaults to
1128
1269
  // the process cwd, which is what the CLI's \`--cwd\` sets on the child.
1129
1270
  //
1130
- // Contrast with \`main.ts\`, the editor entry: there the LSP head owns stdio and
1271
+ ${
1272
+ composition.heads.includes('lsp')
1273
+ ? `// Contrast with \`main.ts\`, the editor entry: there the LSP head owns stdio and
1131
1274
  // the data head is a socket published over the LSP connection. Here there is no
1132
1275
  // LSP connection at all, so the workspace initialization an
1133
1276
  // \`initialize\`/\`initialized\` pair would otherwise drive has to happen here —
1134
1277
  // which is why this entry uses \`startStdioServer\` rather than wiring a
1135
- // connection directly.
1278
+ // connection directly.`
1279
+ : `// Contrast with \`main.ts\`, where the data head is a socket whose port it
1280
+ // prints. Here it owns stdio, and as there, no LSP connection drives the
1281
+ // workspace initialization, so \`startStdioServer\` brings the workspace up
1282
+ // before it listens.`
1283
+ }
1136
1284
  //
1137
1285
  // Everything here runs at module scope, so this file is an executable rather
1138
1286
  // than a library entry — import \`./index.js\` instead to compose the language.
@@ -1140,7 +1288,7 @@ function dataServerMainFile(composition: InitComposition): string {
1140
1288
  ${importList(['NodeFileSystem', 'startStdioServer'], '@hydranium/core/node', columns)}
1141
1289
  import { DataServer } from '@hydranium/data-server';
1142
1290
  // The TRANSFER root${plural}, not the AST one${plural} — same reasoning as \`main.ts\`.
1143
- ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1291
+ ${importList(roots, memberPackageName(composition, 'protocol'), columns, true)}
1144
1292
  import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1145
1293
 
1146
1294
  const { shared } = create__NAME__Services({ ...NodeFileSystem });
@@ -1189,7 +1337,7 @@ function servicesTest(composition: InitComposition): string {
1189
1337
  // is missing from the composition.
1190
1338
 
1191
1339
  import { describe, expect, it } from 'vitest';
1192
- import { createServices } from '../src/services.js';
1340
+ import { createServices } from '../src/node/services.js';
1193
1341
 
1194
1342
  describe('__NAME__ services', () => {
1195
1343
  it('composes the DI tree and registers ${title}', () => {
@@ -1259,8 +1407,8 @@ function serializationTest(composition: InitComposition): string {
1259
1407
  const transferTypes = composition.grammars.map(grammar => `${grammar.entryRule} as Transfer${grammar.entryRule}`);
1260
1408
  const text = `// Round-trips each grammar through its serializer: parse the source, serialize
1261
1409
  // the model, compare the text. The serializer is the parser's inverse, so this
1262
- // is the assertion that keeps \`ModelService.update\` / \`save\` writing files the
1263
- // language server can read back.
1410
+ // is the assertion that keeps a client session's \`update\` / \`save\` writing
1411
+ // files the language server can read back.
1264
1412
  //
1265
1413
  // The transfer case is the one that catches the mistake worth catching.
1266
1414
  // \`ModelService.modelToText\` short-circuits only a RAW STRING, so a typed model
@@ -1270,8 +1418,8 @@ function serializationTest(composition: InitComposition): string {
1270
1418
  import { parseHelper } from '@hydranium/core/testing';
1271
1419
  import { describe, expect, it } from 'vitest';
1272
1420
  ${importList(astTypes, '../src/language-server/ast.js', columns, true)}
1273
- ${importList(transferTypes, '../src/language-server/generated-hydranium/transfer-model.js', columns, true)}
1274
- import { createServices } from '../src/services.js';
1421
+ ${importList(transferTypes, memberPackageName(composition, 'protocol'), columns, true)}
1422
+ import { createServices } from '../src/node/services.js';
1275
1423
 
1276
1424
  ${composition.grammars.map(grammar => render(SERIALIZATION_SUITE, composition, grammar)).join('\n\n')}
1277
1425
  `;
@@ -1398,7 +1546,7 @@ function grammarTest(composition: InitComposition, header: string, imports: read
1398
1546
  import { parseHelper } from '@hydranium/core/testing';
1399
1547
  import { describe, expect, it } from 'vitest';
1400
1548
  ${imports.join('\n')}${imports.length ? '\n' : ''}${importList(astTypes, '../src/language-server/ast.js', columns, true)}
1401
- import { createServices } from '../src/services.js';
1549
+ import { createServices } from '../src/node/services.js';
1402
1550
 
1403
1551
  ${composition.grammars.map(grammar => render(suite, composition, grammar)).join('\n\n')}
1404
1552
  `;
@@ -1429,6 +1577,7 @@ function readme(composition: InitComposition): string {
1429
1577
  const multi = composition.grammars.length > 1;
1430
1578
  const data = composition.heads.includes('data');
1431
1579
  const primaryExtension = composition.grammars[0].extensions[0];
1580
+ const diagramCount = composition.grammars.filter(grammar => grammar.diagram).length;
1432
1581
  const grammarBullets = composition.grammars.map(
1433
1582
  grammar => `- \`src/grammar/${grammar.grammarId}.langium\` — the \`${grammar.grammar}\` grammar (.${grammar.extensions.join(', .')})`
1434
1583
  );
@@ -1439,20 +1588,6 @@ function readme(composition: InitComposition): string {
1439
1588
  '',
1440
1589
  '## Getting started',
1441
1590
  '',
1442
- // Conditional on the pin this scaffold actually carries — see
1443
- // `UNPUBLISHED_FRAMEWORK_VERSION`.
1444
- ...(composition.frameworkVersion === UNPUBLISHED_FRAMEWORK_VERSION
1445
- ? [
1446
- '> **Pre-publish note.** `@hydranium/*` is not on npm yet, so the `0.0.0`',
1447
- '> pins below are placeholders and `npm install` will fail with a 404 until',
1448
- '> the framework is released. Until then, supply the packages from a local',
1449
- '> framework checkout with [yalc](https://github.com/wclr/yalc) — a plain',
1450
- '> `file:` path or `npm link` is not enough, because the framework packages',
1451
- '> depend on each other by version and npm would try to fetch those from the',
1452
- '> registry too.',
1453
- ''
1454
- ]
1455
- : []),
1456
1591
  '```bash',
1457
1592
  'npm install',
1458
1593
  'npm run langium:generate # generate the AST from the grammar',
@@ -1467,25 +1602,26 @@ function readme(composition: InitComposition): string {
1467
1602
  // nowhere else.
1468
1603
  '```bash',
1469
1604
  '# Grammar / AST reflection',
1470
- 'npx hydranium-cli reflect --services ./lib/services.js',
1605
+ 'npx hydranium-cli reflect --services ./lib/node/services.js',
1471
1606
  '',
1472
1607
  '# Grammar-convention lint (CI gate)',
1473
- 'npx hydranium-cli lint-grammar --services ./lib/services.js',
1608
+ 'npx hydranium-cli lint-grammar --services ./lib/node/services.js',
1474
1609
  '',
1475
1610
  '# Validate a workspace of model files (non-zero exit on errors)',
1476
- 'npx hydranium-cli validate --services ./lib/services.js <workspace-dir>',
1611
+ 'npx hydranium-cli validate --services ./lib/node/services.js ../workspace',
1477
1612
  ...(data
1478
1613
  ? [
1479
1614
  '',
1480
1615
  '# Data-head operations. `--server` is a command line the CLI spawns and then',
1481
1616
  "# drives the data protocol over the child's stdin/stdout, so it has to name",
1482
- '# `data-server-main.js` — `main.js` gives stdio to LSP and answers these',
1483
- '# methods with "Unhandled method".',
1617
+ ...(composition.heads.includes('lsp')
1618
+ ? ['# `data-server-main.js` — `main.js` gives stdio to LSP and answers these', '# methods with "Unhandled method".']
1619
+ : ['# `data-server-main.js` — `main.js` serves the data head on a socket.']),
1484
1620
  '#',
1485
1621
  '# The workspace goes to the ENTRY, not to `--cwd`: `--cwd` re-roots the child,',
1486
1622
  '# so a relative entry path is refused by name (an absolute one is fine).',
1487
- 'npx hydranium-cli projects --server "node ./lib/data-server-main.js <workspace-dir>"',
1488
- `npx hydranium-cli query --server "node ./lib/data-server-main.js <workspace-dir>" --uri <file:// URI of a .${primaryExtension} file>`
1623
+ 'npx hydranium-cli projects --server "node ./lib/data-server-main.js ../workspace"',
1624
+ `npx hydranium-cli query --server "node ./lib/data-server-main.js ../workspace" --uri <file:// URI of ../workspace/nodes.${primaryExtension}>`
1489
1625
  ]
1490
1626
  : []),
1491
1627
  '```',
@@ -1499,8 +1635,8 @@ function readme(composition: InitComposition): string {
1499
1635
  grammar =>
1500
1636
  `- \`src/language-server/${grammar.grammarId}-serializer.ts\` — emits \`${grammar.grammar}\` back to text (the framework defaults this to a throw)`
1501
1637
  ),
1502
- '- `src/services.ts` — zero-arg `createServices()` for the headless CLI',
1503
- '- `src/index.ts` — the package entry (`main`): DI factory + generated AST',
1638
+ "- `src/node/services.ts` — zero-arg `createServices()` for the headless CLI, the package's `./node` entry",
1639
+ `- \`src/index.ts\` — the package root, loadable in a browser worker: DI factory + generated AST${diagramCount === 0 ? '' : diagramCount === 1 ? ' + diagram module' : ' + diagram modules'}`,
1504
1640
  `- \`src/main.ts\` — starts ${composition.heads.join(' + ')}, the \`__PROJECT_ID__\` bin entry`,
1505
1641
  ...(data
1506
1642
  ? [
@@ -1512,8 +1648,9 @@ function readme(composition: InitComposition): string {
1512
1648
  .filter(grammar => grammar.diagram)
1513
1649
  .map(
1514
1650
  grammar =>
1515
- `- \`src/glsp/${grammar.grammarId}/\` — the \`${grammar.grammar}\` diagram: type ids, state, storage, submission handler, GModel factory, configuration, create-node handler, DI module`
1651
+ `- \`src/glsp/${grammar.grammarId}/\` — the \`${grammar.grammar}\` diagram: state, storage, submission handler, GModel factory, configuration, create-node handler, DI module`
1516
1652
  ),
1653
+ `- \`../protocol/\` — the \`${memberPackageName(composition, 'protocol')}\` package: what this server and its clients both import (language ids, ${headPortConstants(composition).length === 0 ? '' : composition.heads.includes('lsp') ? 'port commands, ' : 'port variables, '}${composition.grammars.some(grammar => grammar.diagram) ? 'diagram ids, ' : ''}the transfer model), written partly by \`npm run generate\``,
1517
1654
  '- `test/services.test.ts` — the DI tree composes; grows as your language does',
1518
1655
  '- `test/parsing.test.ts` / `linking.test.ts` / `validating.test.ts` — the three tiers a new language breaks first',
1519
1656
  '- `test/serialization.test.ts` — each grammar round-trips through its serializer',
@@ -1566,6 +1703,24 @@ function readme(composition: InitComposition): string {
1566
1703
  'atomic set and depends on a single physical copy, so a floating range can',
1567
1704
  'silently resolve a second one.',
1568
1705
  '',
1706
+ '`vscode-languageserver` is pinned to `10.0.1`, and',
1707
+ '`vscode-languageserver-protocol` and `vscode-jsonrpc` to the `3.18.1` and',
1708
+ '`9.0.0` it pins, so Langium, the LSP connection and the framework share one',
1709
+ 'copy of each. With two, an error a Langium handler returns, such as a request',
1710
+ 'for a document the server does not have, reaches the client as a result, and a',
1711
+ 'framework error your own LSP handler throws loses its code. The Langium 4.4',
1712
+ 'upgrade',
1713
+ '([eclipse-emfcloud/hydranium#142](https://github.com/eclipse-emfcloud/hydranium/issues/142))',
1714
+ 'replaces these pins.',
1715
+ '',
1716
+ ...(pinsVitest(composition)
1717
+ ? [
1718
+ '`vitest` is held below 4.1, whose peer set crashes the npm every Node 22',
1719
+ 'release bundles. Raise it once every npm that installs this project is',
1720
+ '11.6 or later.',
1721
+ ''
1722
+ ]
1723
+ : []),
1569
1724
  '## If your repo gates license headers',
1570
1725
  '',
1571
1726
  'The emitted `.ts` files carry no copyright header — the scaffold cannot know',
@@ -1585,10 +1740,9 @@ function readme(composition: InitComposition): string {
1585
1740
  /**
1586
1741
  * The GLSP files for ONE grammar's diagram.
1587
1742
  *
1588
- * Per grammar rather than per project because a diagram type has exactly one
1589
- * grammar — `AbstractHydraniumGlspDiagramModule.declareLanguage()` returns that
1590
- * grammar's `LanguageMetaData`, and it is bound on the SESSION container so a
1591
- * grammar with no diagram can sit on the same server.
1743
+ * Per grammar rather than per project because the state, storage and GModel
1744
+ * factory are typed over that grammar's model, and a grammar with no diagram
1745
+ * can sit on the same server.
1592
1746
  *
1593
1747
  * The index and the computed-bounds handler are framework classes used
1594
1748
  * unmodified; storage and the submission handler are thin typed subclasses,
@@ -1602,157 +1756,11 @@ function readme(composition: InitComposition): string {
1602
1756
  */
1603
1757
  function glspFiles(composition: InitComposition, grammar: InitGrammarNames): InitFile[] {
1604
1758
  const columns = columnsFor(composition);
1605
- const upper = screamingSnake(grammar.grammarId);
1606
- const dir = `src/glsp/${grammar.grammarId}`;
1607
- const render = (text: string): string => grammarTier(project(text, composition), grammar);
1608
-
1609
- const types = `// GLSP diagram-type and element-type ids for the __GRAMMAR__ diagram.
1610
- //
1611
- // **Authoritative half of a client/server contract.** Every id here has to be
1612
- // registered on the client too: sprotty's registries are exact-key maps with no
1613
- // prefix fallback, so an id the client does not know yields an element with none
1614
- // of a node's features, rendered by \`MissingView\` with only a
1615
- // \`no registered view for type '…'\` console warning. Nothing fails server-side.
1616
- //
1617
- // The ids stay namespaced under the GLSP defaults (\`node:\` / \`edge:\`) so a
1618
- // reader can tell a node id from an edge id at a glance.
1619
-
1620
- import { DefaultTypes } from '@eclipse-glsp/server';
1621
-
1622
- /**
1623
- * The GLSP diagram type — the string GLSP routes every per-diagram-type request
1624
- * by. Mirrored by the client; a mismatch silently DROPS the request rather than
1625
- * reporting an unknown diagram type. Same value as the language id, so the two
1626
- * cannot drift apart as a diagram gains element types.
1627
- */
1628
- export const ${upper}_DIAGRAM_TYPE = '__LANGUAGE_ID__';
1629
-
1630
- /** A \`__NODE_RULE__\` — the starter grammar's one named, referenceable node. */
1631
- export const ${upper}_NODE_TYPE = \`\${DefaultTypes.NODE}:__GRAMMAR_ID__-node\`;
1632
-
1633
- /** A resolved \`target\` reference, drawn as a connection between two nodes. */
1634
- export const ${upper}_EDGE_TYPE = \`\${DefaultTypes.EDGE}:__GRAMMAR_ID__-target\`;
1635
- `;
1636
-
1637
- const state = `// GLSP state for the __GRAMMAR__ diagram.
1638
- //
1639
- // \`FullTextHydraniumGlspState\` is the simplest of the framework source-model
1640
- // strategies (the others project a structured transfer model, over one document
1641
- // or several): the source model is the whole document text, serialised through
1642
- // the per-URI \`Serializer\` and round-tripped by re-parsing. Both seams resolve
1643
- // through shared services, so narrowing the root type is all an adopter adds.
1644
- //
1645
- // It cannot field-merge — every concurrent edit on a whole-document model is a
1646
- // same-document collision, so undo / redo degrade to drop-on-divergence. Move to
1647
- // \`ReconcilingTransferHydraniumGlspState\` when you need field-level undo.
1648
-
1649
- import { FullTextHydraniumGlspState } from '@hydranium/glsp-server';
1650
- import { injectable } from 'inversify';
1651
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1652
-
1653
- @injectable()
1654
- export class __GRAMMAR__GlspState extends FullTextHydraniumGlspState<__ENTRY_RULE__> {}
1655
- `;
1656
-
1657
- const storage = `// Source-model storage for the __GRAMMAR__ diagram, inheriting both framework
1658
- // defaults: \`loadSourceModel\` (open + settle + \`setSourceRoot\`) and
1659
- // \`saveSourceModel\` (through \`ModelService.save\` → the per-URI \`Serializer\` →
1660
- // the multi-client text store → \`WritableFileSystemProvider\`).
1661
- //
1662
- // The subclass exists so a bespoke load or save has a stable place to land.
1663
-
1664
- import { type FullTextSourceModel, HydraniumGlspStorage } from '@hydranium/glsp-server';
1665
- import { injectable } from 'inversify';
1666
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1667
-
1668
- @injectable()
1669
- export class __GRAMMAR__GlspStorage extends HydraniumGlspStorage<__ENTRY_RULE__, FullTextSourceModel> {}
1670
- `;
1671
-
1672
- const submission = `// Submission handler for the __GRAMMAR__ diagram.
1673
- //
1674
- // Inherits the framework's \`readyEvent = IntegrityService.SettledState\`, which
1675
- // is load-bearing rather than incidental: the GModel factory resolves
1676
- // \`target.ref\`, so it needs a fully-linked AST. Without the gate those reads
1677
- // fire mid-build and warn about resolution before scopes are computed.
1678
- //
1679
- // Only \`formatSourceRoot\` is overridden, so the submit log names the model and
1680
- // its node count instead of a bare \`$type\`.
1681
-
1682
- import { ModelState } from '@eclipse-glsp/server';
1683
- import { type FullTextSourceModel, HydraniumGlspSubmissionHandler } from '@hydranium/glsp-server';
1684
- import { inject, injectable } from 'inversify';
1685
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1686
- import type { __GRAMMAR__GlspState } from './state.js';
1687
-
1688
- @injectable()
1689
- export class __GRAMMAR__SubmissionHandler extends HydraniumGlspSubmissionHandler<__ENTRY_RULE__, FullTextSourceModel> {
1690
- @inject(ModelState) declare protected modelState: __GRAMMAR__GlspState;
1691
-
1692
- protected override formatSourceRoot(root: __ENTRY_RULE__ | undefined): string {
1693
- return root ? \`__ENTRY_RULE__ nodes=\${root.nodes.length}\` : 'none';
1694
- }
1695
- }
1696
- `;
1697
-
1698
- const factory = `// AST → GModel for the __GRAMMAR__ diagram: one node per \`__NODE_RULE__\`, one
1699
- // edge per resolved \`target\` reference.
1700
- //
1701
- // Ids come from the index rather than being composed here, so the id strategy
1702
- // stays in one place. Nodes are emitted before edges, because a GLSP edge
1703
- // pointing at an id that does not exist fails client-side with a far less
1704
- // obvious error than a missing edge. An unresolved reference is SKIPPED rather
1705
- // than treated as an error: a dangling reference is ordinary editing state, and
1706
- // the LSP head already reports it as a diagnostic.
1707
-
1708
- import { DefaultTypes, GEdge, GGraph, GLabel, type GModelFactory, GNode, ModelState } from '@eclipse-glsp/server';
1709
- import { inject, injectable } from 'inversify';
1710
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1711
- import { ${upper}_EDGE_TYPE, ${upper}_NODE_TYPE } from './types.js';
1712
- import type { __GRAMMAR__GlspState } from './state.js';
1713
-
1714
- @injectable()
1715
- export class __GRAMMAR__GModelFactory implements GModelFactory {
1716
- @inject(ModelState) protected readonly modelState!: __GRAMMAR__GlspState;
1717
-
1718
- createModel(): void {
1719
- const root = this.modelState.sourceRoot;
1720
- const graph = GGraph.builder().id(this.modelState.sourceUri).build();
1721
- if (root) {
1722
- this.buildGraph(root, graph);
1723
- }
1724
- this.modelState.updateRoot(graph);
1725
- }
1726
-
1727
- protected buildGraph(root: __ENTRY_RULE__, graph: GGraph): void {
1728
- for (const node of root.nodes) {
1729
- const id = this.modelState.index.createId(node);
1730
- graph.children.push(
1731
- GNode.builder()
1732
- .id(id)
1733
- .type(${upper}_NODE_TYPE)
1734
- .add(GLabel.builder().id(\`\${id}_name\`).text(node.name).type(DefaultTypes.LABEL).build())
1735
- .build()
1736
- );
1737
- }
1738
- for (const node of root.nodes) {
1739
- const target = node.target?.ref;
1740
- if (!target) {
1741
- continue;
1742
- }
1743
- const sourceId = this.modelState.index.createId(node);
1744
- graph.children.push(
1745
- GEdge.builder()
1746
- .id(\`\${sourceId}_target\`)
1747
- .type(${upper}_EDGE_TYPE)
1748
- .sourceId(sourceId)
1749
- .targetId(this.modelState.index.createId(target))
1750
- .build()
1751
- );
1752
- }
1753
- }
1754
- }
1755
- `;
1759
+ const fixed = (file: string): InitFile => grammarTemplate(`server/src/glsp/__GRAMMAR_ID__/${file}`, composition, grammar);
1760
+ const computed = (file: string, text: string): InitFile => ({
1761
+ path: `server/src/glsp/${grammar.grammarId}/${file}`,
1762
+ content: render(text, composition, grammar)
1763
+ });
1756
1764
 
1757
1765
  const configuration = `// Diagram configuration for the __GRAMMAR__ diagram.
1758
1766
  //
@@ -1777,7 +1785,7 @@ ${importList(
1777
1785
  columns
1778
1786
  )}
1779
1787
  import { injectable } from 'inversify';
1780
- import { ${upper}_EDGE_TYPE, ${upper}_NODE_TYPE } from './types.js';
1788
+ import { __GRAMMAR_ID_CONST___EDGE_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from '__PROTOCOL_PACKAGE__';
1781
1789
 
1782
1790
  @injectable()
1783
1791
  export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
@@ -1789,7 +1797,7 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
1789
1797
 
1790
1798
  readonly shapeTypeHints: ShapeTypeHint[] = [
1791
1799
  {
1792
- elementTypeId: ${upper}_NODE_TYPE,
1800
+ elementTypeId: __GRAMMAR_ID_CONST___NODE_TYPE,
1793
1801
  deletable: false,
1794
1802
  reparentable: false,
1795
1803
  repositionable: false,
@@ -1799,107 +1807,19 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
1799
1807
 
1800
1808
  readonly edgeTypeHints: EdgeTypeHint[] = [
1801
1809
  {
1802
- elementTypeId: ${upper}_EDGE_TYPE,
1810
+ elementTypeId: __GRAMMAR_ID_CONST___EDGE_TYPE,
1803
1811
  deletable: false,
1804
1812
  repositionable: false,
1805
1813
  routable: false,
1806
- sourceElementTypeIds: [${upper}_NODE_TYPE],
1807
- targetElementTypeIds: [${upper}_NODE_TYPE]
1814
+ sourceElementTypeIds: [__GRAMMAR_ID_CONST___NODE_TYPE],
1815
+ targetElementTypeIds: [__GRAMMAR_ID_CONST___NODE_TYPE]
1808
1816
  }
1809
1817
  ];
1810
1818
  }
1811
- `;
1812
-
1813
- const createHandler = `// The starter operation handler for the __GRAMMAR__ diagram: the tool-palette
1814
- // entry that creates a \`__NODE_RULE__\`.
1815
- //
1816
- // **Deleting this file and its \`configureOperationHandlers\` registration in
1817
- // \`diagram-module.ts\` gives a read-only viewer**, and nothing else has to change
1818
- // — creation is offered through the palette rather than through a type hint, so
1819
- // every hint in \`diagram-configuration.ts\` is already \`false\`. The scaffold emits
1820
- // the editable direction because that asymmetry runs one way: editable →
1821
- // read-only is a deletion the compiler checks, while read-only → editable is
1822
- // authoring against a seam you have not used yet.
1823
- //
1824
- // **It composes TEXT rather than mutating the AST.** The source model of
1825
- // \`FullTextHydraniumGlspState\` is the document text, and reading it back through
1826
- // \`state.sourceModel\` serialises the AST through the per-URI \`Serializer\`, which
1827
- // this scaffold does not bind — so that getter throws until you do. Appending a
1828
- // declaration to the text the parser last read needs no serializer, which is what
1829
- // makes a scaffolded diagram editable on day one. Bind a \`Serializer\` at
1830
- // \`services.serializer.Serializer\` and this becomes a
1831
- // \`HydraniumGlspRecordingCommand\` over \`state.sourceModel\` instead — the same
1832
- // binding the diagram's own save action needs.
1833
- //
1834
- // **The drop location is discarded.** \`needsClientLayout\` is \`true\` and the
1835
- // starter grammar persists no bounds, so there is nowhere to put a coordinate: the
1836
- // node is appended at the end of the document and the client places it.
1837
- //
1838
- // Like \`types.ts\`, \`gmodel-factory.ts\` and \`diagram-configuration.ts\`, this file
1839
- // knows the starter grammar's concrete syntax — the \`node\` keyword below is that
1840
- // grammar's. Replacing the grammar means replacing these four together.
1841
-
1842
- import { type Command, type CreateNodeOperation, JsonCreateNodeOperationHandler, type MaybePromise } from '@eclipse-glsp/server';
1843
- import { findNextUnique } from '@hydranium/protocol';
1844
- import { injectable } from 'inversify';
1845
- import { ${upper}_NODE_TYPE } from './types.js';
1846
- import type { __GRAMMAR__GlspState } from './state.js';
1847
-
1848
- /** Proposed name for a new node, uniquified against the ones the document already has. */
1849
- const NODE_NAME_STEM = 'Node';
1850
-
1851
- @injectable()
1852
- export class __GRAMMAR__CreateNodeOperationHandler extends JsonCreateNodeOperationHandler {
1853
- declare protected modelState: __GRAMMAR__GlspState;
1854
-
1855
- /** The palette's word for the thing it creates, so a noun rather than an action. */
1856
- override readonly label = '__NODE_RULE__';
1857
- elementTypeIds = [${upper}_NODE_TYPE];
1858
-
1859
- override createCommand(operation: CreateNodeOperation): MaybePromise<Command | undefined> {
1860
- if (!this.elementTypeIds.includes(operation.elementTypeId)) {
1861
- return undefined;
1862
- }
1863
- const state = this.modelState;
1864
- const before = this.documentText();
1865
- const after = this.withNode(
1866
- before,
1867
- findNextUnique(
1868
- NODE_NAME_STEM,
1869
- state.sourceRoot.nodes.map(node => node.name)
1870
- )
1871
- );
1872
- // Whole-document undo, which is all a full-text source model can offer: it
1873
- // has exactly one field, so there is nothing to merge a concurrent edit into.
1874
- return {
1875
- execute: () => state.updateSourceModel({ text: after }),
1876
- undo: () => state.updateSourceModel({ text: before }),
1877
- redo: () => state.updateSourceModel({ text: after })
1878
- };
1879
- }
1880
-
1881
- /** The text the captured source root was parsed from — the baseline an edit appends to. */
1882
- protected documentText(): string {
1883
- return this.modelState.sourceRoot.$document?.textDocument.getText() ?? '';
1884
- }
1885
-
1886
- /** \`text\` with one more node declaration, under exactly one trailing newline. */
1887
- protected withNode(text: string, name: string): string {
1888
- const body = text.trimEnd();
1889
- const declaration = \`node \${name}\`;
1890
- return body.length === 0 ? \`\${declaration}\\n\` : \`\${body}\\n\${declaration}\\n\`;
1891
- }
1892
- }
1893
1819
  `;
1894
1820
 
1895
1821
  const module = `// GLSP diagram module for __GRAMMAR__ — the DI wiring of one diagram type.
1896
1822
  //
1897
- // \`declareLanguage\` is the multi-grammar seam: it binds the grammar this diagram
1898
- // edits on the SESSION container, so \`modelState.diagramLanguage\` and the
1899
- // per-language lookups resolve to __LANGUAGE_ID__. That is what stops one
1900
- // diagram type from fighting over a process-wide binding when a project holds
1901
- // several grammars — a grammar with no diagram is registered on the same server.
1902
- //
1903
1823
  // \`configureActionHandlers\` REBINDS rather than adds: GLSP's own
1904
1824
  // \`DiagramModule\` already registers \`ComputedBoundsActionHandler\`, and two
1905
1825
  // handlers for one action would both run.
@@ -1926,27 +1846,21 @@ ${importList(
1926
1846
  true
1927
1847
  )}
1928
1848
  import { ComputedBoundsActionHandler } from '@eclipse-glsp/server';
1929
- import type { LanguageMetaData } from '@hydranium/langium';
1930
1849
  ${importList(
1931
1850
  ['HydraniumGlspComputedBoundsActionHandler', 'AbstractHydraniumGlspDiagramModule', 'HydraniumGlspIndex'],
1932
1851
  '@hydranium/glsp-server',
1933
1852
  columns
1934
1853
  )}
1935
- import { __GRAMMAR__LanguageMetaData } from '../../language-server/generated/module.js';
1936
1854
  import { __GRAMMAR__CreateNodeOperationHandler } from './create-node-operation-handler.js';
1937
1855
  import { __GRAMMAR__DiagramConfiguration } from './diagram-configuration.js';
1938
1856
  import { __GRAMMAR__GModelFactory } from './gmodel-factory.js';
1939
1857
  import { __GRAMMAR__GlspState } from './state.js';
1940
1858
  import { __GRAMMAR__GlspStorage } from './storage.js';
1941
1859
  import { __GRAMMAR__SubmissionHandler } from './submission-handler.js';
1942
- import { ${upper}_DIAGRAM_TYPE } from './types.js';
1860
+ import { __GRAMMAR_ID_CONST___DIAGRAM_TYPE } from '__PROTOCOL_PACKAGE__';
1943
1861
 
1944
1862
  export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule {
1945
- readonly diagramType = ${upper}_DIAGRAM_TYPE;
1946
-
1947
- protected override declareLanguage(): LanguageMetaData {
1948
- return __GRAMMAR__LanguageMetaData;
1949
- }
1863
+ readonly diagramType = __GRAMMAR_ID_CONST___DIAGRAM_TYPE;
1950
1864
 
1951
1865
  protected override bindModelState(): BindingTarget<ModelState> {
1952
1866
  return { service: __GRAMMAR__GlspState };
@@ -1986,68 +1900,83 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1986
1900
  `;
1987
1901
 
1988
1902
  return [
1989
- { path: `${dir}/types.ts`, content: render(types) },
1990
- { path: `${dir}/state.ts`, content: render(state) },
1991
- { path: `${dir}/storage.ts`, content: render(storage) },
1992
- { path: `${dir}/submission-handler.ts`, content: render(submission) },
1993
- { path: `${dir}/gmodel-factory.ts`, content: render(factory) },
1994
- { path: `${dir}/diagram-configuration.ts`, content: render(configuration) },
1995
- { path: `${dir}/create-node-operation-handler.ts`, content: render(createHandler) },
1996
- { path: `${dir}/diagram-module.ts`, content: render(module) }
1903
+ fixed('state.ts'),
1904
+ fixed('storage.ts'),
1905
+ fixed('submission-handler.ts'),
1906
+ fixed('gmodel-factory.ts'),
1907
+ computed('diagram-configuration.ts', configuration),
1908
+ fixed('create-node-operation-handler.ts'),
1909
+ computed('diagram-module.ts', module),
1910
+ grammarTemplate('server/test/__GRAMMAR_ID__-diagram.test.ts', composition, grammar)
1997
1911
  ];
1998
1912
  }
1999
1913
 
2000
1914
  /**
2001
- * Every file the scaffold emits, fully rendered.
1915
+ * The server member: the grammar, the language and its heads.
2002
1916
  *
2003
1917
  * Grammar-count-dependent emissions live here rather than in a template string:
2004
1918
  * one `.langium` file per grammar, and the shared `common.langium` fragment only
2005
1919
  * when there is more than one grammar to share it.
2006
1920
  */
2007
- export function buildInitTemplates(composition: InitComposition): InitFile[] {
1921
+ function serverFiles(composition: InitComposition): InitFile[] {
2008
1922
  const multi = composition.grammars.length > 1;
2009
1923
  const grammarFiles: InitFile[] = composition.grammars.map(grammar => ({
2010
- path: `src/grammar/${grammar.grammarId}.langium`,
1924
+ path: `server/src/grammar/${grammar.grammarId}.langium`,
2011
1925
  content: render(grammarFile(multi), composition, grammar)
2012
1926
  }));
1927
+ // The shared lexical fragment, only for several grammars: each declaring its
1928
+ // own `WS` / `ID` lets the token sets drift apart.
2013
1929
  if (multi) {
2014
- grammarFiles.unshift({ path: 'src/grammar/common.langium', content: COMMON_GRAMMAR });
1930
+ grammarFiles.unshift(projectTemplate('server/src/grammar/common.langium', composition));
2015
1931
  }
2016
1932
 
2017
1933
  const diagramFiles = composition.grammars.filter(grammar => grammar.diagram).flatMap(grammar => glspFiles(composition, grammar));
2018
1934
 
2019
1935
  return [
2020
- { path: 'package.json', content: packageJson(composition) },
2021
- { path: 'langium-config.json', content: langiumConfig(composition) },
2022
- { path: 'tsconfig.json', content: tsconfigJson(composition) },
2023
- { path: 'tsconfig.test.json', content: TSCONFIG_TEST },
2024
- { path: 'vitest.config.ts', content: VITEST_CONFIG },
2025
- {
2026
- path: '.gitignore',
2027
- content: composition.packaging.workspace === undefined ? GITIGNORE : GITIGNORE_WORKSPACE_MEMBER
2028
- },
2029
- { path: 'README.md', content: readme(composition) },
1936
+ { path: 'server/package.json', content: packageJson(composition) },
1937
+ { path: 'server/langium-config.json', content: langiumConfig(composition) },
1938
+ { path: 'server/tsconfig.json', content: serverTsconfigJson(composition) },
1939
+ // `isolatedModules` is what makes this check agree with the transform that
1940
+ // actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
1941
+ // vitest compiles each file on its own — a transform with no type
1942
+ // information, which cannot tell a re-exported TYPE from a re-exported value.
1943
+ // Without the flag `tsc` accepts `export { SomeType } from './x'` that the
1944
+ // transform then emits as a real import of a symbol that does not exist at
1945
+ // runtime, so the typecheck passes and the suite fails with a confusing
1946
+ // missing-export.
1947
+ projectTemplate('server/tsconfig.test.json', composition),
1948
+ // Found by name, unlike `tsconfig.test.json`. Under vite 8, vitest's transform
1949
+ // applies a tsconfig only to the files its own `include` lists, so without this
1950
+ // a test declaring a decorated class fails to load.
1951
+ projectTemplate('server/test/tsconfig.json', composition),
1952
+ projectTemplate('server/vitest.config.ts', composition),
1953
+ // Written for a workspace member too: its root may not ignore `lib/` (a `yo
1954
+ // langium` root ignores `out/`), and a rule the root repeats costs nothing.
1955
+ ignoreFile('server'),
1956
+ { path: 'server/README.md', content: readme(composition) },
2030
1957
  ...grammarFiles,
2031
- { path: `src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },
2032
- { path: 'src/language-server/ast.ts', content: render(AST, composition, composition.grammars[0]) },
2033
- ...composition.grammars.map(grammar => ({
2034
- path: `src/language-server/${grammar.grammarId}-serializer.ts`,
2035
- content: render(SERIALIZER, composition, grammar)
2036
- })),
2037
- { path: 'src/index.ts', content: indexFile(composition) },
2038
- { path: 'src/services.ts', content: project(SERVICES, composition) },
2039
- { path: 'src/main.ts', content: mainFile(composition) },
2040
- ...(composition.heads.includes('data') ? [{ path: 'src/data-server-main.ts', content: dataServerMainFile(composition) }] : []),
2041
- ...(portCommandNames(composition).length ? [{ path: 'src/head-ports.ts', content: headPortsFile(composition) }] : []),
1958
+ { path: `server/src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },
1959
+ grammarTemplate('server/src/language-server/ast.ts', composition, composition.grammars[0]),
1960
+ // Per grammar, because `services.serializer.Serializer` is a per-language slot
1961
+ // and a serializer is grammar-shaped by definition. That is also why the
1962
+ // framework refuses to default it: its `UnboundSerializer` throws, naming the
1963
+ // binding to add, rather than guessing a syntax.
1964
+ ...composition.grammars.map(grammar =>
1965
+ grammarTemplate('server/src/language-server/__GRAMMAR_ID__-serializer.ts', composition, grammar)
1966
+ ),
1967
+ { path: 'server/src/index.ts', content: indexFile(composition) },
1968
+ projectTemplate('server/src/node/services.ts', composition),
1969
+ { path: 'server/src/main.ts', content: mainFile(composition) },
1970
+ ...(composition.heads.includes('data') ? [{ path: 'server/src/data-server-main.ts', content: dataServerMainFile(composition) }] : []),
2042
1971
  ...diagramFiles,
2043
- { path: 'test/services.test.ts', content: servicesTest(composition) },
2044
- { path: 'test/parsing.test.ts', content: grammarTest(composition, PARSING_HEADER, [], PARSING_SUITE) },
1972
+ { path: 'server/test/services.test.ts', content: servicesTest(composition) },
1973
+ { path: 'server/test/parsing.test.ts', content: grammarTest(composition, PARSING_HEADER, [], PARSING_SUITE) },
2045
1974
  {
2046
- path: 'test/linking.test.ts',
1975
+ path: 'server/test/linking.test.ts',
2047
1976
  content: grammarTest(composition, LINKING_HEADER, ["import { AstUtils } from '@hydranium/langium';"], LINKING_SUITE)
2048
1977
  },
2049
1978
  {
2050
- path: 'test/validating.test.ts',
1979
+ path: 'server/test/validating.test.ts',
2051
1980
  content: grammarTest(
2052
1981
  composition,
2053
1982
  VALIDATING_HEADER,
@@ -2055,6 +1984,171 @@ export function buildInitTemplates(composition: InitComposition): InitFile[] {
2055
1984
  VALIDATING_SUITE
2056
1985
  )
2057
1986
  },
2058
- { path: 'test/serialization.test.ts', content: serializationTest(composition) }
1987
+ { path: 'server/test/serialization.test.ts', content: serializationTest(composition) }
1988
+ ];
1989
+ }
1990
+
1991
+ /**
1992
+ * The protocol member's `package.json`.
1993
+ *
1994
+ * Its `build` runs the server's `generate` first, because the server writes
1995
+ * this package's `generated-hydranium/` modules from the grammar: a runner that
1996
+ * builds in dependency order reaches this package before the server, and would
1997
+ * otherwise compile modules that do not exist yet. A full build therefore
1998
+ * generates twice; the second pass rewrites nothing.
1999
+ */
2000
+ function protocolPackageJson(composition: InitComposition): string {
2001
+ const member = forMember(composition, 'protocol');
2002
+ const dependencies: Record<string, string> = {};
2003
+ for (const head of composition.heads) {
2004
+ Object.assign(dependencies, PROTOCOL_THIRD_PARTY_DEPENDENCIES[head]);
2005
+ }
2006
+ const dependencyLines =
2007
+ Object.keys(dependencies).length === 0 ? '' : ` "dependencies": {\n${sortedBlock(dependencies, ' ')}\n },\n`;
2008
+ return `{
2009
+ "name": "${memberPackageName(composition, 'protocol')}",
2010
+ "version": "0.0.0",
2011
+ ${composition.packaging.private ? ' "private": true,\n' : ''} "description": "What the ${composition.name} language server and its clients share: language ids, wire types and diagram ids.",
2012
+ "license": "UNLICENSED",
2013
+ "author": "",
2014
+ "files": [
2015
+ "lib",
2016
+ "src"
2017
+ ],
2018
+ "type": "module",
2019
+ "main": "lib/index.js",
2020
+ "types": "lib/index.d.ts",
2021
+ "scripts": {
2022
+ "build": "npm --prefix ../server run generate && tsc -b",
2023
+ "clean": "rimraf lib tsconfig.tsbuildinfo",
2024
+ ${lintScript(member, 'src')} "watch": "tsc -b --watch"
2025
+ },
2026
+ ${dependencyLines} "devDependencies": {
2027
+ "rimraf": "^5.0.0",
2028
+ "typescript": "^7.0.2"
2029
+ },
2030
+ "engines": {
2031
+ "node": ">=22.13"
2032
+ }
2033
+ }
2034
+ `;
2035
+ }
2036
+
2037
+ /** The protocol member's root entry, one re-export per module. */
2038
+ function protocolIndexFile(composition: InitComposition): string {
2039
+ const modules = [
2040
+ './generated-hydranium/languages.js',
2041
+ './generated-hydranium/transfer-model.js',
2042
+ ...(headPortConstants(composition).length ? ['./head-ports.js'] : []),
2043
+ ...composition.grammars.filter(grammar => grammar.diagram).map(grammar => `./${grammar.grammarId}-diagram-types.js`)
2044
+ ];
2045
+ const text = `// What the __NAME__ server and its clients both import. A client depends on
2046
+ // this package instead of the server, so it never installs Langium to reach a
2047
+ // constant or a wire type.
2048
+ //
2049
+ // The server's \`generate\` writes \`generated-hydranium/\` from the grammar;
2050
+ // commit those modules with the grammar change that produced them.
2051
+
2052
+ ${modules.map(module => `export * from '${module}';`).join('\n')}
2053
+ `;
2054
+ return project(text, composition);
2055
+ }
2056
+
2057
+ /** The protocol member's README. */
2058
+ function protocolReadme(composition: InitComposition): string {
2059
+ const lines = [
2060
+ `# ${memberPackageName(composition, 'protocol')}`,
2061
+ '',
2062
+ 'What the __NAME__ language server and its clients both import, so a client',
2063
+ 'never depends on the server or installs Langium.',
2064
+ '',
2065
+ '- `src/generated-hydranium/languages.ts` — each language id and its file extensions',
2066
+ '- `src/generated-hydranium/transfer-model.ts` — the wire types of the model, references as plain strings',
2067
+ ...(headPortConstants(composition).length === 0
2068
+ ? []
2069
+ : composition.heads.includes('lsp')
2070
+ ? ['- `src/head-ports.ts` — the LSP requests a host sends to find the socket heads']
2071
+ : ["- `src/head-ports.ts` — the environment variables naming the socket heads' ports"]),
2072
+ ...composition.grammars
2073
+ .filter(grammar => grammar.diagram)
2074
+ .map(grammar => `- \`src/${grammar.grammarId}-diagram-types.ts\` — the \`${grammar.grammar}\` diagram's type ids`),
2075
+ '',
2076
+ "The server's `npm run generate` writes `src/generated-hydranium/` from the",
2077
+ 'grammar, and `npm run build` here runs it first. Commit the generated',
2078
+ 'modules with the grammar change that produced them.',
2079
+ ''
2080
+ ];
2081
+ return project(lines.join('\n'), composition);
2082
+ }
2083
+
2084
+ /** The protocol member: everything the server and its clients both import. */
2085
+ function protocolFiles(composition: InitComposition): InitFile[] {
2086
+ return [
2087
+ { path: 'protocol/package.json', content: protocolPackageJson(composition) },
2088
+ { path: 'protocol/tsconfig.json', content: tsconfigJson(forMember(composition, 'protocol'), PROTOCOL_COMPILER_OPTIONS, '["src"]') },
2089
+ ignoreFile('protocol'),
2090
+ { path: 'protocol/README.md', content: protocolReadme(composition) },
2091
+ { path: 'protocol/src/index.ts', content: protocolIndexFile(composition) },
2092
+ ...(headPortConstants(composition).length ? [{ path: 'protocol/src/head-ports.ts', content: headPortsFile(composition) }] : []),
2093
+ ...composition.grammars
2094
+ .filter(grammar => grammar.diagram)
2095
+ .map(grammar => grammarTemplate('protocol/src/__GRAMMAR_ID__-diagram-types.ts', composition, grammar))
2096
+ ];
2097
+ }
2098
+
2099
+ /**
2100
+ * The starter model files every host and the CLI open, two per language: one
2101
+ * declaring nodes, one referencing a node in the other file.
2102
+ *
2103
+ * Named by extension alone, since `init` refuses two grammars sharing one.
2104
+ */
2105
+ function workspaceFiles(composition: InitComposition): InitFile[] {
2106
+ return composition.grammars.flatMap(grammar => [
2107
+ grammarTemplate('workspace/nodes.__EXTENSION__', composition, grammar),
2108
+ grammarTemplate('workspace/references.__EXTENSION__', composition, grammar)
2109
+ ]);
2110
+ }
2111
+
2112
+ /**
2113
+ * A standalone project's root: the members as npm workspaces.
2114
+ *
2115
+ * Always private: it is never published, whatever `--public` makes of the
2116
+ * members. npm runs a script across the members in the order listed here, not
2117
+ * by their dependencies; listing protocol first is safe because its `build`
2118
+ * runs the server's `generate` before compiling.
2119
+ */
2120
+ function rootFiles(composition: InitComposition): InitFile[] {
2121
+ const content = `{
2122
+ "name": "${composition.projectId}-root",
2123
+ "version": "0.0.0",
2124
+ "private": true,
2125
+ "workspaces": [
2126
+ "protocol",
2127
+ "server"
2128
+ ],
2129
+ "scripts": {
2130
+ "build": "npm run build --workspaces",
2131
+ "clean": "npm run clean --workspaces --if-present",
2132
+ "test": "npm test --workspaces --if-present"
2133
+ },
2134
+ "engines": {
2135
+ "node": ">=22.13"
2136
+ }
2137
+ }
2138
+ `;
2139
+ return [{ path: 'package.json', content }, ignoreFile('')];
2140
+ }
2141
+
2142
+ /**
2143
+ * Every file the scaffold emits, fully rendered, at its path in the project
2144
+ * directory. A workspace member's project gets no root of its own: it joins the
2145
+ * surrounding workspace, whose manifest `init` prints the changes for.
2146
+ */
2147
+ export function buildInitTemplates(composition: InitComposition): InitFile[] {
2148
+ return [
2149
+ ...(composition.packaging.workspace === undefined ? rootFiles(composition) : []),
2150
+ ...protocolFiles(composition),
2151
+ ...serverFiles(composition),
2152
+ ...workspaceFiles(composition)
2059
2153
  ];
2060
2154
  }