@hydranium/cli 1.0.0-next.42 → 1.0.0-next.421

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 (371) hide show
  1. package/README.md +125 -204
  2. package/bin/hydranium-cli.js +13 -0
  3. package/heap-analysis/heap/core/load.mjs +2 -2
  4. package/heap-analysis/heap/langium/langium.classifier.mjs +22 -11
  5. package/lib/cli-version.js.map +1 -1
  6. package/lib/cli.js +11 -2
  7. package/lib/cli.js.map +1 -1
  8. package/lib/commands/analyze-heap-args.d.ts.map +1 -1
  9. package/lib/commands/analyze-heap-args.js +1 -2
  10. package/lib/commands/analyze-heap-args.js.map +1 -1
  11. package/lib/commands/analyze-heap.d.ts +3 -0
  12. package/lib/commands/analyze-heap.d.ts.map +1 -1
  13. package/lib/commands/analyze-heap.js +2 -1
  14. package/lib/commands/analyze-heap.js.map +1 -1
  15. package/lib/commands/ast-ground-truth-args.d.ts +3 -2
  16. package/lib/commands/ast-ground-truth-args.d.ts.map +1 -1
  17. package/lib/commands/ast-ground-truth-args.js +12 -10
  18. package/lib/commands/ast-ground-truth-args.js.map +1 -1
  19. package/lib/commands/ast-ground-truth-driver.js +4 -2
  20. package/lib/commands/ast-ground-truth-driver.js.map +1 -1
  21. package/lib/commands/ast-ground-truth.js +1 -1
  22. package/lib/commands/ast-ground-truth.js.map +1 -1
  23. package/lib/commands/check-skills-args.d.ts +11 -0
  24. package/lib/commands/check-skills-args.d.ts.map +1 -0
  25. package/lib/commands/check-skills-args.js +71 -0
  26. package/lib/commands/check-skills-args.js.map +1 -0
  27. package/lib/commands/client-session.d.ts +17 -0
  28. package/lib/commands/client-session.d.ts.map +1 -0
  29. package/lib/commands/client-session.js +27 -0
  30. package/lib/commands/client-session.js.map +1 -0
  31. package/lib/commands/create-args.d.ts +17 -0
  32. package/lib/commands/create-args.d.ts.map +1 -0
  33. package/lib/commands/create-args.js +53 -0
  34. package/lib/commands/create-args.js.map +1 -0
  35. package/lib/commands/create.d.ts +38 -0
  36. package/lib/commands/create.d.ts.map +1 -0
  37. package/lib/commands/create.js +43 -0
  38. package/lib/commands/create.js.map +1 -0
  39. package/lib/commands/delete-args.d.ts +17 -0
  40. package/lib/commands/delete-args.d.ts.map +1 -0
  41. package/lib/commands/delete-args.js +49 -0
  42. package/lib/commands/delete-args.js.map +1 -0
  43. package/lib/commands/delete.d.ts +30 -0
  44. package/lib/commands/delete.d.ts.map +1 -0
  45. package/lib/commands/delete.js +32 -0
  46. package/lib/commands/delete.js.map +1 -0
  47. package/lib/commands/generate-ast-builder.d.ts +1 -1
  48. package/lib/commands/generate-ast-builder.d.ts.map +1 -1
  49. package/lib/commands/generate-ast-builder.js +2 -11
  50. package/lib/commands/generate-ast-builder.js.map +1 -1
  51. package/lib/commands/generate-languages.d.ts +35 -0
  52. package/lib/commands/generate-languages.d.ts.map +1 -0
  53. package/lib/commands/generate-languages.js +105 -0
  54. package/lib/commands/generate-languages.js.map +1 -0
  55. package/lib/commands/generate-transfer-builder.d.ts +33 -0
  56. package/lib/commands/generate-transfer-builder.d.ts.map +1 -0
  57. package/lib/commands/generate-transfer-builder.js +86 -0
  58. package/lib/commands/generate-transfer-builder.js.map +1 -0
  59. package/lib/commands/generate-transfer-model-args.d.ts.map +1 -1
  60. package/lib/commands/generate-transfer-model-args.js +20 -5
  61. package/lib/commands/generate-transfer-model-args.js.map +1 -1
  62. package/lib/commands/generate-transfer-model-config.d.ts.map +1 -1
  63. package/lib/commands/generate-transfer-model-config.js +8 -3
  64. package/lib/commands/generate-transfer-model-config.js.map +1 -1
  65. package/lib/commands/generate-transfer-model.d.ts +41 -9
  66. package/lib/commands/generate-transfer-model.d.ts.map +1 -1
  67. package/lib/commands/generate-transfer-model.js +225 -68
  68. package/lib/commands/generate-transfer-model.js.map +1 -1
  69. package/lib/commands/generated-module-path.d.ts +16 -0
  70. package/lib/commands/generated-module-path.d.ts.map +1 -0
  71. package/lib/commands/generated-module-path.js +21 -0
  72. package/lib/commands/generated-module-path.js.map +1 -0
  73. package/lib/commands/harness-args.d.ts +46 -43
  74. package/lib/commands/harness-args.d.ts.map +1 -1
  75. package/lib/commands/harness-args.js +170 -29
  76. package/lib/commands/harness-args.js.map +1 -1
  77. package/lib/commands/headless-harness.d.ts +37 -20
  78. package/lib/commands/headless-harness.d.ts.map +1 -1
  79. package/lib/commands/headless-harness.js +71 -19
  80. package/lib/commands/headless-harness.js.map +1 -1
  81. package/lib/commands/init-args.d.ts +5 -18
  82. package/lib/commands/init-args.d.ts.map +1 -1
  83. package/lib/commands/init-args.js +131 -35
  84. package/lib/commands/init-args.js.map +1 -1
  85. package/lib/commands/init-hosts.d.ts +24 -0
  86. package/lib/commands/init-hosts.d.ts.map +1 -0
  87. package/lib/commands/init-hosts.js +37 -0
  88. package/lib/commands/init-hosts.js.map +1 -0
  89. package/lib/commands/init-prompt.d.ts +3 -3
  90. package/lib/commands/init-prompt.d.ts.map +1 -1
  91. package/lib/commands/init-prompt.js +4 -5
  92. package/lib/commands/init-prompt.js.map +1 -1
  93. package/lib/commands/init-skills.d.ts +41 -0
  94. package/lib/commands/init-skills.d.ts.map +1 -0
  95. package/lib/commands/init-skills.js +110 -0
  96. package/lib/commands/init-skills.js.map +1 -0
  97. package/lib/commands/init-templates.d.ts +20 -14
  98. package/lib/commands/init-templates.d.ts.map +1 -1
  99. package/lib/commands/init-templates.js +3134 -763
  100. package/lib/commands/init-templates.js.map +1 -1
  101. package/lib/commands/init-wizard.d.ts +10 -0
  102. package/lib/commands/init-wizard.d.ts.map +1 -1
  103. package/lib/commands/init-wizard.js +76 -31
  104. package/lib/commands/init-wizard.js.map +1 -1
  105. package/lib/commands/init-workspace.d.ts +13 -11
  106. package/lib/commands/init-workspace.d.ts.map +1 -1
  107. package/lib/commands/init-workspace.js +21 -7
  108. package/lib/commands/init-workspace.js.map +1 -1
  109. package/lib/commands/init.d.ts +61 -24
  110. package/lib/commands/init.d.ts.map +1 -1
  111. package/lib/commands/init.js +244 -50
  112. package/lib/commands/init.js.map +1 -1
  113. package/lib/commands/kebab.d.ts +15 -0
  114. package/lib/commands/kebab.d.ts.map +1 -0
  115. package/lib/commands/kebab.js +17 -0
  116. package/lib/commands/kebab.js.map +1 -0
  117. package/lib/commands/lint-grammar-args.d.ts +3 -2
  118. package/lib/commands/lint-grammar-args.d.ts.map +1 -1
  119. package/lib/commands/lint-grammar-args.js +14 -11
  120. package/lib/commands/lint-grammar-args.js.map +1 -1
  121. package/lib/commands/lint-grammar-driver.js +2 -1
  122. package/lib/commands/lint-grammar-driver.js.map +1 -1
  123. package/lib/commands/lint-grammar-report.js.map +1 -1
  124. package/lib/commands/lint-grammar.js +1 -1
  125. package/lib/commands/lint-grammar.js.map +1 -1
  126. package/lib/commands/measure-memory-args.d.ts +3 -2
  127. package/lib/commands/measure-memory-args.d.ts.map +1 -1
  128. package/lib/commands/measure-memory-args.js +26 -14
  129. package/lib/commands/measure-memory-args.js.map +1 -1
  130. package/lib/commands/measure-memory-driver.js +5 -3
  131. package/lib/commands/measure-memory-driver.js.map +1 -1
  132. package/lib/commands/measure-memory.d.ts +1 -1
  133. package/lib/commands/measure-memory.d.ts.map +1 -1
  134. package/lib/commands/measure-memory.js +6 -3
  135. package/lib/commands/measure-memory.js.map +1 -1
  136. package/lib/commands/message-catalogue-args.d.ts +16 -0
  137. package/lib/commands/message-catalogue-args.d.ts.map +1 -0
  138. package/lib/commands/message-catalogue-args.js +53 -0
  139. package/lib/commands/message-catalogue-args.js.map +1 -0
  140. package/lib/commands/message-catalogue.d.ts +21 -0
  141. package/lib/commands/message-catalogue.d.ts.map +1 -0
  142. package/lib/commands/message-catalogue.js +48 -0
  143. package/lib/commands/message-catalogue.js.map +1 -0
  144. package/lib/commands/model-docs-args.d.ts +4 -3
  145. package/lib/commands/model-docs-args.d.ts.map +1 -1
  146. package/lib/commands/model-docs-args.js +13 -11
  147. package/lib/commands/model-docs-args.js.map +1 -1
  148. package/lib/commands/model-docs-driver.js +2 -1
  149. package/lib/commands/model-docs-driver.js.map +1 -1
  150. package/lib/commands/model-docs.js +1 -1
  151. package/lib/commands/model-docs.js.map +1 -1
  152. package/lib/commands/projects-args.d.ts.map +1 -1
  153. package/lib/commands/projects-args.js +5 -5
  154. package/lib/commands/projects-args.js.map +1 -1
  155. package/lib/commands/projects.d.ts +3 -4
  156. package/lib/commands/projects.d.ts.map +1 -1
  157. package/lib/commands/projects.js +2 -2
  158. package/lib/commands/projects.js.map +1 -1
  159. package/lib/commands/query-args.d.ts.map +1 -1
  160. package/lib/commands/query-args.js +6 -5
  161. package/lib/commands/query-args.js.map +1 -1
  162. package/lib/commands/query.d.ts +6 -5
  163. package/lib/commands/query.d.ts.map +1 -1
  164. package/lib/commands/query.js +5 -4
  165. package/lib/commands/query.js.map +1 -1
  166. package/lib/commands/reflect-args.d.ts +3 -2
  167. package/lib/commands/reflect-args.d.ts.map +1 -1
  168. package/lib/commands/reflect-args.js +14 -11
  169. package/lib/commands/reflect-args.js.map +1 -1
  170. package/lib/commands/reflect-driver.js +2 -1
  171. package/lib/commands/reflect-driver.js.map +1 -1
  172. package/lib/commands/reflect-report.js.map +1 -1
  173. package/lib/commands/reflect.js +1 -1
  174. package/lib/commands/reflect.js.map +1 -1
  175. package/lib/commands/save-args.d.ts.map +1 -1
  176. package/lib/commands/save-args.js +7 -4
  177. package/lib/commands/save-args.js.map +1 -1
  178. package/lib/commands/save.d.ts +8 -4
  179. package/lib/commands/save.d.ts.map +1 -1
  180. package/lib/commands/save.js +12 -23
  181. package/lib/commands/save.js.map +1 -1
  182. package/lib/commands/skills.d.ts +22 -0
  183. package/lib/commands/skills.d.ts.map +1 -0
  184. package/lib/commands/skills.js +129 -0
  185. package/lib/commands/skills.js.map +1 -0
  186. package/lib/commands/validate-args.d.ts +3 -2
  187. package/lib/commands/validate-args.d.ts.map +1 -1
  188. package/lib/commands/validate-args.js +14 -11
  189. package/lib/commands/validate-args.js.map +1 -1
  190. package/lib/commands/validate-driver.js +2 -1
  191. package/lib/commands/validate-driver.js.map +1 -1
  192. package/lib/commands/validate-report.js.map +1 -1
  193. package/lib/commands/validate.js +1 -1
  194. package/lib/commands/validate.js.map +1 -1
  195. package/lib/commands/watch-args.d.ts.map +1 -1
  196. package/lib/commands/watch-args.js +6 -4
  197. package/lib/commands/watch-args.js.map +1 -1
  198. package/lib/commands/watch.d.ts +3 -4
  199. package/lib/commands/watch.d.ts.map +1 -1
  200. package/lib/commands/watch.js +5 -2
  201. package/lib/commands/watch.js.map +1 -1
  202. package/lib/driver-heap.d.ts +52 -0
  203. package/lib/driver-heap.d.ts.map +1 -0
  204. package/lib/driver-heap.js +51 -0
  205. package/lib/driver-heap.js.map +1 -0
  206. package/lib/index.d.ts +3 -0
  207. package/lib/index.d.ts.map +1 -1
  208. package/lib/index.js +3 -0
  209. package/lib/index.js.map +1 -1
  210. package/lib/log-level.d.ts +21 -11
  211. package/lib/log-level.d.ts.map +1 -1
  212. package/lib/log-level.js +25 -14
  213. package/lib/log-level.js.map +1 -1
  214. package/lib/spawn-data-server.d.ts +9 -4
  215. package/lib/spawn-data-server.d.ts.map +1 -1
  216. package/lib/spawn-data-server.js +5 -4
  217. package/lib/spawn-data-server.js.map +1 -1
  218. package/lib/stderr-connection-logger.d.ts +12 -0
  219. package/lib/stderr-connection-logger.d.ts.map +1 -0
  220. package/lib/stderr-connection-logger.js +16 -0
  221. package/lib/stderr-connection-logger.js.map +1 -0
  222. package/lib/testing/echo-server.js +9 -1
  223. package/lib/testing/echo-server.js.map +1 -1
  224. package/package.json +99 -41
  225. package/src/cli.ts +11 -2
  226. package/src/commands/analyze-heap-args.ts +1 -2
  227. package/src/commands/analyze-heap.ts +4 -1
  228. package/src/commands/ast-ground-truth-args.ts +16 -13
  229. package/src/commands/ast-ground-truth-driver.ts +4 -2
  230. package/src/commands/ast-ground-truth.ts +1 -1
  231. package/src/commands/check-skills-args.ts +70 -0
  232. package/src/commands/client-session.ts +33 -0
  233. package/src/commands/create-args.ts +68 -0
  234. package/src/commands/create.ts +83 -0
  235. package/src/commands/delete-args.ts +64 -0
  236. package/src/commands/delete.ts +61 -0
  237. package/src/commands/generate-ast-builder.ts +2 -12
  238. package/src/commands/generate-languages.ts +132 -0
  239. package/src/commands/generate-transfer-builder.ts +101 -0
  240. package/src/commands/generate-transfer-model-args.ts +20 -5
  241. package/src/commands/generate-transfer-model-config.ts +8 -3
  242. package/src/commands/generate-transfer-model.ts +297 -66
  243. package/src/commands/generated-module-path.ts +22 -0
  244. package/src/commands/harness-args.ts +163 -33
  245. package/src/commands/headless-harness.ts +89 -28
  246. package/src/commands/init-args.ts +135 -35
  247. package/src/commands/init-hosts.ts +51 -0
  248. package/src/commands/init-prompt.ts +5 -6
  249. package/src/commands/init-skills.ts +129 -0
  250. package/src/commands/init-templates.ts +3326 -785
  251. package/src/commands/init-wizard.ts +97 -32
  252. package/src/commands/init-workspace.ts +31 -16
  253. package/src/commands/init.ts +311 -65
  254. package/src/commands/kebab.ts +17 -0
  255. package/src/commands/lint-grammar-args.ts +18 -14
  256. package/src/commands/lint-grammar-driver.ts +2 -1
  257. package/src/commands/lint-grammar.ts +1 -1
  258. package/src/commands/measure-memory-args.ts +31 -17
  259. package/src/commands/measure-memory-driver.ts +5 -3
  260. package/src/commands/measure-memory.ts +6 -3
  261. package/src/commands/message-catalogue-args.ts +55 -0
  262. package/src/commands/message-catalogue.ts +56 -0
  263. package/src/commands/model-docs-args.ts +17 -14
  264. package/src/commands/model-docs-driver.ts +2 -1
  265. package/src/commands/model-docs.ts +1 -1
  266. package/src/commands/projects-args.ts +5 -4
  267. package/src/commands/projects.ts +4 -6
  268. package/src/commands/query-args.ts +6 -4
  269. package/src/commands/query.ts +9 -9
  270. package/src/commands/reflect-args.ts +18 -14
  271. package/src/commands/reflect-driver.ts +2 -1
  272. package/src/commands/reflect.ts +1 -1
  273. package/src/commands/save-args.ts +7 -3
  274. package/src/commands/save.ts +24 -27
  275. package/src/commands/skills.ts +146 -0
  276. package/src/commands/validate-args.ts +18 -14
  277. package/src/commands/validate-driver.ts +2 -1
  278. package/src/commands/validate.ts +1 -1
  279. package/src/commands/watch-args.ts +6 -3
  280. package/src/commands/watch.ts +7 -6
  281. package/src/driver-heap.ts +65 -0
  282. package/src/index.ts +3 -0
  283. package/src/log-level.ts +42 -14
  284. package/src/spawn-data-server.ts +17 -5
  285. package/src/stderr-connection-logger.ts +18 -0
  286. package/src/testing/echo-server.ts +11 -2
  287. package/templates/client/gitignore +3 -0
  288. package/templates/client/src/__GRAMMAR_ID__-diagram-module.ts +82 -0
  289. package/templates/editorconfig +15 -0
  290. package/templates/gitignore +1 -0
  291. package/templates/host-browser/esbuild.mjs +82 -0
  292. package/templates/host-browser/gitignore +4 -0
  293. package/templates/host-browser/hydranium-mark.svg +40 -0
  294. package/templates/host-browser/index.html +287 -0
  295. package/templates/host-browser/playwright.config.mts +24 -0
  296. package/templates/host-browser/src/data-status.ts +15 -0
  297. package/templates/host-browser/src/diagram-save.ts +60 -0
  298. package/templates/host-browser/src/diagram.ts +121 -0
  299. package/templates/host-browser/src/editor.ts +116 -0
  300. package/templates/host-browser/src/log.ts +14 -0
  301. package/templates/host-browser/src/monaco-editor-core-worker.d.ts +7 -0
  302. package/templates/host-browser/src/monaco-editor-worker.ts +10 -0
  303. package/templates/host-browser/src/posix-path-shim.ts +23 -0
  304. package/templates/host-browser/src/shown-file.ts +61 -0
  305. package/templates/host-browser/src/workspace.ts +5 -0
  306. package/templates/host-browser/tsconfig.e2e.json +16 -0
  307. package/templates/host-browser/tsconfig.page.json +16 -0
  308. package/templates/host-browser/tsconfig.worker.json +16 -0
  309. package/templates/host-theia-browser/esbuild.mjs +24 -0
  310. package/templates/host-theia-browser/gitignore +9 -0
  311. package/templates/host-theia-browser/playwright.config.mts +42 -0
  312. package/templates/host-theia-browser/start.mjs +50 -0
  313. package/templates/host-theia-browser/tsconfig.e2e.json +16 -0
  314. package/templates/host-theia-electron/esbuild.mjs +27 -0
  315. package/templates/host-theia-electron/gitignore +8 -0
  316. package/templates/host-theia-electron/playwright.config.mts +12 -0
  317. package/templates/host-theia-electron/start.mjs +47 -0
  318. package/templates/host-theia-electron/tsconfig.e2e.json +16 -0
  319. package/templates/host-vscode/esbuild.mjs +38 -0
  320. package/templates/host-vscode/gitignore +7 -0
  321. package/templates/host-vscode/playwright.config.mts +14 -0
  322. package/templates/host-vscode/src/__GRAMMAR_ID__-diagram-editor.ts +19 -0
  323. package/templates/host-vscode/src/show-model-command.ts +24 -0
  324. package/templates/host-vscode/src/webview/__GRAMMAR_ID__-diagram.ts +18 -0
  325. package/templates/host-vscode/src/webview/diagram.css +40 -0
  326. package/templates/host-vscode/start.mjs +22 -0
  327. package/templates/host-vscode/test/content-server.cjs +40 -0
  328. package/templates/host-vscode/test/install-vscode.mts +21 -0
  329. package/templates/host-vscode/test/virtual-documents.spec.mts +28 -0
  330. package/templates/host-vscode/test/vscode-version.mts +10 -0
  331. package/templates/host-vscode/tsconfig.e2e.json +17 -0
  332. package/templates/host-vscode/tsconfig.json +19 -0
  333. package/templates/host-vscode/tsconfig.webview.json +17 -0
  334. package/templates/oxlintrc.json +4 -0
  335. package/templates/protocol/gitignore +3 -0
  336. package/templates/protocol/src/__GRAMMAR_ID__-diagram-types.ts +47 -0
  337. package/templates/server/gitignore +6 -0
  338. package/templates/server/src/glsp/__GRAMMAR_ID__/create-node-operation-handler.ts +62 -0
  339. package/templates/server/src/glsp/__GRAMMAR_ID__/delete-operation-handler.ts +63 -0
  340. package/templates/server/src/glsp/__GRAMMAR_ID__/gmodel-factory.ts +144 -0
  341. package/templates/server/src/glsp/__GRAMMAR_ID__/state.ts +18 -0
  342. package/templates/server/src/glsp/__GRAMMAR_ID__/storage.ts +16 -0
  343. package/templates/server/src/glsp/__GRAMMAR_ID__/submission-handler.ts +24 -0
  344. package/templates/server/src/grammar/common.langium +12 -0
  345. package/templates/server/src/language-server/__GRAMMAR_ID__-semantic-tokens.ts +10 -0
  346. package/templates/server/src/language-server/__GRAMMAR_ID__-serializer.ts +53 -0
  347. package/templates/server/src/language-server/__GRAMMAR_ID__-validation.ts +30 -0
  348. package/templates/server/src/language-server/ast.ts +23 -0
  349. package/templates/server/src/node/services.ts +16 -0
  350. package/templates/server/test/__GRAMMAR_ID__-diagram.test.ts +403 -0
  351. package/templates/server/test/__GRAMMAR_ID__-semantic-tokens.test.ts +34 -0
  352. package/templates/server/test/tsconfig.json +4 -0
  353. package/templates/server/tsconfig.test.json +10 -0
  354. package/templates/server/vitest.config.ts +14 -0
  355. package/templates/theia/gitignore +3 -0
  356. package/templates/theia/src/browser/__GRAMMAR_ID__-diagram-frontend-module.ts +65 -0
  357. package/templates/theia/src/browser/__PROJECT_ID__-connection-preload-module.ts +15 -0
  358. package/templates/theia/src/browser/__PROJECT_ID__-data-connection.ts +15 -0
  359. package/templates/theia/src/browser/__PROJECT_ID__-data-port.ts +11 -0
  360. package/templates/theia/src/browser/__PROJECT_ID__-glsp-client-contribution.ts +13 -0
  361. package/templates/theia/src/browser/__PROJECT_ID__-show-model-command.ts +51 -0
  362. package/templates/theia/src/common/__PROJECT_ID__-glsp-contribution.ts +3 -0
  363. package/templates/theia/src/node/__PROJECT_ID__-connection-backend-module.ts +12 -0
  364. package/templates/theia/src/node/__PROJECT_ID__-data-backend-module.ts +15 -0
  365. package/templates/theia/src/node/__PROJECT_ID__-glsp-backend-module.ts +16 -0
  366. package/templates/theia/style/diagram.css +20 -0
  367. package/templates/theia/tsconfig.json +24 -0
  368. package/templates/vscode-servers/gitignore +4 -0
  369. package/templates/vscode-servers/tsconfig.json +19 -0
  370. package/templates/workspace/nodes.__EXTENSION__ +5 -0
  371. package/templates/workspace/references.__EXTENSION__ +2 -0
@@ -21,6 +21,12 @@
21
21
  * `bin` key, the DI module filename, and the data-server port command.
22
22
  * - `__CONFIG_ROOT_CONST__` — SCREAMING_SNAKE project id, naming the
23
23
  * `lsp.configurationRoot` constant a multi-grammar project binds.
24
+ * - `__PROTOCOL_PACKAGE__` — the protocol member's package name, scope
25
+ * included, which the server and every client import ids and wire types
26
+ * from.
27
+ * - `__SERVER_PACKAGE__` / `__CLIENT_PACKAGE__` — the server and client
28
+ * members' package names, which a host imports the language and the diagram
29
+ * definition from.
24
30
  *
25
31
  * GRAMMAR tokens, substituted once per grammar:
26
32
  *
@@ -28,6 +34,10 @@
28
34
  * Drives the PER-LANGUAGE generated symbols (`<grammar>GeneratedModule`,
29
35
  * `<grammar>LanguageMetaData`).
30
36
  * - `__GRAMMAR_ID__` — kebab grammar id: the `.langium` filename.
37
+ * - `__GRAMMAR_ID_CONST__` — SCREAMING_SNAKE grammar id, prefixing the
38
+ * diagram's type-id constants.
39
+ * - `__GRAMMAR_CAMEL__` — lowerCamelCase grammar name, naming the client's
40
+ * diagram module constant.
31
41
  * - `__LANGUAGE_ID__` — kebab routing key: the `langium-config` entry id and
32
42
  * its TextMate output.
33
43
  * - `__EXTENSION__` — the grammar's FIRST file extension, without the dot.
@@ -48,10 +58,22 @@
48
58
  * generator emits `interface <projectName>Element` as the base type every rule
49
59
  * extends, which a rule of that name would collide with.
50
60
  *
51
- * Kept as in-source strings (not on-disk assets) so no build-time copy step
52
- * is needed and the substitution is unit-testable. The `.ts` / `.langium` / JSON
53
- * templates are derived from the in-repo reference example so the generated project
54
- * compiles against the real `@hydranium/*` API once `langium generate` has run.
61
+ * The project is its packages and a starter workspace, each in its own
62
+ * directory: `server/` (the grammar, the language and its heads), `protocol/`
63
+ * (what the server and its clients both import, so a client never installs
64
+ * Langium), `client/` with a diagram (its host-neutral client side),
65
+ * `host-<name>/` per host, `vscode-servers/` with the VS Code host (the server
66
+ * launch it shares with a Theia app), and `workspace/` (starter model files
67
+ * every host and the CLI open). A standalone project adds a root manifest
68
+ * naming them as npm workspaces; a workspace member joins the surrounding root
69
+ * instead.
70
+ *
71
+ * A file that is fixed text with tokens lives in the `templates/` tree the
72
+ * package ships beside `lib`, at the path it is emitted at, tokens included
73
+ * (each ignore file without its dot); one computed from the composition is
74
+ * built here. The tree is read synchronously, so `runInit` stays synchronous,
75
+ * and it is neither formatted with the repo's settings nor given a license
76
+ * header, since either would change what `init` emits.
55
77
  *
56
78
  * Two conventions the templates follow deliberately:
57
79
  *
@@ -70,16 +92,10 @@
70
92
  * is what the detection cannot fix. A very long `--name` can still push a line
71
93
  * over; the adopter's own formatter settles it.
72
94
  */
95
+ import { readFileSync } from 'node:fs';
73
96
  import { readCliVersion } from '../cli-version.js';
74
- /**
75
- * The version every framework package carries before the first release.
76
- *
77
- * A scaffold made by a CLI still at this version pins a range the registry
78
- * cannot serve, so the emitted README keeps its yalc note and `init` keeps its
79
- * install warning while — and only while — it holds: from a published CLI the
80
- * derived pins resolve and either note would be false as printed.
81
- */
82
- export const UNPUBLISHED_FRAMEWORK_VERSION = '0.0.0';
97
+ import { initMembers } from './init.js';
98
+ import { isTheiaHost } from './init-hosts.js';
83
99
  /**
84
100
  * The version `init` pins every `@hydranium/*` dependency at: the scaffolding
85
101
  * CLI's own.
@@ -107,12 +123,52 @@ function frameworkPin(composition) {
107
123
  function screamingSnake(projectId) {
108
124
  return projectId.replace(/-/g, '_').toUpperCase();
109
125
  }
126
+ const VSCODE_EXTENSION_MEMBERS = ['vscode-servers', 'host-vscode'];
127
+ /**
128
+ * A member's package name: `<id>-<member>`, under the scope when there is one,
129
+ * except for the VS Code extensions, whose scoped name `vsce` refuses to
130
+ * package.
131
+ *
132
+ * Suffixed even for the server, because a project of several packages has no
133
+ * member entitled to the bare id, and a later client or host would otherwise
134
+ * read as a part of the server.
135
+ */
136
+ export function memberPackageName(composition, member) {
137
+ const { scope } = composition.packaging;
138
+ const name = `${composition.projectId}-${member}`;
139
+ return scope === undefined || VSCODE_EXTENSION_MEMBERS.includes(member) ? name : `${scope}/${name}`;
140
+ }
141
+ /**
142
+ * The composition as one member sees it: inside a workspace, its placement
143
+ * sits one directory below the project's, so its `extends` and its lint
144
+ * config path reach one level further up.
145
+ */
146
+ function forMember(composition, member) {
147
+ const workspace = composition.packaging.workspace;
148
+ if (workspace === undefined) {
149
+ return composition;
150
+ }
151
+ return {
152
+ ...composition,
153
+ packaging: {
154
+ ...composition.packaging,
155
+ workspace: {
156
+ ...workspace,
157
+ targetPath: `${workspace.targetPath}/${member}`,
158
+ baseTsconfig: workspace.baseTsconfig === undefined ? undefined : `../${workspace.baseTsconfig}`
159
+ }
160
+ }
161
+ };
162
+ }
110
163
  /** Substitute the project-tier tokens. */
111
164
  function project(text, composition) {
112
165
  return text
113
166
  .replace(/__NAME__/g, composition.name)
114
167
  .replace(/__PROJECT_ID__/g, composition.projectId)
115
- .replace(/__CONFIG_ROOT_CONST__/g, screamingSnake(composition.projectId));
168
+ .replace(/__CONFIG_ROOT_CONST__/g, screamingSnake(composition.projectId))
169
+ .replace(/__PROTOCOL_PACKAGE__/g, memberPackageName(composition, 'protocol'))
170
+ .replace(/__SERVER_PACKAGE__/g, memberPackageName(composition, 'server'))
171
+ .replace(/__CLIENT_PACKAGE__/g, memberPackageName(composition, 'client'));
116
172
  }
117
173
  /**
118
174
  * Substitute the grammar-tier tokens for ONE grammar.
@@ -124,7 +180,9 @@ function project(text, composition) {
124
180
  */
125
181
  function grammarTier(text, grammar) {
126
182
  return text
183
+ .replace(/__GRAMMAR_ID_CONST__/g, screamingSnake(grammar.grammarId))
127
184
  .replace(/__GRAMMAR_ID__/g, grammar.grammarId)
185
+ .replace(/__GRAMMAR_CAMEL__/g, grammar.grammar.charAt(0).toLowerCase() + grammar.grammar.slice(1))
128
186
  .replace(/__GRAMMAR__/g, grammar.grammar)
129
187
  .replace(/__LANGUAGE_ID__/g, grammar.languageId)
130
188
  .replace(/__EXTENSION__/g, grammar.extensions[0])
@@ -135,6 +193,25 @@ function grammarTier(text, grammar) {
135
193
  function render(text, composition, grammar) {
136
194
  return grammarTier(project(text, composition), grammar);
137
195
  }
196
+ /** The template tree, the same two levels up from `src/commands` and `lib/commands`. */
197
+ const TEMPLATES = new URL('../../templates/', import.meta.url);
198
+ /** One template file's text. */
199
+ function template(path) {
200
+ return readFileSync(new URL(path, TEMPLATES), 'utf-8');
201
+ }
202
+ /** A template emitted once per project at its own path, rendered with the project tokens. */
203
+ function projectTemplate(path, composition) {
204
+ return { path, content: project(template(path), composition) };
205
+ }
206
+ /** A template emitted once per grammar, its path rendered like its content. */
207
+ function grammarTemplate(path, composition, grammar) {
208
+ return { path: render(path, composition, grammar), content: render(template(path), composition, grammar) };
209
+ }
210
+ /** An ignore file, stored without its dot because `npm pack` drops a nested `.gitignore` and applies it as an ignore file. */
211
+ function ignoreFile(directory) {
212
+ const prefix = directory === '' ? '' : `${directory}/`;
213
+ return { path: `${prefix}.gitignore`, content: template(`${prefix}gitignore`) };
214
+ }
138
215
  /** A comma-separated list of single-quoted TypeScript/JS string literals. */
139
216
  function quotedList(values) {
140
217
  return values.map(value => `'${value}'`).join(', ');
@@ -142,21 +219,22 @@ function quotedList(values) {
142
219
  /**
143
220
  * The column budget the emitted sources wrap at when nothing better is known.
144
221
  *
145
- * **Not "prettier's default"** — that is 80. 120 is a choice, and stating it as
146
- * a default was wrong in a way that mattered: no single width is stable for an
222
+ * **Not "prettier's default"** — that is 80. No single width is stable for an
147
223
  * unknown repo, so emitted code only passes a formatter check relative to a
148
224
  * config. A workspace scaffold detects the surrounding repo's `printWidth` and
149
225
  * uses it (see {@link columnsFor}); a standalone one has no config to read and
150
- * falls back here.
226
+ * falls back here. It is the framework's own width, so a fixed template, which
227
+ * wraps at no budget, reads the same in a standalone scaffold as in the
228
+ * examples this repo formats.
151
229
  */
152
- const DEFAULT_COLUMNS = 120;
230
+ const DEFAULT_COLUMNS = 140;
153
231
  /** The width THIS composition wraps at: the detected workspace one, else {@link DEFAULT_COLUMNS}. */
154
232
  function columnsFor(composition) {
155
233
  return composition.packaging.workspace?.printWidth ?? DEFAULT_COLUMNS;
156
234
  }
157
235
  /**
158
236
  * A named import on one line while it fits the column budget, else one symbol
159
- * per line. Emitted files are supposed to pass a prettier check as-is, and the
237
+ * per line. Emitted files are supposed to pass a format check as-is, and the
160
238
  * generated-module import grows by one symbol per grammar.
161
239
  */
162
240
  function importList(symbols, from, columns, typeOnly = false) {
@@ -175,9 +253,13 @@ function arrayLiteral(elements, indent, columns) {
175
253
  * The FRAMEWORK packages each head adds, carrying no version: every
176
254
  * `@hydranium/*` pin is derived at scaffold time from {@link frameworkPin}, so
177
255
  * none of them can name a version its siblings were not published at.
256
+ *
257
+ * `base` is every head set's: the services every head serves from are composed
258
+ * the same with or without an LSP head.
178
259
  */
179
260
  const HEAD_FRAMEWORK_DEPENDENCIES = {
180
- lsp: ['@hydranium/core', '@hydranium/langium', '@hydranium/protocol'],
261
+ base: ['@hydranium/core', '@hydranium/langium', '@hydranium/protocol'],
262
+ lsp: [],
181
263
  data: ['@hydranium/data-server'],
182
264
  glsp: ['@hydranium/glsp-server']
183
265
  };
@@ -197,44 +279,191 @@ const HEAD_FRAMEWORK_DEPENDENCIES = {
197
279
  * `overrides` block exists to prevent — so a repo gate holds each entry here
198
280
  * against the manifest that declares it.
199
281
  *
200
- * `langium` is required by `lsp` even though hand-written code goes through the
282
+ * `langium` is required even though hand-written code goes through the
201
283
  * `@hydranium/langium` chokepoint, because `langium-cli` emits direct imports
202
- * into the generated files and regenerates them on every build.
284
+ * into the generated files and regenerates them on every build. The `vscode-*`
285
+ * packages are required peers of `@hydranium/core`, so they are `base` too.
203
286
  */
204
287
  const HEAD_THIRD_PARTY_DEPENDENCIES = {
205
- lsp: {
206
- langium: '4.3.1',
207
- 'vscode-languageserver': '~10.0.1'
288
+ base: {
289
+ langium: '4.4.0',
290
+ // Exact, as are the protocol and transport it pins, so Langium and the
291
+ // framework share their copies; across copies, an error a handler throws
292
+ // or returns loses its code, and a later patch can pin another protocol.
293
+ 'vscode-languageserver': '10.1.2',
294
+ 'vscode-jsonrpc': '9.0.3',
295
+ 'vscode-languageserver-protocol': '3.18.4',
296
+ // The copy that protocol pins; a root without it takes the one Theia pins.
297
+ 'vscode-languageserver-types': '3.18.4'
208
298
  },
299
+ lsp: {},
209
300
  data: {},
210
301
  glsp: {
211
- '@eclipse-glsp/graph': '2.7.0',
212
- '@eclipse-glsp/server': '2.7.0',
213
- // `^6.1.3` and NOT `^6.0.0`: `@eclipse-glsp/server@2.7.0` requires it, and
302
+ '@eclipse-glsp/graph': '2.8.0',
303
+ '@eclipse-glsp/server': '2.8.0',
304
+ // `^6.1.3` and NOT `^6.0.0`: `@eclipse-glsp/server` requires it, and
214
305
  // `^6.0.0` resolved only because the hoisted copy happened to satisfy it.
215
306
  inversify: '^6.1.3',
216
307
  'reflect-metadata': '~0.2.2'
217
308
  }
218
309
  };
219
- /** The dependency block for a head set, merged and sorted as npm writes it. */
310
+ /**
311
+ * What each head adds to the protocol member, which carries only what a
312
+ * client may install. Keyed by head because the `glsp` head implies a diagram,
313
+ * whose element ids extend GLSP's default type names.
314
+ */
315
+ const PROTOCOL_THIRD_PARTY_DEPENDENCIES = {
316
+ lsp: {},
317
+ data: {},
318
+ glsp: { '@eclipse-glsp/protocol': '2.8.0' }
319
+ };
320
+ /** The client member's packages: the diagram definition and what it registers its elements with. */
321
+ const CLIENT_FRAMEWORK_DEPENDENCIES = ['@hydranium/glsp-client'];
322
+ const CLIENT_THIRD_PARTY_DEPENDENCIES = {
323
+ '@eclipse-glsp/client': '2.8.0',
324
+ inversify: '^6.1.3'
325
+ };
326
+ /**
327
+ * What each head adds to the browser host, which bundles the server into its
328
+ * worker and the head's client into its page. `base` is what every page needs.
329
+ *
330
+ * `monaco-editor-core` is exact for the reason `langium` is: Monaco's models
331
+ * live in one copy, and `@hydranium/client-browser` must reach the page's.
332
+ */
333
+ const HOST_BROWSER_FRAMEWORK_DEPENDENCIES = {
334
+ base: ['@hydranium/client-browser', '@hydranium/core', '@hydranium/protocol'],
335
+ lsp: [],
336
+ data: ['@hydranium/data-server'],
337
+ glsp: ['@hydranium/glsp-client', '@hydranium/glsp-server']
338
+ };
339
+ const HOST_BROWSER_THIRD_PARTY_DEPENDENCIES = {
340
+ base: { 'vscode-jsonrpc': '9.0.3', 'vscode-languageserver-protocol': '3.18.4' },
341
+ lsp: { 'monaco-editor-core': '0.56.0' },
342
+ data: {},
343
+ glsp: { '@eclipse-glsp/server': '2.8.0', 'reflect-metadata': '~0.2.2' }
344
+ };
345
+ /** The browser host's tools: the bundler, the smoke tier and the type check. */
346
+ const HOST_BROWSER_DEV_DEPENDENCIES = {
347
+ '@playwright/test': '^1.55.0',
348
+ '@types/node': '^22.0.0',
349
+ esbuild: '^0.28.0',
350
+ rimraf: '^5.0.0',
351
+ typescript: '^7.0.2'
352
+ };
353
+ /** The language client both VS Code extensions start the server with. */
354
+ const VSCODE_LANGUAGE_CLIENT = { 'vscode-languageclient': '10.1.2' };
355
+ /** The VS Code API's types, at the oldest VS Code both extensions run in, which `engines` names too. */
356
+ const VSCODE_ENGINE = '^1.101.0';
357
+ /**
358
+ * What the GLSP head adds to the VS Code extension: the connector in the
359
+ * extension host and the diagram webview's starter, which bundles the client
360
+ * member.
361
+ */
362
+ const HOST_VSCODE_GLSP_FRAMEWORK_DEPENDENCIES = [
363
+ '@hydranium/client-vscode',
364
+ '@hydranium/glsp-client',
365
+ '@hydranium/glsp-client-vscode',
366
+ '@hydranium/protocol'
367
+ ];
368
+ const HOST_VSCODE_GLSP_THIRD_PARTY_DEPENDENCIES = {
369
+ '@eclipse-glsp/client': '2.8.0',
370
+ '@eclipse-glsp/vscode-integration': '2.8.0',
371
+ '@eclipse-glsp/vscode-integration-webview': '2.8.0',
372
+ inversify: '^6.1.3',
373
+ 'vscode-jsonrpc': '9.0.3'
374
+ };
375
+ /** What the data head adds to the VS Code extension: the Show Model command's socket to it. */
376
+ const HOST_VSCODE_DATA_FRAMEWORK_DEPENDENCIES = [
377
+ '@hydranium/client-vscode',
378
+ '@hydranium/data-client-vscode',
379
+ '@hydranium/protocol'
380
+ ];
381
+ // Exact, as the server's is: the socket's connection and the framework's must be one copy.
382
+ const HOST_VSCODE_DATA_THIRD_PARTY_DEPENDENCIES = { 'vscode-jsonrpc': '9.0.3' };
383
+ /** The VS Code extension's tools: the smoke tier, the download it runs in, the type check and the watch. */
384
+ const HOST_VSCODE_DEV_DEPENDENCIES = {
385
+ '@playwright/test': '^1.55.0',
386
+ '@types/node': '^22.0.0',
387
+ '@types/vscode': VSCODE_ENGINE,
388
+ '@vscode/test-electron': '^3.1.0',
389
+ concurrently: '^9.0.0',
390
+ rimraf: '^5.0.0',
391
+ typescript: '^7.0.2'
392
+ };
393
+ /**
394
+ * The exact pins of every member the composition emits, for a workspace root
395
+ * to declare too. npm installs a root's own dependencies at the top of the
396
+ * tree, so another member's version of one nests beneath that member instead of
397
+ * taking the place of the copy this package shares with the framework.
398
+ */
399
+ export function workspaceRootPins(composition) {
400
+ const { heads } = composition;
401
+ const members = initMembers(heads, composition.hosts);
402
+ const pins = new Map([
403
+ ...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES.base),
404
+ ...heads.flatMap(head => [
405
+ ...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES[head]),
406
+ ...Object.entries(PROTOCOL_THIRD_PARTY_DEPENDENCIES[head])
407
+ ]),
408
+ ...(members.includes('client') ? Object.entries(CLIENT_THIRD_PARTY_DEPENDENCIES) : []),
409
+ ...(members.includes('host-browser')
410
+ ? ['base', ...heads].flatMap(head => Object.entries(HOST_BROWSER_THIRD_PARTY_DEPENDENCIES[head]))
411
+ : []),
412
+ ...(members.includes('vscode-servers') ? Object.entries(VSCODE_LANGUAGE_CLIENT) : []),
413
+ ...(members.includes('host-vscode') && heads.includes('glsp') ? Object.entries(HOST_VSCODE_GLSP_THIRD_PARTY_DEPENDENCIES) : []),
414
+ ...(members.includes('host-vscode') && heads.includes('data') ? Object.entries(HOST_VSCODE_DATA_THIRD_PARTY_DEPENDENCIES) : []),
415
+ ...(members.includes('theia') ? theiaThirdPartyDependencies(heads) : []),
416
+ ...(members.includes('host-theia-browser') ? hostTheiaBrowserTheiaDependencies() : []),
417
+ ...(members.includes('host-theia-electron') ? hostTheiaElectronPins() : [])
418
+ ]);
419
+ return [...pins].filter(([, version]) => /^\d+\.\d+\.\d+$/.test(version)).sort(([left], [right]) => left.localeCompare(right));
420
+ }
421
+ /** 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. */
422
+ function installsVitest41(npmVersion) {
423
+ const [release, prerelease] = (npmVersion ?? '0.0.0').split('-', 2);
424
+ const [major, minor, patch] = release.split('.').map(Number);
425
+ return major > 11 || (major === 11 && (minor > 6 || (minor === 6 && (patch > 0 || prerelease === undefined))));
426
+ }
427
+ /**
428
+ * Whether the scaffold holds `vitest` below 4.1, whose peer set crashes npm
429
+ * before 11.6 (every npm a Node 22 release bundles) on the first install. Only a
430
+ * workspace root declaring a newer npm lifts it, and only when `init` runs under
431
+ * an npm known to be newer: npm does not enforce `packageManager`.
432
+ */
433
+ function pinsVitest(composition) {
434
+ const workspace = composition.packaging.workspace;
435
+ return !(installsVitest41(workspace?.packageManagerNpm) && installsVitest41(workspace?.npmVersion));
436
+ }
437
+ /**
438
+ * The version a member pins a sibling member at: exact, at the version every
439
+ * member starts with. npm links a sibling only when the range admits its local
440
+ * version, and otherwise looks for it in the registry, where it was never
441
+ * published.
442
+ */
443
+ const MEMBER_PIN = '0.0.0';
444
+ /** A dependency block, sorted as npm writes it. */
445
+ function sortedBlock(dependencies, indent) {
446
+ return Object.keys(dependencies)
447
+ .sort()
448
+ .map(name => `${indent}"${name}": "${dependencies[name]}"`)
449
+ .join(',\n');
450
+ }
451
+ /** The server's dependency block: the base and head set's packages and the protocol member. */
220
452
  function dependencyBlock(composition, indent) {
221
- const merged = {};
222
- for (const head of composition.heads) {
453
+ const merged = { [memberPackageName(composition, 'protocol')]: MEMBER_PIN };
454
+ for (const head of ['base', ...composition.heads]) {
223
455
  Object.assign(merged, HEAD_THIRD_PARTY_DEPENDENCIES[head]);
224
456
  for (const framework of HEAD_FRAMEWORK_DEPENDENCIES[head]) {
225
457
  merged[framework] = frameworkPin(composition);
226
458
  }
227
459
  }
228
- return Object.keys(merged)
229
- .sort()
230
- .map(name => `${indent}"${name}": "${merged[name]}"`)
231
- .join(',\n');
460
+ return sortedBlock(merged, indent);
232
461
  }
233
462
  // JSON at two-space indent, not the three the `.ts` templates use: that is the
234
463
  // npm convention, and `npm install` rewrites `package.json` at two spaces
235
464
  // regardless — so three would not survive the adopter's first install.
236
465
  //
237
- // The KEY ORDER is `prettier-plugin-packagejson`'s canonical one, `private`
466
+ // The KEY ORDER is the one Oxfmt's `sortPackageJson` canonicalises to, `private`
238
467
  // before `license` included. Any other order is rewritten by the scaffolded
239
468
  // project's own first `format` run — a diff on a file the adopter never touched
240
469
  // — and it makes a byte-compare against a formatted copy of this emission
@@ -254,6 +483,11 @@ function dependencyBlock(composition, indent) {
254
483
  // from this package's own grammar and can reach a consumer from nowhere else; a
255
484
  // listed path that does not exist yet is inert.
256
485
  //
486
+ // `exports` puts `createServices`, the one module reading the real filesystem,
487
+ // behind `./node`, so the root loads in a browser worker that has no `node:fs`.
488
+ // `./lib/*` and `./package.json` stay reachable for a host that resolves
489
+ // `<server>/lib/main.js` to spawn the server.
490
+ //
257
491
  // `repository` is deliberately NOT emitted while `author` is emitted empty. An
258
492
  // empty `author` is the form `npm init` itself writes and asserts nothing, but
259
493
  // `repository` is CONSUMED — the registry page, `npm repo` and publish
@@ -265,16 +499,9 @@ const PACKAGE_JSON = `{
265
499
  "version": "0.0.0",
266
500
  __PRIVATE__ "description": "__NAME__ language server, built with the Hydranium framework.",
267
501
  "keywords": [
268
- "hydranium",
269
- "langium",
270
- "language-server",
271
- "__PROJECT_ID__"
272
- ],
502
+ __KEYWORDS__ ],
273
503
  "license": "UNLICENSED",
274
504
  "author": "",
275
- "type": "module",
276
- "main": "lib/index.js",
277
- "types": "lib/index.d.ts",
278
505
  "bin": {
279
506
  __BIN__ },
280
507
  "files": [
@@ -282,17 +509,32 @@ __BIN__ },
282
509
  "src",
283
510
  "syntaxes"
284
511
  ],
512
+ "type": "module",
513
+ "main": "lib/index.js",
514
+ "types": "lib/index.d.ts",
515
+ "exports": {
516
+ ".": {
517
+ "types": "./lib/index.d.ts",
518
+ "default": "./lib/index.js"
519
+ },
520
+ "./node": {
521
+ "types": "./lib/node/services.d.ts",
522
+ "default": "./lib/node/services.js"
523
+ },
524
+ "./lib/*": "./lib/*",
525
+ "./package.json": "./package.json"
526
+ },
285
527
  "scripts": {
286
- "build": "npm run generate && tsc",
287
- "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-hydranium tsconfig.tsbuildinfo",
528
+ "build": "npm run generate && tsc -b",
529
+ "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-hydranium test/generated-hydranium tsconfig.tsbuildinfo",
288
530
  "generate": "npm run langium:generate && npm run generate:transfer-model",
289
- "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\\"",
531
+ "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 --transfer-builder-file test/generated-hydranium/transfer-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\\"",
290
532
  "langium:generate": "langium generate",
291
533
  "langium:watch": "langium generate --watch",
292
- __LINT__ "start": "node lib/main.js --stdio",
534
+ __LINT__ "start": "node lib/main.js__START_ARGS__",
293
535
  "test": "npm run typecheck:test && vitest run",
294
536
  "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
295
- "watch": "tsc --watch"
537
+ "watch": "concurrently --kill-others --names langium,transfer-model,tsc \\"npm run langium:watch\\" \\"npm run generate:transfer-model -- --watch\\" \\"tsc -b --watch\\""
296
538
  },
297
539
  "dependencies": {
298
540
  __DEPENDENCIES__
@@ -300,27 +542,32 @@ __DEPENDENCIES__
300
542
  "devDependencies": {
301
543
  "@hydranium/cli": "__FRAMEWORK_PIN__",
302
544
  "@types/node": "^22.0.0",
303
- "langium-cli": "4.3.0",
545
+ "concurrently": "^9.0.0",
546
+ "langium-cli": "4.4.0",
304
547
  "rimraf": "^5.0.0",
305
- "typescript": "^5.8.0",
306
- "vitest": "^4.0.0"
548
+ "typescript": "^7.0.2",
549
+ "vitest": "__VITEST__"
307
550
  },
308
551
  "engines": {
309
552
  "node": ">=22.13"
553
+ },
554
+ "hydranium": {
555
+ "services": "./lib/node/services.js"
310
556
  }
311
557
  }
312
558
  `;
313
559
  /**
314
- * How the scaffold names `npm run` when talking about itself.
560
+ * How the scaffold names `npm run` for one member's script.
315
561
  *
316
- * A workspace member's scripts are routinely invoked from the repo root, where
317
- * a bare `npm run` reaches the ROOT manifest and fails — so the regen hint a
318
- * generated file prints has to name the package, or it sends every reader who
319
- * follows it to the wrong place.
562
+ * Always with `--prefix`, from the directory commands are run in: the
563
+ * workspace root for a member, the project root for a standalone project. A
564
+ * bare `npm run` there reaches the ROOT manifest and fails, and the regen hint
565
+ * a generated file prints is read in `protocol/` as well, where the script it
566
+ * names does not exist.
320
567
  */
321
- function npmRun(composition) {
568
+ function npmRun(composition, member) {
322
569
  const workspace = composition.packaging.workspace;
323
- return workspace === undefined ? 'npm run' : `npm --prefix ${workspace.targetPath} run`;
570
+ return `npm --prefix ${workspace === undefined ? member : `${workspace.targetPath}/${member}`} run`;
324
571
  }
325
572
  /**
326
573
  * The `bin` targets, one per executable entry the head set emits.
@@ -331,7 +578,7 @@ function npmRun(composition) {
331
578
  * name. Without the second key a scaffolded project has no command line that
332
579
  * reaches its own data head at all.
333
580
  *
334
- * Alphabetical, because `prettier-plugin-packagejson` sorts `bin` and any other
581
+ * Alphabetical, because Oxfmt's `sortPackageJson` sorts `bin` and any other
335
582
  * order is rewritten by the scaffolded project's first `format` run.
336
583
  */
337
584
  function binBlock(composition) {
@@ -341,21 +588,37 @@ function binBlock(composition) {
341
588
  }
342
589
  return entries.map(([name, target]) => ` "${name}": "${target}"`).join(',\n') + '\n';
343
590
  }
344
- /** `package.json` with the name, the private flag and the dependency block all derived. */
591
+ /**
592
+ * A member's `lint` script line, over `directories`, or nothing.
593
+ *
594
+ * Emitted only where a root lint config says the repo lints. The absence is
595
+ * the dangerous direction: a task runner runs a script only where one is
596
+ * declared, so a package with no `lint` is SKIPPED rather than reported, and
597
+ * that reads as a clean lint. A wrong invocation is one visible line to edit.
598
+ */
599
+ function lintScript(member, directories) {
600
+ const workspace = member.packaging.workspace;
601
+ if (workspace?.oxlintConfig !== undefined) {
602
+ const config = `${'../'.repeat(workspace.targetPath.split('/').length)}${workspace.oxlintConfig}`;
603
+ return ` "lint": "oxlint --report-unused-disable-directives --format stylish --config ${config} ${directories} --max-warnings 0",\n`;
604
+ }
605
+ return workspace?.eslintConfig !== undefined ? ` "lint": "eslint ${directories} --max-warnings 0",\n` : '';
606
+ }
607
+ /** The server's `package.json`, with the name, the private flag and the dependency block all derived. */
345
608
  function packageJson(composition) {
346
- const { scope } = composition.packaging;
347
- const packageName = scope === undefined ? composition.projectId : `${scope}/${composition.projectId}`;
348
- // Emitted only where a root eslint config says the repo lints. The absence is
349
- // the dangerous direction: a task runner runs a script only where one is
350
- // declared, so a package with no `lint` is SKIPPED rather than reported, and
351
- // that reads as a clean lint. A wrong invocation is one visible line to edit.
352
- const lint = composition.packaging.workspace?.eslintConfig === undefined ? '' : ' "lint": "eslint src test --max-warnings 0",\n';
609
+ const keywords = ['hydranium', 'langium', 'language-server', composition.projectId]
610
+ .sort()
611
+ .map(keyword => ` ${JSON.stringify(keyword)}`)
612
+ .join(',\n') + '\n';
353
613
  return project(PACKAGE_JSON, composition)
354
- .replace('__PACKAGE_NAME__', packageName)
614
+ .replace('__PACKAGE_NAME__', memberPackageName(composition, 'server'))
615
+ .replace('__KEYWORDS__', keywords)
355
616
  .replace('__BIN__', binBlock(composition))
356
- .replace('__LINT__', lint)
617
+ .replace('__LINT__', lintScript(forMember(composition, 'server'), 'src test'))
618
+ .replace('__START_ARGS__', composition.heads.includes('lsp') ? ' --stdio' : ' ../workspace')
619
+ .replace('__VITEST__', pinsVitest(composition) ? '~4.0.18' : '^4.0.0')
357
620
  .replace('__PRIVATE__', composition.packaging.private ? ' "private": true,\n' : '')
358
- .replace('__NPM_RUN__', npmRun(composition))
621
+ .replace('__NPM_RUN__', npmRun(composition, 'server'))
359
622
  .replace('__FRAMEWORK_PIN__', frameworkPin(composition))
360
623
  .replace('__DEPENDENCIES__', dependencyBlock(composition, ' '));
361
624
  }
@@ -418,6 +681,23 @@ const TSCONFIG_COMPILER_OPTIONS = [
418
681
  ['forceConsistentCasingInFileNames', true],
419
682
  ['types', ['node']]
420
683
  ];
684
+ /** The protocol member's compiler options, pruned against a workspace base like the server's. */
685
+ const PROTOCOL_COMPILER_OPTIONS = [
686
+ ['target', 'ES2022'],
687
+ ['lib', ['ES2022']],
688
+ ['module', 'NodeNext'],
689
+ ['moduleResolution', 'NodeNext'],
690
+ ['rootDir', 'src'],
691
+ ['outDir', 'lib'],
692
+ ['strict', true],
693
+ ['skipLibCheck', true],
694
+ ['declaration', true],
695
+ // The server's `tsc -b` builds this project through a reference, which
696
+ // `tsc` refuses unless the referenced project is composite.
697
+ ['composite', true],
698
+ // No ambient Node types: a browser client compiles against this package.
699
+ ['types', []]
700
+ ];
421
701
  /**
422
702
  * The options that describe where THIS package's own files live. Never pruned,
423
703
  * and emitted first in the workspace case: a base config that supplied them
@@ -425,9 +705,19 @@ const TSCONFIG_COMPILER_OPTIONS = [
425
705
  * base is wrong rather than that the member is redundant.
426
706
  */
427
707
  const PACKAGE_LOCAL_OPTIONS = ['rootDir', 'outDir'];
428
- /** Compare two JSON option values. Sound here because compiler options are scalars and flat arrays. */
429
- function jsonEquals(left, right) {
430
- return JSON.stringify(left) === JSON.stringify(right);
708
+ /**
709
+ * The enum-valued options of {@link TSCONFIG_COMPILER_OPTIONS}, whose values
710
+ * `tsc` reads case-insensitively, so `nodenext` and `NodeNext` are one value.
711
+ * An enum-valued option added there belongs here too.
712
+ */
713
+ const CASE_INSENSITIVE_OPTIONS = ['target', 'lib', 'module', 'moduleResolution'];
714
+ /** Compare two option values. Sound here because compiler options are scalars and flat arrays. */
715
+ function optionEquals(key, left, right) {
716
+ const normalise = (value) => {
717
+ const text = JSON.stringify(value);
718
+ return CASE_INSENSITIVE_OPTIONS.includes(key) ? text?.toLowerCase() : text;
719
+ };
720
+ return normalise(left) === normalise(right);
431
721
  }
432
722
  /**
433
723
  * `tsconfig.json`, extending the workspace's base config when there is one.
@@ -436,104 +726,43 @@ function jsonEquals(left, right) {
436
726
  * the same key: inheriting `"module": "CommonJS"` where the scaffold needs
437
727
  * `NodeNext` would break every emitted import, so a differing base value is a
438
728
  * reason to keep the override rather than to trust the parent.
729
+ *
730
+ * `member` is the composition as that member sees it, so `extends` reaches the
731
+ * workspace base from the member's own directory.
439
732
  */
440
- function tsconfigJson(composition) {
441
- const workspace = composition.packaging.workspace;
733
+ function tsconfigJson(member, compilerOptions, include, references = '') {
734
+ const workspace = member.packaging.workspace;
442
735
  const base = workspace?.baseTsconfig === undefined ? undefined : (workspace.baseCompilerOptions ?? {});
443
736
  const options = base === undefined
444
- ? TSCONFIG_COMPILER_OPTIONS
737
+ ? compilerOptions
445
738
  : [
446
- ...TSCONFIG_COMPILER_OPTIONS.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
447
- ...TSCONFIG_COMPILER_OPTIONS.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !jsonEquals(base[key], value))
739
+ ...compilerOptions.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
740
+ ...compilerOptions.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !optionEquals(key, base[key], value))
448
741
  ];
449
742
  const extendsLine = workspace?.baseTsconfig === undefined ? '' : ` "extends": "${workspace.baseTsconfig}",\n`;
450
- const body = options.map(([key, value]) => ` "${key}": ${JSON.stringify(value)}`).join(',\n');
451
- return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src", "src/**/*.json"]\n}\n`;
452
- }
453
- // `isolatedModules` is what makes this check agree with the transform that
454
- // actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
455
- // vitest compiles through esbuild — a per-file transform with no type
456
- // information, which cannot tell a re-exported TYPE from a re-exported value.
457
- // Without the flag `tsc` accepts `export { SomeType } from './x'` that esbuild
458
- // then emits as a real import of a symbol that does not exist at runtime, so
459
- // the typecheck passes and the suite fails with a confusing missing-export.
460
- const TSCONFIG_TEST = `{
461
- "extends": "./tsconfig.json",
462
- "compilerOptions": {
463
- "rootDir": ".",
464
- "noEmit": true,
465
- "isolatedModules": true,
466
- "types": ["node"]
467
- },
468
- "include": ["src", "test", "src/**/*.json"]
743
+ // An array spaced as a formatter writes it, which `JSON.stringify` does not.
744
+ const json = (value) => (Array.isArray(value) ? `[${value.map(json).join(', ')}]` : JSON.stringify(value));
745
+ const body = options.map(([key, value]) => ` "${key}": ${json(value)}`).join(',\n');
746
+ const referencesLine = references === '' ? '' : `,\n "references": ${references}`;
747
+ return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ${include}${referencesLine}\n}\n`;
469
748
  }
470
- `;
471
- const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
472
-
473
- // Vitest transforms TypeScript itself and resolves \`.js\` specifiers to their
474
- // \`.ts\` source, so tests import from \`../src/\` with the same specifiers the
475
- // compiled output uses. \`include\` is scoped to \`test/\` so \`lib/\` is never
476
- // scanned; \`npm test\` runs \`typecheck:test\` first, because the esbuild
477
- // transform strips types without checking them.
478
-
479
- export default defineConfig({
480
- test: {
481
- environment: 'node',
482
- include: ['test/**/*.{test,spec}.ts']
483
- }
484
- });
485
- `;
486
- const GITIGNORE = `node_modules/
487
- lib/
488
- *.tsbuildinfo
489
-
490
- # Langium-generated TextMate grammar (rewritten by every \`langium generate\`).
491
- syntaxes/
492
- `;
493
749
  /**
494
- * The workspace-member `.gitignore` — one entry, and the one entry a root
495
- * cannot be assumed to have.
496
- *
497
- * A member inherits the root's rules, and `node_modules/`, `lib/` and
498
- * `*.tsbuildinfo` are in every monorepo root already, so repeating them here
499
- * would be three lines to keep in step for no coverage. `syntaxes/` is not like
500
- * them: it is a Langium artefact, so a root that has never held a Langium
501
- * package has no rule for it, and the first `langium generate` then offers
502
- * generated output up for commit with nothing to warn the adopter. Dropping the
503
- * whole file rather than this subset is the mistake this template exists to
504
- * undo.
505
- */
506
- const GITIGNORE_WORKSPACE_MEMBER = `# The workspace root already covers \`node_modules/\`, \`lib/\` and \`*.tsbuildinfo\`.
507
- # It has no reason to know about this one: the TextMate grammar is a Langium
508
- # artefact, rewritten by every \`langium generate\`, so a root that has never held
509
- # a Langium package ignores nothing here and the generated file is offered for
510
- # commit.
511
- syntaxes/
512
- `;
513
- /** The terminals every grammar needs, shared verbatim by the fragment and the lone-grammar case. */
514
- const TERMINALS = `hidden terminal WS: /\\s+/;
515
- terminal ID: /[_a-zA-Z][\\w_]*/;
516
- hidden terminal SL_COMMENT: /\\/\\/[^\\n\\r]*/;
517
- hidden terminal ML_COMMENT: /\\/\\*[\\s\\S]*?\\*\\//;
518
- `;
750
+ * The server's `tsconfig.json`. It references the protocol member, so `tsc -b`
751
+ * compiles protocol first, after `generate` has written its generated modules.
752
+ */
753
+ function serverTsconfigJson(composition) {
754
+ return tsconfigJson(forMember(composition, 'server'), TSCONFIG_COMPILER_OPTIONS, '["src", "src/**/*.json"]', '[{ "path": "../protocol" }]');
755
+ }
519
756
  /**
520
- * The shared lexical fragment, emitted only for a multi-grammar project.
521
- *
522
- * The alternative — every grammar declaring its own `WS` / `ID` — lets the token
523
- * sets drift apart.
757
+ * The terminals every grammar needs: the shared fragment's text after its
758
+ * leading comment run, so a lone grammar declares the same set.
524
759
  */
525
- const COMMON_GRAMMAR = `// Shared lexical basis for this project's grammars. Imported, never registered:
526
- // it has no entry rule and no \`langium-config.json\` entry, so it is a fragment
527
- // rather than a language.
528
- //
529
- // Keeping the tokens here rather than in one of the languages means a grammar
530
- // imports \`common\` for its TOKENS and another language only when it needs that
531
- // language's TYPES — separate reasons that are worth keeping separate.
532
-
533
- ${TERMINALS}`;
760
+ function terminals() {
761
+ return template('server/src/grammar/common.langium').replace(/^(\/\/.*\n)*\n/, '');
762
+ }
534
763
  /** One starter grammar. `shared` decides whether it imports the fragment or declares its own terminals. */
535
764
  function grammarFile(shared) {
536
- const lexis = shared ? "import './common'\n" : `\n${TERMINALS}`;
765
+ const lexis = shared ? "import './common'\n" : `\n${terminals()}`;
537
766
  return `grammar __GRAMMAR__
538
767
  ${shared ? '\n' + lexis : ''}
539
768
  // A minimal starter grammar — replace with your own language. \`__NODE_RULE__\` is
@@ -592,22 +821,36 @@ export const __CONFIG_ROOT_CONST___CONFIGURATION_ROOT = '__PROJECT_ID__';
592
821
  }
593
822
  };`
594
823
  : `const __NAME__SharedModule: Module<__NAME__SharedServices, PartialLangiumSharedServices> = {};`;
595
- const serializerImports = composition.grammars
596
- .map(grammar => `import { ${grammar.grammar}Serializer } from './${grammar.grammarId}-serializer.js';`)
824
+ const languageImports = composition.grammars
825
+ .flatMap(grammar => [
826
+ `import { ${grammar.grammar}SemanticTokenProvider } from './${grammar.grammarId}-semantic-tokens.js';`,
827
+ `import { ${grammar.grammar}Serializer } from './${grammar.grammarId}-serializer.js';`,
828
+ `import { ${grammar.grammar}ValidationContribution } from './${grammar.grammarId}-validation.js';`
829
+ ])
597
830
  .join('\n');
598
831
  // Each language gets its own adopter module because `Serializer` is a
599
832
  // per-language slot. `additionalLanguages` defaults its `adopter` to the
600
833
  // primary's, which would give every further grammar the FIRST grammar's
601
834
  // serializer — and that one throws on a `$type` it has no emitter for.
602
835
  const languageModules = composition.grammars
603
- .map(grammar => `const ${grammar.grammar}LanguageModule: Module<__NAME__Services, PartialLangiumServices & DeepPartial<ServerAddedServices>> = {
836
+ .map(grammar => `const ${grammar.grammar}LanguageModule = (
837
+ options: __NAME__Options
838
+ ): Module<__NAME__Services, PartialLangiumServices & DeepPartial<ServerAddedServices>> => ({
839
+ lsp: {
840
+ SemanticTokenProvider: services => new ${grammar.grammar}SemanticTokenProvider(services, options)
841
+ },
604
842
  serializer: {
605
843
  Serializer: services => new ${grammar.grammar}Serializer(services)
844
+ },
845
+ validation: {
846
+ checks: {
847
+ names: () => new ${grammar.grammar}ValidationContribution()
848
+ }
606
849
  }
607
- };`)
850
+ });`)
608
851
  .join('\n\n');
609
852
  const additionalLanguages = multi
610
- ? `,\n additionalLanguages: ${arrayLiteral(additional.map(grammar => `{ generated: ${grammar.grammar}GeneratedModule, adopter: () => ${grammar.grammar}LanguageModule }`), ' ', columns)}`
853
+ ? `,\n additionalLanguages: ${arrayLiteral(additional.map(grammar => `{ generated: ${grammar.grammar}GeneratedModule, adopter: () => ${grammar.grammar}LanguageModule(options) }`), ' ', columns)}`
611
854
  : '';
612
855
  const destructure = multi ? '{ shared, languages }' : '{ shared, language }';
613
856
  const returnType = composition.grammars.map(grammar => ` ${grammar.grammar}: __NAME__Services;`).join('\n');
@@ -616,11 +859,11 @@ export const __CONFIG_ROOT_CONST___CONFIGURATION_ROOT = '__PROJECT_ID__';
616
859
  : `${primary.grammar}: language`;
617
860
  const text = `// DI bootstrap for __NAME__. Composes the framework defaults with adopter
618
861
  // overrides via Langium's \`inject()\` (through the framework's
619
- // \`createIntegrationServices\`). Each adopter module binds ONE slot — its
620
- // language's \`Serializer\`, which the framework cannot default because a
621
- // concrete syntax is grammar knowledge. Everything else (scope, naming, project
622
- // management, build pipeline) boots on the framework defaults; scope
623
- // computation, validation checks and AST extensions go in these same modules.
862
+ // \`createIntegrationServices\`). Each adopter module binds its language's
863
+ // \`Serializer\`, which the framework cannot default because a concrete syntax
864
+ // is grammar knowledge, its semantic tokens and its validation checks. Scope,
865
+ // naming, project management and the build pipeline use the framework defaults;
866
+ // scope computation and AST extensions go in these same modules.
624
867
  //
625
868
  // Note which generated symbol comes from which name: the SHARED module is
626
869
  // \`__NAME__GeneratedSharedModule\` (from \`projectName\`) and there is one of it,
@@ -634,29 +877,37 @@ export const __CONFIG_ROOT_CONST___CONFIGURATION_ROOT = '__PROJECT_ID__';
634
877
  : `
635
878
  //
636
879
  // A second grammar goes in the \`additionalLanguages\` option of
637
- // \`createIntegrationServices\`, generated from this same \`langium-config.json\`.`}
880
+ // \`createIntegrationServices\`, generated from this same \`langium-config.json\`.
881
+ // With several languages, bind \`lsp.configurationRoot\` in
882
+ // \`__NAME__SharedModule\`, or the framework warns and takes the first
883
+ // language's id. Type that module's second \`Module\` argument as
884
+ // \`PartialLangiumSharedServices & { lsp: { configurationRoot: string } }\`.`}
638
885
 
639
- ${importList(['createLspServerLanguageModule', 'createLspServerSharedModule', 'type LspServerAddedServices', 'type LspServerAddedSharedServices'], '@hydranium/core/lsp', columns)}
886
+ ${importList([
887
+ 'createLspServerLanguageModule',
888
+ 'createLspServerSharedModule',
889
+ 'type HydraniumSemanticTokenProviderOptions',
890
+ 'type LspServerAddedSharedServices'
891
+ ], '@hydranium/core/lsp', columns)}
640
892
  import { createIntegrationServices, type ServerAddedServices, type ServerModuleContext, type ServerSharedServices } from '@hydranium/core';
641
893
  import { type DeepPartial, EmptyFileSystem, type Module } from '@hydranium/langium';
642
894
  import { type LangiumServices, type PartialLangiumServices, type PartialLangiumSharedServices } from '@hydranium/langium/lsp';
643
895
  ${importList(generatedImports, './generated/module.js', columns)}
644
- ${serializerImports}
896
+ ${languageImports}
645
897
 
646
898
  export type __NAME__SharedServices = ServerSharedServices & LspServerAddedSharedServices;
647
899
  export type __NAME__Services = LangiumServices &
648
- ServerAddedServices &
649
- LspServerAddedServices & {
900
+ ServerAddedServices & {
650
901
  shared: __NAME__SharedServices;
651
902
  };
652
903
 
653
904
  /** What a host or a test may vary about this composition. */
654
- export interface __NAME__Options {
905
+ export interface __NAME__Options extends HydraniumSemanticTokenProviderOptions {
655
906
  /**
656
907
  * Shared modules layered in after the framework's own bindings.
657
908
  *
658
909
  * The framework constructs most shared services with no options —
659
- * \`DocumentBuilder: services => new HydraniumDocumentBuilder(services)\` — so
910
+ * \`DocumentBuilder: services => new DefaultHydraniumDocumentBuilder(services)\` — so
660
911
  * rebinding the slot is the only way to boot one configured differently, and
661
912
  * a factory that hard-codes its composition leaves a test nowhere to do it.
662
913
  * That is what this is for: pass a module binding \`workspace.DocumentBuilder\`
@@ -692,7 +943,7 @@ ${returnType}
692
943
  },
693
944
  languageModules: {
694
945
  generated: ${primary.grammar}GeneratedModule,
695
- adopter: () => ${primary.grammar}LanguageModule,
946
+ adopter: () => ${primary.grammar}LanguageModule(options),
696
947
  extra: [createLspServerLanguageModule(fullContext)]
697
948
  }${additionalLanguages}
698
949
  });
@@ -701,179 +952,121 @@ ${returnType}
701
952
  `;
702
953
  return project(text, composition);
703
954
  }
704
- const SERVICES = `// Zero-arg service factory for the headless \`hydranium-cli\` tooling: the
705
- // \`reflect\` / \`lint-grammar\` / \`validate\` subcommands import this via
706
- // \`--services ./lib/services.js\`. The head wires its own filesystem here.
707
- //
708
- // The contract is language-count-agnostic — a second grammar needs no edit here.
709
-
710
- import { NodeFileSystem } from '@hydranium/core/node';
711
- import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
712
-
713
- export function createServices(): ReturnType<typeof create__NAME__Services> {
714
- return create__NAME__Services({ ...NodeFileSystem });
715
- }
716
- `;
717
- const AST = `// The language's AST entry point: a re-export of what \`langium generate\`
718
- // emits, plus anywhere you augment those types.
719
- //
720
- // Import the AST from HERE rather than from \`./generated/ast.js\`, so any
721
- // augmentation below travels with every import. One \`langium-cli\` run over N
722
- // grammars emits ONE combined AST module sharing one reflection, so a further
723
- // grammar needs no change here.
724
- //
725
- // \`generate:transfer-model\` reads this file as its \`--augmentation-file\`: the
726
- // generated wire types are derived from the AST *as augmented*, not from the
727
- // raw generated module. Augment a type here and the transfer model follows.
728
- //
729
- // A \`@derived\` property is computed at build time rather than parsed, so it is
730
- // declared here and populated by an AST-extension contribution:
731
- //
732
- // declare module './generated/ast.js' {
733
- // interface __ENTRY_RULE__ {
734
- // /** @derived Populated by an \`ast.extensions.computedProperties\` contribution. */
735
- // readonly _summary?: string;
736
- // }
737
- // }
738
-
739
- export * from './generated/ast.js';
740
- `;
741
955
  /**
742
- * The starter serializer — the concrete-syntax emitter for ONE grammar.
956
+ * How a host finds each socket head's port, in emission order: an LSP request
957
+ * when the LSP head publishes the ports, else the environment variable
958
+ * `main.ts` reads a port from.
743
959
  *
744
- * Emitted per grammar, not per project, because `services.serializer.Serializer`
745
- * is a per-language slot and a serializer is grammar-shaped by definition. That
746
- * is also why the framework refuses to default it: its `UnboundSerializer`
747
- * throws, naming the binding to add, rather than guessing a syntax.
748
- */
749
- const SERIALIZER = `// Concrete-syntax emitter for __GRAMMAR__ — the parser's inverse, turning a
750
- // model back into text this grammar accepts. Derived from the starter grammar
751
- // exactly as \`generated/ast.ts\` is, so replacing the grammar replaces this too.
752
- //
753
- // Without it every structured write fails: the framework's default binding at
754
- // \`services.serializer.Serializer\` THROWS, because a concrete syntax is
755
- // language knowledge no framework can derive. \`ModelService.update\` / \`save\`,
756
- // the data head's \`saveModelDocument\` and a GLSP \`SaveModelAction\` all reach
757
- // it.
758
- //
759
- // \`AbstractSerializer\`'s generic property walk lays out FORMAT-structured
760
- // output — its YAML and JSON subclasses are what it exists for — and cannot
761
- // produce a keyword-delimited syntax like \`node a -> b\`. So \`serializeNode\` is
762
- // a per-\`$type\` emitter here and the two array hooks are unreachable.
763
-
764
- import { AbstractSerializer } from '@hydranium/core';
765
- import type { AstNode } from '@hydranium/langium';
766
- import { type __ENTRY_RULE__, type __NODE_RULE__, is__ENTRY_RULE__, is__NODE_RULE__ } from './ast.js';
767
-
768
- export class __GRAMMAR__Serializer extends AbstractSerializer<__ENTRY_RULE__> {
769
- protected override serializeNode(node: AstNode | Record<string, unknown>): string {
770
- if (is__ENTRY_RULE__(node)) {
771
- return node.nodes.map(child => this.emitNode(child)).join('\\n');
772
- }
773
- if (is__NODE_RULE__(node)) {
774
- return this.emitNode(node);
775
- }
776
- // Defensive: a rule added to the grammar with no emitter added here.
777
- throw new Error(\`__GRAMMAR__Serializer: no emitter for $type \${(node as AstNode).$type}\`);
778
- }
779
-
780
- /** Unreachable — this grammar's one list is emitted by its \`__ENTRY_RULE__\` parent. */
781
- protected override serializeArray(): string {
782
- throw new Error('__GRAMMAR__Serializer: arrays are emitted by the per-$type parent, not the generic dispatch.');
783
- }
784
-
785
- /** Unreachable — same reasoning as {@link serializeArray}. */
786
- protected override serializeReferenceArray(): string {
787
- throw new Error('__GRAMMAR__Serializer: reference arrays are emitted by the per-$type parent, not the generic dispatch.');
788
- }
789
-
790
- /**
791
- * \`serializeReferenceText\` rather than \`node.target?.$refText\`: it is the one
792
- * read that spans BOTH input shapes. A transfer model reaching
793
- * \`serializeTransfer\` carries \`target\` as a plain string rather than a
794
- * \`Reference\`, and a serializer that reaches for \`$refText\` directly emits
795
- * the AST correctly and drops every reference on the transfer path.
796
- */
797
- private emitNode(node: __NODE_RULE__): string {
798
- const target = this.serializeReferenceText(node.target);
799
- return target === undefined ? \`node \${node.name}\` : \`node \${node.name} -> \${target}\`;
800
- }
801
- }
802
- `;
803
- /**
804
- * The exported constant names for the head set, in emission order. Both are
805
- * keyed by the PROJECT rather than by a language: one server of each kind per
960
+ * Keyed by the PROJECT rather than by a language: one server of each kind per
806
961
  * process serves every registered grammar, so a language-derived name would tie
807
962
  * a project-level endpoint to whichever grammar was scaffolded first.
808
963
  */
809
- function portCommandNames(composition) {
964
+ function headPortConstants(composition) {
810
965
  const upper = screamingSnake(composition.projectId);
966
+ const lsp = composition.heads.includes('lsp');
967
+ return socketHeads(composition).map(head => lsp
968
+ ? {
969
+ name: `${upper}_${head.key}_PORT_COMMAND`,
970
+ value: `${composition.projectId}/${head.path}/port`,
971
+ doc: `LSP request the host queries to discover the ${head.label} socket port.`
972
+ }
973
+ : {
974
+ name: `${upper}_${head.key}_PORT_ENV`,
975
+ value: `${upper}_${head.key}_PORT`,
976
+ doc: `Environment variable naming the ${head.label} socket port when no \`--${head.path}-port\` flag does.`
977
+ });
978
+ }
979
+ /** The socket heads of the composition, as `head-ports.ts` names them. */
980
+ function socketHeads(composition) {
811
981
  return [
812
- ...(composition.heads.includes('data') ? [`${upper}_DATA_SERVER_PORT_COMMAND`] : []),
813
- ...(composition.heads.includes('glsp') ? [`${upper}_GLSP_PORT_COMMAND`] : [])
982
+ ...(composition.heads.includes('data')
983
+ ? [{ key: 'DATA_SERVER', path: 'data-server', command: 'dataServer', label: 'data-server' }]
984
+ : []),
985
+ ...(composition.heads.includes('glsp') ? [{ key: 'GLSP_SERVER', path: 'glsp-server', command: 'glsp', label: 'GLSP' }] : [])
814
986
  ];
815
987
  }
988
+ /** Whether the composition emits the servers-only extension, which VS Code starts the server through and a Theia app sideloads. */
989
+ function emitsVscodeServers(composition) {
990
+ return initMembers(composition.heads, composition.hosts).includes('vscode-servers');
991
+ }
992
+ /**
993
+ * The host commands the servers extension answers each socket head's port
994
+ * under, for a host without a language client of its own.
995
+ */
996
+ function hostCommandConstants(composition) {
997
+ if (!emitsVscodeServers(composition)) {
998
+ return [];
999
+ }
1000
+ const upper = screamingSnake(composition.projectId);
1001
+ return socketHeads(composition).map(head => ({
1002
+ name: `${upper}_${head.key}_HOST_COMMAND`,
1003
+ value: `${composition.projectId}.port.${head.command}`,
1004
+ doc: `Host command the servers extension answers with the ${head.label} socket port.`
1005
+ }));
1006
+ }
816
1007
  /**
817
- * The socket heads' discovery commands, as an importable module.
1008
+ * The socket heads' port commands or variables, in the protocol member.
818
1009
  *
819
- * Deliberately NOT declared in `main.ts`. A host shell has to name the same
820
- * string to reach the head, and `main.ts` opens a connection at module scope,
821
- * so importing a constant from it would start a server. A host that retypes the
822
- * literal instead gets no error when it drifts: the framework's port poll
823
- * defaults to `findPortAttempts = -1`, so a wrong command retries forever
824
- * rather than failing.
1010
+ * A host shell has to name the same string the server reads, and reaches it
1011
+ * here without depending on the server. A host that retypes the literal
1012
+ * instead gets no error when it drifts: the framework's port poll defaults to
1013
+ * `findPortAttempts = -1`, so a wrong command retries forever rather than
1014
+ * failing, and a wrong variable is never read.
825
1015
  */
826
1016
  function headPortsFile(composition) {
827
- const upper = screamingSnake(composition.projectId);
828
- const data = composition.heads.includes('data');
829
- const glsp = composition.heads.includes('glsp');
830
- const blocks = [
831
- ...(data
832
- ? [
833
- `/** LSP request the host queries to discover the data-server socket port. */\nexport const ${upper}_DATA_SERVER_PORT_COMMAND = '${composition.projectId}/data-server/port';`
834
- ]
835
- : []),
836
- ...(glsp
837
- ? [
838
- `/** LSP request the host queries to discover the GLSP socket port. */\nexport const ${upper}_GLSP_PORT_COMMAND = '${composition.projectId}/glsp/port';`
839
- ]
840
- : [])
841
- ];
842
- return `// The LSP requests a host queries to discover this server's socket head${blocks.length > 1 ? 's' : ''}.
1017
+ const constants = [...headPortConstants(composition), ...hostCommandConstants(composition)];
1018
+ const blocks = constants.map(constant => `/** ${constant.doc} */\nexport const ${constant.name} = '${constant.value}';`);
1019
+ const socketHeadCount = socketHeads(composition).length;
1020
+ const heads = `socket head${socketHeadCount > 1 ? 's' : ''}`;
1021
+ const opening = composition.heads.includes('lsp')
1022
+ ? `// The LSP requests a host queries to discover this server's ${heads}.`
1023
+ : `// The environment variables that name the ports of this server's ${heads}.`;
1024
+ const usage = composition.heads.includes('lsp')
1025
+ ? `// The server publishes them and a host shell has to name the same string to
1026
+ // reach the head; both import them from here. A shell that retypes the literal
1027
+ // gets no error when it drifts: the framework's port poll retries indefinitely
1028
+ // by default.`
1029
+ : `// The server reads them and a process that starts it sets them; both import
1030
+ // them from here. A variable retyped with a typo is never read, and the head
1031
+ // then listens on whichever port is free.`;
1032
+ const hostCommands = emitsVscodeServers(composition)
1033
+ ? `
843
1034
  //
844
- // Both are keyed by the PROJECT, not the language: one server of each kind per
1035
+ // The host commands are the same ports for a host with no language client,
1036
+ // such as a Theia app sideloading the servers extension: it runs the command
1037
+ // and the extension sends the request.`
1038
+ : '';
1039
+ return `${opening}
1040
+ //
1041
+ // ${socketHeadCount > 1 ? 'Both are' : 'It is'} keyed by the PROJECT, not the language: one server of each kind per
845
1042
  // process serves every registered grammar, so a language-derived name would tie
846
1043
  // a project-level endpoint to whichever grammar was scaffolded first.
847
1044
  //
848
- // They live here rather than in \`main.ts\` because a host shell has to name the
849
- // same string to reach the head, and \`main.ts\` is an executable entry — nothing
850
- // can import from it. A shell that retypes the literal gets no error when it
851
- // drifts: the framework's port poll retries indefinitely by default. Import
852
- // these instead, and assert any host-side copy against them.
1045
+ ${usage}${hostCommands}
853
1046
 
854
1047
  ${blocks.join('\n\n')}
855
1048
  `;
856
1049
  }
857
- const INDEX = `// Public surface of the __NAME__ language server: the DI factory, the generated
858
- // AST, and the headless \`createServices\` entry.
1050
+ /**
1051
+ * The server's root entry: the DI factory, the generated AST and each diagram
1052
+ * module, all loadable in a browser worker.
1053
+ */
1054
+ function indexFile(composition) {
1055
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
1056
+ const diagramPhrase = diagrams.length === 0 ? '' : diagrams.length === 1 ? ', plus the diagram module' : ', plus the diagram modules';
1057
+ const text = `// Public surface of the __NAME__ language server: the DI factory and the
1058
+ // generated AST${diagramPhrase}.
859
1059
  //
860
- // The entry points under \`src/\` are deliberately NOT re-exported. Each opens a
861
- // transport at module scope, so importing one starts a server as a side effect
862
- // — which is why they are \`bin\` targets and this file is \`main\`.
1060
+ // Everything here loads in a browser worker as well as in Node, so a host can
1061
+ // compose the language wherever it runs it. \`createServices\`, which reads the
1062
+ // real filesystem, is the package's \`./node\` entry instead. The entry points
1063
+ // under \`src/\` are not re-exported either: each opens a transport at module
1064
+ // scope, so importing one starts a server, which is why they are \`bin\` targets.
863
1065
 
864
1066
  export * from './language-server/__PROJECT_ID__-module.js';
865
1067
  export * from './language-server/ast.js';
866
- export { createServices } from './services.js';
867
- `;
868
- /**
869
- * The public surface, plus the head-port commands when there is a socket head.
870
- *
871
- * The commands belong here rather than only in `head-ports.ts` so a host shell
872
- * reaches them from the package root, the same way it reaches `createServices`.
873
- */
874
- function indexFile(composition) {
875
- const base = project(INDEX, composition);
876
- return portCommandNames(composition).length ? `${base}export * from './head-ports.js';\n` : base;
1068
+ ${diagrams.map(grammar => `export * from './glsp/${grammar.grammarId}/diagram-module.js';\n`).join('')}`;
1069
+ return project(text, composition);
877
1070
  }
878
1071
  /**
879
1072
  * The `#!` line every emitted `bin` target carries.
@@ -887,6 +1080,10 @@ function indexFile(composition) {
887
1080
  * sweep adds below it.
888
1081
  */
889
1082
  const SHEBANG = '#!/usr/bin/env node\n';
1083
+ const DIAGNOSTICS_ARGUMENTS = "'enable-diagnostics': { type: 'boolean' }, 'diagnostics-directory': { type: 'string' }";
1084
+ const DIAGNOSTICS_BINDING = `// The host opts in for every connection to this process.
1085
+ const diagnostics = diagnosticsFromArgs();
1086
+ `;
890
1087
  /**
891
1088
  * The launcher, assembled from the head set.
892
1089
  *
@@ -899,12 +1096,14 @@ const SHEBANG = '#!/usr/bin/env node\n';
899
1096
  */
900
1097
  function mainFile(composition) {
901
1098
  const columns = columnsFor(composition);
902
- const roots = [...composition.grammars.map(grammar => grammar.entryRule)].sort();
1099
+ const roots = composition.grammars.map(grammar => grammar.entryRule).sort();
903
1100
  const upper = screamingSnake(composition.projectId);
1101
+ const lsp = composition.heads.includes('lsp');
904
1102
  const data = composition.heads.includes('data');
905
1103
  const glsp = composition.heads.includes('glsp');
906
1104
  const diagrams = composition.grammars.filter(grammar => grammar.diagram);
907
1105
  const plural = roots.length > 1 ? 's' : '';
1106
+ const portFlags = [...(data ? ['--data-server-port'] : []), ...(glsp ? ['--glsp-server-port'] : [])];
908
1107
  const headNames = composition.heads.map(head => head === 'lsp' ? 'the Langium LSP head' : head === 'data' ? 'a socket data-server (model server) head' : 'a GLSP head');
909
1108
  // Comma-joined but for the last, which takes `and`: a bare `, ` join reads as
910
1109
  // a truncated list, and the emitted comment is the first thing an adopter
@@ -913,7 +1112,7 @@ function mainFile(composition) {
913
1112
  // `reflect-metadata` must be imported before anything that reads a
914
1113
  // decorator's emitted metadata, which inversify does at module scope.
915
1114
  const reflectImport = glsp ? "import 'reflect-metadata';\n" : '';
916
- const coreNodeSymbols = ['NodeFileSystem', ...(data ? ['publishPortOnLspConnection', 'startSocketServer'] : [])];
1115
+ const coreNodeSymbols = ['NodeFileSystem', ...(data ? [...(lsp ? ['publishPortOnLspConnection'] : []), 'startSocketServer'] : [])];
917
1116
  const glspImports = glsp
918
1117
  ? `import { ServerModule } from '@eclipse-glsp/server/node.js';
919
1118
  import { GlspClientLogger, HydraniumGlspAppModule } from '@hydranium/glsp-server';
@@ -930,44 +1129,57 @@ import { startGlspServer } from '@hydranium/glsp-server/node';
930
1129
  // \`TransferElement\` structurally, so naming the AST type here compiles fine and
931
1130
  // silently tells every typed client that a reference is a resolvable object
932
1131
  // rather than a name.
933
- ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1132
+ ${importList(roots, memberPackageName(composition, 'protocol'), columns, true)}
934
1133
  `
935
1134
  : '';
936
- // The commands live in `head-ports.ts`, not here: a host shell has to name
937
- // the same string to reach the head, and this file is an executable entry, so
938
- // nothing can import from it.
939
- const portCommands = portCommandNames(composition).length
940
- ? importList(portCommandNames(composition), './head-ports.js', columns) + '\n'
941
- : '';
942
- const dataBlock = data
943
- ? `
1135
+ // The names live in the protocol member, not here: a host shell has to name
1136
+ // the same string to reach the head, and this file is an executable entry,
1137
+ // so nothing can import from it.
1138
+ const portNames = headPortConstants(composition).map(constant => constant.name);
1139
+ const portImports = portNames.length ? importList(portNames, memberPackageName(composition, 'protocol'), columns) + '\n' : '';
1140
+ const dataBlock = !data
1141
+ ? ''
1142
+ : !lsp
1143
+ ? `
1144
+ // Data-server head: each accepted client gets its own DataServer.
1145
+ const dataServer = startSocketServer(
1146
+ { shared, workspace, port: portFrom('data-server-port', ${upper}_DATA_SERVER_PORT_ENV), logTag: 'ModelServer', logger: shared.Logger },
1147
+ dataConnection => {
1148
+ new DataServer<${roots.join(' | ')}>(dataConnection, shared, { diagnostics });
1149
+ return { dispose: () => undefined };
1150
+ }
1151
+ );
1152
+ `
1153
+ : `
944
1154
  // Data-server head alongside LSP: binds an ephemeral port, published over the LSP
945
1155
  // connection for the host to discover. Each accepted client gets its own DataServer.
946
1156
  //
947
1157
  // Neither the bind nor the publish may be swallowed: either failure leaves the LSP
948
1158
  // head serving text edits while every data client waits on a port command that was
949
1159
  // never registered, and the launcher reports a bind failure only if given a logger.
950
- const dataServer = startSocketServer({ port: 0, logTag: 'ModelServer', logger: shared.Logger }, dataConnection => {
951
- new DataServer<${roots.join(' | ')}>(dataConnection, shared);
1160
+ const dataServer = startSocketServer({ shared, port: 0, logTag: 'ModelServer', logger: shared.Logger }, dataConnection => {
1161
+ new DataServer<${roots.join(' | ')}>(dataConnection, shared, { diagnostics });
952
1162
  return { dispose: () => undefined };
953
1163
  });
954
1164
  dataServer.started
955
- .then(() => {
956
- const { port } = dataServer;
957
- if (port === undefined) {
958
- // \`started\` resolves only once the address is resolved, so this is
959
- // unreachable; a non-null assertion in its place would publish
960
- // \`undefined\`, which the host cannot tell from an unreachable port.
961
- throw new Error('the data head started without a resolved port');
962
- }
963
- publishPortOnLspConnection(shared.lsp.Connection, ${upper}_DATA_SERVER_PORT_COMMAND, port);
964
- })
1165
+ .then(
1166
+ () => {
1167
+ const { port } = dataServer;
1168
+ if (port === undefined) {
1169
+ // \`started\` resolves only once the address is resolved, so this is
1170
+ // unreachable; a non-null assertion in its place would publish
1171
+ // \`undefined\`, which the host cannot tell from an unreachable port.
1172
+ throw new Error('the data head started without a resolved port');
1173
+ }
1174
+ publishPortOnLspConnection(shared.lsp.Connection, ${upper}_DATA_SERVER_PORT_COMMAND, port);
1175
+ },
1176
+ // The launcher logged its own bind failure; the rejection is control flow.
1177
+ () => undefined
1178
+ )
965
1179
  .catch((error: unknown) => {
966
- const reason = error instanceof Error ? error.message : String(error);
967
- shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${reason}\`);
1180
+ shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${describeErrorWithStack(error)}\`);
968
1181
  });
969
- `
970
- : '';
1182
+ `;
971
1183
  const glspBlock = glsp
972
1184
  ? `
973
1185
  // GLSP head on the same shared workspace. \`HydraniumGlspAppModule\` is used
@@ -975,99 +1187,166 @@ dataServer.started
975
1187
  // grammar a request concerns, so nothing per-language belongs there — each
976
1188
  // diagram module declares its own language instead.
977
1189
  //
978
- // GLSP framework logs route through the LSP connection rather than stdout, which
979
- // IS the LSP transport in stdio mode; writing there corrupts the protocol stream.
1190
+ ${lsp
1191
+ ? `// GLSP framework logs route through the LSP connection rather than stdout, which
1192
+ // IS the LSP transport in stdio mode; writing there corrupts the protocol stream.`
1193
+ : `// GLSP framework logs route through the framework's logger to stderr. GLSP's
1194
+ // own startup line goes to stdout, beside the ports line below.`}
980
1195
  const glspServer = startGlspServer({
981
1196
  // The GLSP log threshold lives on the logger, not beside it: the framework
982
1197
  // replaces GLSP's own \`Logger\` binding, so a launcher-level \`logLevel\` would
983
1198
  // be discarded. The logger tracks the framework's process-wide threshold, so
984
- // \`HYDRANIUM_LOG_LEVEL\` and the LSP log-level setting govern GLSP output too;
1199
+ // \`HYDRANIUM_LOG_LEVEL\` ${lsp ? 'and the LSP log-level setting govern' : 'governs'} GLSP output too;
985
1200
  // pass \`logLevel\` only to make GLSP quieter than the rest of the server.
986
1201
  createLogger: caller => new GlspClientLogger(shared, { component: caller }),
987
1202
  serverModule: new ServerModule()${diagrams.map(grammar => `.configureDiagramModule(new ${grammar.grammar}DiagramModule())`).join('')},
988
1203
  appModules: [new HydraniumGlspAppModule({ shared })],
989
- lspConnection: shared.lsp.Connection,
990
- portCommand: ${upper}_GLSP_PORT_COMMAND
1204
+ ${lsp
1205
+ ? ` lspConnection: shared.lsp.Connection,
1206
+ portCommand: ${upper}_GLSP_SERVER_PORT_COMMAND`
1207
+ : ` workspace,
1208
+ port: portFrom('glsp-server-port', ${upper}_GLSP_SERVER_PORT_ENV),
1209
+ logger: shared.Logger`}
991
1210
  });
992
- void glspServer;
993
- `
1211
+ ${lsp ? 'void glspServer;\n' : ''}`
994
1212
  : '';
995
- const text = `${SHEBANG}// Standalone entry point: starts ${headSummary} in the same
996
- // process, so every head shares one model store.
997
- // Invocation: \`node lib/main.js --stdio\`, or the package's \`__PROJECT_ID__\` bin
998
- // script.${data
999
- ? `
1000
- //
1001
- // NOT the entry \`hydranium-cli query\` / \`save\` / \`projects\` / \`watch\` speak to:
1002
- // stdio here carries LSP, and the data head is a socket whose port is published
1003
- // over the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
1004
- : ''}
1005
- //
1006
- // Everything here runs at module scope, so this file is an executable rather
1007
- // than a library entry — import \`./index.js\` instead to compose the language.
1008
-
1009
- ${reflectImport}${glspImports}${importList(coreNodeSymbols, '@hydranium/core/node', columns)}
1010
- ${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}// The framework's entry point, NOT Langium's \`@hydranium/langium/lsp\` one. It is
1213
+ const lspImports = `// The framework's entry point, NOT Langium's \`@hydranium/langium/lsp\` one. It is
1011
1214
  // signature-compatible and delegates straight through; what it adds first is
1012
1215
  // \`assertLspHeadComposed\`, which fails the start if the LSP head's SHARED module
1013
1216
  // was never composed. Reaching for Langium's is the natural mistake and it is
1014
1217
  // silent: the server boots, links and completes, while echo suppression, the
1015
1218
  // didChangeContent debounce and the last-client-close rebuild are all inert.
1016
- import { startLanguageServer } from '@hydranium/core/lsp';
1219
+ import { startLanguageServer, withHydraniumLspFeatures } from '@hydranium/core/lsp';
1017
1220
  import { ProposedFeatures, createConnection } from 'vscode-languageserver/node';
1018
- ${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1019
- ${portCommands === '' ? '' : '\n' + portCommands}
1020
- const connection = createConnection(ProposedFeatures.all);
1221
+ `;
1222
+ const lspStart = `
1223
+ ${data
1224
+ ? `${DIAGNOSTICS_BINDING}
1225
+ `
1226
+ : ''}const connection = createConnection(withHydraniumLspFeatures(ProposedFeatures.all));
1021
1227
  const { shared } = create__NAME__Services({ connection, ...NodeFileSystem });
1022
1228
  startLanguageServer(shared);
1023
- ${dataBlock}${glspBlock}`;
1229
+ `;
1230
+ // No LSP client names the workspace or asks for the ports here, so the
1231
+ // command line does, and the ports go to stdout once every head listens.
1232
+ const servers = [...(data ? ['dataServer'] : []), ...(glsp ? ['glspServer'] : [])];
1233
+ const socketStart = `
1234
+ const { values, positionals } = parseArgs({
1235
+ options: { ${[...portFlags.map(flag => `'${flag.slice(2)}': { type: 'string' }`), ...(data ? [DIAGNOSTICS_ARGUMENTS] : [])].join(', ')} },
1236
+ allowPositionals: true
1237
+ });
1238
+ // The workspace root: the first argument, else the process cwd.
1239
+ const workspace = positionals[0] ?? process.cwd();
1240
+ ${data ? '\n' + DIAGNOSTICS_BINDING : ''}
1241
+ /** A head's port: its flag, else its environment variable, else 0 for any free port. */
1242
+ function portFrom(flag: ${portFlags.map(flag => `'${flag.slice(2)}'`).join(' | ')}, variable: string): number {
1243
+ const text = values[flag] ?? process.env[variable] ?? '0';
1244
+ const port = Number(text);
1245
+ // Decimal digits only: \`Number\` also reads '' as 0 and takes '1e3' or '0x10'.
1246
+ if (!/^\\d+$/.test(text) || port > 65_535) {
1247
+ throw new Error(\`--\${flag} or \${variable} is '\${text}', not a port\`);
1248
+ }
1249
+ return port;
1250
+ }
1251
+
1252
+ const { shared } = create__NAME__Services({ ...NodeFileSystem });
1253
+ `;
1254
+ const portsLine = `
1255
+ // One JSON line on stdout once every head listens, for the process that started
1256
+ // this one to read the ports from: the one line that starts with \`{\`. Each
1257
+ // head brings the workspace up before it listens; a head that fails to start
1258
+ // has logged why and ends the process, which would otherwise serve without it.
1259
+ ${servers.length === 1 ? `${servers[0]}.started` : `Promise.all([${servers.map(server => `${server}.started`).join(', ')}])`}.then(
1260
+ () => process.stdout.write(\`\${JSON.stringify({ ${servers.map(server => `${server}: ${server}.port`).join(', ')} })}\\n\`),
1261
+ () => process.exit(1)
1262
+ );
1263
+ `;
1264
+ const invocation = lsp
1265
+ ? `// Invocation: \`node lib/main.js --stdio\`, or the package's \`__PROJECT_ID__\` bin
1266
+ // script.`
1267
+ : `// Invocation: \`node lib/main.js [<workspace-path>] ${portFlags.map(flag => `[${flag} <port>]`).join(' ')}\`,
1268
+ // or the package's \`__PROJECT_ID__\` bin script. Each port falls back to its
1269
+ // environment variable, then to any free port.`;
1270
+ const dataNote = !data
1271
+ ? ''
1272
+ : lsp
1273
+ ? `
1274
+ //
1275
+ // NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: stdio
1276
+ // here carries LSP, and the data head is a socket whose port is published over
1277
+ // the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
1278
+ : `
1279
+ //
1280
+ // NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: they
1281
+ // drive the data protocol over a child's stdio, and the data head here is a
1282
+ // socket. Those subcommands spawn \`data-server-main.js\`.`;
1283
+ const text = `${SHEBANG}// Standalone entry point: starts ${headSummary} in the same
1284
+ // process, so every head shares one model store.
1285
+ ${invocation}${dataNote}
1286
+ //
1287
+ // Everything here runs at module scope, so this file is an executable rather
1288
+ // than a library entry — import \`./index.js\` instead to compose the language.
1289
+
1290
+ ${reflectImport}${glspImports}${importList(coreNodeSymbols, '@hydranium/core/node', columns)}
1291
+ ${data ? "import { DataServer } from '@hydranium/data-server';\nimport { diagnosticsFromArgs } from '@hydranium/data-server/node';\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';
1292
+ ${portImports === '' ? '' : '\n' + portImports}${lsp ? lspStart : socketStart}${dataBlock}${glspBlock}${lsp ? '' : portsLine}`;
1024
1293
  return project(text, composition);
1025
1294
  }
1026
1295
  /**
1027
1296
  * The second executable entry: the data head alone, on stdio.
1028
1297
  *
1029
1298
  * Emitted with the `data` head because it is the ONLY command line that reaches
1030
- * that head from outside the editor. `main.ts` gives stdio to LSP and publishes
1031
- * the data head's socket port over the LSP connection, so a client that is not
1032
- * an LSP client cannot discover it — which is every one of `hydranium-cli`'s
1033
- * `query` / `save` / `projects` / `watch`, all of which drive the data protocol
1034
- * over a spawned child's stdin/stdout. Without this file a scaffolded project
1035
- * has no `--server` value, and the closest guess — the LSP entry — fails as an
1036
- * unrecognised method rather than as a missing head.
1299
+ * that head from outside the editor. `main.ts` serves the data head on a
1300
+ * socket, so a client driving the data protocol over a spawned child's
1301
+ * stdin/stdout cannot reach it — which is every data-server subcommand of
1302
+ * `hydranium-cli`. Without this file a scaffolded project has no `--server`
1303
+ * value, and the closest guess, `main.ts`, fails as an unrecognised method or
1304
+ * an unknown flag rather than as a missing head.
1037
1305
  *
1038
1306
  * A separate process rather than a flag on `main.ts`: only one protocol can own
1039
1307
  * stdio, so the two entries are mutually exclusive by construction.
1040
1308
  */
1041
1309
  function dataServerMainFile(composition) {
1042
1310
  const columns = columnsFor(composition);
1043
- const roots = [...composition.grammars.map(grammar => grammar.entryRule)].sort();
1311
+ const roots = composition.grammars.map(grammar => grammar.entryRule).sort();
1044
1312
  const plural = roots.length > 1 ? 's' : '';
1045
1313
  const text = `${SHEBANG}// Standalone entry point: a data-server head on **stdio**, with no LSP head in
1046
- // the process. This is the transport \`hydranium-cli\` speaks — \`query\`, \`save\`,
1047
- // \`projects\` and \`watch\` all spawn a server command and drive JSON-RPC over its
1048
- // stdin/stdout — so it is what makes those subcommands usable against this
1049
- // language.
1314
+ // the process. This is the transport \`hydranium-cli\` speaks — its data-server
1315
+ // subcommands spawn a server command and drive JSON-RPC over its stdin/stdout —
1316
+ // so it is what makes those subcommands usable against this language.
1050
1317
  //
1051
1318
  // Invocation: \`node lib/data-server-main.js [<workspace-path>]\`, or the
1052
1319
  // package's \`__PROJECT_ID__-data-server\` bin script. The workspace path defaults to
1053
1320
  // the process cwd, which is what the CLI's \`--cwd\` sets on the child.
1054
1321
  //
1055
- // Contrast with \`main.ts\`, the editor entry: there the LSP head owns stdio and
1322
+ ${composition.heads.includes('lsp')
1323
+ ? `// Contrast with \`main.ts\`, the editor entry: there the LSP head owns stdio and
1056
1324
  // the data head is a socket published over the LSP connection. Here there is no
1057
1325
  // LSP connection at all, so the workspace initialization an
1058
1326
  // \`initialize\`/\`initialized\` pair would otherwise drive has to happen here —
1059
1327
  // which is why this entry uses \`startStdioServer\` rather than wiring a
1060
- // connection directly.
1328
+ // connection directly.`
1329
+ : `// Contrast with \`main.ts\`, where the data head is a socket whose port it
1330
+ // prints. Here it owns stdio, and as there, no LSP connection drives the
1331
+ // workspace initialization, so \`startStdioServer\` brings the workspace up
1332
+ // before it listens.`}
1061
1333
  //
1062
1334
  // Everything here runs at module scope, so this file is an executable rather
1063
1335
  // than a library entry — import \`./index.js\` instead to compose the language.
1064
1336
 
1065
1337
  ${importList(['NodeFileSystem', 'startStdioServer'], '@hydranium/core/node', columns)}
1066
1338
  import { DataServer } from '@hydranium/data-server';
1339
+ import { diagnosticsFromArgs } from '@hydranium/data-server/node';
1340
+ import { parseArgs } from 'node:util';
1067
1341
  // The TRANSFER root${plural}, not the AST one${plural} — same reasoning as \`main.ts\`.
1068
- ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1342
+ ${importList(roots, memberPackageName(composition, 'protocol'), columns, true)}
1069
1343
  import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1070
1344
 
1345
+ const { positionals } = parseArgs({
1346
+ options: { ${DIAGNOSTICS_ARGUMENTS} },
1347
+ allowPositionals: true
1348
+ });
1349
+ ${DIAGNOSTICS_BINDING}
1071
1350
  const { shared } = create__NAME__Services({ ...NodeFileSystem });
1072
1351
 
1073
1352
  // \`startStdioServer\` owns the transport AND the workspace bring-up, including
@@ -1081,12 +1360,12 @@ const server = startStdioServer(
1081
1360
  shared,
1082
1361
  // Defaults to the process cwd, which is what the CLI's \`--cwd\` sets on the
1083
1362
  // spawned child; an explicit path argument overrides it.
1084
- workspace: process.argv[2] ?? process.cwd(),
1363
+ workspace: positionals[0] ?? process.cwd(),
1085
1364
  logger: shared.Logger,
1086
1365
  logTag: 'ModelServer'
1087
1366
  },
1088
1367
  connection => {
1089
- new DataServer<${roots.join(' | ')}>(connection, shared);
1368
+ new DataServer<${roots.join(' | ')}>(connection, shared, { diagnostics });
1090
1369
  // The DataServer self-cleans via \`connection.onClose\`, so there is nothing
1091
1370
  // extra to tear down here.
1092
1371
  return { dispose: () => undefined };
@@ -1113,7 +1392,7 @@ function servicesTest(composition) {
1113
1392
  // is missing from the composition.
1114
1393
 
1115
1394
  import { describe, expect, it } from 'vitest';
1116
- import { createServices } from '../src/services.js';
1395
+ import { createServices } from '../src/node/services.js';
1117
1396
 
1118
1397
  describe('__NAME__ services', () => {
1119
1398
  it('composes the DI tree and registers ${title}', () => {
@@ -1154,13 +1433,9 @@ const SERIALIZATION_SUITE = `describe('__GRAMMAR__ serialization', () => {
1154
1433
 
1155
1434
  it('emits the same text from a transfer model, whose references are plain strings', async () => {
1156
1435
  const { __GRAMMAR__ } = createServices();
1157
- const model: Transfer__ENTRY_RULE__ = {
1158
- $type: '__ENTRY_RULE__',
1159
- nodes: [
1160
- { $type: '__NODE_RULE__', name: 'first', target: 'second' },
1161
- { $type: '__NODE_RULE__', name: 'second' }
1162
- ]
1163
- };
1436
+ const model = transferNode(Transfer__ENTRY_RULE__, {
1437
+ nodes: __TRANSFER_NODES__
1438
+ });
1164
1439
 
1165
1440
  expect(await __GRAMMAR__.serializer.Serializer.serializeTransfer(model)).toBe(source);
1166
1441
  });
@@ -1178,11 +1453,23 @@ const SERIALIZATION_SUITE = `describe('__GRAMMAR__ serialization', () => {
1178
1453
  function serializationTest(composition) {
1179
1454
  const columns = columnsFor(composition);
1180
1455
  const astTypes = composition.grammars.map(grammar => grammar.entryRule);
1181
- const transferTypes = composition.grammars.map(grammar => `${grammar.entryRule} as Transfer${grammar.entryRule}`);
1456
+ const transferConstants = composition.grammars.flatMap(grammar => [
1457
+ `${grammar.entryRule} as Transfer${grammar.entryRule}`,
1458
+ grammar.nodeRule
1459
+ ]);
1460
+ const suite = (grammar) => {
1461
+ const nodes = [
1462
+ `transferNode(${grammar.nodeRule}, { name: 'first', target: 'second' })`,
1463
+ `transferNode(${grammar.nodeRule}, { name: 'second' })`
1464
+ ];
1465
+ const single = `[${nodes.join(', ')}]`;
1466
+ const literal = ` nodes: ${single}`.length <= columns ? single : `[\n${nodes.map(node => ` ${node}`).join(',\n')}\n ]`;
1467
+ return render(SERIALIZATION_SUITE, composition, grammar).replace('__TRANSFER_NODES__', literal);
1468
+ };
1182
1469
  const text = `// Round-trips each grammar through its serializer: parse the source, serialize
1183
1470
  // the model, compare the text. The serializer is the parser's inverse, so this
1184
- // is the assertion that keeps \`ModelService.update\` / \`save\` writing files the
1185
- // language server can read back.
1471
+ // is the assertion that keeps a client session's \`update\` / \`save\` writing
1472
+ // files the language server can read back.
1186
1473
  //
1187
1474
  // The transfer case is the one that catches the mistake worth catching.
1188
1475
  // \`ModelService.modelToText\` short-circuits only a RAW STRING, so a typed model
@@ -1192,10 +1479,11 @@ function serializationTest(composition) {
1192
1479
  import { parseHelper } from '@hydranium/core/testing';
1193
1480
  import { describe, expect, it } from 'vitest';
1194
1481
  ${importList(astTypes, '../src/language-server/ast.js', columns, true)}
1195
- ${importList(transferTypes, '../src/language-server/generated-hydranium/transfer-model.js', columns, true)}
1196
- import { createServices } from '../src/services.js';
1482
+ ${importList(transferConstants, memberPackageName(composition, 'protocol'), columns)}
1483
+ import { createServices } from '../src/node/services.js';
1484
+ import { transferNode } from './generated-hydranium/transfer-builder.js';
1197
1485
 
1198
- ${composition.grammars.map(grammar => render(SERIALIZATION_SUITE, composition, grammar)).join('\n\n')}
1486
+ ${composition.grammars.map(suite).join('\n\n')}
1199
1487
  `;
1200
1488
  return text;
1201
1489
  }
@@ -1272,14 +1560,9 @@ const LINKING_SUITE = `describe('__GRAMMAR__ linking', () => {
1272
1560
  });
1273
1561
  });`;
1274
1562
  /**
1275
- * The validation tier, asserting the FRAMEWORK's diagnostics rather than an
1276
- * adopter rule.
1277
- *
1278
- * The scaffold binds no `validation.checks`, so a test over an adopter check
1279
- * would have to invent one and would then assert the test's own fixture rather
1280
- * than the language. What a scaffolded project really guarantees on day one is
1281
- * that a dangling reference is reported, which is a linker diagnostic — and
1282
- * that is the tier a new adopter breaks third.
1563
+ * The validation tier: the linker's diagnostic for a dangling reference, the
1564
+ * tier a new adopter breaks third, and the scaffold's one check, a warning on
1565
+ * a repeated name.
1283
1566
  */
1284
1567
  const VALIDATING_SUITE = `describe('__GRAMMAR__ validation', () => {
1285
1568
  it('reports nothing for a well-formed document', async () => {
@@ -1306,6 +1589,21 @@ const VALIDATING_SUITE = `describe('__GRAMMAR__ validation', () => {
1306
1589
  expect(diagnostics[0].severity).toBe(DiagnosticSeverity.Error);
1307
1590
  expect(diagnostics[0].message).toContain('absent');
1308
1591
  });
1592
+
1593
+ it('warns on each node named like one before it', async () => {
1594
+ const { shared, __GRAMMAR__ } = createServices();
1595
+
1596
+ const document = await parseHelper<__ENTRY_RULE__>(__GRAMMAR__)('node first\\nnode second\\nnode first', {
1597
+ documentUri: 'file:///duplicate.__EXTENSION__'
1598
+ });
1599
+ await shared.workspace.DocumentBuilder.build([document], { validation: true });
1600
+
1601
+ const diagnostics = document.diagnostics ?? [];
1602
+ expect(diagnostics).toHaveLength(1);
1603
+ expect(diagnostics[0].severity).toBe(DiagnosticSeverity.Warning);
1604
+ expect(diagnostics[0].message).toBe("A node named 'first' is already declared.");
1605
+ expect(diagnostics[0].range.start.line).toBe(2);
1606
+ });
1309
1607
  });`;
1310
1608
  /** Assemble one grammar-derived test file: a fixed preamble plus one suite per grammar. */
1311
1609
  function grammarTest(composition, header, imports, suite) {
@@ -1316,7 +1614,7 @@ function grammarTest(composition, header, imports, suite) {
1316
1614
  import { parseHelper } from '@hydranium/core/testing';
1317
1615
  import { describe, expect, it } from 'vitest';
1318
1616
  ${imports.join('\n')}${imports.length ? '\n' : ''}${importList(astTypes, '../src/language-server/ast.js', columns, true)}
1319
- import { createServices } from '../src/services.js';
1617
+ import { createServices } from '../src/node/services.js';
1320
1618
 
1321
1619
  ${composition.grammars.map(grammar => render(suite, composition, grammar)).join('\n\n')}
1322
1620
  `;
@@ -1334,15 +1632,15 @@ const LINKING_HEADER = `// Tier two: does my cross-reference resolve.
1334
1632
  // multi-file workspace depends on and what a single-document test cannot see.`;
1335
1633
  const VALIDATING_HEADER = `// Tier three: does a broken model get reported.
1336
1634
  //
1337
- // These are the FRAMEWORK's own linker diagnostics, not adopter checks — the
1338
- // scaffold binds no \`validation.checks\`, and a test over an invented one would
1339
- // assert its own fixture rather than the language. Bind a check in your
1340
- // language module and assert it here alongside these.`;
1635
+ // A dangling reference is the FRAMEWORK's own linker diagnostic; a repeated
1636
+ // name is reported by the scaffold's own validation check. Bind a further
1637
+ // check in your language module and assert it here alongside these.`;
1341
1638
  /** The README, assembled from lines to avoid escaping the Markdown code fences. */
1342
1639
  function readme(composition) {
1343
1640
  const multi = composition.grammars.length > 1;
1344
1641
  const data = composition.heads.includes('data');
1345
1642
  const primaryExtension = composition.grammars[0].extensions[0];
1643
+ const diagramCount = composition.grammars.filter(grammar => grammar.diagram).length;
1346
1644
  const grammarBullets = composition.grammars.map(grammar => `- \`src/grammar/${grammar.grammarId}.langium\` — the \`${grammar.grammar}\` grammar (.${grammar.extensions.join(', .')})`);
1347
1645
  const lines = [
1348
1646
  '# __NAME__',
@@ -1351,24 +1649,11 @@ function readme(composition) {
1351
1649
  '',
1352
1650
  '## Getting started',
1353
1651
  '',
1354
- // Conditional on the pin this scaffold actually carries — see
1355
- // `UNPUBLISHED_FRAMEWORK_VERSION`.
1356
- ...(composition.frameworkVersion === UNPUBLISHED_FRAMEWORK_VERSION
1357
- ? [
1358
- '> **Pre-publish note.** `@hydranium/*` is not on npm yet, so the `0.0.0`',
1359
- '> pins below are placeholders and `npm install` will fail with a 404 until',
1360
- '> the framework is released. Until then, supply the packages from a local',
1361
- '> framework checkout with [yalc](https://github.com/wclr/yalc) — a plain',
1362
- '> `file:` path or `npm link` is not enough, because the framework packages',
1363
- '> depend on each other by version and npm would try to fetch those from the',
1364
- '> registry too.',
1365
- ''
1366
- ]
1367
- : []),
1368
1652
  '```bash',
1369
1653
  'npm install',
1370
1654
  'npm run langium:generate # generate the AST from the grammar',
1371
1655
  'npm run build # generate + compile to lib/',
1656
+ 'npm run watch # regenerate and recompile on every grammar or source change',
1372
1657
  'npm test # typecheck the tests, then run them',
1373
1658
  '```',
1374
1659
  '',
@@ -1379,37 +1664,51 @@ function readme(composition) {
1379
1664
  // nowhere else.
1380
1665
  '```bash',
1381
1666
  '# Grammar / AST reflection',
1382
- 'npx hydranium-cli reflect --services ./lib/services.js',
1667
+ 'npx hydranium-cli reflect',
1383
1668
  '',
1384
1669
  '# Grammar-convention lint (CI gate)',
1385
- 'npx hydranium-cli lint-grammar --services ./lib/services.js',
1670
+ 'npx hydranium-cli lint-grammar',
1386
1671
  '',
1387
1672
  '# Validate a workspace of model files (non-zero exit on errors)',
1388
- 'npx hydranium-cli validate --services ./lib/services.js <workspace-dir>',
1673
+ 'npx hydranium-cli validate ../workspace',
1389
1674
  ...(data
1390
1675
  ? [
1391
1676
  '',
1392
1677
  '# Data-head operations. `--server` is a command line the CLI spawns and then',
1393
1678
  "# drives the data protocol over the child's stdin/stdout, so it has to name",
1394
- '# `data-server-main.js` — `main.js` gives stdio to LSP and answers these',
1395
- '# methods with "Unhandled method".',
1679
+ ...(composition.heads.includes('lsp')
1680
+ ? ['# `data-server-main.js` — `main.js` gives stdio to LSP and answers these', '# methods with "Unhandled method".']
1681
+ : ['# `data-server-main.js` — `main.js` serves the data head on a socket.']),
1396
1682
  '#',
1397
1683
  '# The workspace goes to the ENTRY, not to `--cwd`: `--cwd` re-roots the child,',
1398
1684
  '# so a relative entry path is refused by name (an absolute one is fine).',
1399
- 'npx hydranium-cli projects --server "node ./lib/data-server-main.js <workspace-dir>"',
1400
- `npx hydranium-cli query --server "node ./lib/data-server-main.js <workspace-dir>" --uri <file:// URI of a .${primaryExtension} file>`
1685
+ 'npx hydranium-cli projects --server "node ./lib/data-server-main.js ../workspace"',
1686
+ `npx hydranium-cli query --server "node ./lib/data-server-main.js ../workspace" --uri <file:// URI of ../workspace/nodes.${primaryExtension}>`
1401
1687
  ]
1402
1688
  : []),
1403
1689
  '```',
1404
1690
  '',
1691
+ ...(data
1692
+ ? [
1693
+ 'For an authorized process capture, add `--enable-diagnostics` to either',
1694
+ 'Node server entry. `--diagnostics-directory <path>` chooses where it writes',
1695
+ 'artifacts. A directory alone does not enable diagnostics.',
1696
+ 'Only enable this for callers you trust to inspect the whole process.',
1697
+ ''
1698
+ ]
1699
+ : []),
1405
1700
  '## Layout',
1406
1701
  '',
1407
1702
  ...grammarBullets,
1408
1703
  ...(multi ? ['- `src/grammar/common.langium` — shared terminals, imported by each grammar (not a language)'] : []),
1409
1704
  '- `src/language-server/__PROJECT_ID__-module.ts` — `create__NAME__Services` DI wiring',
1410
- ...composition.grammars.map(grammar => `- \`src/language-server/${grammar.grammarId}-serializer.ts\` — emits \`${grammar.grammar}\` back to text (the framework defaults this to a throw)`),
1411
- '- `src/services.ts` — zero-arg `createServices()` for the headless CLI',
1412
- '- `src/index.ts` — the package entry (`main`): DI factory + generated AST',
1705
+ ...composition.grammars.flatMap(grammar => [
1706
+ `- \`src/language-server/${grammar.grammarId}-serializer.ts\` — emits \`${grammar.grammar}\` back to text (the framework defaults this to a throw)`,
1707
+ `- \`src/language-server/${grammar.grammarId}-semantic-tokens.ts\` — colours declarations and references; the browser enables keywords and comments too`,
1708
+ `- \`src/language-server/${grammar.grammarId}-validation.ts\` — the \`${grammar.grammar}\` checks: a warning on a repeated name`
1709
+ ]),
1710
+ "- `src/node/services.ts` — zero-arg `createServices()` for the headless CLI, the package's `./node` entry",
1711
+ `- \`src/index.ts\` — the package root, loadable in a browser worker: DI factory + generated AST${diagramCount === 0 ? '' : diagramCount === 1 ? ' + diagram module' : ' + diagram modules'}`,
1413
1712
  `- \`src/main.ts\` — starts ${composition.heads.join(' + ')}, the \`__PROJECT_ID__\` bin entry`,
1414
1713
  ...(data
1415
1714
  ? [
@@ -1419,7 +1718,8 @@ function readme(composition) {
1419
1718
  : []),
1420
1719
  ...composition.grammars
1421
1720
  .filter(grammar => grammar.diagram)
1422
- .map(grammar => `- \`src/glsp/${grammar.grammarId}/\` — the \`${grammar.grammar}\` diagram: type ids, state, storage, submission handler, GModel factory, configuration, create-node handler, DI module`),
1721
+ .map(grammar => `- \`src/glsp/${grammar.grammarId}/\` — the \`${grammar.grammar}\` diagram: state, storage, submission handler, GModel factory, configuration, create-node handler, DI module`),
1722
+ `- \`../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\``,
1423
1723
  '- `test/services.test.ts` — the DI tree composes; grows as your language does',
1424
1724
  '- `test/parsing.test.ts` / `linking.test.ts` / `validating.test.ts` — the three tiers a new language breaks first',
1425
1725
  '- `test/serialization.test.ts` — each grammar round-trips through its serializer',
@@ -1472,6 +1772,21 @@ function readme(composition) {
1472
1772
  'atomic set and depends on a single physical copy, so a floating range can',
1473
1773
  'silently resolve a second one.',
1474
1774
  '',
1775
+ '`vscode-languageserver` is pinned to `10.1.2`, and',
1776
+ '`vscode-languageserver-protocol` and `vscode-jsonrpc` to the `3.18.4` and',
1777
+ '`9.0.3` it pins, so Langium, the LSP connection and the framework share one',
1778
+ 'copy of each. With two, an error a Langium handler returns, such as a request',
1779
+ 'for a document the server does not have, reaches the client as a result, and a',
1780
+ 'framework error your own LSP handler throws loses its code.',
1781
+ '',
1782
+ ...(pinsVitest(composition)
1783
+ ? [
1784
+ '`vitest` is held below 4.1, whose peer set crashes the npm every Node 22',
1785
+ 'release bundles. Raise it once every npm that installs this project is',
1786
+ '11.6 or later.',
1787
+ ''
1788
+ ]
1789
+ : []),
1475
1790
  '## If your repo gates license headers',
1476
1791
  '',
1477
1792
  'The emitted `.ts` files carry no copyright header — the scaffold cannot know',
@@ -1490,189 +1805,49 @@ function readme(composition) {
1490
1805
  /**
1491
1806
  * The GLSP files for ONE grammar's diagram.
1492
1807
  *
1493
- * Per grammar rather than per project because a diagram type has exactly one
1494
- * grammar — `AbstractHydraniumGlspDiagramModule.declareLanguage()` returns that
1495
- * grammar's `LanguageMetaData`, and it is bound on the SESSION container so a
1496
- * grammar with no diagram can sit on the same server.
1808
+ * Per grammar rather than per project because the state, storage and GModel
1809
+ * factory are typed over that grammar's model, and a grammar with no diagram
1810
+ * can sit on the same server.
1497
1811
  *
1498
1812
  * The index and the computed-bounds handler are framework classes used
1499
1813
  * unmodified; storage and the submission handler are thin typed subclasses,
1500
1814
  * which is where an adopter's own load/save and log formatting land.
1501
1815
  *
1502
- * The set is EDITABLE rather than read-only: one create-node handler, registered
1503
- * on the module. GLSP's own `DiagramModule` binds no handler that mutates a
1504
- * source model, so without it the head renders and answers while telling the
1505
- * client it may change nothing — which reads as a broken diagram rather than as a
1506
- * deliberate viewer.
1816
+ * The set is EDITABLE rather than read-only: a create-node and a delete handler,
1817
+ * registered on the module. GLSP's own `DiagramModule` binds no handler that
1818
+ * mutates a source model, so without them the head renders and answers while
1819
+ * telling the client it may change nothing — which reads as a broken diagram
1820
+ * rather than as a deliberate viewer.
1507
1821
  */
1508
1822
  function glspFiles(composition, grammar) {
1509
1823
  const columns = columnsFor(composition);
1510
- const upper = screamingSnake(grammar.grammarId);
1511
- const dir = `src/glsp/${grammar.grammarId}`;
1512
- const render = (text) => grammarTier(project(text, composition), grammar);
1513
- const types = `// GLSP diagram-type and element-type ids for the __GRAMMAR__ diagram.
1824
+ const fixed = (file) => grammarTemplate(`server/src/glsp/__GRAMMAR_ID__/${file}`, composition, grammar);
1825
+ const computed = (file, text) => ({
1826
+ path: `server/src/glsp/${grammar.grammarId}/${file}`,
1827
+ content: render(text, composition, grammar)
1828
+ });
1829
+ const configuration = `// Diagram configuration for the __GRAMMAR__ diagram.
1514
1830
  //
1515
- // **Authoritative half of a client/server contract.** Every id here has to be
1516
- // registered on the client too: sprotty's registries are exact-key maps with no
1517
- // prefix fallback, so an id the client does not know yields an element with none
1518
- // of a node's features, rendered by \`MissingView\` with only a
1519
- // \`no registered view for type '…'\` console warning. Nothing fails server-side.
1831
+ // **A hint is \`true\` only where a handler backs it.** The starter
1832
+ // __GRAMMAR__CreateNodeOperationHandler offers creation through the tool palette,
1833
+ // which GLSP assembles from the create handlers' trigger actions — there is no
1834
+ // \`creatable\` hint. __GRAMMAR__DeleteOperationHandler backs \`deletable\` on a
1835
+ // node and an edge; a node of another document stays undeletable, since this
1836
+ // diagram does not own it. Nothing backs reparent / reposition / resize, so
1837
+ // declaring one would offer a gesture whose operation the server rejects: a worse
1838
+ // failure than the tool being absent. Turn a hint on in the same change that adds
1839
+ // its handler.
1520
1840
  //
1521
- // The ids stay namespaced under the GLSP defaults (\`node:\` / \`edge:\`) so a
1522
- // reader can tell a node id from an edge id at a glance.
1523
-
1524
- import { DefaultTypes } from '@eclipse-glsp/server';
1841
+ // \`needsClientLayout\` is \`true\` and \`layoutKind\` is \`NONE\`: the starter grammar
1842
+ // persists no bounds, so the client measures and places everything. That is also
1843
+ // why \`ChangeBoundsOperation\` is a poor second handler to add — with nowhere in
1844
+ // the grammar to write a position, a move never reaches the text and is lost on
1845
+ // the next reload.
1525
1846
 
1526
- /**
1527
- * The GLSP diagram type — the string GLSP routes every per-diagram-type request
1528
- * by. Mirrored by the client; a mismatch silently DROPS the request rather than
1529
- * reporting an unknown diagram type. Same value as the language id, so the two
1530
- * cannot drift apart as a diagram gains element types.
1531
- */
1532
- export const ${upper}_DIAGRAM_TYPE = '__LANGUAGE_ID__';
1533
-
1534
- /** A \`__NODE_RULE__\` — the starter grammar's one named, referenceable node. */
1535
- export const ${upper}_NODE_TYPE = \`\${DefaultTypes.NODE}:__GRAMMAR_ID__-node\`;
1536
-
1537
- /** A resolved \`target\` reference, drawn as a connection between two nodes. */
1538
- export const ${upper}_EDGE_TYPE = \`\${DefaultTypes.EDGE}:__GRAMMAR_ID__-target\`;
1539
- `;
1540
- const state = `// GLSP state for the __GRAMMAR__ diagram.
1541
- //
1542
- // \`FullTextHydraniumGlspState\` is the simplest of the framework source-model
1543
- // strategies (the others project a structured transfer model, over one document
1544
- // or several): the source model is the whole document text, serialised through
1545
- // the per-URI \`Serializer\` and round-tripped by re-parsing. Both seams resolve
1546
- // through shared services, so narrowing the root type is all an adopter adds.
1547
- //
1548
- // It cannot field-merge — every concurrent edit on a whole-document model is a
1549
- // same-document collision, so undo / redo degrade to drop-on-divergence. Move to
1550
- // \`ReconcilingTransferHydraniumGlspState\` when you need field-level undo.
1551
-
1552
- import { FullTextHydraniumGlspState } from '@hydranium/glsp-server';
1553
- import { injectable } from 'inversify';
1554
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1555
-
1556
- @injectable()
1557
- export class __GRAMMAR__GlspState extends FullTextHydraniumGlspState<__ENTRY_RULE__> {}
1558
- `;
1559
- const storage = `// Source-model storage for the __GRAMMAR__ diagram, inheriting both framework
1560
- // defaults: \`loadSourceModel\` (open + settle + \`setSourceRoot\`) and
1561
- // \`saveSourceModel\` (through \`ModelService.save\` → the per-URI \`Serializer\` →
1562
- // the multi-client text store → \`WritableFileSystemProvider\`).
1563
- //
1564
- // The subclass exists so a bespoke load or save has a stable place to land.
1565
-
1566
- import { type FullTextSourceModel, HydraniumGlspStorage } from '@hydranium/glsp-server';
1567
- import { injectable } from 'inversify';
1568
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1569
-
1570
- @injectable()
1571
- export class __GRAMMAR__GlspStorage extends HydraniumGlspStorage<__ENTRY_RULE__, FullTextSourceModel> {}
1572
- `;
1573
- const submission = `// Submission handler for the __GRAMMAR__ diagram.
1574
- //
1575
- // Inherits the framework's \`readyEvent = IntegrityService.SettledState\`, which
1576
- // is load-bearing rather than incidental: the GModel factory resolves
1577
- // \`target.ref\`, so it needs a fully-linked AST. Without the gate those reads
1578
- // fire mid-build and warn about resolution before scopes are computed.
1579
- //
1580
- // Only \`formatSourceRoot\` is overridden, so the submit log names the model and
1581
- // its node count instead of a bare \`$type\`.
1582
-
1583
- import { ModelState } from '@eclipse-glsp/server';
1584
- import { type FullTextSourceModel, HydraniumGlspSubmissionHandler } from '@hydranium/glsp-server';
1585
- import { inject, injectable } from 'inversify';
1586
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1587
- import type { __GRAMMAR__GlspState } from './state.js';
1588
-
1589
- @injectable()
1590
- export class __GRAMMAR__SubmissionHandler extends HydraniumGlspSubmissionHandler<__ENTRY_RULE__, FullTextSourceModel> {
1591
- @inject(ModelState) declare protected modelState: __GRAMMAR__GlspState;
1592
-
1593
- protected override formatSourceRoot(root: __ENTRY_RULE__ | undefined): string {
1594
- return root ? \`__ENTRY_RULE__ nodes=\${root.nodes.length}\` : 'none';
1595
- }
1596
- }
1597
- `;
1598
- const factory = `// AST → GModel for the __GRAMMAR__ diagram: one node per \`__NODE_RULE__\`, one
1599
- // edge per resolved \`target\` reference.
1600
- //
1601
- // Ids come from the index rather than being composed here, so the id strategy
1602
- // stays in one place. Nodes are emitted before edges, because a GLSP edge
1603
- // pointing at an id that does not exist fails client-side with a far less
1604
- // obvious error than a missing edge. An unresolved reference is SKIPPED rather
1605
- // than treated as an error: a dangling reference is ordinary editing state, and
1606
- // the LSP head already reports it as a diagnostic.
1607
-
1608
- import { DefaultTypes, GEdge, GGraph, GLabel, type GModelFactory, GNode, ModelState } from '@eclipse-glsp/server';
1609
- import { inject, injectable } from 'inversify';
1610
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1611
- import { ${upper}_EDGE_TYPE, ${upper}_NODE_TYPE } from './types.js';
1612
- import type { __GRAMMAR__GlspState } from './state.js';
1613
-
1614
- @injectable()
1615
- export class __GRAMMAR__GModelFactory implements GModelFactory {
1616
- @inject(ModelState) protected readonly modelState!: __GRAMMAR__GlspState;
1617
-
1618
- createModel(): void {
1619
- const root = this.modelState.sourceRoot;
1620
- const graph = GGraph.builder().id(this.modelState.sourceUri).build();
1621
- if (root) {
1622
- this.buildGraph(root, graph);
1623
- }
1624
- this.modelState.updateRoot(graph);
1625
- }
1626
-
1627
- protected buildGraph(root: __ENTRY_RULE__, graph: GGraph): void {
1628
- for (const node of root.nodes) {
1629
- const id = this.modelState.index.createId(node);
1630
- graph.children.push(
1631
- GNode.builder()
1632
- .id(id)
1633
- .type(${upper}_NODE_TYPE)
1634
- .add(GLabel.builder().id(\`\${id}_name\`).text(node.name).type(DefaultTypes.LABEL).build())
1635
- .build()
1636
- );
1637
- }
1638
- for (const node of root.nodes) {
1639
- const target = node.target?.ref;
1640
- if (!target) {
1641
- continue;
1642
- }
1643
- const sourceId = this.modelState.index.createId(node);
1644
- graph.children.push(
1645
- GEdge.builder()
1646
- .id(\`\${sourceId}_target\`)
1647
- .type(${upper}_EDGE_TYPE)
1648
- .sourceId(sourceId)
1649
- .targetId(this.modelState.index.createId(target))
1650
- .build()
1651
- );
1652
- }
1653
- }
1654
- }
1655
- `;
1656
- const configuration = `// Diagram configuration for the __GRAMMAR__ diagram.
1657
- //
1658
- // **Every hint is \`false\`, and that is not the same as read-only.** The starter
1659
- // __GRAMMAR__CreateNodeOperationHandler makes this diagram editable through the
1660
- // tool palette, which GLSP assembles from the create handlers' trigger actions —
1661
- // there is no \`creatable\` hint. What the hints govern is delete / reparent /
1662
- // reposition / resize, and nothing backs those, so declaring one would offer a
1663
- // gesture whose operation the server rejects: a worse failure than the tool being
1664
- // absent. Turn a hint on in the same change that adds its handler.
1665
- //
1666
- // \`needsClientLayout\` is \`true\` and \`layoutKind\` is \`NONE\`: the starter grammar
1667
- // persists no bounds, so the client measures and places everything. That is also
1668
- // why \`ChangeBoundsOperation\` is a poor second handler to add — with nowhere in
1669
- // the grammar to write a position, a move never reaches the text and is lost on
1670
- // the next reload.
1671
-
1672
- import { type GModelElementConstructor } from '@eclipse-glsp/graph';
1673
- ${importList(['type DiagramConfiguration', 'type EdgeTypeHint', 'ServerLayoutKind', 'type ShapeTypeHint', 'getDefaultMapping'], '@eclipse-glsp/server', columns)}
1674
- import { injectable } from 'inversify';
1675
- import { ${upper}_EDGE_TYPE, ${upper}_NODE_TYPE } from './types.js';
1847
+ import { type GModelElementConstructor } from '@eclipse-glsp/graph';
1848
+ ${importList(['type DiagramConfiguration', 'type EdgeTypeHint', 'ServerLayoutKind', 'type ShapeTypeHint', 'getDefaultMapping'], '@eclipse-glsp/server', columns)}
1849
+ import { injectable } from 'inversify';
1850
+ import { __GRAMMAR_ID_CONST___EDGE_TYPE, __GRAMMAR_ID_CONST___EXTERNAL_NODE_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from '__PROTOCOL_PACKAGE__';
1676
1851
 
1677
1852
  @injectable()
1678
1853
  export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
@@ -1684,7 +1859,14 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
1684
1859
 
1685
1860
  readonly shapeTypeHints: ShapeTypeHint[] = [
1686
1861
  {
1687
- elementTypeId: ${upper}_NODE_TYPE,
1862
+ elementTypeId: __GRAMMAR_ID_CONST___NODE_TYPE,
1863
+ deletable: true,
1864
+ reparentable: false,
1865
+ repositionable: false,
1866
+ resizable: false
1867
+ },
1868
+ {
1869
+ elementTypeId: __GRAMMAR_ID_CONST___EXTERNAL_NODE_TYPE,
1688
1870
  deletable: false,
1689
1871
  reparentable: false,
1690
1872
  repositionable: false,
@@ -1694,143 +1876,69 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
1694
1876
 
1695
1877
  readonly edgeTypeHints: EdgeTypeHint[] = [
1696
1878
  {
1697
- elementTypeId: ${upper}_EDGE_TYPE,
1698
- deletable: false,
1879
+ elementTypeId: __GRAMMAR_ID_CONST___EDGE_TYPE,
1880
+ deletable: true,
1699
1881
  repositionable: false,
1700
1882
  routable: false,
1701
- sourceElementTypeIds: [${upper}_NODE_TYPE],
1702
- targetElementTypeIds: [${upper}_NODE_TYPE]
1883
+ sourceElementTypeIds: [__GRAMMAR_ID_CONST___NODE_TYPE],
1884
+ targetElementTypeIds: [__GRAMMAR_ID_CONST___NODE_TYPE, __GRAMMAR_ID_CONST___EXTERNAL_NODE_TYPE]
1703
1885
  }
1704
1886
  ];
1705
1887
  }
1706
- `;
1707
- const createHandler = `// The starter operation handler for the __GRAMMAR__ diagram: the tool-palette
1708
- // entry that creates a \`__NODE_RULE__\`.
1709
- //
1710
- // **Deleting this file and its \`configureOperationHandlers\` registration in
1711
- // \`diagram-module.ts\` gives a read-only viewer**, and nothing else has to change
1712
- // — creation is offered through the palette rather than through a type hint, so
1713
- // every hint in \`diagram-configuration.ts\` is already \`false\`. The scaffold emits
1714
- // the editable direction because that asymmetry runs one way: editable →
1715
- // read-only is a deletion the compiler checks, while read-only → editable is
1716
- // authoring against a seam you have not used yet.
1717
- //
1718
- // **It composes TEXT rather than mutating the AST.** The source model of
1719
- // \`FullTextHydraniumGlspState\` is the document text, and reading it back through
1720
- // \`state.sourceModel\` serialises the AST through the per-URI \`Serializer\`, which
1721
- // this scaffold does not bind — so that getter throws until you do. Appending a
1722
- // declaration to the text the parser last read needs no serializer, which is what
1723
- // makes a scaffolded diagram editable on day one. Bind a \`Serializer\` at
1724
- // \`services.serializer.Serializer\` and this becomes a
1725
- // \`HydraniumGlspRecordingCommand\` over \`state.sourceModel\` instead — the same
1726
- // binding the diagram's own save action needs.
1727
- //
1728
- // **The drop location is discarded.** \`needsClientLayout\` is \`true\` and the
1729
- // starter grammar persists no bounds, so there is nowhere to put a coordinate: the
1730
- // node is appended at the end of the document and the client places it.
1731
- //
1732
- // Like \`types.ts\`, \`gmodel-factory.ts\` and \`diagram-configuration.ts\`, this file
1733
- // knows the starter grammar's concrete syntax — the \`node\` keyword below is that
1734
- // grammar's. Replacing the grammar means replacing these four together.
1735
-
1736
- import { type Command, type CreateNodeOperation, JsonCreateNodeOperationHandler, type MaybePromise } from '@eclipse-glsp/server';
1737
- import { findNextUnique } from '@hydranium/protocol';
1738
- import { injectable } from 'inversify';
1739
- import { ${upper}_NODE_TYPE } from './types.js';
1740
- import type { __GRAMMAR__GlspState } from './state.js';
1741
-
1742
- /** Proposed name for a new node, uniquified against the ones the document already has. */
1743
- const NODE_NAME_STEM = 'Node';
1744
-
1745
- @injectable()
1746
- export class __GRAMMAR__CreateNodeOperationHandler extends JsonCreateNodeOperationHandler {
1747
- declare protected modelState: __GRAMMAR__GlspState;
1748
-
1749
- /** The palette's word for the thing it creates, so a noun rather than an action. */
1750
- override readonly label = '__NODE_RULE__';
1751
- elementTypeIds = [${upper}_NODE_TYPE];
1752
-
1753
- override createCommand(operation: CreateNodeOperation): MaybePromise<Command | undefined> {
1754
- if (!this.elementTypeIds.includes(operation.elementTypeId)) {
1755
- return undefined;
1756
- }
1757
- const state = this.modelState;
1758
- const before = this.documentText();
1759
- const after = this.withNode(
1760
- before,
1761
- findNextUnique(
1762
- NODE_NAME_STEM,
1763
- state.sourceRoot.nodes.map(node => node.name)
1764
- )
1765
- );
1766
- // Whole-document undo, which is all a full-text source model can offer: it
1767
- // has exactly one field, so there is nothing to merge a concurrent edit into.
1768
- return {
1769
- execute: () => state.updateSourceModel({ text: after }),
1770
- undo: () => state.updateSourceModel({ text: before }),
1771
- redo: () => state.updateSourceModel({ text: after })
1772
- };
1773
- }
1774
-
1775
- /** The text the captured source root was parsed from — the baseline an edit appends to. */
1776
- protected documentText(): string {
1777
- return this.modelState.sourceRoot.$document?.textDocument.getText() ?? '';
1778
- }
1779
-
1780
- /** \`text\` with one more node declaration, under exactly one trailing newline. */
1781
- protected withNode(text: string, name: string): string {
1782
- const body = text.trimEnd();
1783
- const declaration = \`node \${name}\`;
1784
- return body.length === 0 ? \`\${declaration}\\n\` : \`\${body}\\n\${declaration}\\n\`;
1785
- }
1786
- }
1787
1888
  `;
1788
1889
  const module = `// GLSP diagram module for __GRAMMAR__ — the DI wiring of one diagram type.
1789
1890
  //
1790
- // \`declareLanguage\` is the multi-grammar seam: it binds the grammar this diagram
1791
- // edits on the SESSION container, so \`modelState.diagramLanguage\` and the
1792
- // per-language lookups resolve to __LANGUAGE_ID__. That is what stops one
1793
- // diagram type from fighting over a process-wide binding when a project holds
1794
- // several grammars — a grammar with no diagram is registered on the same server.
1795
- //
1796
1891
  // \`configureActionHandlers\` REBINDS rather than adds: GLSP's own
1797
1892
  // \`DiagramModule\` already registers \`ComputedBoundsActionHandler\`, and two
1798
1893
  // handlers for one action would both run.
1799
1894
  //
1800
1895
  // \`configureOperationHandlers\` ADDS: GLSP's default pair
1801
1896
  // (\`CompoundOperationHandler\` and \`LayoutOperationHandler\`) mutates no source
1802
- // model, so the starter create handler is what makes this diagram editable at all.
1897
+ // model, so the starter create and delete handlers are what make this diagram
1898
+ // editable at all.
1803
1899
 
1804
1900
  ${importList([
1805
1901
  'ActionHandlerConstructor',
1806
1902
  'BindingTarget',
1903
+ 'ComputedBoundsAction',
1807
1904
  'DiagramConfiguration',
1808
1905
  'GModelFactory',
1809
1906
  'GModelIndex',
1907
+ 'GModelRoot',
1810
1908
  'InstanceMultiBinding',
1811
1909
  'ModelState',
1812
1910
  'ModelSubmissionHandler',
1911
+ 'ModelValidator',
1813
1912
  'OperationHandlerConstructor',
1814
1913
  'SourceModelStorage'
1815
1914
  ], '@eclipse-glsp/server', columns, true)}
1816
1915
  import { ComputedBoundsActionHandler } from '@eclipse-glsp/server';
1817
- import type { LanguageMetaData } from '@hydranium/langium';
1818
- ${importList(['HydraniumGlspComputedBoundsActionHandler', 'AbstractHydraniumGlspDiagramModule', 'HydraniumGlspIndex'], '@hydranium/glsp-server', columns)}
1819
- import { __GRAMMAR__LanguageMetaData } from '../../language-server/generated/module.js';
1916
+ ${importList(['HydraniumGlspComputedBoundsActionHandler', 'AbstractHydraniumGlspDiagramModule', 'HydraniumGlspIndex', 'HydraniumGlspModelValidator'], '@hydranium/glsp-server', columns)}
1820
1917
  import { __GRAMMAR__CreateNodeOperationHandler } from './create-node-operation-handler.js';
1918
+ import { __GRAMMAR__DeleteOperationHandler } from './delete-operation-handler.js';
1821
1919
  import { __GRAMMAR__DiagramConfiguration } from './diagram-configuration.js';
1822
1920
  import { __GRAMMAR__GModelFactory } from './gmodel-factory.js';
1823
1921
  import { __GRAMMAR__GlspState } from './state.js';
1824
1922
  import { __GRAMMAR__GlspStorage } from './storage.js';
1825
1923
  import { __GRAMMAR__SubmissionHandler } from './submission-handler.js';
1826
- import { ${upper}_DIAGRAM_TYPE } from './types.js';
1827
-
1828
- export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule {
1829
- readonly diagramType = ${upper}_DIAGRAM_TYPE;
1924
+ import { __GRAMMAR_ID_CONST___DIAGRAM_TYPE } from '__PROTOCOL_PACKAGE__';
1925
+ import { injectable } from 'inversify';
1830
1926
 
1831
- protected override declareLanguage(): LanguageMetaData {
1832
- return __GRAMMAR__LanguageMetaData;
1927
+ /**
1928
+ * The framework handler, keeping none of the edge routes the client reports:
1929
+ * it routes an edge before it sizes the nodes, so a self-reference's loop would
1930
+ * stay drawn inside its node. The starter's edges have no routing points, and
1931
+ * the client routes them afresh from the sized nodes.
1932
+ */
1933
+ @injectable()
1934
+ export class __GRAMMAR__ComputedBoundsActionHandler extends HydraniumGlspComputedBoundsActionHandler {
1935
+ protected override applyBounds(root: GModelRoot, action: ComputedBoundsAction): void {
1936
+ super.applyBounds(root, { ...action, routes: undefined });
1833
1937
  }
1938
+ }
1939
+
1940
+ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule {
1941
+ readonly diagramType = __GRAMMAR_ID_CONST___DIAGRAM_TYPE;
1834
1942
 
1835
1943
  protected override bindModelState(): BindingTarget<ModelState> {
1836
1944
  return { service: __GRAMMAR__GlspState };
@@ -1857,80 +1965,2343 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1857
1965
  return { service: HydraniumGlspIndex };
1858
1966
  }
1859
1967
 
1968
+ /**
1969
+ * The language's diagnostics as diagram markers, following each edit. GLSP
1970
+ * binds no validator by default, and without one no marker ever shows.
1971
+ */
1972
+ protected override bindModelValidator(): BindingTarget<ModelValidator> {
1973
+ return HydraniumGlspModelValidator;
1974
+ }
1975
+
1860
1976
  protected override configureActionHandlers(binding: InstanceMultiBinding<ActionHandlerConstructor>): void {
1861
1977
  super.configureActionHandlers(binding);
1862
- binding.rebind(ComputedBoundsActionHandler, HydraniumGlspComputedBoundsActionHandler);
1978
+ binding.rebind(ComputedBoundsActionHandler, __GRAMMAR__ComputedBoundsActionHandler);
1863
1979
  }
1864
1980
 
1865
1981
  protected override configureOperationHandlers(binding: InstanceMultiBinding<OperationHandlerConstructor>): void {
1866
1982
  super.configureOperationHandlers(binding);
1867
1983
  binding.add(__GRAMMAR__CreateNodeOperationHandler);
1984
+ binding.add(__GRAMMAR__DeleteOperationHandler);
1868
1985
  }
1869
1986
  }
1870
1987
  `;
1871
1988
  return [
1872
- { path: `${dir}/types.ts`, content: render(types) },
1873
- { path: `${dir}/state.ts`, content: render(state) },
1874
- { path: `${dir}/storage.ts`, content: render(storage) },
1875
- { path: `${dir}/submission-handler.ts`, content: render(submission) },
1876
- { path: `${dir}/gmodel-factory.ts`, content: render(factory) },
1877
- { path: `${dir}/diagram-configuration.ts`, content: render(configuration) },
1878
- { path: `${dir}/create-node-operation-handler.ts`, content: render(createHandler) },
1879
- { path: `${dir}/diagram-module.ts`, content: render(module) }
1989
+ fixed('state.ts'),
1990
+ fixed('storage.ts'),
1991
+ fixed('submission-handler.ts'),
1992
+ fixed('gmodel-factory.ts'),
1993
+ computed('diagram-configuration.ts', configuration),
1994
+ fixed('create-node-operation-handler.ts'),
1995
+ fixed('delete-operation-handler.ts'),
1996
+ computed('diagram-module.ts', module),
1997
+ grammarTemplate('server/test/__GRAMMAR_ID__-diagram.test.ts', composition, grammar)
1880
1998
  ];
1881
1999
  }
1882
2000
  /**
1883
- * Every file the scaffold emits, fully rendered.
2001
+ * The server member: the grammar, the language and its heads.
1884
2002
  *
1885
2003
  * Grammar-count-dependent emissions live here rather than in a template string:
1886
2004
  * one `.langium` file per grammar, and the shared `common.langium` fragment only
1887
2005
  * when there is more than one grammar to share it.
1888
2006
  */
1889
- export function buildInitTemplates(composition) {
2007
+ function serverFiles(composition) {
1890
2008
  const multi = composition.grammars.length > 1;
1891
2009
  const grammarFiles = composition.grammars.map(grammar => ({
1892
- path: `src/grammar/${grammar.grammarId}.langium`,
2010
+ path: `server/src/grammar/${grammar.grammarId}.langium`,
1893
2011
  content: render(grammarFile(multi), composition, grammar)
1894
2012
  }));
2013
+ // The shared lexical fragment, only for several grammars: each declaring its
2014
+ // own `WS` / `ID` lets the token sets drift apart.
1895
2015
  if (multi) {
1896
- grammarFiles.unshift({ path: 'src/grammar/common.langium', content: COMMON_GRAMMAR });
2016
+ grammarFiles.unshift(projectTemplate('server/src/grammar/common.langium', composition));
1897
2017
  }
1898
2018
  const diagramFiles = composition.grammars.filter(grammar => grammar.diagram).flatMap(grammar => glspFiles(composition, grammar));
1899
2019
  return [
1900
- { path: 'package.json', content: packageJson(composition) },
1901
- { path: 'langium-config.json', content: langiumConfig(composition) },
1902
- { path: 'tsconfig.json', content: tsconfigJson(composition) },
1903
- { path: 'tsconfig.test.json', content: TSCONFIG_TEST },
1904
- { path: 'vitest.config.ts', content: VITEST_CONFIG },
1905
- {
1906
- path: '.gitignore',
1907
- content: composition.packaging.workspace === undefined ? GITIGNORE : GITIGNORE_WORKSPACE_MEMBER
1908
- },
1909
- { path: 'README.md', content: readme(composition) },
2020
+ { path: 'server/package.json', content: packageJson(composition) },
2021
+ { path: 'server/langium-config.json', content: langiumConfig(composition) },
2022
+ { path: 'server/tsconfig.json', content: serverTsconfigJson(composition) },
2023
+ // `isolatedModules` is what makes this check agree with the transform that
2024
+ // actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
2025
+ // vitest compiles each file on its own — a transform with no type
2026
+ // information, which cannot tell a re-exported TYPE from a re-exported value.
2027
+ // Without the flag `tsc` accepts `export { SomeType } from './x'` that the
2028
+ // transform then emits as a real import of a symbol that does not exist at
2029
+ // runtime, so the typecheck passes and the suite fails with a confusing
2030
+ // missing-export.
2031
+ projectTemplate('server/tsconfig.test.json', composition),
2032
+ // Found by name, unlike `tsconfig.test.json`. Under vite 8, vitest's transform
2033
+ // applies a tsconfig only to the files its own `include` lists, so without this
2034
+ // a test declaring a decorated class fails to load.
2035
+ projectTemplate('server/test/tsconfig.json', composition),
2036
+ projectTemplate('server/vitest.config.ts', composition),
2037
+ // Written for a workspace member too: its root may not ignore `lib/` (a `yo
2038
+ // langium` root ignores `out/`), and a rule the root repeats costs nothing.
2039
+ ignoreFile('server'),
2040
+ { path: 'server/README.md', content: readme(composition) },
1910
2041
  ...grammarFiles,
1911
- { path: `src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },
1912
- { path: 'src/language-server/ast.ts', content: render(AST, composition, composition.grammars[0]) },
1913
- ...composition.grammars.map(grammar => ({
1914
- path: `src/language-server/${grammar.grammarId}-serializer.ts`,
1915
- content: render(SERIALIZER, composition, grammar)
1916
- })),
1917
- { path: 'src/index.ts', content: indexFile(composition) },
1918
- { path: 'src/services.ts', content: project(SERVICES, composition) },
1919
- { path: 'src/main.ts', content: mainFile(composition) },
1920
- ...(composition.heads.includes('data') ? [{ path: 'src/data-server-main.ts', content: dataServerMainFile(composition) }] : []),
1921
- ...(portCommandNames(composition).length ? [{ path: 'src/head-ports.ts', content: headPortsFile(composition) }] : []),
2042
+ { path: `server/src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },
2043
+ grammarTemplate('server/src/language-server/ast.ts', composition, composition.grammars[0]),
2044
+ // Per grammar, because `services.serializer.Serializer` is a per-language slot
2045
+ // and a serializer is grammar-shaped by definition. That is also why the
2046
+ // framework refuses to default it: its `UnboundSerializer` throws, naming the
2047
+ // binding to add, rather than guessing a syntax.
2048
+ ...composition.grammars.flatMap(grammar => [
2049
+ grammarTemplate('server/src/language-server/__GRAMMAR_ID__-serializer.ts', composition, grammar),
2050
+ grammarTemplate('server/src/language-server/__GRAMMAR_ID__-semantic-tokens.ts', composition, grammar),
2051
+ grammarTemplate('server/test/__GRAMMAR_ID__-semantic-tokens.test.ts', composition, grammar),
2052
+ grammarTemplate('server/src/language-server/__GRAMMAR_ID__-validation.ts', composition, grammar)
2053
+ ]),
2054
+ { path: 'server/src/index.ts', content: indexFile(composition) },
2055
+ projectTemplate('server/src/node/services.ts', composition),
2056
+ { path: 'server/src/main.ts', content: mainFile(composition) },
2057
+ ...(composition.heads.includes('data') ? [{ path: 'server/src/data-server-main.ts', content: dataServerMainFile(composition) }] : []),
1922
2058
  ...diagramFiles,
1923
- { path: 'test/services.test.ts', content: servicesTest(composition) },
1924
- { path: 'test/parsing.test.ts', content: grammarTest(composition, PARSING_HEADER, [], PARSING_SUITE) },
2059
+ { path: 'server/test/services.test.ts', content: servicesTest(composition) },
2060
+ { path: 'server/test/parsing.test.ts', content: grammarTest(composition, PARSING_HEADER, [], PARSING_SUITE) },
1925
2061
  {
1926
- path: 'test/linking.test.ts',
2062
+ path: 'server/test/linking.test.ts',
1927
2063
  content: grammarTest(composition, LINKING_HEADER, ["import { AstUtils } from '@hydranium/langium';"], LINKING_SUITE)
1928
2064
  },
1929
2065
  {
1930
- path: 'test/validating.test.ts',
2066
+ path: 'server/test/validating.test.ts',
1931
2067
  content: grammarTest(composition, VALIDATING_HEADER, ["import { DiagnosticSeverity } from 'vscode-languageserver';"], VALIDATING_SUITE)
1932
2068
  },
1933
- { path: 'test/serialization.test.ts', content: serializationTest(composition) }
2069
+ { path: 'server/test/serialization.test.ts', content: serializationTest(composition) }
2070
+ ];
2071
+ }
2072
+ /**
2073
+ * The protocol member's `package.json`.
2074
+ *
2075
+ * Its `build` runs the server's `generate` first, because the server writes
2076
+ * this package's `generated-hydranium/` modules from the grammar: a runner that
2077
+ * builds in dependency order reaches this package before the server, and would
2078
+ * otherwise compile modules that do not exist yet. A full build therefore
2079
+ * generates twice; the second pass rewrites nothing.
2080
+ */
2081
+ function protocolPackageJson(composition) {
2082
+ const member = forMember(composition, 'protocol');
2083
+ const dependencies = {};
2084
+ for (const head of composition.heads) {
2085
+ Object.assign(dependencies, PROTOCOL_THIRD_PARTY_DEPENDENCIES[head]);
2086
+ }
2087
+ const dependencyLines = Object.keys(dependencies).length === 0 ? '' : ` "dependencies": {\n${sortedBlock(dependencies, ' ')}\n },\n`;
2088
+ return `{
2089
+ "name": "${memberPackageName(composition, 'protocol')}",
2090
+ "version": "0.0.0",
2091
+ ${composition.packaging.private ? ' "private": true,\n' : ''} "description": "What the ${composition.name} language server and its clients share: language ids, wire types and diagram ids.",
2092
+ "license": "UNLICENSED",
2093
+ "author": "",
2094
+ "files": [
2095
+ "lib",
2096
+ "src"
2097
+ ],
2098
+ "type": "module",
2099
+ "main": "lib/index.js",
2100
+ "types": "lib/index.d.ts",
2101
+ "scripts": {
2102
+ "build": "npm --prefix ../server run generate && tsc -b",
2103
+ "clean": "rimraf lib tsconfig.tsbuildinfo",
2104
+ ${lintScript(member, 'src')} "watch": "tsc -b --watch"
2105
+ },
2106
+ ${dependencyLines} "devDependencies": {
2107
+ "rimraf": "^5.0.0",
2108
+ "typescript": "^7.0.2"
2109
+ },
2110
+ "engines": {
2111
+ "node": ">=22.13"
2112
+ }
2113
+ }
2114
+ `;
2115
+ }
2116
+ /** The protocol member's root entry, one re-export per module. */
2117
+ function protocolIndexFile(composition) {
2118
+ const modules = [
2119
+ './generated-hydranium/languages.js',
2120
+ './generated-hydranium/transfer-model.js',
2121
+ ...(headPortConstants(composition).length ? ['./head-ports.js'] : []),
2122
+ ...composition.grammars.filter(grammar => grammar.diagram).map(grammar => `./${grammar.grammarId}-diagram-types.js`)
2123
+ ];
2124
+ const text = `// What the __NAME__ server and its clients both import. A client depends on
2125
+ // this package instead of the server, so it never installs Langium to reach a
2126
+ // constant or a wire type.
2127
+ //
2128
+ // The server's \`generate\` writes \`generated-hydranium/\` from the grammar;
2129
+ // commit those modules with the grammar change that produced them. An interface
2130
+ // narrowed here hides the generated constant of the same name; re-export that
2131
+ // constant beside it, and declare the type map under its own name with the
2132
+ // narrowed entry, or the builder and the GLSP state keep the generated one.
2133
+
2134
+ ${modules.map(module => `export * from '${module}';`).join('\n')}
2135
+ `;
2136
+ return project(text, composition);
2137
+ }
2138
+ /** The protocol member's README. */
2139
+ function protocolReadme(composition) {
2140
+ const lines = [
2141
+ `# ${memberPackageName(composition, 'protocol')}`,
2142
+ '',
2143
+ 'What the __NAME__ language server and its clients both import, so a client',
2144
+ 'never depends on the server or installs Langium.',
2145
+ '',
2146
+ '- `src/generated-hydranium/languages.ts` — each language id and its file extensions',
2147
+ '- `src/generated-hydranium/transfer-model.ts` — the wire types of the model, references as plain strings',
2148
+ ...(headPortConstants(composition).length === 0
2149
+ ? []
2150
+ : composition.heads.includes('lsp')
2151
+ ? ['- `src/head-ports.ts` — the LSP requests a host sends to find the socket heads']
2152
+ : ["- `src/head-ports.ts` — the environment variables naming the socket heads' ports"]),
2153
+ ...composition.grammars
2154
+ .filter(grammar => grammar.diagram)
2155
+ .map(grammar => `- \`src/${grammar.grammarId}-diagram-types.ts\` — the \`${grammar.grammar}\` diagram's type ids`),
2156
+ '',
2157
+ "The server's `npm run generate` writes `src/generated-hydranium/` from the",
2158
+ 'grammar, and `npm run build` here runs it first. Commit the generated',
2159
+ 'modules with the grammar change that produced them.',
2160
+ ''
2161
+ ];
2162
+ return project(lines.join('\n'), composition);
2163
+ }
2164
+ /** The protocol member: everything the server and its clients both import. */
2165
+ function protocolFiles(composition) {
2166
+ return [
2167
+ { path: 'protocol/package.json', content: protocolPackageJson(composition) },
2168
+ { path: 'protocol/tsconfig.json', content: tsconfigJson(forMember(composition, 'protocol'), PROTOCOL_COMPILER_OPTIONS, '["src"]') },
2169
+ ignoreFile('protocol'),
2170
+ { path: 'protocol/README.md', content: protocolReadme(composition) },
2171
+ { path: 'protocol/src/index.ts', content: protocolIndexFile(composition) },
2172
+ ...(headPortConstants(composition).length ? [{ path: 'protocol/src/head-ports.ts', content: headPortsFile(composition) }] : []),
2173
+ ...composition.grammars
2174
+ .filter(grammar => grammar.diagram)
2175
+ .map(grammar => grammarTemplate('protocol/src/__GRAMMAR_ID__-diagram-types.ts', composition, grammar))
2176
+ ];
2177
+ }
2178
+ /**
2179
+ * The starter model files every host and the CLI open, two per language: one
2180
+ * declaring nodes, one referencing a node in the other file.
2181
+ *
2182
+ * Named by extension alone, since `init` refuses two grammars sharing one.
2183
+ */
2184
+ function workspaceFiles(composition) {
2185
+ return composition.grammars.flatMap(grammar => [
2186
+ grammarTemplate('workspace/nodes.__EXTENSION__', composition, grammar),
2187
+ grammarTemplate('workspace/references.__EXTENSION__', composition, grammar)
2188
+ ]);
2189
+ }
2190
+ /** The client member's compiler options: the protocol's, with the DOM its diagram renders into. */
2191
+ const CLIENT_COMPILER_OPTIONS = [
2192
+ ['target', 'ES2022'],
2193
+ ['lib', ['ES2022', 'DOM']],
2194
+ ['module', 'NodeNext'],
2195
+ ['moduleResolution', 'NodeNext'],
2196
+ ['rootDir', 'src'],
2197
+ ['outDir', 'lib'],
2198
+ ['strict', true],
2199
+ ['skipLibCheck', true],
2200
+ ['declaration', true],
2201
+ // For inversify's `@injectable`, which a GLSP view needs to be bound.
2202
+ ['experimentalDecorators', true],
2203
+ ['types', []],
2204
+ // A base that sets it fails every `import` in this CommonJS member.
2205
+ ['verbatimModuleSyntax', false]
2206
+ ];
2207
+ /**
2208
+ * The client member's `package.json`.
2209
+ *
2210
+ * CommonJS, unlike its siblings, as GLSP's client is: inversify ships an ES
2211
+ * build and a CommonJS one, so an ES module here would load the first while
2212
+ * GLSP's client loads the second, giving a page two containers' worth of
2213
+ * classes. Node from 22.12 and `tsc` under `nodenext` both let it require the
2214
+ * ES protocol member.
2215
+ */
2216
+ function clientPackageJson(composition) {
2217
+ const dependencies = {
2218
+ [memberPackageName(composition, 'protocol')]: MEMBER_PIN,
2219
+ ...CLIENT_THIRD_PARTY_DEPENDENCIES
2220
+ };
2221
+ for (const framework of CLIENT_FRAMEWORK_DEPENDENCIES) {
2222
+ dependencies[framework] = frameworkPin(composition);
2223
+ }
2224
+ return `{
2225
+ "name": "${memberPackageName(composition, 'client')}",
2226
+ "version": "0.0.0",
2227
+ ${composition.packaging.private ? ' "private": true,\n' : ''} "description": "The host-neutral client side of the ${composition.name} language: what every host of it mounts.",
2228
+ "license": "UNLICENSED",
2229
+ "author": "",
2230
+ "files": [
2231
+ "lib",
2232
+ "src"
2233
+ ],
2234
+ "main": "lib/index.js",
2235
+ "types": "lib/index.d.ts",
2236
+ "scripts": {
2237
+ "build": "tsc -b",
2238
+ "clean": "rimraf lib tsconfig.tsbuildinfo",
2239
+ ${lintScript(forMember(composition, 'client'), 'src')} "watch": "tsc --watch"
2240
+ },
2241
+ "dependencies": {
2242
+ ${sortedBlock(dependencies, ' ')}
2243
+ },
2244
+ "devDependencies": {
2245
+ "rimraf": "^5.0.0",
2246
+ "typescript": "^7.0.2"
2247
+ },
2248
+ "engines": {
2249
+ "node": ">=22.13"
2250
+ }
2251
+ }
2252
+ `;
2253
+ }
2254
+ /** The client member's README. */
2255
+ function clientReadme(composition) {
2256
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
2257
+ const lines = [
2258
+ `# ${memberPackageName(composition, 'client')}`,
2259
+ '',
2260
+ 'The host-neutral client side of the __NAME__ language: what every host of it',
2261
+ 'mounts, whatever that host is. It reads the element ids from the protocol',
2262
+ 'package and never depends on the server.',
2263
+ '',
2264
+ ...diagrams.map(grammar => `- \`src/${grammar.grammarId}-diagram-module.ts\` — the \`${grammar.grammar}\` diagram: a registration per element type, its container module, and \`initialize${grammar.grammar}DiagramContainer\``),
2265
+ '- `src/index.ts` — the package root, re-exporting each module',
2266
+ '',
2267
+ 'A host builds a diagram container with the initializer, passing its own',
2268
+ 'modules: they go first and the diagram definition last, since GLSP gives an',
2269
+ 'element type to whichever module registered it last.',
2270
+ ''
2271
+ ];
2272
+ return project(lines.join('\n'), composition);
2273
+ }
2274
+ /** The client member's root entry, one re-export per diagram module. */
2275
+ function clientIndexFile(composition) {
2276
+ const modules = composition.grammars.filter(grammar => grammar.diagram).map(grammar => `./${grammar.grammarId}-diagram-module.js`);
2277
+ return `// What every host of the ${composition.name} language mounts.
2278
+
2279
+ ${modules.map(module => `export * from '${module}';`).join('\n')}
2280
+ `;
2281
+ }
2282
+ /**
2283
+ * The client member: the host-neutral client side of the language, emitted
2284
+ * when there is a diagram to define. Each host mounts it rather than defining
2285
+ * the diagram again.
2286
+ */
2287
+ function clientFiles(composition) {
2288
+ return [
2289
+ { path: 'client/package.json', content: clientPackageJson(composition) },
2290
+ {
2291
+ path: 'client/tsconfig.json',
2292
+ content: tsconfigJson(forMember(composition, 'client'), CLIENT_COMPILER_OPTIONS, '["src"]', '[{ "path": "../protocol" }]')
2293
+ },
2294
+ ignoreFile('client'),
2295
+ { path: 'client/README.md', content: clientReadme(composition) },
2296
+ { path: 'client/src/index.ts', content: clientIndexFile(composition) },
2297
+ ...composition.grammars
2298
+ .filter(grammar => grammar.diagram)
2299
+ .map(grammar => grammarTemplate('client/src/__GRAMMAR_ID__-diagram-module.ts', composition, grammar))
2300
+ ];
2301
+ }
2302
+ /**
2303
+ * The browser host's `package.json`. Always private: it is an application,
2304
+ * which nothing installs as a dependency.
2305
+ */
2306
+ function hostBrowserPackageJson(composition) {
2307
+ const dependencies = {
2308
+ [memberPackageName(composition, 'protocol')]: MEMBER_PIN,
2309
+ [memberPackageName(composition, 'server')]: MEMBER_PIN,
2310
+ ...(composition.heads.includes('glsp') ? { [memberPackageName(composition, 'client')]: MEMBER_PIN } : {}),
2311
+ // A page with no editor saves through the diagram's own action dispatcher.
2312
+ ...(composition.heads.includes('lsp') ? {} : { '@eclipse-glsp/client': CLIENT_THIRD_PARTY_DEPENDENCIES['@eclipse-glsp/client'] })
2313
+ };
2314
+ for (const head of ['base', ...composition.heads]) {
2315
+ Object.assign(dependencies, HOST_BROWSER_THIRD_PARTY_DEPENDENCIES[head]);
2316
+ for (const framework of HOST_BROWSER_FRAMEWORK_DEPENDENCIES[head]) {
2317
+ dependencies[framework] = frameworkPin(composition);
2318
+ }
2319
+ }
2320
+ return `{
2321
+ "name": "${memberPackageName(composition, 'host-browser')}",
2322
+ "version": "0.0.0",
2323
+ "private": true,
2324
+ "description": "${composition.name} in a browser page, with every head in a web worker and no backend.",
2325
+ "license": "UNLICENSED",
2326
+ "author": "",
2327
+ "type": "module",
2328
+ "scripts": {
2329
+ "build": "npm run typecheck && node esbuild.mjs",
2330
+ "clean": "rimraf out test-results playwright-report",
2331
+ ${lintScript(forMember(composition, 'host-browser'), 'src test')} "start": "node esbuild.mjs --serve",
2332
+ "test:e2e": "playwright test --tsconfig tsconfig.e2e.json",
2333
+ "test:e2e:install": "playwright install chromium",
2334
+ "typecheck": "tsc --noEmit -p tsconfig.worker.json && tsc --noEmit -p tsconfig.page.json && tsc --noEmit -p tsconfig.e2e.json"
2335
+ },
2336
+ "dependencies": {
2337
+ ${sortedBlock(dependencies, ' ')}
2338
+ },
2339
+ "devDependencies": {
2340
+ ${sortedBlock(HOST_BROWSER_DEV_DEPENDENCIES, ' ')}
2341
+ },
2342
+ "engines": {
2343
+ "node": ">=22.13"
2344
+ }
2345
+ }
2346
+ `;
2347
+ }
2348
+ /** The workspace URI of a grammar's first starter file, as a template literal over `WORKSPACE_ROOT_URI`. */
2349
+ function starterUri(grammar) {
2350
+ return `\`\${WORKSPACE_ROOT_URI}/nodes.${grammar.extensions[0]}\``;
2351
+ }
2352
+ /**
2353
+ * The worker hosting the heads: every head over the one store, with the
2354
+ * workspace seeded from `../workspace` and saved files kept in IndexedDB.
2355
+ */
2356
+ function hostWorkerFile(composition) {
2357
+ const columns = columnsFor(composition);
2358
+ const lsp = composition.heads.includes('lsp');
2359
+ const data = composition.heads.includes('data');
2360
+ const glsp = composition.heads.includes('glsp');
2361
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
2362
+ const roots = composition.grammars.map(grammar => grammar.entryRule).sort();
2363
+ const serverSymbols = [`create${composition.name}Services`, ...diagrams.map(grammar => `${grammar.grammar}DiagramModule`)].sort((left, right) => left.localeCompare(right));
2364
+ const imports = [
2365
+ ...(glsp
2366
+ ? [
2367
+ '// First, before any class carrying a decorator loads: GLSP reads the metadata',
2368
+ '// its decorators emit as each class is defined.',
2369
+ "import 'reflect-metadata';",
2370
+ "import { ServerModule } from '@eclipse-glsp/server/browser.js';"
2371
+ ]
2372
+ : []),
2373
+ "import { persistentFileSystem } from '@hydranium/core';",
2374
+ importList(['hostHeadsInWorker', 'IndexedDbFileSystemStore', ...(lsp ? ['startLanguageServerInWorker'] : [])], '@hydranium/core/browser', columns),
2375
+ ...(data
2376
+ ? [
2377
+ "import { DataServer } from '@hydranium/data-server';",
2378
+ "import { startDataServerInWorker } from '@hydranium/data-server/browser';"
2379
+ ]
2380
+ : []),
2381
+ ...(glsp
2382
+ ? [
2383
+ "import { GlspClientLogger, HydraniumGlspAppModule } from '@hydranium/glsp-server';",
2384
+ "import { startGlspServerInWorker } from '@hydranium/glsp-server/browser';"
2385
+ ]
2386
+ : []),
2387
+ ...(data ? [importList(roots, memberPackageName(composition, 'protocol'), columns, true)] : []),
2388
+ importList(serverSymbols, memberPackageName(composition, 'server'), columns),
2389
+ "import { WORKSPACE_ROOT_URI } from './workspace.js';"
2390
+ ];
2391
+ const services = `const shared = create${composition.name}Services({ ...fileSystem }).shared;`;
2392
+ const lspCall = `startLanguageServerInWorker(ports.lsp, connection => create${composition.name}Services({ connection, ...fileSystem }, { highlightKeywords: true, highlightComments: true }).shared);`;
2393
+ const startServices = !lsp
2394
+ ? services
2395
+ : ` const shared = ${lspCall}`.length <= columns
2396
+ ? `const shared = ${lspCall}`
2397
+ : `const shared = startLanguageServerInWorker(
2398
+ ports.lsp,
2399
+ connection => create${composition.name}Services({ connection, ...fileSystem }, { highlightKeywords: true, highlightComments: true }).shared
2400
+ );`;
2401
+ const dataServer = `connection => new DataServer<${roots.join(' | ')}>(connection, shared)`;
2402
+ const dataCall = `startDataServerInWorker(ports.data, shared, ${dataServer});`;
2403
+ const dataHead = data
2404
+ ? `
2405
+ // The data head serves from the ${lsp ? "LSP head's" : "services'"} store, on its own port.
2406
+ ${` ${dataCall}`.length <= columns
2407
+ ? dataCall
2408
+ : `startDataServerInWorker(
2409
+ ports.data,
2410
+ shared,
2411
+ ${dataServer}
2412
+ );`}`
2413
+ : '';
2414
+ const glspHead = glsp
2415
+ ? `
2416
+ const glsp = startGlspServerInWorker({
2417
+ context: ports.glsp,
2418
+ createLogger: caller => new GlspClientLogger(shared, { component: caller }),
2419
+ serverModule: new ServerModule()${diagrams.map(grammar => `.configureDiagramModule(new ${grammar.grammar}DiagramModule())`).join('')},
2420
+ appModules: [new HydraniumGlspAppModule({ shared })]
2421
+ });
2422
+ // The GLSP head reports a failed start through \`started\`, which the
2423
+ // ready message waits for.
2424
+ return { shared, started: [glsp.started] };`
2425
+ : `
2426
+ return shared;`;
2427
+ const text = `// The worker hosting the heads: every head over one store, in the browser and
2428
+ // with no backend. The page reaches each head over a \`MessagePort\` of its own.
2429
+
2430
+ ${imports.join('\n')}
2431
+
2432
+ /** The files of \`../workspace\`, read by \`esbuild.mjs\` and keyed by their path below the root. */
2433
+ declare const __WORKSPACE_SEED__: Record<string, string>;
2434
+ /** This project's directory, hashed by \`esbuild.mjs\`, so that no other project on the origin opens its store. */
2435
+ declare const __DIRECTORY_KEY__: string;
2436
+
2437
+ // A saved file is kept in IndexedDB and laid over the seed on the next load;
2438
+ // the heads start once the stored files are restored.
2439
+ const store = new IndexedDbFileSystemStore(\`__PROJECT_ID__-workspace-\${__DIRECTORY_KEY__}\`);
2440
+
2441
+ hostHeadsInWorker(
2442
+ persistentFileSystem({ store, seed: __WORKSPACE_SEED__, rootUri: WORKSPACE_ROOT_URI }),
2443
+ (ports, fileSystem) => {
2444
+ ${lsp ? '// The LSP head composes the services, as the Node entry does around stdio.\n ' : ''}${startServices}${dataHead}${glspHead}
2445
+ },
2446
+ ${lsp
2447
+ ? '// The ready message then carries the workspace files the page opens.'
2448
+ : '// With no LSP head the workspace comes up from this root, and the ready\n // message then carries its files.'}
2449
+ { rootUri: WORKSPACE_ROOT_URI }
2450
+ );
2451
+ `;
2452
+ return project(text, composition);
2453
+ }
2454
+ /**
2455
+ * The page, and the one place deciding which head's part of it runs: each
2456
+ * part is a module of its own, and this calls the ones the heads serve.
2457
+ */
2458
+ function hostPageFile(composition) {
2459
+ const lsp = composition.heads.includes('lsp');
2460
+ const data = composition.heads.includes('data');
2461
+ const diagram = composition.grammars.find(grammar => grammar.diagram);
2462
+ // A host takes lsp or glsp, and glsp marks a diagram grammar, so without lsp there is one.
2463
+ const diagramLanguage = lsp || diagram === undefined ? undefined : `${screamingSnake(diagram.grammarId)}_LANGUAGE`;
2464
+ const shownLanguage = diagramLanguage ?? 'ALL_LANGUAGES';
2465
+ const parts = [
2466
+ // Every part follows the file the page shows, so the switch comes first. With
2467
+ // no editor it lists only the diagram's files, the only ones the page shows.
2468
+ {
2469
+ module: './shown-file.js',
2470
+ imports: 'startShownFile',
2471
+ call: `const shownFile = await startShownFile(heads, ${diagramLanguage === undefined ? shownLanguage : `[${diagramLanguage}]`});`
2472
+ },
2473
+ // Save is armed with the editor, so it works before the other panes load;
2474
+ // with a diagram it waits for the edits of the one shown, once mounted.
2475
+ ...(lsp
2476
+ ? [
2477
+ {
2478
+ module: './editor.js',
2479
+ imports: 'armEditorSave, startEditor',
2480
+ call: [
2481
+ 'const editor = await startEditor(heads, shownFile);',
2482
+ ...(diagram === undefined
2483
+ ? ['armEditorSave(editor);']
2484
+ : [
2485
+ 'let shownDiagram: Awaited<ReturnType<typeof mountDiagram>> | undefined = undefined;',
2486
+ 'armEditorSave(editor, () => settleDiagramEdits(shownDiagram?.()));'
2487
+ ])
2488
+ ].join('\n ')
2489
+ }
2490
+ ]
2491
+ : []),
2492
+ ...(data
2493
+ ? [
2494
+ {
2495
+ module: './data-status.js',
2496
+ imports: 'showDataStatus',
2497
+ call: `await showDataStatus(heads.data, ${starterUri(composition.grammars[0])});`
2498
+ }
2499
+ ]
2500
+ : []),
2501
+ ...(diagram === undefined
2502
+ ? []
2503
+ : [
2504
+ {
2505
+ module: './diagram.js',
2506
+ imports: lsp ? 'mountDiagram, settleDiagramEdits' : 'mountDiagram',
2507
+ call: `${lsp ? 'shownDiagram' : 'const shownDiagram'} = await mountDiagram(heads.glsp, shownFile, logger, ${lsp ? 'settleDiagramEdits' : 'saveBeforeLeaving'});`
2508
+ }
2509
+ ]),
2510
+ // Without the editor, the diagram is what the page saves through.
2511
+ ...(lsp ? [] : [{ module: './diagram-save.js', imports: 'armDiagramSave, saveBeforeLeaving', call: 'armDiagramSave(shownDiagram);' }])
2512
+ ];
2513
+ const imports = [
2514
+ ...parts.map(part => `import { ${part.imports} } from '${part.module}';`),
2515
+ "import { appendLog, setStatus } from './log.js';",
2516
+ ...(data ? ["import { WORKSPACE_ROOT_URI } from './workspace.js';"] : [])
2517
+ ].sort((left, right) => left.slice(left.indexOf("'")).localeCompare(right.slice(right.indexOf("'"))));
2518
+ return `// The page: it starts the worker, hands each head a channel of its own, and
2519
+ // fills the pane of each head the server runs, one after the other.
2520
+
2521
+ import { connectWorkerHeads } from '@hydranium/client-browser';
2522
+ import { ConsoleLogger, describeError } from '@hydranium/protocol';
2523
+ import { ${shownLanguage} } from '${memberPackageName(composition, 'protocol')}';
2524
+ ${imports.join('\n')}
2525
+
2526
+ /** Where the worker bundle is served, substituted by \`esbuild.mjs\`. */
2527
+ declare const __WORKER_BUNDLE_URL__: string;
2528
+
2529
+ async function main(): Promise<void> {
2530
+ // The page's end of each head's connection logs to the browser console.
2531
+ const logger = new ConsoleLogger();
2532
+ const heads = connectWorkerHeads(new Worker(__WORKER_BUNDLE_URL__), { logger });
2533
+ heads.onWorkerError(report => appendLog(\`worker error during \${report.phase}: \${report.message}\`));
2534
+ setStatus('starting…');
2535
+ ${parts.map(part => ` ${part.call}`).join('\n')}
2536
+ setStatus('ready');
2537
+ }
2538
+
2539
+ main().catch((error: unknown) => setStatus(\`failed: \${describeError(error)}\`));
2540
+ `;
2541
+ }
2542
+ /**
2543
+ * The smoke test: what each head shows on the page, and an edit that survives
2544
+ * a reload, typed in the editor or, with no LSP head, made in the diagram. With
2545
+ * a diagram, also a diagram edit the reload guard and Save catch before the
2546
+ * server has answered it.
2547
+ */
2548
+ function hostSmokeSpec(composition) {
2549
+ const lsp = composition.heads.includes('lsp');
2550
+ const data = composition.heads.includes('data');
2551
+ const diagram = composition.grammars.find(grammar => grammar.diagram);
2552
+ const primary = composition.grammars[0];
2553
+ const checks = [
2554
+ ...(lsp ? [" await expect(editor).toContainText('node First');"] : []),
2555
+ ...(data
2556
+ ? [
2557
+ ' // The data head reads the document out of the same store.',
2558
+ ` await expect(page.locator('#data-status')).toHaveText('data head: nodes.${primary.extensions[0]} holds a ${primary.entryRule}');`
2559
+ ]
2560
+ : []),
2561
+ ...(diagram === undefined
2562
+ ? []
2563
+ : [
2564
+ ' // The diagram follows the switch: two nodes in one file, and in the other its',
2565
+ ' // node and the target it references there, drawn as external.',
2566
+ " const first = await page.locator('#file').inputValue();",
2567
+ ` await page.locator('#file').selectOption('nodes.${diagram.extensions[0]}');`,
2568
+ " await expect(page.locator('#diagram .sprotty-node')).toHaveCount(2);",
2569
+ ` await page.locator('#file').selectOption('references.${diagram.extensions[0]}');`,
2570
+ ` await expect(page.locator('#diagram .external')).toHaveText('Second (nodes.${diagram.extensions[0]})');`,
2571
+ " await page.locator('#file').selectOption(first);"
2572
+ ])
2573
+ ];
2574
+ const edit = lsp
2575
+ ? ` await editor.click();
2576
+ await page.keyboard.press('ControlOrMeta+End');
2577
+ await page.keyboard.type('\\nnode Added');
2578
+ await page.locator('#save').click();
2579
+ await expect(page.locator('#status')).toHaveText('saved 1 file(s)');
2580
+
2581
+ await page.reload();
2582
+ await expect(page.locator('#status')).toHaveText('ready');
2583
+ await expect(editor).toContainText('node Added');`
2584
+ : ` // A node from the palette, dropped low and left of centre: the starter nodes
2585
+ // sit in the upper band and the palette in the upper right.
2586
+ const canvas = page.locator('#diagram');
2587
+ const create = async (): Promise<void> => {
2588
+ await page.locator('#diagram .tool-button', { hasText: '${diagram?.nodeRule}' }).click();
2589
+ const size = await canvas.evaluate(element => ({ width: element.clientWidth, height: element.clientHeight }));
2590
+ await canvas.click({ position: { x: size.width * 0.4, y: size.height * 0.85 } });
2591
+ };
2592
+ await create();
2593
+ await expect(nodes).toHaveCount(3);
2594
+ await page.locator('#save').click();
2595
+ await expect(page.locator('#status')).toHaveText('saved the diagram');
2596
+ // A second, left unsaved: the switch saves the diagram it leaves.
2597
+ await create();
2598
+ await expect(nodes).toHaveCount(4);
2599
+ await page.locator('#file').selectOption('references.${diagram?.extensions[0]}');
2600
+ await expect(page.locator('#diagram .external')).toBeVisible();
2601
+
2602
+ await page.reload();
2603
+ await expect(page.locator('#status')).toHaveText('ready');
2604
+ await expect(nodes).toHaveCount(4);`;
2605
+ const guardTest = diagram === undefined
2606
+ ? ''
2607
+ : `
2608
+ test('asks before a reload drops a diagram edit the server has not answered, and saves it with Save at once', async ({ page }) => {
2609
+ const nodes = page.locator('#diagram .sprotty-node');
2610
+ const askGuard = (): Promise<boolean> =>
2611
+ page.evaluate(() => {
2612
+ const event = new Event('beforeunload', { cancelable: true });
2613
+ window.dispatchEvent(event);
2614
+ return event.defaultPrevented;
2615
+ });
2616
+ await page.goto('/');
2617
+ await expect(page.locator('#status')).toHaveText('ready');
2618
+ await page.locator('#file').selectOption('nodes.${diagram.extensions[0]}');
2619
+ await expect(nodes).toHaveCount(2);
2620
+
2621
+ const canvas = page.locator('#diagram');
2622
+ await page.locator('#diagram .tool-button', { hasText: '${diagram.nodeRule}' }).click();
2623
+ const size = await canvas.evaluate(element => ({ width: element.clientWidth, height: element.clientHeight }));
2624
+ // Asked and clicked right after the click's own handlers, in the same turn
2625
+ // of the page, so the server's answer cannot have marked anything unsaved.
2626
+ await page.evaluate(() => {
2627
+ window.addEventListener(
2628
+ 'mouseup',
2629
+ () =>
2630
+ queueMicrotask(() => {
2631
+ const event = new Event('beforeunload', { cancelable: true });
2632
+ window.dispatchEvent(event);
2633
+ document.getElementById('save')?.click();
2634
+ Reflect.set(window, 'guardedOnCreate', event.defaultPrevented);
2635
+ }),
2636
+ { once: true }
2637
+ );
2638
+ });
2639
+ await canvas.click({ position: { x: size.width * 0.4, y: size.height * 0.85 } });
2640
+ expect(await page.evaluate(() => Reflect.get(window, 'guardedOnCreate'))).toBe(true);
2641
+ await expect(page.locator('#status')).toHaveText('${lsp ? 'saved 1 file(s)' : 'saved the diagram'}');
2642
+ expect(await askGuard()).toBe(false);
2643
+
2644
+ await page.reload();
2645
+ await expect(page.locator('#status')).toHaveText('ready');
2646
+ await page.locator('#file').selectOption('nodes.${diagram.extensions[0]}');
2647
+ await expect(nodes).toHaveCount(3);
2648
+ });
2649
+ `;
2650
+ const locators = [
2651
+ ...(lsp ? [" const editor = page.locator('#editor .view-lines');"] : []),
2652
+ ...(lsp ? [] : [" const nodes = page.locator('#diagram .sprotty-node');"])
2653
+ ];
2654
+ return `// The page loads with every head answering, and an edit saved through the
2655
+ // ${lsp ? 'LSP head' : 'GLSP head'} is still there after a reload; any console error fails that
2656
+ // test. The other asks before a reload drops a diagram edit the server has not
2657
+ // answered yet, and keeps it when Save is clicked at once.
2658
+
2659
+ import { expect, test } from '@playwright/test';
2660
+
2661
+ test('loads every head and keeps a saved ${lsp ? 'edit' : 'diagram edit'} across a reload', async ({ page }) => {
2662
+ const errors: string[] = [];
2663
+ page.on('pageerror', error => errors.push(error.message));
2664
+ page.on('console', message => {
2665
+ if (message.type() === 'error') {
2666
+ errors.push(message.text());
2667
+ }
2668
+ });
2669
+ ${locators.join('\n')}
2670
+
2671
+ await page.goto('/');
2672
+ await expect(page.locator('#status')).toHaveText('ready');
2673
+ ${checks.join('\n')}
2674
+
2675
+ ${edit}
2676
+ expect(errors).toEqual([]);
2677
+ });
2678
+ ${guardTest}${lsp
2679
+ ? `
2680
+ test('colours declarations, references, keywords and comments', async ({ page }) => {
2681
+ await page.goto('/');
2682
+ await expect(page.locator('#status')).toHaveText('ready');
2683
+ const editor = page.locator('#editor .view-lines');
2684
+ const second = editor.locator('span').filter({ hasText: /^Second$/ });
2685
+ const keyword = editor
2686
+ .locator('span')
2687
+ .filter({ hasText: /^node$/ })
2688
+ .first();
2689
+ for (const extension of [${composition.grammars.map(grammar => `'${grammar.extensions[0]}'`).join(', ')}]) {
2690
+ await page.locator('#file').selectOption(\`nodes.\${extension}\`);
2691
+ await expect(editor.locator('span').filter({ hasText: /^First$/ })).toHaveCSS('color', 'rgb(36, 122, 147)');
2692
+ await expect(second).toHaveCount(2);
2693
+ for (const target of await second.all()) {
2694
+ await expect(target).toHaveCSS('color', 'rgb(36, 122, 147)');
2695
+ }
2696
+ await expect(keyword).toHaveCSS('color', 'rgb(0, 0, 255)');
2697
+ await expect(editor.locator('.view-line > span > span').filter({ hasText: /A.second.node/ })).toHaveCSS('color', 'rgb(0, 128, 0)');
2698
+ await page.locator('#file').selectOption(\`references.\${extension}\`);
2699
+ await expect(editor.locator('span').filter({ hasText: /^Third$/ })).toHaveCSS('color', 'rgb(36, 122, 147)');
2700
+ await expect(second).toHaveCSS('color', 'rgb(36, 122, 147)');
2701
+ }
2702
+ });
2703
+ `
2704
+ : ''}`;
2705
+ }
2706
+ /** The browser host's README. */
2707
+ function hostBrowserReadme(composition) {
2708
+ const lsp = composition.heads.includes('lsp');
2709
+ const data = composition.heads.includes('data');
2710
+ const diagram = composition.grammars.find(grammar => grammar.diagram);
2711
+ const lines = [
2712
+ `# ${memberPackageName(composition, 'host-browser')}`,
2713
+ '',
2714
+ '__NAME__ in a browser page: every head runs in a web worker, with no backend',
2715
+ `and no Node runtime. The page saves through the ${lsp ? 'LSP head' : 'GLSP head'}, and the worker`,
2716
+ 'keeps each saved file in IndexedDB, laid over `../workspace` on the next',
2717
+ 'load.',
2718
+ '',
2719
+ '```bash',
2720
+ 'npm run build # type-check and bundle into out/',
2721
+ 'npm start # serve the page at the URL it prints, rebundled on every source change',
2722
+ 'npm run test:e2e:install # download the Chromium the smoke test drives, once',
2723
+ 'npm run test:e2e # the smoke test, which serves the page itself',
2724
+ '```',
2725
+ '',
2726
+ '`PORT` moves the page to another port, for `npm start` and the smoke test alike;',
2727
+ 'stop `npm start` before the smoke test, or give one of them another port.',
2728
+ '`npm start` reads `../workspace` once, so restart it after editing a file',
2729
+ 'there.',
2730
+ '',
2731
+ '## Layout',
2732
+ '',
2733
+ '- `index.html` — the page, with a pane for each head that stays hidden until its part fills it',
2734
+ '- `hydranium-mark.svg` — the tab icon',
2735
+ `- \`esbuild.mjs\` — bundles the page${lsp ? ', the worker and Monaco’s worker' : ' and the worker'}, reading \`../workspace\` in as the seed`,
2736
+ '- `src/worker.ts` — the worker: the heads over one store, seeded and persisted',
2737
+ '- `src/page.ts` — starts the worker and runs the part of the page each head serves',
2738
+ '- `src/shown-file.ts` — the file switch above the panes, which every part follows',
2739
+ ...(lsp ? ['- `src/editor.ts` — the LSP part: a Monaco editor and Save'] : []),
2740
+ ...(data ? ['- `src/data-status.ts` — the data part: one document read through the data head'] : []),
2741
+ ...(diagram === undefined
2742
+ ? []
2743
+ : [
2744
+ `- \`src/diagram.ts\` — the GLSP part: the \`${diagram.grammar}\` diagram of the file the page shows, defined in the client package`
2745
+ ]),
2746
+ ...(lsp ? [] : ['- `src/diagram-save.ts` — Save with no editor: the diagram saved through the GLSP head']),
2747
+ '- `src/log.ts` — the status line and the log area',
2748
+ '- `src/workspace.ts` — the workspace root URI both ends name',
2749
+ ...(lsp ? ['- `src/monaco-editor-worker.ts` — Monaco’s own worker, which hosts no head'] : []),
2750
+ '- `src/posix-path-shim.ts` — the `path` function the framework imports, for the bundle',
2751
+ '- `test/smoke.spec.mts` — the page loads, each head answers, and a saved edit survives a reload',
2752
+ ''
2753
+ ];
2754
+ return project(lines.join('\n'), composition);
2755
+ }
2756
+ /** The page with the line under its log view saying what shows there, which the LSP head decides. */
2757
+ function withLogHint(page, lsp) {
2758
+ const hint = lsp
2759
+ ? 'What the server logs over the LSP head, and the errors the page reports.'
2760
+ : "The errors the page reports. The server logs to the worker's console, in the browser's developer tools.";
2761
+ return { ...page, content: page.content.replace('__LOG_HINT__', hint) };
2762
+ }
2763
+ /** The browser host: a page and a worker, bundled by esbuild and served as static files. */
2764
+ function hostBrowserFiles(composition) {
2765
+ const fixed = (file) => projectTemplate(`host-browser/${file}`, composition);
2766
+ const lsp = composition.heads.includes('lsp');
2767
+ const diagram = composition.grammars.find(grammar => grammar.diagram);
2768
+ return [
2769
+ { path: 'host-browser/package.json', content: hostBrowserPackageJson(composition) },
2770
+ ignoreFile('host-browser'),
2771
+ { path: 'host-browser/README.md', content: hostBrowserReadme(composition) },
2772
+ withLogHint(fixed('index.html'), lsp),
2773
+ fixed('hydranium-mark.svg'),
2774
+ fixed('esbuild.mjs'),
2775
+ fixed('playwright.config.mts'),
2776
+ fixed('tsconfig.worker.json'),
2777
+ fixed('tsconfig.page.json'),
2778
+ fixed('tsconfig.e2e.json'),
2779
+ { path: 'host-browser/src/worker.ts', content: hostWorkerFile(composition) },
2780
+ { path: 'host-browser/src/page.ts', content: hostPageFile(composition) },
2781
+ fixed('src/shown-file.ts'),
2782
+ ...(lsp ? [fixed('src/editor.ts')] : []),
2783
+ ...(composition.heads.includes('data') ? [fixed('src/data-status.ts')] : []),
2784
+ ...(diagram === undefined ? [] : [grammarTemplate('host-browser/src/diagram.ts', composition, diagram)]),
2785
+ ...(lsp ? [] : [fixed('src/diagram-save.ts')]),
2786
+ fixed('src/log.ts'),
2787
+ fixed('src/workspace.ts'),
2788
+ ...(lsp ? [fixed('src/monaco-editor-worker.ts'), fixed('src/monaco-editor-core-worker.d.ts')] : []),
2789
+ fixed('src/posix-path-shim.ts'),
2790
+ { path: 'host-browser/test/smoke.spec.mts', content: hostSmokeSpec(composition) }
2791
+ ];
2792
+ }
2793
+ /** A JSON value as it sits at `indent` in a manifest npm writes. */
2794
+ function jsonAt(value, indent) {
2795
+ return JSON.stringify(value, null, 2).replace(/\n/g, `\n${indent}`);
2796
+ }
2797
+ /**
2798
+ * The languages and grammars a VS Code extension contributes. Each extension
2799
+ * declares its own, since either may be installed without the other; the
2800
+ * grammars are the server's, copied in by `copy:syntaxes`.
2801
+ */
2802
+ function vscodeLanguageContributions(composition) {
2803
+ return {
2804
+ grammars: composition.grammars.map(grammar => ({
2805
+ language: grammar.languageId,
2806
+ scopeName: `source.${grammar.languageId}`,
2807
+ path: `./syntaxes/${grammar.languageId}.tmLanguage.json`
2808
+ })),
2809
+ languages: composition.grammars.map(grammar => ({
2810
+ id: grammar.languageId,
2811
+ aliases: [grammar.grammar],
2812
+ extensions: grammar.extensions.map(extension => `.${extension}`)
2813
+ }))
2814
+ };
2815
+ }
2816
+ /** VS Code finds the grammars only inside the extension, so the build copies the server's. */
2817
+ const COPY_SYNTAXES_SCRIPT = "require('node:fs').cpSync('../server/syntaxes', 'syntaxes', { recursive: true })";
2818
+ const COPY_SYNTAXES = `node -e \\"${COPY_SYNTAXES_SCRIPT}\\"`;
2819
+ /** `copy:syntaxes`, then again each time the server's `langium:watch` rewrites a grammar. */
2820
+ const WATCH_SYNTAXES = `node -e \\"const copy = () => ${COPY_SYNTAXES_SCRIPT}; copy(); require('node:fs').watch('../server/syntaxes', copy)\\"`;
2821
+ /**
2822
+ * The servers-only extension's `package.json`. Always private, like a host:
2823
+ * it is installed into an application, never as a dependency.
2824
+ *
2825
+ * `onStartupFinished` rather than `onLanguage`, since a Theia backend asks for
2826
+ * a port before any document is open.
2827
+ */
2828
+ function vscodeServersPackageJson(composition) {
2829
+ const dependencies = {
2830
+ [memberPackageName(composition, 'protocol')]: MEMBER_PIN,
2831
+ [memberPackageName(composition, 'server')]: MEMBER_PIN,
2832
+ ...VSCODE_LANGUAGE_CLIENT,
2833
+ '@hydranium/client-vscode': frameworkPin(composition)
2834
+ };
2835
+ return `{
2836
+ "name": "${memberPackageName(composition, 'vscode-servers')}",
2837
+ "displayName": "${composition.name} Servers",
2838
+ "version": "0.0.0",
2839
+ "private": true,
2840
+ "description": "Starts the ${composition.name} server${socketHeads(composition).length > 0 ? " and publishes its socket heads' ports as host commands" : ''}, with no editor of its own.",
2841
+ "license": "UNLICENSED",
2842
+ "author": "",
2843
+ "publisher": "${composition.projectId}",
2844
+ "main": "./out/extension.js",
2845
+ "scripts": {
2846
+ "build": "npm run copy:syntaxes && tsc",
2847
+ "clean": "rimraf out syntaxes tsconfig.tsbuildinfo",
2848
+ "copy:syntaxes": "${COPY_SYNTAXES}",
2849
+ ${lintScript(forMember(composition, 'vscode-servers'), 'src')} "watch": "concurrently --kill-others --names syntaxes,tsc \\"npm run watch:syntaxes\\" \\"tsc --watch\\"",
2850
+ "watch:syntaxes": "${WATCH_SYNTAXES}"
2851
+ },
2852
+ "dependencies": {
2853
+ ${sortedBlock(dependencies, ' ')}
2854
+ },
2855
+ "devDependencies": {
2856
+ ${sortedBlock({ '@types/node': '^22.0.0', '@types/vscode': VSCODE_ENGINE, concurrently: '^9.0.0', rimraf: '^5.0.0', typescript: '^7.0.2' }, ' ')}
2857
+ },
2858
+ "contributes": ${jsonAt(vscodeLanguageContributions(composition), ' ')},
2859
+ "activationEvents": [
2860
+ "onStartupFinished"
2861
+ ],
2862
+ "engines": {
2863
+ "vscode": "${VSCODE_ENGINE}"
2864
+ }
2865
+ }
2866
+ `;
2867
+ }
2868
+ /**
2869
+ * The servers-only extension's activation: the shared launch, and with a socket
2870
+ * head the port commands, which only this extension registers.
2871
+ */
2872
+ function vscodeServersExtensionFile(composition) {
2873
+ const upper = screamingSnake(composition.projectId);
2874
+ const heads = socketHeads(composition).map(head => head.key);
2875
+ const protocolSymbols = heads.flatMap(head => [`${upper}_${head}_HOST_COMMAND`, `${upper}_${head}_PORT_COMMAND`]);
2876
+ const imports = heads.length === 0
2877
+ ? ''
2878
+ : `import { registerPortCommands } from '@hydranium/client-vscode/node';
2879
+ ${importList(protocolSymbols, memberPackageName(composition, 'protocol'), columnsFor(composition))}
2880
+ `;
2881
+ const portCommands = heads.length === 0
2882
+ ? ''
2883
+ : `
2884
+
2885
+ // Each socket head's port as a host command, for a host with no language
2886
+ // client of its own. Not in the shared launch: VS Code throws on a second
2887
+ // registration of one id, so the VS Code extension beside this one would fail
2888
+ // to activate. A pass-through: awaiting anything else in it would hang a
2889
+ // Theia backend on a user dismissing a notification.
2890
+ registerPortCommands(context, client, {
2891
+ ${heads.map(head => ` [${upper}_${head}_HOST_COMMAND]: ${upper}_${head}_PORT_COMMAND`).join(',\n')}
2892
+ });`;
2893
+ return `// The servers-only extension: it starts the ${composition.name} server and contributes no
2894
+ // editor, view or command. A Theia app sideloads this one rather than the VS
2895
+ // Code extension, whose diagram editors would give its files a second editor.
2896
+
2897
+ ${imports}import type { ExtensionContext } from 'vscode';
2898
+ import type { LanguageClient } from 'vscode-languageclient/node';
2899
+ import { start${composition.name}LanguageClient } from './language-client.js';
2900
+
2901
+ let client: LanguageClient | undefined;
2902
+
2903
+ export async function activate(context: ExtensionContext): Promise<void> {
2904
+ client = await start${composition.name}LanguageClient(context);${portCommands}
2905
+ }
2906
+
2907
+ export async function deactivate(): Promise<void> {
2908
+ await client?.stop();
2909
+ client = undefined;
2910
+ }
2911
+ `;
2912
+ }
2913
+ /**
2914
+ * The server launch both extensions share: one launch, so VS Code and a Theia
2915
+ * app sideloading the servers extension hand the server the same documents.
2916
+ */
2917
+ function vscodeLanguageClientFile(composition) {
2918
+ return `// Starts the ${composition.name} server for VS Code, and for a Theia app that sideloads
2919
+ // this extension: one launch, so both hand the server the same documents.
2920
+
2921
+ import { textDocumentContentMiddleware, textDocumentSyncMiddleware } from '@hydranium/client-vscode/node';
2922
+ import { ALL_LANGUAGES } from '${memberPackageName(composition, 'protocol')}';
2923
+ import { type ExtensionContext, workspace } from 'vscode';
2924
+ import { LanguageClient, type ServerOptions, TransportKind } from 'vscode-languageclient/node';
2925
+
2926
+ /** Fork the server, start its language client and return it once started. */
2927
+ export async function start${composition.name}LanguageClient(context: ExtensionContext, serverOptions?: ServerOptions): Promise<LanguageClient> {
2928
+ // Resolved through the package, so it works from the workspace and from an
2929
+ // installed extension alike. Over IPC, which keeps the server's stdout free.
2930
+ const serverModule = require.resolve('${memberPackageName(composition, 'server')}/lib/main.js');
2931
+ const extensions = ALL_LANGUAGES.flatMap(language => language.fileExtensions).join(',');
2932
+ const client: LanguageClient = new LanguageClient(
2933
+ '${composition.projectId}',
2934
+ '${composition.name}',
2935
+ serverOptions ?? { module: serverModule, transport: TransportKind.ipc },
2936
+ {
2937
+ documentSelector: ALL_LANGUAGES.flatMap(({ languageId }) => [
2938
+ { scheme: 'file', language: languageId },
2939
+ { scheme: 'virtual', language: languageId }
2940
+ ]),
2941
+ // Changes on disk the editor does not hold, such as a file another tool writes.
2942
+ synchronize: { fileEvents: workspace.createFileSystemWatcher(\`**/*{\${extensions}}\`) },
2943
+ // The text of a document only the server holds, such as a built-in library.
2944
+ middleware: {
2945
+ ...textDocumentContentMiddleware(() => client),
2946
+ ...textDocumentSyncMiddleware(() => client)
2947
+ }
2948
+ }
2949
+ );
2950
+ context.subscriptions.push(client);
2951
+ await client.start();
2952
+
2953
+ return client;
2954
+ }
2955
+ `;
2956
+ }
2957
+ /** The generated launcher exercised in a real extension host against a content server. */
2958
+ function hostVscodeVirtualTest(composition) {
2959
+ return `const assert = require('node:assert/strict');
2960
+ const path = require('node:path');
2961
+ const fs = require('node:fs');
2962
+ const os = require('node:os');
2963
+ const { TransportKind } = require('vscode-languageclient/node');
2964
+ const vscode = require('vscode');
2965
+ const { start${composition.name}LanguageClient } = require('${memberPackageName(composition, 'vscode-servers')}/out/language-client.js');
2966
+
2967
+ module.exports.run = async () => {
2968
+ const subscriptions = [];
2969
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'content-host-'));
2970
+ const client = await start${composition.name}LanguageClient(
2971
+ { subscriptions },
2972
+ {
2973
+ module: path.join(__dirname, 'content-server.cjs'),
2974
+ transport: TransportKind.ipc
2975
+ }
2976
+ );
2977
+ try {
2978
+ const file = path.join(scratch, 'file.${composition.grammars[0].extensions[0]}');
2979
+ fs.writeFileSync(file, 'node Local');
2980
+ await vscode.window.showTextDocument(vscode.Uri.file(file));
2981
+ const uri = vscode.Uri.parse('virtual:fixture/library.${composition.grammars[0].extensions[0]}');
2982
+ const editor = await vscode.window.showTextDocument(uri);
2983
+ const position = new vscode.Position(0, 5);
2984
+ const hover = await vscode.commands.executeCommand('vscode.executeHoverProvider', uri, position);
2985
+ assert.ok(
2986
+ hover.some(value => value.contents.some(content => content.value === 'fixture hover')),
2987
+ 'virtual hover'
2988
+ );
2989
+ const tokens = await vscode.commands.executeCommand('vscode.provideDocumentSemanticTokens', uri);
2990
+ assert.ok(tokens?.data.length, 'virtual semantic tokens');
2991
+ const definitions = await vscode.commands.executeCommand('vscode.executeDefinitionProvider', uri, position);
2992
+ assert.ok(definitions.length, 'virtual definition');
2993
+ const before = editor.document.getText();
2994
+ await editor.edit(builder => builder.insert(position, 'changed'));
2995
+ assert.equal(editor.document.getText(), before);
2996
+ await vscode.languages.setTextDocumentLanguage(editor.document, 'plaintext');
2997
+ const received = await client.sendRequest('fixture/sync');
2998
+ assert.ok(
2999
+ received.some(event => event.method === 'textDocument/didOpen' && event.uri.startsWith('file:')),
3000
+ 'file sync reaches the server'
3001
+ );
3002
+ assert.deepEqual(
3003
+ received.filter(event => event.uri.startsWith('virtual:')),
3004
+ [],
3005
+ 'no virtual document synchronization reaches the server'
3006
+ );
3007
+ } finally {
3008
+ await client.stop();
3009
+ for (const subscription of subscriptions) subscription.dispose();
3010
+ fs.rmSync(scratch, { recursive: true, force: true });
3011
+ }
3012
+ };
3013
+ `;
3014
+ }
3015
+ /** The folders of the Theia applications the composition emits, as code spans. */
3016
+ function theiaApplicationPaths(composition) {
3017
+ return composition.hosts.filter(isTheiaHost).map(host => `\`../host-${host}\``);
3018
+ }
3019
+ /** The servers-only extension's README. */
3020
+ function vscodeServersReadme(composition) {
3021
+ const vscode = composition.hosts.includes('vscode');
3022
+ const theiaApps = theiaApplicationPaths(composition);
3023
+ const intro = [
3024
+ 'A VS Code extension that starts the __NAME__ server and contributes no editor, view or command: the languages, their grammars and the server.',
3025
+ `With a socket head, it also answers each head’s port under a host command, which a host without a language client of its own runs${theiaApps.length > 0
3026
+ ? theiaApps.length === 1
3027
+ ? `: the Theia application in ${theiaApps[0]}, which sideloads this extension.`
3028
+ : `: the Theia applications in ${listPhrase(theiaApps)}, which sideload this extension.`
3029
+ : ', such as a Theia app sideloading this extension.'}`,
3030
+ ...(vscode
3031
+ ? [
3032
+ 'The VS Code extension in `../host-vscode` starts the server through the same function.',
3033
+ 'Both work side by side, but each starts its own server and language client, so diagnostics may show twice: install one.'
3034
+ ]
3035
+ : [])
3036
+ ];
3037
+ const lines = [
3038
+ `# ${memberPackageName(composition, 'vscode-servers')}`,
3039
+ '',
3040
+ ...wrapProse(intro.join(' ')),
3041
+ '',
3042
+ '```bash',
3043
+ 'npm run build # copy the server’s grammars and compile into out/; build the server first',
3044
+ 'npm run watch # the same on every change',
3045
+ '```',
3046
+ '',
3047
+ '- `src/extension.ts` — activation, which starts the server and, with a socket head, registers its port commands',
3048
+ `- \`src/language-client.ts\` — the server launch${vscode ? ', shared with `../host-vscode`' : ''}`,
3049
+ ''
3050
+ ];
3051
+ return project(lines.join('\n'), composition);
3052
+ }
3053
+ /** The servers-only extension: the server's launch, without an editor. */
3054
+ function vscodeServersFiles(composition) {
3055
+ return [
3056
+ { path: 'vscode-servers/package.json', content: vscodeServersPackageJson(composition) },
3057
+ ignoreFile('vscode-servers'),
3058
+ { path: 'vscode-servers/README.md', content: vscodeServersReadme(composition) },
3059
+ projectTemplate('vscode-servers/tsconfig.json', composition),
3060
+ { path: 'vscode-servers/src/extension.ts', content: vscodeServersExtensionFile(composition) },
3061
+ { path: 'vscode-servers/src/language-client.ts', content: vscodeLanguageClientFile(composition) }
3062
+ ];
3063
+ }
3064
+ /**
3065
+ * The custom-editor view type of a grammar's diagram, as the editor module
3066
+ * declares it. VS Code keys custom editors by view type alone, across every
3067
+ * installed extension, so it carries the language id, which `init` qualifies
3068
+ * by the project wherever the grammar is named apart from it.
3069
+ */
3070
+ function diagramViewType(grammar) {
3071
+ return `${grammar.languageId}.diagram`;
3072
+ }
3073
+ /**
3074
+ * The VS Code extension's `package.json`. Always private, as every host is.
3075
+ *
3076
+ * A diagram file opens in its diagram by default; `Reopen Editor With` reaches
3077
+ * the text.
3078
+ */
3079
+ function hostVscodePackageJson(composition) {
3080
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
3081
+ const dependencies = {
3082
+ [memberPackageName(composition, 'vscode-servers')]: MEMBER_PIN,
3083
+ ...VSCODE_LANGUAGE_CLIENT
3084
+ };
3085
+ if (diagrams.length > 0) {
3086
+ dependencies[memberPackageName(composition, 'client')] = MEMBER_PIN;
3087
+ dependencies[memberPackageName(composition, 'protocol')] = MEMBER_PIN;
3088
+ Object.assign(dependencies, HOST_VSCODE_GLSP_THIRD_PARTY_DEPENDENCIES);
3089
+ for (const framework of HOST_VSCODE_GLSP_FRAMEWORK_DEPENDENCIES) {
3090
+ dependencies[framework] = frameworkPin(composition);
3091
+ }
3092
+ }
3093
+ if (composition.heads.includes('data')) {
3094
+ dependencies[memberPackageName(composition, 'protocol')] = MEMBER_PIN;
3095
+ Object.assign(dependencies, HOST_VSCODE_DATA_THIRD_PARTY_DEPENDENCIES);
3096
+ for (const framework of HOST_VSCODE_DATA_FRAMEWORK_DEPENDENCIES) {
3097
+ dependencies[framework] = frameworkPin(composition);
3098
+ }
3099
+ }
3100
+ const devDependencies = { ...HOST_VSCODE_DEV_DEPENDENCIES, ...(diagrams.length > 0 ? { esbuild: '^0.28.0' } : {}) };
3101
+ const watches = {
3102
+ syntaxes: 'npm run watch:syntaxes',
3103
+ tsc: 'tsc --watch',
3104
+ ...(diagrams.length > 0 ? { webview: 'node esbuild.mjs --watch' } : {})
3105
+ };
3106
+ const watchNames = Object.keys(watches).join(',');
3107
+ const watchCommands = Object.values(watches)
3108
+ .map(command => `\\"${command}\\"`)
3109
+ .join(' ');
3110
+ const contributes = {
3111
+ ...(composition.heads.includes('data')
3112
+ ? { commands: [{ command: `${composition.projectId}.showModel`, title: 'Show Model', category: composition.name }] }
3113
+ : {}),
3114
+ ...(diagrams.length > 0
3115
+ ? {
3116
+ customEditors: diagrams.map(grammar => ({
3117
+ viewType: diagramViewType(grammar),
3118
+ displayName: `${grammar.grammar} Diagram`,
3119
+ selector: grammar.extensions.map(extension => ({ filenamePattern: `*.${extension}` })),
3120
+ priority: 'default'
3121
+ }))
3122
+ }
3123
+ : {}),
3124
+ ...vscodeLanguageContributions(composition)
3125
+ };
3126
+ const typecheck = [...(diagrams.length > 0 ? ['tsconfig.webview.json'] : []), 'tsconfig.e2e.json']
3127
+ .map(project => `tsc --noEmit -p ${project}`)
3128
+ .join(' && ');
3129
+ return `{
3130
+ "name": "${memberPackageName(composition, 'host-vscode')}",
3131
+ "displayName": "${composition.name}",
3132
+ "version": "0.0.0",
3133
+ "private": true,
3134
+ "description": "${composition.name} in VS Code: ${hostVscodeParts(composition)}.",
3135
+ "license": "UNLICENSED",
3136
+ "author": "",
3137
+ "publisher": "${composition.projectId}",
3138
+ "main": "./out/extension.js",
3139
+ "scripts": {
3140
+ "build": "npm run copy:syntaxes && npm run typecheck && tsc${diagrams.length > 0 ? ' && node esbuild.mjs' : ''}",
3141
+ "clean": "rimraf out syntaxes tsconfig.tsbuildinfo test-results playwright-report",
3142
+ "copy:syntaxes": "${COPY_SYNTAXES}",
3143
+ ${lintScript(forMember(composition, 'host-vscode'), 'src test')} "start": "node start.mjs",
3144
+ "test:e2e": "playwright test --tsconfig tsconfig.e2e.json",
3145
+ "test:e2e:install": "node --experimental-strip-types test/install-vscode.mts",
3146
+ "typecheck": "${typecheck}",
3147
+ "watch": "concurrently --kill-others --names ${watchNames} ${watchCommands}",
3148
+ "watch:syntaxes": "${WATCH_SYNTAXES}"
3149
+ },
3150
+ "dependencies": {
3151
+ ${sortedBlock(dependencies, ' ')}
3152
+ },
3153
+ "devDependencies": {
3154
+ ${sortedBlock(devDependencies, ' ')}
3155
+ },
3156
+ "contributes": ${jsonAt(contributes, ' ')},
3157
+ "activationEvents": ${jsonAt(composition.grammars.map(grammar => `onLanguage:${grammar.languageId}`), ' ')},
3158
+ "engines": {
3159
+ "vscode": "${VSCODE_ENGINE}"
3160
+ }
3161
+ }
3162
+ `;
3163
+ }
3164
+ /** What the VS Code extension brings, as one phrase; `started` follows the language client. */
3165
+ function hostVscodeParts(composition, started = '') {
3166
+ const parts = [
3167
+ `the language client${started}`,
3168
+ ...(composition.heads.includes('data') ? ['a Show Model command reading a file through the data head'] : []),
3169
+ ...(composition.grammars.some(grammar => grammar.diagram) ? ['a custom editor for each diagram'] : [])
3170
+ ];
3171
+ return listPhrase(parts);
3172
+ }
3173
+ /** `parts` as one English list: `a`, `a and b`, `a, b, and c`. */
3174
+ function listPhrase(parts) {
3175
+ return parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')}${parts.length > 2 ? ',' : ''} and ${parts.at(-1)}`;
3176
+ }
3177
+ /**
3178
+ * The VS Code extension's entry: the shared server launch, then the Show Model
3179
+ * command over the data head and a diagram editor per diagram grammar.
3180
+ */
3181
+ function hostVscodeExtensionFile(composition) {
3182
+ const columns = columnsFor(composition);
3183
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
3184
+ const data = composition.heads.includes('data');
3185
+ const start = `start${composition.name}LanguageClient`;
3186
+ const servers = `${memberPackageName(composition, 'vscode-servers')}/out/language-client.js`;
3187
+ const upper = screamingSnake(composition.projectId);
3188
+ const editors = diagrams.map(grammar => ({
3189
+ module: `./${grammar.grammarId}-diagram-editor.js`,
3190
+ viewType: `${screamingSnake(grammar.grammarId)}_DIAGRAM_VIEW_TYPE`,
3191
+ provider: `${grammar.grammar}DiagramEditorProvider`,
3192
+ local: `${grammar.grammar.charAt(0).toLowerCase()}${grammar.grammar.slice(1)}Editor`
3193
+ }));
3194
+ const registrations = editors.map(editor => ` const ${editor.local} = new ${editor.provider}(glspConnector, context.extensionUri);
3195
+ context.subscriptions.push(
3196
+ window.registerCustomEditorProvider(${editor.viewType}, ${editor.local}, {
3197
+ // A hidden tab keeps its diagram session rather than repeating the handshake.
3198
+ webviewOptions: { retainContextWhenHidden: true },
3199
+ // Two views of one document would be two GLSP clients racing on one model.
3200
+ supportsMultipleEditorsPerDocument: false
3201
+ })
3202
+ );`);
3203
+ const sockets = data || diagrams.length > 0;
3204
+ const protocolSymbols = [
3205
+ ...(data ? [`${upper}_DATA_SERVER_PORT_COMMAND`] : []),
3206
+ ...(diagrams.length > 0 ? [`${upper}_GLSP_SERVER_PORT_COMMAND`] : []),
3207
+ ...(data ? [`type ${composition.name}Element`] : [])
3208
+ ];
3209
+ const imports = [
3210
+ ...(sockets ? ["import { findPort } from '@hydranium/client-vscode/node';"] : []),
3211
+ ...(data ? ["import { SocketDataPort } from '@hydranium/data-client-vscode/node';"] : []),
3212
+ ...(diagrams.length > 0
3213
+ ? ["import { HydraniumGlspVscodeConnector, HydraniumGlspVscodeServer } from '@hydranium/glsp-client-vscode/node';"]
3214
+ : []),
3215
+ ...(sockets
3216
+ ? [
3217
+ importList([
3218
+ 'ConsoleLogger',
3219
+ ...(data ? ['DataConnectionWithEvents'] : []),
3220
+ ...(diagrams.length > 0 ? ['describeError'] : []),
3221
+ ...(data ? ['renderFrameworkMessage'] : [])
3222
+ ], '@hydranium/protocol', columns)
3223
+ ]
3224
+ : []),
3225
+ ...(sockets ? [importList(protocolSymbols, memberPackageName(composition, 'protocol'), columns)] : []),
3226
+ ...(sockets
3227
+ ? [`import { ${[...(data ? ['commands'] : []), 'type ExtensionContext', 'window'].join(', ')} } from 'vscode';`]
3228
+ : ["import type { ExtensionContext } from 'vscode';"]),
3229
+ "import type { LanguageClient } from 'vscode-languageclient/node';",
3230
+ `import { ${start} } from '${servers}';`,
3231
+ ...editors.map(editor => importList([editor.viewType, editor.provider], editor.module, columns)),
3232
+ ...(data ? ["import { showModelCommand } from './show-model-command.js';"] : [])
3233
+ ];
3234
+ const dataBlock = data
3235
+ ? `
3236
+
3237
+ // The data head, read by the Show Model command. Nothing connects until it runs.
3238
+ const dataPort = new SocketDataPort({
3239
+ findPort: () => findPort(running, ${upper}_DATA_SERVER_PORT_COMMAND),
3240
+ logger: new ConsoleLogger().with('data head'),
3241
+ // A failed read shown to the user, not only logged; the message carries the detail.
3242
+ reportError: (_error, reported) => void window.showErrorMessage(renderFrameworkMessage(reported))
3243
+ });
3244
+ const dataConnection = new DataConnectionWithEvents<${composition.name}Element>(dataPort);
3245
+ context.subscriptions.push(dataConnection, dataPort, commands.registerCommand('${composition.projectId}.showModel', showModelCommand(dataConnection)));`
3246
+ : '';
3247
+ const glspBlock = diagrams.length > 0
3248
+ ? `
3249
+
3250
+ // The GLSP head, and the connector bridging it to every diagram webview. No
3251
+ // \`messenger\` option: the connector's own keeps messages flowing to a hidden tab.
3252
+ const glspServer = new HydraniumGlspVscodeServer({
3253
+ clientId: '${composition.projectId}-glsp',
3254
+ clientName: '${composition.name}',
3255
+ findPort: () => findPort(running, ${upper}_GLSP_SERVER_PORT_COMMAND),
3256
+ logger: new ConsoleLogger().with('GLSP head')
3257
+ });
3258
+ const glspConnector = new HydraniumGlspVscodeConnector({ server: glspServer, logging: false });
3259
+ context.subscriptions.push(glspServer, glspConnector);
3260
+ // Not awaited, so a session that opens no diagram does not wait on the port.
3261
+ // \`start()\` resolves either way; a failure is reported off \`onReady\`.
3262
+ void glspServer.start();
3263
+ void glspServer.onReady.catch((error: unknown) => {
3264
+ void window.showErrorMessage(\`${composition.name} diagram unavailable: \${describeError(error)}\`);
3265
+ });
3266
+ ${registrations.join('\n')}`
3267
+ : '';
3268
+ const opening = wrapComment(`The ${composition.name} VS Code extension: ${hostVscodeParts(composition, ', started the way the servers extension starts it')}.${sockets ? ' Node extension host only: the server is forked and its socket heads reached over TCP.' : ''}`);
3269
+ const activation = sockets
3270
+ ? ` const running = await ${start}(context);
3271
+ client = running;${dataBlock}${glspBlock}`
3272
+ : ` client = await ${start}(context);`;
3273
+ return `${opening}
3274
+
3275
+ ${imports.join('\n')}
3276
+
3277
+ let client: LanguageClient | undefined;
3278
+
3279
+ export async function activate(context: ExtensionContext): Promise<void> {
3280
+ ${activation}
3281
+ }
3282
+
3283
+ export async function deactivate(): Promise<void> {
3284
+ await client?.stop();
3285
+ client = undefined;
3286
+ }
3287
+ `;
3288
+ }
3289
+ /** A smoke test's title: what it checks of each head the composition has. */
3290
+ function hostVscodeSmokeTitle(composition) {
3291
+ return listPhrase([
3292
+ ...(composition.heads.includes('data') ? ['reads the model'] : []),
3293
+ ...(composition.grammars.some(grammar => grammar.diagram) ? ['draws the diagram'] : []),
3294
+ 'reports a broken reference'
3295
+ ]);
3296
+ }
3297
+ /**
3298
+ * The smoke test: VS Code opens the starter workspace with the extension
3299
+ * loaded, Show Model names the model with the data head, each diagram file
3300
+ * draws, and the server reports a broken reference.
3301
+ */
3302
+ function hostVscodeSmokeSpec(composition) {
3303
+ const diagram = composition.grammars.find(grammar => grammar.diagram);
3304
+ const file = `nodes.${(diagram ?? composition.grammars[0]).extensions[0]}`;
3305
+ const diagramSteps = diagram
3306
+ ? `
3307
+ // The diagram file opens in its diagram, which draws the two starter nodes.
3308
+ const webview = page.frameLocator('iframe.webview.ready').frameLocator('#active-frame');
3309
+ // In the editor's container: sprotty measures shapes in a hidden copy beside it.
3310
+ await expect(webview.locator('.hydranium-diagram-container .sprotty-node')).toHaveCount(2);
3311
+ // Then as text. The row is clicked, since Enter runs the best fuzzy match,
3312
+ // and its name ends in its keybinding, if it has one.
3313
+ await page.keyboard.press('ControlOrMeta+Shift+P');
3314
+ await page.locator('.quick-input-widget input').fill('>View: Reopen Editor with Text Editor');
3315
+ await page
3316
+ .locator('.quick-input-list')
3317
+ .getByRole('option', { name: /^View: Reopen Editor with Text Editor(,|$)/ })
3318
+ .click();
3319
+ `
3320
+ : '';
3321
+ const primary = diagram ?? composition.grammars[0];
3322
+ const showModelSteps = composition.heads.includes('data')
3323
+ ? ` // The data head reads the open file.
3324
+ await page.keyboard.press('ControlOrMeta+Shift+P');
3325
+ await page.locator('.quick-input-widget input').fill('>${composition.name}: Show Model');
3326
+ await page
3327
+ .locator('.quick-input-list')
3328
+ .getByRole('option', { name: /^${composition.name}: Show Model(,|$)/ })
3329
+ .click();
3330
+ await expect(page.locator('.notification-toast').filter({ hasText: '${file} holds a ${primary.entryRule}' })).toBeVisible();
3331
+ `
3332
+ : '';
3333
+ const opening = wrapComment(`VS Code opens a copy of the starter workspace with this extension loaded, ${listPhrase([
3334
+ ...(composition.heads.includes('data') ? ['the data head reads the file'] : []),
3335
+ ...(diagram ? ['the diagram file draws in its diagram editor'] : []),
3336
+ 'the server reports a reference that resolves to nothing'
3337
+ ])}.`);
3338
+ return `${opening}
3339
+
3340
+ import { _electron, expect, test, type ElectronApplication, type Page } from '@playwright/test';
3341
+ import { downloadAndUnzipVSCode } from '@vscode/test-electron';
3342
+ import { cpSync, mkdtempSync, rmSync } from 'node:fs';
3343
+ import { tmpdir } from 'node:os';
3344
+ import { join } from 'node:path';
3345
+ import { fileURLToPath } from 'node:url';
3346
+ import { VSCODE_CACHE, VSCODE_VERSION } from './vscode-version.mts';
3347
+
3348
+ const extensionRoot = fileURLToPath(new URL('..', import.meta.url));
3349
+
3350
+ test('${hostVscodeSmokeTitle(composition)}', async () => {
3351
+ // A copy, since the server writes its repairs to the folder it opens, and a
3352
+ // fresh profile, since a reused one restores the last run's editors.
3353
+ const scratch = mkdtempSync(join(tmpdir(), '${composition.projectId}-vscode-'));
3354
+ const workspace = join(scratch, 'workspace');
3355
+ cpSync(fileURLToPath(new URL('../../workspace', import.meta.url)), workspace, { recursive: true });
3356
+ let app: ElectronApplication | undefined;
3357
+ try {
3358
+ app = await _electron.launch({
3359
+ executablePath: await downloadAndUnzipVSCode({ version: VSCODE_VERSION, cachePath: VSCODE_CACHE }),
3360
+ args: [
3361
+ '--no-sandbox',
3362
+ '--disable-gpu-sandbox',
3363
+ '--disable-updates',
3364
+ '--skip-welcome',
3365
+ '--skip-release-notes',
3366
+ '--disable-workspace-trust',
3367
+ '--disable-extensions',
3368
+ \`--extensionDevelopmentPath=\${extensionRoot}\`,
3369
+ \`--user-data-dir=\${join(scratch, 'user-data')}\`,
3370
+ \`--extensions-dir=\${join(scratch, 'extensions')}\`,
3371
+ workspace
3372
+ ]
3373
+ });
3374
+ const page = await app.firstWindow();
3375
+ await expect(page.locator('.monaco-workbench')).toBeVisible();
3376
+ await quickOpen(page, '${file}');
3377
+ ${showModelSteps}${diagramSteps}
3378
+ const editor = page.locator('.monaco-editor .view-lines').first();
3379
+ await expect(editor).toContainText('node First');
3380
+ await editor.click();
3381
+ await page.keyboard.press('ControlOrMeta+End');
3382
+ // In one edit: typed key by key, the half-typed reference is reported on the way.
3383
+ await page.keyboard.insertText('\\nnode Broken -> Missing');
3384
+ await expect(page.locator('.monaco-editor .squiggly-error').first()).toBeVisible();
3385
+ } finally {
3386
+ await app?.close();
3387
+ rmSync(scratch, { recursive: true, force: true });
3388
+ }
3389
+ });
3390
+
3391
+ /** Open \`file\` through Quick Open, so it gets the editor a user would. */
3392
+ async function quickOpen(page: Page, file: string): Promise<void> {
3393
+ await page.keyboard.press('ControlOrMeta+P');
3394
+ const input = page.locator('.quick-input-widget input');
3395
+ await input.fill(file);
3396
+ await expect(page.locator('.quick-input-list .monaco-list-row').first()).toContainText(file);
3397
+ await input.press('Enter');
3398
+ await expect(page.locator('.quick-input-widget')).toBeHidden();
3399
+ }
3400
+ `;
3401
+ }
3402
+ /**
3403
+ * `text` broken at spaces into lines of at most 80 columns, the width
3404
+ * `check:docs` holds Markdown to. A link or code span stays whole, and runs
3405
+ * long on a line of its own.
3406
+ */
3407
+ function wrapProse(text, width = 80) {
3408
+ const lines = [];
3409
+ for (const piece of text.match(/!?\[[^\]]*\]\([^)]*\)\S*|`+[^`]*`+\S*|\S+/g) ?? []) {
3410
+ const last = lines.at(-1);
3411
+ if (last !== undefined && last.length + 1 + piece.length <= width) {
3412
+ lines[lines.length - 1] = `${last} ${piece}`;
3413
+ }
3414
+ else {
3415
+ lines.push(piece);
3416
+ }
3417
+ }
3418
+ return lines;
3419
+ }
3420
+ /** `text` as a `//` comment of lines at most 80 columns wide. */
3421
+ function wrapComment(text) {
3422
+ return wrapProse(text, 77)
3423
+ .map(line => `// ${line}`)
3424
+ .join('\n');
3425
+ }
3426
+ /** The VS Code extension's README. */
3427
+ function hostVscodeReadme(composition) {
3428
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
3429
+ const data = composition.heads.includes('data');
3430
+ const intro = [
3431
+ diagrams.length > 0
3432
+ ? `${composition.name} in VS Code: the language client, and a custom editor drawing each diagram file.`
3433
+ : `${composition.name} in VS Code: the language client.`,
3434
+ 'The server is started by `../vscode-servers`, whose launch this extension calls, so the two never hand the server different documents.',
3435
+ socketHeads(composition).length > 0
3436
+ ? 'It needs a Node extension host: the server is forked, and its socket heads are reached over TCP.'
3437
+ : 'It needs a Node extension host, since the server is forked.'
3438
+ ];
3439
+ const dataNote = `\`${composition.name}: Show Model\`, in the Command Palette, reads the active file through the data head over a socket from the extension host, and names its root type. A webview reaches the data head too: see the Host in VS Code guide.`;
3440
+ const lines = [
3441
+ `# ${memberPackageName(composition, 'host-vscode')}`,
3442
+ '',
3443
+ ...wrapProse(intro.join(' ')),
3444
+ ...(data ? ['', ...wrapProse(dataNote)] : []),
3445
+ '',
3446
+ '```bash',
3447
+ `npm run build # copy the server’s grammars, type-check and compile${diagrams.length > 0 ? ', then bundle' : ''}`,
3448
+ `npm run watch # copy the grammars and recompile${diagrams.length > 0 ? ' and rebundle' : ''} on every change; Developer: Reload Window picks it up`,
3449
+ 'npm start # VS Code with this extension, on ../workspace; needs `code` on the PATH',
3450
+ 'npm run test:e2e:install # download the VS Code the smoke test drives, once',
3451
+ 'npm run test:e2e # the smoke test; under `xvfb-run -a` on Linux without a display',
3452
+ '```',
3453
+ '',
3454
+ '## Layout',
3455
+ '',
3456
+ `- \`src/extension.ts\` — activation: ${listPhrase([
3457
+ 'the server, through `../vscode-servers`',
3458
+ ...(data ? ['the Show Model command'] : []),
3459
+ ...(diagrams.length > 0 ? ['the diagram editors'] : [])
3460
+ ])}`,
3461
+ ...(data ? ['- `src/show-model-command.ts` — Show Model: the active file, read through the data head'] : []),
3462
+ ...diagrams.flatMap(grammar => [
3463
+ `- \`src/${grammar.grammarId}-diagram-editor.ts\` — the \`${grammar.grammar}\` diagram’s custom editor; its view type is the one \`package.json\` declares`,
3464
+ `- \`src/webview/${grammar.grammarId}-diagram.ts\` — the webview it loads, over the client package’s diagram`
3465
+ ]),
3466
+ ...(diagrams.length > 0
3467
+ ? ['- `src/webview/diagram.css` — the webview’s size and colours', '- `esbuild.mjs` — bundles each webview into `out/webview/`']
3468
+ : []),
3469
+ '- `start.mjs` — `npm start`: VS Code with this extension, on `../workspace`',
3470
+ '- `test/smoke.spec.mts` — VS Code opens the workspace, ' +
3471
+ (composition.heads.includes('data') ? 'Show Model names the model, ' : '') +
3472
+ (diagrams.length > 0 ? 'the diagram draws, ' : '') +
3473
+ 'and the server reports a broken reference',
3474
+ '- `test/virtual-documents.spec.mts` — the generated launcher serves a',
3475
+ ' content document in the extension host without sending its text back',
3476
+ '- `test/virtual-documents.cjs` and `test/content-server.cjs` — the host',
3477
+ ' assertions and the content-owning LSP fixture they drive',
3478
+ '- `test/vscode-version.mts` — the VS Code release the smoke tests run',
3479
+ ''
3480
+ ];
3481
+ return project(lines.join('\n'), composition);
3482
+ }
3483
+ /** The VS Code extension: the language client and a diagram editor per diagram grammar. */
3484
+ function hostVscodeFiles(composition) {
3485
+ const fixed = (file) => projectTemplate(`host-vscode/${file}`, composition);
3486
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
3487
+ return [
3488
+ { path: 'host-vscode/package.json', content: hostVscodePackageJson(composition) },
3489
+ ignoreFile('host-vscode'),
3490
+ { path: 'host-vscode/README.md', content: hostVscodeReadme(composition) },
3491
+ fixed('tsconfig.json'),
3492
+ fixed('tsconfig.e2e.json'),
3493
+ fixed('playwright.config.mts'),
3494
+ fixed('start.mjs'),
3495
+ { path: 'host-vscode/src/extension.ts', content: hostVscodeExtensionFile(composition) },
3496
+ ...(composition.heads.includes('data') ? [fixed('src/show-model-command.ts')] : []),
3497
+ ...(diagrams.length > 0 ? [fixed('tsconfig.webview.json'), fixed('esbuild.mjs'), fixed('src/webview/diagram.css')] : []),
3498
+ ...diagrams.flatMap(grammar => [
3499
+ grammarTemplate('host-vscode/src/__GRAMMAR_ID__-diagram-editor.ts', composition, grammar),
3500
+ grammarTemplate('host-vscode/src/webview/__GRAMMAR_ID__-diagram.ts', composition, grammar)
3501
+ ]),
3502
+ { path: 'host-vscode/test/smoke.spec.mts', content: hostVscodeSmokeSpec(composition) },
3503
+ fixed('test/vscode-version.mts'),
3504
+ fixed('test/install-vscode.mts'),
3505
+ { path: 'host-vscode/test/virtual-documents.cjs', content: hostVscodeVirtualTest(composition) },
3506
+ fixed('test/content-server.cjs'),
3507
+ fixed('test/virtual-documents.spec.mts')
3508
+ ];
3509
+ }
3510
+ /**
3511
+ * The Theia release of every Theia member, the one the framework's own
3512
+ * Theia examples take, so a scaffold builds against what the repo tests: the
3513
+ * Theia community release. Exact, as Theia's members pin each other, so a
3514
+ * workspace root declares it too and GLSP's open Theia peer resolves to it.
3515
+ */
3516
+ const THEIA_VERSION = '1.74.3';
3517
+ /**
3518
+ * What each head adds to the Theia extension; `base` is the connection hardening
3519
+ * and editor sync every one gets. With the third-party entries below, they name
3520
+ * the peers of the framework's Theia packages, so an installer that adds no peers
3521
+ * still finds them.
3522
+ */
3523
+ const THEIA_FRAMEWORK_DEPENDENCIES = {
3524
+ base: ['@hydranium/client-theia', '@hydranium/protocol'],
3525
+ lsp: [],
3526
+ data: ['@hydranium/data-client-theia'],
3527
+ glsp: ['@hydranium/glsp-client', '@hydranium/glsp-client-theia']
3528
+ };
3529
+ const THEIA_THIRD_PARTY_DEPENDENCIES = {
3530
+ base: {
3531
+ '@theia/core': THEIA_VERSION,
3532
+ '@theia/editor': THEIA_VERSION,
3533
+ '@theia/filesystem': THEIA_VERSION,
3534
+ '@theia/output': THEIA_VERSION,
3535
+ inversify: '^6.1.3',
3536
+ 'vscode-jsonrpc': '9.0.3'
3537
+ },
3538
+ lsp: {},
3539
+ data: { '@theia/workspace': THEIA_VERSION },
3540
+ glsp: {
3541
+ '@eclipse-glsp/client': '2.8.0',
3542
+ '@eclipse-glsp/theia-integration': '2.8.0',
3543
+ '@theia/process': THEIA_VERSION,
3544
+ '@theia/workspace': THEIA_VERSION
3545
+ }
3546
+ };
3547
+ /** The Theia extension's third-party pins for `heads`, for its manifest and a workspace root's. */
3548
+ function theiaThirdPartyDependencies(heads) {
3549
+ return ['base', ...heads].flatMap(head => Object.entries(THEIA_THIRD_PARTY_DEPENDENCIES[head]));
3550
+ }
3551
+ /**
3552
+ * The Theia packages the application assembles: the workbench a user expects,
3553
+ * and `plugin-ext` with its VS Code API to sideload the servers extension.
3554
+ */
3555
+ const HOST_THEIA_BROWSER_DEPENDENCIES = [
3556
+ '@theia/core',
3557
+ '@theia/editor',
3558
+ '@theia/filesystem',
3559
+ '@theia/markers',
3560
+ '@theia/messages',
3561
+ '@theia/monaco',
3562
+ '@theia/navigator',
3563
+ '@theia/output',
3564
+ '@theia/plugin-ext',
3565
+ '@theia/plugin-ext-vscode',
3566
+ '@theia/preferences',
3567
+ '@theia/process',
3568
+ '@theia/workspace'
3569
+ ];
3570
+ /** The Theia tooling the application builds with. */
3571
+ const HOST_THEIA_BROWSER_DEV_DEPENDENCIES = ['@theia/cli'];
3572
+ /** Every Theia package the application declares, for a workspace root's pins. */
3573
+ function hostTheiaBrowserTheiaDependencies() {
3574
+ return [...HOST_THEIA_BROWSER_DEPENDENCIES, ...HOST_THEIA_BROWSER_DEV_DEPENDENCIES].map(name => [name, THEIA_VERSION]);
3575
+ }
3576
+ /** The Electron application's Theia packages: the browser application's, and Theia's Electron integration. */
3577
+ const HOST_THEIA_ELECTRON_DEPENDENCIES = [...HOST_THEIA_BROWSER_DEPENDENCIES, '@theia/electron'].sort();
3578
+ /** The Electron release `@theia/electron` peers on, exactly. */
3579
+ const ELECTRON_VERSION = '42.11.16';
3580
+ /** Every package the Electron application pins, for a workspace root's pins. */
3581
+ function hostTheiaElectronPins() {
3582
+ return [
3583
+ ...[...HOST_THEIA_ELECTRON_DEPENDENCIES, ...HOST_THEIA_BROWSER_DEV_DEPENDENCIES].map((name) => [
3584
+ name,
3585
+ THEIA_VERSION
3586
+ ]),
3587
+ ['electron', ELECTRON_VERSION]
3588
+ ];
3589
+ }
3590
+ /** The Theia extension's `theiaExtensions` entries: one per head it forwards, and one per diagram. */
3591
+ function theiaExtensionEntries(composition) {
3592
+ const id = composition.projectId;
3593
+ return [
3594
+ {
3595
+ frontendPreload: `lib/browser/${id}-connection-preload-module`,
3596
+ frontend: `lib/browser/${id}-frontend-module`,
3597
+ backend: `lib/node/${id}-connection-backend-module`
3598
+ },
3599
+ ...(composition.heads.includes('data') ? [{ backend: `lib/node/${id}-data-backend-module` }] : []),
3600
+ ...(composition.heads.includes('glsp') ? [{ backend: `lib/node/${id}-glsp-backend-module` }] : []),
3601
+ ...composition.grammars
3602
+ .filter(grammar => grammar.diagram)
3603
+ .map(grammar => ({ frontend: `lib/browser/${grammar.grammarId}-diagram-frontend-module` }))
3604
+ ];
3605
+ }
3606
+ /** The Theia extension's `package.json`. Private unless `--public`, as the client member is. */
3607
+ function theiaPackageJson(composition) {
3608
+ const dependencies = {
3609
+ // The socket heads' host commands, and the data head's model type.
3610
+ ...(socketHeads(composition).length > 0 ? { [memberPackageName(composition, 'protocol')]: MEMBER_PIN } : {}),
3611
+ ...(composition.heads.includes('glsp') ? { [memberPackageName(composition, 'client')]: MEMBER_PIN } : {}),
3612
+ ...Object.fromEntries(theiaThirdPartyDependencies(composition.heads))
3613
+ };
3614
+ for (const head of ['base', ...composition.heads]) {
3615
+ for (const framework of THEIA_FRAMEWORK_DEPENDENCIES[head]) {
3616
+ dependencies[framework] = frameworkPin(composition);
3617
+ }
3618
+ }
3619
+ return `{
3620
+ "name": "${memberPackageName(composition, 'theia')}",
3621
+ "version": "0.0.0",
3622
+ ${composition.packaging.private ? ' "private": true,\n' : ''} "description": "The ${composition.name} Theia extension: ${theiaParts(composition)}.",
3623
+ "license": "UNLICENSED",
3624
+ "author": "",
3625
+ "files": [
3626
+ "lib",
3627
+ "src",
3628
+ "style"
3629
+ ],
3630
+ "scripts": {
3631
+ "build": "tsc",
3632
+ "clean": "rimraf lib tsconfig.tsbuildinfo",
3633
+ ${lintScript(forMember(composition, 'theia'), 'src')} "watch": "tsc --watch"
3634
+ },
3635
+ "dependencies": {
3636
+ ${sortedBlock(dependencies, ' ')}
3637
+ },
3638
+ "devDependencies": {
3639
+ ${sortedBlock({ '@types/node': '^22.0.0', rimraf: '^5.0.0', typescript: '^7.0.2' }, ' ')}
3640
+ },
3641
+ "engines": {
3642
+ "node": ">=22.13"
3643
+ },
3644
+ "theiaExtensions": ${jsonAt(theiaExtensionEntries(composition), ' ')}
3645
+ }
3646
+ `;
3647
+ }
3648
+ /** What the Theia extension brings, as one phrase. */
3649
+ function theiaParts(composition) {
3650
+ return listPhrase([
3651
+ 'reconnect hardening',
3652
+ ...(composition.heads.includes('data') ? ['a Show Model command reading a file through the data head'] : []),
3653
+ ...(composition.grammars.some(grammar => grammar.diagram) ? ['a diagram editor for each diagram'] : [])
3654
+ ]);
3655
+ }
3656
+ /** The extension's one frontend module: the logger, the connection diagnostics and editor sync, and each head's client. */
3657
+ function theiaFrontendModuleFile(composition) {
3658
+ const id = composition.projectId;
3659
+ const name = composition.name;
3660
+ const data = composition.heads.includes('data');
3661
+ const glsp = composition.heads.includes('glsp');
3662
+ const imports = [
3663
+ ...(glsp ? ["import { GLSPClientContribution } from '@eclipse-glsp/theia-integration';"] : []),
3664
+ importList(['bindChannelLogger', 'bindConnectionDiagnostics', 'bindEditorDiskSync', 'bindReadOnlyContentResources'], '@hydranium/client-theia/browser', columnsFor(composition)),
3665
+ ...(data
3666
+ ? [
3667
+ "import { bindDataConnection } from '@hydranium/data-client-theia/browser';",
3668
+ "import { CommandContribution } from '@theia/core';"
3669
+ ]
3670
+ : []),
3671
+ "import { ContainerModule } from '@theia/core/shared/inversify';",
3672
+ ...(data
3673
+ ? [
3674
+ `import { ${name}DataConnection } from './${id}-data-connection.js';`,
3675
+ `import { ${name}DataPort } from './${id}-data-port.js';`
3676
+ ]
3677
+ : []),
3678
+ ...(glsp ? [`import { ${name}GlspClientContribution } from './${id}-glsp-client-contribution.js';`] : []),
3679
+ ...(data ? [`import { ${name}ShowModelCommand } from './${id}-show-model-command.js';`] : [])
3680
+ ];
3681
+ const dataBlock = data
3682
+ ? `
3683
+
3684
+ // The data head: one connection, opened when the command first runs.
3685
+ bind(${name}DataPort).toSelf().inSingletonScope();
3686
+ bindDataConnection(bind, isBound, ${name}DataConnection);
3687
+ bind(${name}ShowModelCommand).toSelf().inSingletonScope();
3688
+ bind(CommandContribution).toService(${name}ShowModelCommand);`
3689
+ : '';
3690
+ const glspBlock = glsp
3691
+ ? `
3692
+
3693
+ // One GLSP client for every diagram, so each diagram module skips binding its own.
3694
+ bind(${name}GlspClientContribution).toSelf().inSingletonScope();
3695
+ bind(GLSPClientContribution).toService(${name}GlspClientContribution);`
3696
+ : '';
3697
+ return `${wrapComment(`The ${name} Theia extension's frontend: ${listPhrase([
3698
+ 'the connection’s lifecycle in an Output channel',
3699
+ 'a warning when an outage outlasts the offline buffer',
3700
+ 'editor disk sync',
3701
+ ...(data ? ['the data head’s connection and the Show Model command'] : []),
3702
+ ...(glsp ? ['the GLSP client the diagrams share'] : [])
3703
+ ])}.`)}
3704
+
3705
+ ${imports.join('\n')}
3706
+
3707
+ export default new ContainerModule((bind, _unbind, isBound, rebind) => {
3708
+ bindChannelLogger(bind, { channelName: '${name} Connection' });
3709
+ bindConnectionDiagnostics(bind);
3710
+ // Keeps an editor from writing over a file a head saved while it held the same text unsaved.
3711
+ bindEditorDiskSync(bind, isBound, rebind);
3712
+ bindReadOnlyContentResources(rebind);${dataBlock}${glspBlock}
3713
+ });
3714
+ `;
3715
+ }
3716
+ /** The Theia extension's README. */
3717
+ function theiaReadme(composition) {
3718
+ const id = composition.projectId;
3719
+ const data = composition.heads.includes('data');
3720
+ const glsp = composition.heads.includes('glsp');
3721
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
3722
+ const theiaApps = theiaApplicationPaths(composition);
3723
+ const intro = [
3724
+ `The ${composition.name} Theia extension: ${theiaParts(composition)}.`,
3725
+ socketHeads(composition).length > 0
3726
+ ? 'The language client is not here: the application sideloads `../vscode-servers`, which starts the server and answers each socket head’s port under a host command, and the backend modules forward the page’s channels to those ports.'
3727
+ : 'The language client is not here: the application sideloads `../vscode-servers`, which starts the server.',
3728
+ theiaApps.length === 1 ? `The application in ${theiaApps[0]} runs it.` : `The applications in ${listPhrase(theiaApps)} run it.`
3729
+ ];
3730
+ const before = [...(socketHeads(composition).length > 0 ? ['protocol'] : []), ...(glsp ? ['client'] : [])];
3731
+ const lines = [
3732
+ `# ${memberPackageName(composition, 'theia')}`,
3733
+ '',
3734
+ ...wrapProse(intro.join(' ')),
3735
+ '',
3736
+ '```bash',
3737
+ 'npm run build # compile into lib/' +
3738
+ (before.length > 0 ? `; build the ${before.join(' and ')} package${before.length > 1 ? 's' : ''} first` : ''),
3739
+ '```',
3740
+ '',
3741
+ '## Layout',
3742
+ '',
3743
+ `- \`src/browser/${id}-connection-preload-module.ts\` and \`src/node/${id}-connection-backend-module.ts\` — reconnect hardening, sized by the application’s \`connectionBufferBytes\``,
3744
+ `- \`src/browser/${id}-frontend-module.ts\` — the frontend bindings`,
3745
+ ...(data
3746
+ ? [
3747
+ `- \`src/browser/${id}-data-port.ts\` and \`${id}-data-connection.ts\` — the data head’s connection`,
3748
+ `- \`src/browser/${id}-show-model-command.ts\` — \`${composition.name}: Show Model\`, reading the current file through the data head`,
3749
+ `- \`src/node/${id}-data-backend-module.ts\` — forwards the data channel to the data head’s port`
3750
+ ]
3751
+ : []),
3752
+ ...(glsp
3753
+ ? [
3754
+ `- \`src/common/${id}-glsp-contribution.ts\` — the id the GLSP client and its forwarder share`,
3755
+ `- \`src/browser/${id}-glsp-client-contribution.ts\` — the GLSP client every diagram shares`,
3756
+ `- \`src/node/${id}-glsp-backend-module.ts\` — forwards the GLSP channel to the GLSP head’s port`
3757
+ ]
3758
+ : []),
3759
+ ...diagrams.map(grammar => `- \`src/browser/${grammar.grammarId}-diagram-frontend-module.ts\` — the \`${grammar.grammar}\` diagram editor, over the client package’s diagram`),
3760
+ ...(diagrams.length > 0 ? ['- `style/diagram.css` — the diagrams’ colours, from the Theia theme'] : []),
3761
+ ''
3762
+ ];
3763
+ return project(lines.join('\n'), composition);
3764
+ }
3765
+ /** The Theia extension: what a Theia application runs of the language, beside the sideloaded servers extension. */
3766
+ function theiaFiles(composition) {
3767
+ const fixed = (file) => ({
3768
+ path: project(`theia/${file}`, composition),
3769
+ content: project(template(`theia/${file}`), composition)
3770
+ });
3771
+ const id = composition.projectId;
3772
+ const data = composition.heads.includes('data');
3773
+ const glsp = composition.heads.includes('glsp');
3774
+ const diagrams = composition.grammars.filter(grammar => grammar.diagram);
3775
+ return [
3776
+ { path: 'theia/package.json', content: theiaPackageJson(composition) },
3777
+ ignoreFile('theia'),
3778
+ { path: 'theia/README.md', content: theiaReadme(composition) },
3779
+ fixed('tsconfig.json'),
3780
+ fixed('src/browser/__PROJECT_ID__-connection-preload-module.ts'),
3781
+ { path: `theia/src/browser/${id}-frontend-module.ts`, content: theiaFrontendModuleFile(composition) },
3782
+ fixed('src/node/__PROJECT_ID__-connection-backend-module.ts'),
3783
+ ...(data
3784
+ ? [
3785
+ fixed('src/browser/__PROJECT_ID__-data-port.ts'),
3786
+ fixed('src/browser/__PROJECT_ID__-data-connection.ts'),
3787
+ fixed('src/browser/__PROJECT_ID__-show-model-command.ts'),
3788
+ fixed('src/node/__PROJECT_ID__-data-backend-module.ts')
3789
+ ]
3790
+ : []),
3791
+ ...(glsp
3792
+ ? [
3793
+ fixed('src/common/__PROJECT_ID__-glsp-contribution.ts'),
3794
+ fixed('src/browser/__PROJECT_ID__-glsp-client-contribution.ts'),
3795
+ fixed('src/node/__PROJECT_ID__-glsp-backend-module.ts')
3796
+ ]
3797
+ : []),
3798
+ ...diagrams.map(grammar => grammarTemplate('theia/src/browser/__GRAMMAR_ID__-diagram-frontend-module.ts', composition, grammar)),
3799
+ ...(diagrams.length > 0 ? [fixed('style/diagram.css')] : [])
3800
+ ];
3801
+ }
3802
+ /**
3803
+ * The Theia application's `package.json`. Always private, as every host is.
3804
+ *
3805
+ * Theia's default `frontendConnectionTimeout` of 0 ends the session on any
3806
+ * dropped socket, and the page reloads; a minute lets the reconnect hardening
3807
+ * resume it.
3808
+ */
3809
+ function hostTheiaBrowserPackageJson(composition) {
3810
+ const dependencies = {
3811
+ [memberPackageName(composition, 'theia')]: MEMBER_PIN,
3812
+ ...Object.fromEntries(HOST_THEIA_BROWSER_DEPENDENCIES.map(name => [name, THEIA_VERSION]))
3813
+ };
3814
+ const devDependencies = {
3815
+ // Built before this application, whose start links it into plugins/.
3816
+ [memberPackageName(composition, 'vscode-servers')]: MEMBER_PIN,
3817
+ '@playwright/test': '^1.55.0',
3818
+ ...Object.fromEntries(HOST_THEIA_BROWSER_DEV_DEPENDENCIES.map(name => [name, THEIA_VERSION])),
3819
+ '@types/node': '^22.0.0',
3820
+ rimraf: '^5.0.0',
3821
+ typescript: '^7.0.2'
3822
+ };
3823
+ const theia = {
3824
+ target: 'browser',
3825
+ frontend: {
3826
+ config: {
3827
+ applicationName: composition.name,
3828
+ connectionBufferBytes: 102400,
3829
+ preferences: { 'security.workspace.trust.enabled': false }
3830
+ }
3831
+ },
3832
+ backend: { config: { connectionBufferBytes: 102400, frontendConnectionTimeout: 60000 } }
3833
+ };
3834
+ return `{
3835
+ "name": "${memberPackageName(composition, 'host-theia-browser')}",
3836
+ "version": "0.0.0",
3837
+ "private": true,
3838
+ "description": "${composition.name} in a Theia browser application: the Theia extension, and the servers extension sideloaded for the language client.",
3839
+ "license": "UNLICENSED",
3840
+ "author": "",
3841
+ "scripts": {
3842
+ "build": "theia build --mode development && npm run typecheck",
3843
+ "clean": "rimraf lib src-gen plugins gen-esbuild.browser.mjs gen-esbuild.node.mjs smoke-workspace test-results playwright-report",
3844
+ ${lintScript(forMember(composition, 'host-theia-browser'), 'test')} "start": "node start.mjs",
3845
+ "test:e2e": "playwright test --tsconfig tsconfig.e2e.json",
3846
+ "test:e2e:install": "playwright install chromium",
3847
+ "typecheck": "tsc --noEmit -p tsconfig.e2e.json",
3848
+ "watch": "theia build --watch --mode development"
3849
+ },
3850
+ "dependencies": {
3851
+ ${sortedBlock(dependencies, ' ')}
3852
+ },
3853
+ "devDependencies": {
3854
+ ${sortedBlock(devDependencies, ' ')}
3855
+ },
3856
+ "engines": {
3857
+ "node": ">=22.13"
3858
+ },
3859
+ "theia": ${jsonAt(theia, ' ')}
3860
+ }
3861
+ `;
3862
+ }
3863
+ /**
3864
+ * The Theia smoke test: the application opens a copy of the starter
3865
+ * workspace, and each head answers. The steps are the same in both
3866
+ * applications; the browser one's page is Chromium on the server the
3867
+ * configuration starts, the Electron one's the window the test launches.
3868
+ */
3869
+ function theiaSmokeSpec(composition, target) {
3870
+ const data = composition.heads.includes('data');
3871
+ const diagram = composition.grammars.find(grammar => grammar.diagram);
3872
+ const primary = diagram ?? composition.grammars[0];
3873
+ const file = `nodes.${primary.extensions[0]}`;
3874
+ const showModelSteps = data
3875
+ ? `
3876
+ // The data head reads the open file, with focus in the Explorer beside it.
3877
+ await page.locator('.theia-FileTree .theia-TreeNode', { hasText: '${file}' }).click();
3878
+ await expect(page.locator('.theia-FileTree:focus-within')).toHaveCount(1);
3879
+ await runCommand(page, '${composition.name}: Show Model');
3880
+ // \`first()\`: Theia lists a notification in its toast and in the notification centre.
3881
+ await expect(
3882
+ page.locator('.theia-notification-message').filter({ hasText: '${file} holds a ${primary.entryRule}' }).first()
3883
+ ).toBeVisible();
3884
+ `
3885
+ : '';
3886
+ const diagramSteps = diagram
3887
+ ? `
3888
+ // The diagram file opens in its diagram, which draws the two starter nodes.
3889
+ // In the editor's copy: sprotty measures shapes in a hidden one on the page.
3890
+ await expect(page.locator('.sprotty:not(.sprotty-hidden) .sprotty-node')).toHaveCount(2);
3891
+ `
3892
+ : '';
3893
+ const textSteps = diagram
3894
+ ? ` // Then as text, through the Explorer's Open With.
3895
+ await page.locator('.theia-FileTree .theia-TreeNode', { hasText: '${file}' }).click({ button: 'right' });
3896
+ await page.locator('.lm-Menu-item', { hasText: 'Open With...' }).click();
3897
+ await page.locator('.quick-input-list .monaco-list-row', { hasText: 'Text Editor' }).click();
3898
+ `
3899
+ : '';
3900
+ return `${wrapComment(`Theia opens a copy of the starter workspace with this application's extensions, ${listPhrase([
3901
+ ...(diagram ? ['the diagram file draws in its diagram editor'] : []),
3902
+ ...(data ? ['the data head reads the file'] : []),
3903
+ 'the server reports a reference that resolves to nothing'
3904
+ ])}.`)}
3905
+
3906
+ ${target === 'browser' ? "import { expect, test, type Page } from '@playwright/test';\n" : theiaElectronSmokeFixture(composition)}
3907
+ test('${listPhrase([...(diagram ? ['draws the diagram'] : []), ...(data ? ['reads the model'] : []), 'reports a broken reference'])}', async ({ page }) => {
3908
+ ${target === 'browser' ? " await page.goto('/');\n" : ''} // Up once the loading screen is gone; a key pressed before then is lost.
3909
+ await expect(page.locator('.theia-preload')).toHaveCount(0);
3910
+ await page.keyboard.press('ControlOrMeta+Shift+E');
3911
+ await expect(page.locator('.theia-FileTree .theia-TreeNode', { hasText: '${file}' })).toBeVisible();
3912
+ await quickOpen(page, '${file}');
3913
+ ${diagramSteps}${showModelSteps}
3914
+ ${textSteps} const editor = page.locator('.monaco-editor .view-lines').first();
3915
+ await expect(editor).toContainText('node First');
3916
+ await editor.click();
3917
+ await page.keyboard.press('ControlOrMeta+End');
3918
+ // In one edit: typed key by key, the half-typed reference is reported on the way.
3919
+ await page.keyboard.insertText('\\nnode Broken -> Missing');
3920
+ await expect(page.locator('.monaco-editor .squiggly-error').first()).toBeVisible();
3921
+ });
3922
+
3923
+ /** Open \`file\` through Quick Open, so it gets the editor a user would. */
3924
+ async function quickOpen(page: Page, file: string): Promise<void> {
3925
+ await page.keyboard.press('ControlOrMeta+P');
3926
+ const input = page.locator('.quick-input-widget input');
3927
+ await input.fill(file);
3928
+ await expect(page.locator('.quick-input-list .monaco-list-row').first()).toContainText(file);
3929
+ await input.press('Enter');
3930
+ await expect(page.locator('.quick-input-widget')).toBeHidden();
3931
+ }
3932
+ ${data
3933
+ ? `
3934
+ /** Run the command whose palette entry reads \`label\`, checked before Enter, since the match is fuzzy. */
3935
+ async function runCommand(page: Page, label: string): Promise<void> {
3936
+ await page.keyboard.press('F1');
3937
+ const input = page.locator('.quick-input-widget input');
3938
+ await input.fill(\`>\${label}\`);
3939
+ await expect(page.locator('.quick-input-list .monaco-list-row.focused')).toContainText(label);
3940
+ await input.press('Enter');
3941
+ await expect(page.locator('.quick-input-widget')).toBeHidden();
3942
+ }
3943
+ `
3944
+ : ''}`;
3945
+ }
3946
+ /** The Theia application's README. */
3947
+ function hostTheiaBrowserReadme(composition) {
3948
+ const intro = [
3949
+ `${composition.name} in a Theia browser application: the Theia extension in \`../theia\`, and \`../vscode-servers\` sideloaded through \`@theia/plugin-ext\`, which starts the server and its language client.`,
3950
+ 'The backend runs the server, so this is a Node application; Theia serves the page.',
3951
+ ...(composition.grammars.some(grammar => grammar.diagram)
3952
+ ? ['The servers extension contributes no editor, so a diagram file opens in the Theia diagram editor alone.']
3953
+ : [])
3954
+ ];
3955
+ const lines = [
3956
+ `# ${memberPackageName(composition, 'host-theia-browser')}`,
3957
+ '',
3958
+ ...wrapProse(intro.join(' ')),
3959
+ '',
3960
+ '```bash',
3961
+ 'npm run build # bundle and type-check; build the members first',
3962
+ 'npm run watch # rebundle whenever the members’ builds change; reload the page to pick it up',
3963
+ 'npm start # Theia at http://localhost:3000 on ../workspace; THEIA_PORT picks another port',
3964
+ 'npm run test:e2e:install # download the Chromium the smoke test drives, once',
3965
+ 'npm run test:e2e # the smoke test, which starts the application itself',
3966
+ '```',
3967
+ '',
3968
+ ...wrapProse('The `theia` block of `package.json` sets `frontendConnectionTimeout`, without which Theia ends the session on any dropped socket and the page reloads, and `connectionBufferBytes`, the messages kept while the socket is down; the Theia extension’s connection modules read both.'),
3969
+ '',
3970
+ '## Layout',
3971
+ '',
3972
+ '- `esbuild.mjs` — the bundling `theia build` runs. It keeps this file, so it is the place to adjust the bundle',
3973
+ '- `start.mjs` — `npm start`: links `../vscode-servers` into `plugins/`, then starts the backend on `THEIA_PORT`, on a folder given as its argument or `../workspace`',
3974
+ '- `playwright.config.mts` — the smoke tier, on port 4938 or `THEIA_PORT`, on a copy of `../workspace` in `smoke-workspace/`',
3975
+ '- `test/smoke.spec.mts` — Theia opens the workspace and each head answers',
3976
+ ''
3977
+ ];
3978
+ return project(lines.join('\n'), composition);
3979
+ }
3980
+ /** The Theia browser application: the Theia extension and the sideloaded servers extension. */
3981
+ function hostTheiaBrowserFiles(composition) {
3982
+ const fixed = (file) => projectTemplate(`host-theia-browser/${file}`, composition);
3983
+ return [
3984
+ { path: 'host-theia-browser/package.json', content: hostTheiaBrowserPackageJson(composition) },
3985
+ ignoreFile('host-theia-browser'),
3986
+ { path: 'host-theia-browser/README.md', content: hostTheiaBrowserReadme(composition) },
3987
+ fixed('esbuild.mjs'),
3988
+ fixed('start.mjs'),
3989
+ fixed('playwright.config.mts'),
3990
+ fixed('tsconfig.e2e.json'),
3991
+ { path: 'host-theia-browser/test/smoke.spec.mts', content: theiaSmokeSpec(composition, 'browser') }
3992
+ ];
3993
+ }
3994
+ /**
3995
+ * The Electron smoke test's imports and its `page`: the application's window,
3996
+ * launched by the test on a scratch copy of the workspace.
3997
+ */
3998
+ function theiaElectronSmokeFixture(composition) {
3999
+ return `import { expect, test as base, type ElectronApplication, type Page } from '@playwright/test';
4000
+ import { once } from 'node:events';
4001
+ import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs';
4002
+ import { tmpdir } from 'node:os';
4003
+ import { join } from 'node:path';
4004
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4005
+
4006
+ /**
4007
+ * The application in Electron, on a copy of the starter workspace, since the
4008
+ * server writes its repairs to the folder it opens, with a profile of its own:
4009
+ * Theia quits at once while another window holds one. Its settings are its own
4010
+ * too, so a run never writes \`~/.theia/settings.json\`.
4011
+ */
4012
+ const test = base.extend<{ page: Page }>({
4013
+ page: async ({ playwright }, use) => {
4014
+ const scratch = mkdtempSync(join(tmpdir(), '${composition.projectId}-theia-electron-'));
4015
+ const workspace = join(scratch, 'workspace');
4016
+ // The servers extension, sideloaded from a plugins folder as \`npm start\` does.
4017
+ const plugins = join(scratch, 'plugins');
4018
+ const link = join(plugins, '${composition.projectId}-vscode-servers');
4019
+ let app: ElectronApplication | undefined;
4020
+ try {
4021
+ cpSync(fileURLToPath(new URL('../../workspace', import.meta.url)), workspace, { recursive: true });
4022
+ mkdirSync(plugins);
4023
+ symlinkSync(fileURLToPath(new URL('../../vscode-servers', import.meta.url)), link, 'junction');
4024
+ // Playwright resolves Electron itself; given its path, Theia opens this
4025
+ // folder instead of the workspace.
4026
+ app = await playwright._electron.launch({
4027
+ args: [
4028
+ fileURLToPath(new URL('..', import.meta.url)),
4029
+ '--no-sandbox',
4030
+ \`--user-data-dir=\${join(scratch, 'user-data')}\`,
4031
+ // In URI form, since Theia reads the argument as a URI.
4032
+ \`--plugins=local-dir:\${pathToFileURL(plugins).pathname}\`,
4033
+ workspace
4034
+ ],
4035
+ env: {
4036
+ ...process.env,
4037
+ THEIA_CONFIG_DIR: join(scratch, 'config'),
4038
+ // Menus drawn in the page, where the test can click them.
4039
+ THEIA_ELECTRON_DISABLE_NATIVE_ELEMENTS: '1'
4040
+ }
4041
+ });
4042
+ await use(await app.firstWindow());
4043
+ } finally {
4044
+ try {
4045
+ // Exited, not closed: closing asks whether to save the edited file. An
4046
+ // app that is already gone is left alone, so the test's own error stands.
4047
+ const child = app?.process();
4048
+ if (app && child?.exitCode === null && child.signalCode === null) {
4049
+ const exited = once(child, 'exit');
4050
+ await app.evaluate(({ app: electron }) => electron.exit(0));
4051
+ await exited;
4052
+ }
4053
+ } finally {
4054
+ // Unlinked first, so the remove never reaches \`../vscode-servers\` through it.
4055
+ rmSync(link, { force: true });
4056
+ rmSync(scratch, { recursive: true, force: true });
4057
+ }
4058
+ }
4059
+ }
4060
+ });
4061
+ `;
4062
+ }
4063
+ /**
4064
+ * The Theia Electron application's `package.json`: the browser application's
4065
+ * `theia` block with the Electron target. Always private, as every host is.
4066
+ */
4067
+ function hostTheiaElectronPackageJson(composition) {
4068
+ const dependencies = {
4069
+ [memberPackageName(composition, 'theia')]: MEMBER_PIN,
4070
+ ...Object.fromEntries(HOST_THEIA_ELECTRON_DEPENDENCIES.map(name => [name, THEIA_VERSION]))
4071
+ };
4072
+ const devDependencies = {
4073
+ // Built before this application, whose start links it into plugins/.
4074
+ [memberPackageName(composition, 'vscode-servers')]: MEMBER_PIN,
4075
+ '@playwright/test': '^1.55.0',
4076
+ ...Object.fromEntries(HOST_THEIA_BROWSER_DEV_DEPENDENCIES.map(name => [name, THEIA_VERSION])),
4077
+ '@types/node': '^22.0.0',
4078
+ electron: ELECTRON_VERSION,
4079
+ rimraf: '^5.0.0',
4080
+ typescript: '^7.0.2'
4081
+ };
4082
+ const theia = {
4083
+ target: 'electron',
4084
+ frontend: {
4085
+ config: {
4086
+ applicationName: composition.name,
4087
+ connectionBufferBytes: 102400,
4088
+ preferences: { 'security.workspace.trust.enabled': false }
4089
+ }
4090
+ },
4091
+ backend: { config: { connectionBufferBytes: 102400, frontendConnectionTimeout: 60000 } }
4092
+ };
4093
+ return `{
4094
+ "name": "${memberPackageName(composition, 'host-theia-electron')}",
4095
+ "version": "0.0.0",
4096
+ "private": true,
4097
+ "description": "${composition.name} in a Theia Electron application: the Theia extension, and the servers extension sideloaded for the language client.",
4098
+ "license": "UNLICENSED",
4099
+ "author": "",
4100
+ "main": "lib/backend/electron-main.js",
4101
+ "scripts": {
4102
+ "build": "theia build --mode development && npm run typecheck",
4103
+ "clean": "rimraf lib src-gen plugins gen-esbuild.browser.mjs gen-esbuild.electron.mjs gen-esbuild.node.mjs test-results playwright-report",
4104
+ ${lintScript(forMember(composition, 'host-theia-electron'), 'test')} "start": "node start.mjs",
4105
+ "test:e2e": "playwright test --tsconfig tsconfig.e2e.json",
4106
+ "typecheck": "tsc --noEmit -p tsconfig.e2e.json",
4107
+ "watch": "theia build --watch --mode development"
4108
+ },
4109
+ "dependencies": {
4110
+ ${sortedBlock(dependencies, ' ')}
4111
+ },
4112
+ "devDependencies": {
4113
+ ${sortedBlock(devDependencies, ' ')}
4114
+ },
4115
+ "engines": {
4116
+ "node": ">=22.13"
4117
+ },
4118
+ "theia": ${jsonAt(theia, ' ')}
4119
+ }
4120
+ `;
4121
+ }
4122
+ /** The Theia Electron application's README. */
4123
+ function hostTheiaElectronReadme(composition) {
4124
+ const intro = [
4125
+ `${composition.name} in a Theia Electron application: the Theia extension in \`../theia\`, and \`../vscode-servers\` sideloaded through \`@theia/plugin-ext\`, which starts the server and its language client.`,
4126
+ 'Electron runs the window, and a backend process it starts runs the server.',
4127
+ ...(composition.grammars.some(grammar => grammar.diagram)
4128
+ ? ['The servers extension contributes no editor, so a diagram file opens in the Theia diagram editor alone.']
4129
+ : [])
4130
+ ];
4131
+ const lines = [
4132
+ `# ${memberPackageName(composition, 'host-theia-electron')}`,
4133
+ '',
4134
+ ...wrapProse(intro.join(' ')),
4135
+ '',
4136
+ '```bash',
4137
+ 'npm run build # bundle and type-check, and download Electron; build the members first',
4138
+ 'npm run watch # rebundle whenever the members’ builds change; reload the window to pick it up',
4139
+ 'npm start # the application on ../workspace',
4140
+ 'npm run test:e2e # the smoke test, which launches the application itself',
4141
+ '```',
4142
+ '',
4143
+ ...wrapProse('There is no `theia rebuild:electron` step: Theia’s native modules are N-API builds, which load in Electron as `npm install` built them for Node.'),
4144
+ '',
4145
+ ...wrapProse('`npm run watch` reports a `theia-source-map-paths` JSON error as it starts: `esbuild.mjs` starts the browser and electron builds together, and they share `lib/frontend`. Later rebuilds are clean.'),
4146
+ '',
4147
+ '## Layout',
4148
+ '',
4149
+ '- `esbuild.mjs` — the bundling `theia build` runs. It keeps this file, so it is the place to adjust the bundle',
4150
+ '- `start.mjs` — `npm start`: links `../vscode-servers` into `plugins/`, then starts the application on a folder given as its argument or `../workspace`',
4151
+ '- `playwright.config.mts` — the smoke tier',
4152
+ '- `test/smoke.spec.mts` — Electron opens a copy of the workspace with a profile of its own, and each head answers',
4153
+ ''
4154
+ ];
4155
+ return project(lines.join('\n'), composition);
4156
+ }
4157
+ /** The Theia Electron application: the browser application's extensions in a desktop window. */
4158
+ function hostTheiaElectronFiles(composition) {
4159
+ const fixed = (file) => projectTemplate(`host-theia-electron/${file}`, composition);
4160
+ return [
4161
+ { path: 'host-theia-electron/package.json', content: hostTheiaElectronPackageJson(composition) },
4162
+ ignoreFile('host-theia-electron'),
4163
+ { path: 'host-theia-electron/README.md', content: hostTheiaElectronReadme(composition) },
4164
+ fixed('esbuild.mjs'),
4165
+ fixed('start.mjs'),
4166
+ fixed('playwright.config.mts'),
4167
+ fixed('tsconfig.e2e.json'),
4168
+ { path: 'host-theia-electron/test/smoke.spec.mts', content: theiaSmokeSpec(composition, 'electron') }
4169
+ ];
4170
+ }
4171
+ /**
4172
+ * The formatter and linter a standalone root runs. Exact, because a formatter
4173
+ * release that changes its output would fail the project's `format:check`.
4174
+ */
4175
+ const ROOT_STYLE_DEV_DEPENDENCIES = { oxfmt: '0.71.0', oxlint: '1.82.0' };
4176
+ /**
4177
+ * The standalone root's formatter config: the style every template is written
4178
+ * in, at the width the emitted sources wrap at. Generated code is ignored
4179
+ * because each run of its generator rewrites it in that generator's style.
4180
+ */
4181
+ function oxfmtConfig(composition) {
4182
+ return `{
4183
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
4184
+ "arrowParens": "avoid",
4185
+ "endOfLine": "lf",
4186
+ "ignorePatterns": ["**/generated", "**/generated-hydranium", "**/syntaxes", "**/*.md"],
4187
+ "jsxSingleQuote": true,
4188
+ "printWidth": ${columnsFor(composition)},
4189
+ "singleQuote": true,
4190
+ "sortPackageJson": true,
4191
+ "tabWidth": 3,
4192
+ "trailingComma": "none",
4193
+ "overrides": [
4194
+ {
4195
+ "files": ["*.json", "*.yml", "*.yaml"],
4196
+ "options": {
4197
+ "printWidth": 100,
4198
+ "tabWidth": 2
4199
+ }
4200
+ }
4201
+ ]
4202
+ }
4203
+ `;
4204
+ }
4205
+ /**
4206
+ * A standalone project's root: the members as npm workspaces.
4207
+ *
4208
+ * Always private: it is never published, whatever `--public` makes of the
4209
+ * members. npm runs a script across the members in the order listed here, not
4210
+ * by their dependencies; listing protocol first is safe because its `build`
4211
+ * runs the server's `generate` before compiling.
4212
+ *
4213
+ * It declares the exact pins `--monorepo` prints for a workspace root, for the
4214
+ * same reason: a package ranging over one, such as a framework package's peer,
4215
+ * otherwise takes the top of the tree with the newest match, and each member's
4216
+ * exact copy nests beneath it.
4217
+ *
4218
+ * `watch` keeps every non-host member built; `watch:<host>` adds one host, so
4219
+ * only the bundler of the host being worked on runs.
4220
+ *
4221
+ * `format` and `lint` run at the root over every member, which is why the
4222
+ * members get no `lint` of their own here.
4223
+ */
4224
+ function rootFiles(composition) {
4225
+ const devDependencies = {
4226
+ ...Object.fromEntries(workspaceRootPins(composition)),
4227
+ concurrently: '^9.0.0',
4228
+ ...ROOT_STYLE_DEV_DEPENDENCIES
4229
+ };
4230
+ const members = initMembers(composition.heads, composition.hosts);
4231
+ // Only the server's watch is `tsc -b`, so protocol is built by one process
4232
+ // rather than two writing the same `lib/`; a host's watch joins only in its
4233
+ // own `watch:<host>`.
4234
+ const watched = members.filter(member => member !== 'protocol' && !member.startsWith('host-'));
4235
+ const parallel = (commands) => `concurrently --kill-others --names ${Object.keys(commands).join(',')} ${Object.values(commands)
4236
+ .map(command => `\\"${command}\\"`)
4237
+ .join(' ')}`;
4238
+ const scripts = {
4239
+ build: 'npm run build --workspaces',
4240
+ clean: 'npm run clean --workspaces --if-present',
4241
+ format: 'oxfmt',
4242
+ 'format:check': 'oxfmt --check',
4243
+ lint: 'oxlint --report-unused-disable-directives --max-warnings 0',
4244
+ ...Object.fromEntries(composition.hosts.map(host => [`start:${host}`, `npm --prefix host-${host} start`])),
4245
+ test: 'npm test --workspaces --if-present',
4246
+ watch: parallel(Object.fromEntries(watched.map(member => [member, `npm --prefix ${member} run watch`]))),
4247
+ // The browser host's `start` is its watch: it rebundles the page as it serves it.
4248
+ ...Object.fromEntries(composition.hosts.map(host => [
4249
+ `watch:${host}`,
4250
+ parallel({
4251
+ watch: 'npm run watch',
4252
+ [`host-${host}`]: `npm --prefix host-${host} ${host === 'browser' ? 'start' : 'run watch'}`
4253
+ })
4254
+ ]))
4255
+ };
4256
+ const content = `{
4257
+ "name": "${composition.projectId}-root",
4258
+ "version": "0.0.0",
4259
+ "private": true,
4260
+ "workspaces": [
4261
+ ${members.map(member => ` "${member}"`).join(',\n')}
4262
+ ],
4263
+ "scripts": {
4264
+ ${Object.entries(scripts)
4265
+ .map(([name, command]) => ` "${name}": "${command}"`)
4266
+ .join(',\n')}
4267
+ },
4268
+ "devDependencies": {
4269
+ ${sortedBlock(devDependencies, ' ')}
4270
+ },
4271
+ "engines": {
4272
+ "node": ">=22.13"
4273
+ },
4274
+ "hydranium": {
4275
+ "services": "./server/lib/node/services.js"
4276
+ }
4277
+ }
4278
+ `;
4279
+ return [
4280
+ { path: 'package.json', content },
4281
+ ignoreFile(''),
4282
+ { path: '.editorconfig', content: template('editorconfig') },
4283
+ { path: '.oxfmtrc.json', content: oxfmtConfig(composition) },
4284
+ { path: '.oxlintrc.json', content: template('oxlintrc.json') }
4285
+ ];
4286
+ }
4287
+ /**
4288
+ * Every file the scaffold emits, fully rendered, at its path in the project
4289
+ * directory. A workspace member's project gets no root of its own: it joins the
4290
+ * surrounding workspace, whose manifest `init` prints the changes for.
4291
+ */
4292
+ export function buildInitTemplates(composition) {
4293
+ return [
4294
+ ...(composition.packaging.workspace === undefined ? rootFiles(composition) : []),
4295
+ ...protocolFiles(composition),
4296
+ ...serverFiles(composition),
4297
+ ...(composition.heads.includes('glsp') ? clientFiles(composition) : []),
4298
+ ...(emitsVscodeServers(composition) ? vscodeServersFiles(composition) : []),
4299
+ ...(composition.hosts.some(isTheiaHost) ? theiaFiles(composition) : []),
4300
+ ...(composition.hosts.includes('browser') ? hostBrowserFiles(composition) : []),
4301
+ ...(composition.hosts.includes('vscode') ? hostVscodeFiles(composition) : []),
4302
+ ...(composition.hosts.includes('theia-browser') ? hostTheiaBrowserFiles(composition) : []),
4303
+ ...(composition.hosts.includes('theia-electron') ? hostTheiaElectronFiles(composition) : []),
4304
+ ...workspaceFiles(composition)
1934
4305
  ];
1935
4306
  }
1936
4307
  //# sourceMappingURL=init-templates.js.map