@webstir-io/webstir 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +69 -0
  2. package/assets/features/client_nav/client_nav.ts +469 -0
  3. package/assets/features/content_nav/content_nav.css +170 -0
  4. package/assets/features/content_nav/content_nav.ts +358 -0
  5. package/assets/features/router/router-types.ts +6 -0
  6. package/assets/features/router/router.ts +118 -0
  7. package/assets/features/search/search.css +204 -0
  8. package/assets/features/search/search.ts +627 -0
  9. package/assets/templates/api/src/backend/index.ts +13 -0
  10. package/assets/templates/api/src/backend/tsconfig.json +15 -0
  11. package/assets/templates/api/src/shared/router-types.ts +23 -0
  12. package/assets/templates/api/src/shared/tsconfig.json +10 -0
  13. package/assets/templates/api/src/shared/types/index.ts +4 -0
  14. package/assets/templates/full/src/backend/index.ts +13 -0
  15. package/assets/templates/full/src/backend/tsconfig.json +15 -0
  16. package/assets/templates/full/src/frontend/app/app.css +65 -0
  17. package/assets/templates/full/src/frontend/app/app.html +13 -0
  18. package/assets/templates/full/src/frontend/app/app.ts +188 -0
  19. package/assets/templates/full/src/frontend/app/error.ts +127 -0
  20. package/assets/templates/full/src/frontend/app/hmr.js +355 -0
  21. package/assets/templates/full/src/frontend/app/navigation.ts +8 -0
  22. package/assets/templates/full/src/frontend/app/refresh.js +114 -0
  23. package/assets/templates/full/src/frontend/app/router.ts +126 -0
  24. package/assets/templates/full/src/frontend/app/styles/base.css +2 -0
  25. package/assets/templates/full/src/frontend/app/styles/reset.css +48 -0
  26. package/assets/templates/full/src/frontend/pages/home/index.css +21 -0
  27. package/assets/templates/full/src/frontend/pages/home/index.html +10 -0
  28. package/assets/templates/full/src/frontend/pages/home/index.ts +18 -0
  29. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +21 -0
  30. package/assets/templates/full/src/frontend/tsconfig.json +20 -0
  31. package/assets/templates/full/src/shared/router-types.ts +23 -0
  32. package/assets/templates/full/src/shared/tsconfig.json +10 -0
  33. package/assets/templates/full/src/shared/types/index.ts +4 -0
  34. package/assets/templates/shared/Errors.404.html +23 -0
  35. package/assets/templates/shared/Errors.500.html +23 -0
  36. package/assets/templates/shared/Errors.default.html +23 -0
  37. package/assets/templates/shared/types/global.d.ts +32 -0
  38. package/assets/templates/shared/types.global.d.ts +32 -0
  39. package/assets/templates/spa/src/frontend/app/app.css +65 -0
  40. package/assets/templates/spa/src/frontend/app/app.html +13 -0
  41. package/assets/templates/spa/src/frontend/app/app.ts +188 -0
  42. package/assets/templates/spa/src/frontend/app/error.ts +127 -0
  43. package/assets/templates/spa/src/frontend/app/hmr.js +355 -0
  44. package/assets/templates/spa/src/frontend/app/navigation.ts +8 -0
  45. package/assets/templates/spa/src/frontend/app/refresh.js +114 -0
  46. package/assets/templates/spa/src/frontend/app/router.ts +126 -0
  47. package/assets/templates/spa/src/frontend/app/styles/base.css +2 -0
  48. package/assets/templates/spa/src/frontend/app/styles/reset.css +48 -0
  49. package/assets/templates/spa/src/frontend/pages/home/index.css +21 -0
  50. package/assets/templates/spa/src/frontend/pages/home/index.html +10 -0
  51. package/assets/templates/spa/src/frontend/pages/home/index.ts +18 -0
  52. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +21 -0
  53. package/assets/templates/spa/src/frontend/tsconfig.json +20 -0
  54. package/assets/templates/spa/src/shared/router-types.ts +23 -0
  55. package/assets/templates/spa/src/shared/tsconfig.json +10 -0
  56. package/assets/templates/spa/src/shared/types/index.ts +4 -0
  57. package/assets/templates/ssg/src/frontend/app/app.css +12 -0
  58. package/assets/templates/ssg/src/frontend/app/app.html +43 -0
  59. package/assets/templates/ssg/src/frontend/app/app.ts +190 -0
  60. package/assets/templates/ssg/src/frontend/app/error.ts +127 -0
  61. package/assets/templates/ssg/src/frontend/app/hmr.js +370 -0
  62. package/assets/templates/ssg/src/frontend/app/refresh.js +163 -0
  63. package/assets/templates/ssg/src/frontend/app/scripts/components/drawer.ts +183 -0
  64. package/assets/templates/ssg/src/frontend/app/scripts/components/menu.ts +75 -0
  65. package/assets/templates/ssg/src/frontend/app/styles/base.css +77 -0
  66. package/assets/templates/ssg/src/frontend/app/styles/components/buttons.css +108 -0
  67. package/assets/templates/ssg/src/frontend/app/styles/components/drawer.css +12 -0
  68. package/assets/templates/ssg/src/frontend/app/styles/components/header.css +164 -0
  69. package/assets/templates/ssg/src/frontend/app/styles/components/markdown.css +25 -0
  70. package/assets/templates/ssg/src/frontend/app/styles/layout.css +41 -0
  71. package/assets/templates/ssg/src/frontend/app/styles/reset.css +56 -0
  72. package/assets/templates/ssg/src/frontend/app/styles/tokens.css +72 -0
  73. package/assets/templates/ssg/src/frontend/app/styles/utilities.css +14 -0
  74. package/assets/templates/ssg/src/frontend/content/_sidebar.json +14 -0
  75. package/assets/templates/ssg/src/frontend/content/content-pipeline.md +82 -0
  76. package/assets/templates/ssg/src/frontend/content/css-playbook.md +68 -0
  77. package/assets/templates/ssg/src/frontend/content/hosting.md +48 -0
  78. package/assets/templates/ssg/src/frontend/pages/about/index.css +33 -0
  79. package/assets/templates/ssg/src/frontend/pages/about/index.html +60 -0
  80. package/assets/templates/ssg/src/frontend/pages/docs/index.css +505 -0
  81. package/assets/templates/ssg/src/frontend/pages/docs/index.html +52 -0
  82. package/assets/templates/ssg/src/frontend/pages/docs/index.ts +495 -0
  83. package/assets/templates/ssg/src/frontend/pages/home/index.css +91 -0
  84. package/assets/templates/ssg/src/frontend/pages/home/index.html +38 -0
  85. package/assets/templates/ssg/src/frontend/pages/home/tests/home.test.ts +24 -0
  86. package/assets/templates/ssg/src/frontend/tsconfig.json +13 -0
  87. package/package.json +41 -0
  88. package/scripts/pack-standalone.mjs +127 -0
  89. package/scripts/sync-assets.mjs +87 -0
  90. package/src/add-backend.ts +164 -0
  91. package/src/add.ts +112 -0
  92. package/src/api-watch.ts +84 -0
  93. package/src/backend-inspect.ts +45 -0
  94. package/src/backend-runtime.ts +286 -0
  95. package/src/build-plan.ts +12 -0
  96. package/src/build.ts +10 -0
  97. package/src/cli.ts +569 -0
  98. package/src/compile-tests.ts +61 -0
  99. package/src/dev-server.ts +393 -0
  100. package/src/enable-assets.ts +196 -0
  101. package/src/enable.ts +477 -0
  102. package/src/execute.ts +85 -0
  103. package/src/format.ts +254 -0
  104. package/src/frontend-watch.ts +145 -0
  105. package/src/full-watch.ts +80 -0
  106. package/src/index.ts +20 -0
  107. package/src/init-assets.ts +96 -0
  108. package/src/init.ts +339 -0
  109. package/src/paths.ts +26 -0
  110. package/src/providers.ts +88 -0
  111. package/src/publish.ts +8 -0
  112. package/src/refresh.ts +56 -0
  113. package/src/repair.ts +414 -0
  114. package/src/runtime.ts +48 -0
  115. package/src/smoke.ts +161 -0
  116. package/src/stop-signal.ts +26 -0
  117. package/src/test.ts +215 -0
  118. package/src/types.ts +29 -0
  119. package/src/watch-daemon-client.ts +171 -0
  120. package/src/watch-events.ts +195 -0
  121. package/src/watch.ts +66 -0
  122. package/src/workspace-watcher.ts +251 -0
  123. package/src/workspace.ts +55 -0
package/src/repair.ts ADDED
@@ -0,0 +1,414 @@
1
+ import path from 'node:path';
2
+ import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+
5
+ import { getBackendScaffoldAssets } from '@webstir-io/webstir-backend';
6
+ import { getModeScaffoldAssets, getRootScaffoldAssets } from './init-assets.ts';
7
+ import {
8
+ getClientNavAssets,
9
+ getContentNavAssets,
10
+ getSearchAssets,
11
+ getSpaAssets,
12
+ renderGithubPagesDeployScript,
13
+ type StaticFeatureAsset,
14
+ } from './enable-assets.ts';
15
+ import { readWorkspaceDescriptor } from './workspace.ts';
16
+
17
+ interface RepairEnableFlags {
18
+ spa?: boolean;
19
+ clientNav?: boolean;
20
+ search?: boolean;
21
+ contentNav?: boolean;
22
+ backend?: boolean;
23
+ githubPages?: boolean;
24
+ }
25
+
26
+ interface RepairPackageJson {
27
+ scripts?: Record<string, unknown>;
28
+ webstir?: {
29
+ mode?: string;
30
+ enable?: RepairEnableFlags;
31
+ };
32
+ }
33
+
34
+ export interface RunRepairOptions {
35
+ readonly workspaceRoot: string;
36
+ readonly rawArgs: readonly string[];
37
+ }
38
+
39
+ export interface RepairResult {
40
+ readonly workspaceRoot: string;
41
+ readonly mode: string;
42
+ readonly dryRun: boolean;
43
+ readonly changes: readonly string[];
44
+ }
45
+
46
+ export async function runRepair(options: RunRepairOptions): Promise<RepairResult> {
47
+ const dryRun = options.rawArgs.includes('--dry-run');
48
+ const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
49
+ const packageJsonPath = path.join(workspace.root, 'package.json');
50
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as RepairPackageJson;
51
+ const enable = packageJson.webstir?.enable ?? {};
52
+ const changes: string[] = [];
53
+
54
+ await restoreScaffoldAssets(workspace.root, getRootScaffoldAssets(), changes, dryRun);
55
+ await restoreScaffoldAssets(workspace.root, await getModeScaffoldAssets(workspace.mode), changes, dryRun);
56
+
57
+ if (enable.spa) {
58
+ await restoreFeatureAssets(workspace.root, getSpaAssets(), changes, dryRun);
59
+ }
60
+ if (enable.clientNav) {
61
+ await restoreFeatureAssets(workspace.root, getClientNavAssets(), changes, dryRun);
62
+ await ensureAppImport(workspace.root, './scripts/features/client-nav.js', changes, dryRun);
63
+ }
64
+ if (enable.search) {
65
+ await restoreFeatureAssets(workspace.root, getSearchAssets(), changes, dryRun);
66
+ await ensureCssLayerIncludes(workspace.root, 'features', changes, dryRun);
67
+ await ensureAppCssImport(workspace.root, './styles/features/search.css', './styles/components/buttons.css', changes, dryRun);
68
+ await ensureAppImport(workspace.root, './scripts/features/search.js', changes, dryRun);
69
+ await ensureHtmlSearchMode(workspace.root, changes, dryRun);
70
+ }
71
+ if (enable.contentNav) {
72
+ await restoreFeatureAssets(workspace.root, getContentNavAssets(), changes, dryRun);
73
+ await ensureCssLayerIncludes(workspace.root, 'features', changes, dryRun);
74
+ await ensureAppCssImport(
75
+ workspace.root,
76
+ './styles/features/content-nav.css',
77
+ './styles/components/buttons.css',
78
+ changes,
79
+ dryRun
80
+ );
81
+ await ensureAppImport(workspace.root, './scripts/features/content-nav.js', changes, dryRun);
82
+ }
83
+ if (enable.backend || workspace.mode === 'api' || workspace.mode === 'full') {
84
+ await restoreBackendAssets(workspace.root, changes, dryRun);
85
+ await ensureBackendTsReference(workspace.root, changes, dryRun);
86
+ }
87
+ if (enable.githubPages) {
88
+ await ensureGithubPagesDeployScript(workspace.root, changes, dryRun);
89
+ await ensureDeployScriptEntry(packageJsonPath, changes, dryRun);
90
+ await ensureFrontendConfigBasePath(workspace.root, changes, dryRun);
91
+ }
92
+
93
+ return {
94
+ workspaceRoot: workspace.root,
95
+ mode: workspace.mode,
96
+ dryRun,
97
+ changes: uniqueSorted(changes),
98
+ };
99
+ }
100
+
101
+ async function restoreScaffoldAssets(
102
+ workspaceRoot: string,
103
+ assets: readonly { sourcePath: string; targetPath: string }[],
104
+ changes: string[],
105
+ dryRun: boolean
106
+ ): Promise<void> {
107
+ for (const asset of assets) {
108
+ const targetPath = path.join(workspaceRoot, asset.targetPath);
109
+ if (existsSync(targetPath)) {
110
+ continue;
111
+ }
112
+
113
+ if (!dryRun) {
114
+ await mkdir(path.dirname(targetPath), { recursive: true });
115
+ await copyFile(asset.sourcePath, targetPath);
116
+ }
117
+
118
+ changes.push(relativeWorkspacePath(workspaceRoot, targetPath));
119
+ }
120
+ }
121
+
122
+ async function restoreFeatureAssets(
123
+ workspaceRoot: string,
124
+ assets: readonly StaticFeatureAsset[],
125
+ changes: string[],
126
+ dryRun: boolean
127
+ ): Promise<void> {
128
+ for (const asset of assets) {
129
+ const targetPath = path.join(workspaceRoot, asset.targetPath);
130
+ if (existsSync(targetPath)) {
131
+ continue;
132
+ }
133
+
134
+ if (!dryRun) {
135
+ await mkdir(path.dirname(targetPath), { recursive: true });
136
+ await copyFile(asset.sourcePath, targetPath);
137
+ if (asset.executable) {
138
+ await chmod(targetPath, 0o755);
139
+ }
140
+ }
141
+
142
+ changes.push(relativeWorkspacePath(workspaceRoot, targetPath));
143
+ }
144
+ }
145
+
146
+ async function restoreBackendAssets(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
147
+ const assets = await getBackendScaffoldAssets();
148
+ for (const asset of assets) {
149
+ const targetPath = path.join(workspaceRoot, asset.targetPath);
150
+ if (existsSync(targetPath)) {
151
+ continue;
152
+ }
153
+
154
+ if (!dryRun) {
155
+ await mkdir(path.dirname(targetPath), { recursive: true });
156
+ await copyFile(asset.sourcePath, targetPath);
157
+ }
158
+
159
+ changes.push(relativeWorkspacePath(workspaceRoot, targetPath));
160
+ }
161
+ }
162
+
163
+ async function ensureAppImport(
164
+ workspaceRoot: string,
165
+ importPath: string,
166
+ changes: string[],
167
+ dryRun: boolean
168
+ ): Promise<void> {
169
+ const appTsPath = path.join(workspaceRoot, 'src', 'frontend', 'app', 'app.ts');
170
+ if (!existsSync(appTsPath)) {
171
+ return;
172
+ }
173
+
174
+ const source = await readFile(appTsPath, 'utf8');
175
+ const updated = ensureSideEffectImport(source, importPath);
176
+ if (updated === source) {
177
+ return;
178
+ }
179
+
180
+ if (!dryRun) {
181
+ await writeFile(appTsPath, updated, 'utf8');
182
+ }
183
+ changes.push(relativeWorkspacePath(workspaceRoot, appTsPath));
184
+ }
185
+
186
+ async function ensureCssLayerIncludes(
187
+ workspaceRoot: string,
188
+ layerName: string,
189
+ changes: string[],
190
+ dryRun: boolean
191
+ ): Promise<void> {
192
+ const appCssPath = path.join(workspaceRoot, 'src', 'frontend', 'app', 'app.css');
193
+ if (!existsSync(appCssPath)) {
194
+ return;
195
+ }
196
+
197
+ const source = await readFile(appCssPath, 'utf8');
198
+ const updated = ensureLayerIncludes(source, layerName);
199
+ if (updated === source) {
200
+ return;
201
+ }
202
+
203
+ if (!dryRun) {
204
+ await writeFile(appCssPath, updated, 'utf8');
205
+ }
206
+ changes.push(relativeWorkspacePath(workspaceRoot, appCssPath));
207
+ }
208
+
209
+ async function ensureAppCssImport(
210
+ workspaceRoot: string,
211
+ importPath: string,
212
+ insertAfterImportPath: string,
213
+ changes: string[],
214
+ dryRun: boolean
215
+ ): Promise<void> {
216
+ const appCssPath = path.join(workspaceRoot, 'src', 'frontend', 'app', 'app.css');
217
+ if (!existsSync(appCssPath)) {
218
+ return;
219
+ }
220
+
221
+ const source = await readFile(appCssPath, 'utf8');
222
+ const updated = ensureImportIncludes(source, importPath, insertAfterImportPath);
223
+ if (updated === source) {
224
+ return;
225
+ }
226
+
227
+ if (!dryRun) {
228
+ await writeFile(appCssPath, updated, 'utf8');
229
+ }
230
+ changes.push(relativeWorkspacePath(workspaceRoot, appCssPath));
231
+ }
232
+
233
+ async function ensureHtmlSearchMode(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
234
+ const appHtmlPath = path.join(workspaceRoot, 'src', 'frontend', 'app', 'app.html');
235
+ if (!existsSync(appHtmlPath)) {
236
+ return;
237
+ }
238
+
239
+ const source = await readFile(appHtmlPath, 'utf8');
240
+ if (source.includes('data-webstir-search-styles=')) {
241
+ return;
242
+ }
243
+
244
+ const updated = source.replace(
245
+ /<html\b(?![^>]*\bdata-webstir-search-styles=)/i,
246
+ '<html data-webstir-search-styles="css"'
247
+ );
248
+ if (updated === source) {
249
+ return;
250
+ }
251
+
252
+ if (!dryRun) {
253
+ await writeFile(appHtmlPath, updated, 'utf8');
254
+ }
255
+ changes.push(relativeWorkspacePath(workspaceRoot, appHtmlPath));
256
+ }
257
+
258
+ async function ensureBackendTsReference(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
259
+ const tsconfigPath = path.join(workspaceRoot, 'base.tsconfig.json');
260
+ if (!existsSync(tsconfigPath)) {
261
+ return;
262
+ }
263
+
264
+ const source = await readFile(tsconfigPath, 'utf8');
265
+ const root = JSON.parse(source) as Record<string, unknown>;
266
+ const references = Array.isArray(root.references) ? [...root.references] : [];
267
+ const exists = references.some((entry) =>
268
+ typeof entry === 'object'
269
+ && entry !== null
270
+ && (entry as Record<string, unknown>).path === 'src/backend'
271
+ );
272
+ if (exists) {
273
+ return;
274
+ }
275
+
276
+ references.push({ path: 'src/backend' });
277
+ root.references = references;
278
+ const updated = `${JSON.stringify(root, null, 2)}\n`;
279
+
280
+ if (!dryRun) {
281
+ await writeFile(tsconfigPath, updated, 'utf8');
282
+ }
283
+ changes.push(relativeWorkspacePath(workspaceRoot, tsconfigPath));
284
+ }
285
+
286
+ async function ensureGithubPagesDeployScript(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
287
+ const deployScriptPath = path.join(workspaceRoot, 'utils', 'deploy-gh-pages.sh');
288
+ if (existsSync(deployScriptPath)) {
289
+ return;
290
+ }
291
+
292
+ if (!dryRun) {
293
+ await mkdir(path.dirname(deployScriptPath), { recursive: true });
294
+ await writeFile(deployScriptPath, renderGithubPagesDeployScript(), 'utf8');
295
+ await chmod(deployScriptPath, 0o755);
296
+ }
297
+ changes.push(relativeWorkspacePath(workspaceRoot, deployScriptPath));
298
+ }
299
+
300
+ async function ensureDeployScriptEntry(packageJsonPath: string, changes: string[], dryRun: boolean): Promise<void> {
301
+ const source = await readFile(packageJsonPath, 'utf8');
302
+ const root = JSON.parse(source) as RepairPackageJson & Record<string, unknown>;
303
+ const scripts = root.scripts && typeof root.scripts === 'object' ? { ...root.scripts } : {};
304
+ if (typeof scripts.deploy === 'string') {
305
+ return;
306
+ }
307
+
308
+ scripts.deploy = 'bash ./utils/deploy-gh-pages.sh';
309
+ root.scripts = scripts;
310
+ const updated = `${JSON.stringify(root, null, 2)}\n`;
311
+
312
+ if (!dryRun) {
313
+ await writeFile(packageJsonPath, updated, 'utf8');
314
+ }
315
+ changes.push(path.basename(packageJsonPath));
316
+ }
317
+
318
+ async function ensureFrontendConfigBasePath(workspaceRoot: string, changes: string[], dryRun: boolean): Promise<void> {
319
+ const configPath = path.join(workspaceRoot, 'src', 'frontend', 'frontend.config.json');
320
+ let root: Record<string, unknown> = {};
321
+
322
+ if (existsSync(configPath)) {
323
+ try {
324
+ root = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>;
325
+ } catch {
326
+ root = {};
327
+ }
328
+ }
329
+
330
+ const publish = asRecord(root.publish);
331
+ if (typeof publish.basePath === 'string' && publish.basePath.length > 0) {
332
+ return;
333
+ }
334
+
335
+ publish.basePath = `/${path.basename(workspaceRoot)}`;
336
+ root.publish = publish;
337
+ const updated = `${JSON.stringify(root, null, 2)}\n`;
338
+
339
+ if (!dryRun) {
340
+ await mkdir(path.dirname(configPath), { recursive: true });
341
+ await writeFile(configPath, updated, 'utf8');
342
+ }
343
+ changes.push(relativeWorkspacePath(workspaceRoot, configPath));
344
+ }
345
+
346
+ function ensureSideEffectImport(source: string, importPath: string): string {
347
+ const escaped = escapeRegExp(importPath);
348
+ const pattern = new RegExp(`^\\s*import\\s+(['"])${escaped}\\1\\s*;?\\s*$`, 'm');
349
+ if (pattern.test(source)) {
350
+ return source;
351
+ }
352
+
353
+ const suffix = source.endsWith('\n') ? '' : '\n';
354
+ return `${source}${suffix}import "${importPath}";\n`;
355
+ }
356
+
357
+ function ensureLayerIncludes(css: string, layerName: string): string {
358
+ const match = css.match(/@layer\s+([^;]+);/);
359
+ if (!match || match.index === undefined) {
360
+ return css;
361
+ }
362
+
363
+ const layers = match[1].split(',').map((layer) => layer.trim()).filter(Boolean);
364
+ if (layers.includes(layerName)) {
365
+ return css;
366
+ }
367
+
368
+ const updated = [...layers];
369
+ const utilitiesIndex = updated.indexOf('utilities');
370
+ const overridesIndex = updated.indexOf('overrides');
371
+ const insertIndex = utilitiesIndex >= 0 ? utilitiesIndex : overridesIndex >= 0 ? overridesIndex : updated.length;
372
+ updated.splice(insertIndex, 0, layerName);
373
+ const replacement = `@layer ${updated.join(', ')};`;
374
+ return `${css.slice(0, match.index)}${replacement}${css.slice(match.index + match[0].length)}`;
375
+ }
376
+
377
+ function ensureImportIncludes(css: string, importPath: string, insertAfterImportPath: string): string {
378
+ if (css.includes(`@import "${importPath}"`) || css.includes(`@import '${importPath}'`)) {
379
+ return css;
380
+ }
381
+
382
+ const doubleNeedle = `@import "${insertAfterImportPath}"`;
383
+ const singleNeedle = `@import '${insertAfterImportPath}'`;
384
+ let insertAfterIndex = css.indexOf(doubleNeedle);
385
+ if (insertAfterIndex < 0) {
386
+ insertAfterIndex = css.indexOf(singleNeedle);
387
+ }
388
+
389
+ if (insertAfterIndex >= 0) {
390
+ const lineEnd = css.indexOf('\n', insertAfterIndex);
391
+ const insertAt = lineEnd >= 0 ? lineEnd + 1 : css.length;
392
+ return `${css.slice(0, insertAt)}@import "${importPath}";\n${css.slice(insertAt)}`;
393
+ }
394
+
395
+ return `${css}\n@import "${importPath}";\n`;
396
+ }
397
+
398
+ function relativeWorkspacePath(workspaceRoot: string, absolutePath: string): string {
399
+ return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, '/');
400
+ }
401
+
402
+ function asRecord(value: unknown): Record<string, unknown> {
403
+ return value && typeof value === 'object' && !Array.isArray(value)
404
+ ? { ...(value as Record<string, unknown>) }
405
+ : {};
406
+ }
407
+
408
+ function escapeRegExp(value: string): string {
409
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
410
+ }
411
+
412
+ function uniqueSorted(values: readonly string[]): string[] {
413
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
414
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,48 @@
1
+ import path from 'node:path';
2
+
3
+ import { packageRoot } from './paths.ts';
4
+
5
+ export type ModuleRuntimeMode = 'build' | 'publish' | 'test';
6
+
7
+ export function createWorkspaceRuntimeEnv(
8
+ workspaceRoot: string,
9
+ mode: ModuleRuntimeMode,
10
+ env: Record<string, string | undefined> = process.env
11
+ ): Record<string, string | undefined> {
12
+ const binPaths = [
13
+ ...collectNodeModuleBins(workspaceRoot),
14
+ ...collectNodeModuleBins(packageRoot),
15
+ env.PATH,
16
+ ].filter(Boolean);
17
+
18
+ return {
19
+ ...env,
20
+ PATH: binPaths.join(path.delimiter),
21
+ WEBSTIR_MODULE_MODE: mode,
22
+ };
23
+ }
24
+
25
+ export function resolveRuntimeCommand(): string {
26
+ if (typeof process.versions.bun === 'string') {
27
+ return process.execPath;
28
+ }
29
+
30
+ return 'bun';
31
+ }
32
+
33
+ function collectNodeModuleBins(startPath: string): string[] {
34
+ const paths: string[] = [];
35
+ let current = path.resolve(startPath);
36
+
37
+ while (true) {
38
+ paths.push(path.join(current, 'node_modules', '.bin'));
39
+ const parent = path.dirname(current);
40
+ if (parent === current) {
41
+ break;
42
+ }
43
+
44
+ current = parent;
45
+ }
46
+
47
+ return paths;
48
+ }
package/src/smoke.ts ADDED
@@ -0,0 +1,161 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { cp, mkdtemp, rm } from 'node:fs/promises';
4
+
5
+ import type { WorkspaceDescriptor } from './types.ts';
6
+
7
+ import { runBackendInspect, type BackendInspectResult } from './backend-inspect.ts';
8
+ import { monorepoRoot } from './paths.ts';
9
+ import { runBuild, type RunBuildOptions } from './build.ts';
10
+ import { scaffoldWorkspace } from './init.ts';
11
+ import { runPublish } from './publish.ts';
12
+ import { runTest, type TestCommandResult } from './test.ts';
13
+ import { readWorkspaceDescriptor } from './workspace.ts';
14
+
15
+ export interface RunSmokeOptions {
16
+ readonly workspaceRoot?: string;
17
+ readonly env?: Record<string, string | undefined>;
18
+ }
19
+
20
+ export interface SmokePhaseResult {
21
+ readonly name: 'build' | 'test' | 'publish' | 'backend-inspect';
22
+ readonly detail: string;
23
+ }
24
+
25
+ export interface SmokeResult {
26
+ readonly workspace: WorkspaceDescriptor;
27
+ readonly phases: readonly SmokePhaseResult[];
28
+ readonly usedTempWorkspace: boolean;
29
+ readonly source?: string;
30
+ }
31
+
32
+ export async function runSmoke(options: RunSmokeOptions = {}): Promise<SmokeResult> {
33
+ const prepared = await prepareWorkspace(options.workspaceRoot);
34
+
35
+ try {
36
+ const workspace = await readWorkspaceDescriptor(prepared.workspaceRoot);
37
+ const env = createSmokeEnv(workspace.root, options.env);
38
+ const phases: SmokePhaseResult[] = [];
39
+
40
+ const buildResult = await runBuild({
41
+ workspaceRoot: workspace.root,
42
+ env,
43
+ } satisfies RunBuildOptions);
44
+ phases.push({
45
+ name: 'build',
46
+ detail: formatBuildDetail(buildResult),
47
+ });
48
+
49
+ const testResult = await runTest({
50
+ workspaceRoot: workspace.root,
51
+ rawArgs: [],
52
+ env,
53
+ });
54
+ if (testResult.hadFailures) {
55
+ throw new Error('Smoke test phase reported failures.');
56
+ }
57
+ phases.push({
58
+ name: 'test',
59
+ detail: formatTestDetail(testResult),
60
+ });
61
+
62
+ const publishResult = await runPublish({
63
+ workspaceRoot: workspace.root,
64
+ env,
65
+ });
66
+ phases.push({
67
+ name: 'publish',
68
+ detail: formatBuildDetail(publishResult),
69
+ });
70
+
71
+ if (workspace.mode === 'api' || workspace.mode === 'full') {
72
+ const backendInspect = await runBackendInspect({
73
+ workspaceRoot: workspace.root,
74
+ env,
75
+ });
76
+ phases.push({
77
+ name: 'backend-inspect',
78
+ detail: formatBackendInspectDetail(backendInspect),
79
+ });
80
+ }
81
+
82
+ return {
83
+ workspace,
84
+ phases,
85
+ usedTempWorkspace: prepared.usedTempWorkspace,
86
+ source: prepared.source,
87
+ };
88
+ } finally {
89
+ if (prepared.cleanupRoot) {
90
+ await rm(prepared.cleanupRoot, { recursive: true, force: true });
91
+ }
92
+ }
93
+ }
94
+
95
+ function createSmokeEnv(
96
+ workspaceRoot: string,
97
+ env: Record<string, string | undefined> = process.env
98
+ ): Record<string, string | undefined> {
99
+ if (env.WEBSTIR_BACKEND_TYPECHECK) {
100
+ return env;
101
+ }
102
+
103
+ const relativeToRepo = monorepoRoot ? path.relative(monorepoRoot, workspaceRoot) : '../external';
104
+ const isExternalWorkspace = !monorepoRoot || relativeToRepo.startsWith('..') || path.isAbsolute(relativeToRepo);
105
+ if (!isExternalWorkspace) {
106
+ return env;
107
+ }
108
+
109
+ return {
110
+ ...env,
111
+ WEBSTIR_BACKEND_TYPECHECK: 'skip',
112
+ };
113
+ }
114
+
115
+ async function prepareWorkspace(workspaceRoot?: string): Promise<{
116
+ readonly workspaceRoot: string;
117
+ readonly cleanupRoot?: string;
118
+ readonly usedTempWorkspace: boolean;
119
+ readonly source?: string;
120
+ }> {
121
+ if (workspaceRoot) {
122
+ return {
123
+ workspaceRoot: path.resolve(workspaceRoot),
124
+ usedTempWorkspace: false,
125
+ };
126
+ }
127
+
128
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'webstir-smoke-'));
129
+ const tempWorkspace = path.join(tempRoot, 'full');
130
+ let source: string | undefined;
131
+
132
+ if (monorepoRoot) {
133
+ const sourceRoot = path.join(monorepoRoot, 'examples', 'demos', 'full');
134
+ await cp(sourceRoot, tempWorkspace, { recursive: true });
135
+ source = sourceRoot;
136
+ } else {
137
+ await scaffoldWorkspace('full', tempWorkspace, { force: true });
138
+ source = 'built-in full template';
139
+ }
140
+
141
+ return {
142
+ workspaceRoot: tempWorkspace,
143
+ cleanupRoot: tempRoot,
144
+ usedTempWorkspace: true,
145
+ source,
146
+ };
147
+ }
148
+
149
+ function formatBuildDetail(result: Awaited<ReturnType<typeof runBuild>>): string {
150
+ return result.targets
151
+ .map((target) => `${target.kind}:${target.result.artifacts.length} artifacts`)
152
+ .join(', ');
153
+ }
154
+
155
+ function formatTestDetail(result: TestCommandResult): string {
156
+ return `${result.summary.passed} passed, ${result.summary.failed} failed`;
157
+ }
158
+
159
+ function formatBackendInspectDetail(result: BackendInspectResult): string {
160
+ return `${result.manifest.routes?.length ?? 0} routes, ${result.manifest.jobs?.length ?? 0} jobs`;
161
+ }
@@ -0,0 +1,26 @@
1
+ export interface StopSignal {
2
+ readonly promise: Promise<void>;
3
+ dispose(): void;
4
+ }
5
+
6
+ export function createStopSignal(): StopSignal {
7
+ let resolvePromise: (() => void) | undefined;
8
+ const promise = new Promise<void>((resolve) => {
9
+ resolvePromise = resolve;
10
+ });
11
+
12
+ const handleSignal = () => {
13
+ resolvePromise?.();
14
+ };
15
+
16
+ process.on('SIGINT', handleSignal);
17
+ process.on('SIGTERM', handleSignal);
18
+
19
+ return {
20
+ promise,
21
+ dispose() {
22
+ process.off('SIGINT', handleSignal);
23
+ process.off('SIGTERM', handleSignal);
24
+ },
25
+ };
26
+ }