@rocket/js 0.1.1 → 0.2.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 (107) hide show
  1. package/README.md +14 -23
  2. package/dist-types/exports/layouts/atlasBlog.d.ts +2 -0
  3. package/dist-types/exports/layouts/atlasBlog.d.ts.map +1 -0
  4. package/dist-types/exports/types/rocket.d.ts +68 -2
  5. package/dist-types/exports/types/rocket.d.ts.map +1 -1
  6. package/dist-types/exports/types.d.ts +1 -1
  7. package/dist-types/exports/types.d.ts.map +1 -1
  8. package/dist-types/src/PageData.d.ts +3 -1
  9. package/dist-types/src/PageData.d.ts.map +1 -1
  10. package/dist-types/src/RocketJsDemo.d.ts.map +1 -1
  11. package/dist-types/src/SocialPreviewPlayground.d.ts +1 -0
  12. package/dist-types/src/SocialPreviewPlayground.d.ts.map +1 -1
  13. package/dist-types/src/asyncMessage.d.ts +5 -0
  14. package/dist-types/src/asyncMessage.d.ts.map +1 -1
  15. package/dist-types/src/cli/RocketBuild.d.ts.map +1 -1
  16. package/dist-types/src/cli/RocketCli.d.ts.map +1 -1
  17. package/dist-types/src/cli/RocketInit.d.ts +5 -1
  18. package/dist-types/src/cli/RocketInit.d.ts.map +1 -1
  19. package/dist-types/src/cli/RocketStart.d.ts +6 -1
  20. package/dist-types/src/cli/RocketStart.d.ts.map +1 -1
  21. package/dist-types/src/component-hydration.d.ts.map +1 -1
  22. package/dist-types/src/components/RocketDrawer.d.ts +8 -0
  23. package/dist-types/src/components/RocketDrawer.d.ts.map +1 -1
  24. package/dist-types/src/components/RocketSocialLink.d.ts.map +1 -1
  25. package/dist-types/src/config.d.ts.map +1 -1
  26. package/dist-types/src/feeds.d.ts +38 -0
  27. package/dist-types/src/feeds.d.ts.map +1 -0
  28. package/dist-types/src/hydration/hydrationLoader.d.ts +13 -4
  29. package/dist-types/src/hydration/hydrationLoader.d.ts.map +1 -1
  30. package/dist-types/src/icons.d.ts.map +1 -1
  31. package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts +42 -0
  32. package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts.map +1 -0
  33. package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts +4 -0
  34. package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts.map +1 -1
  35. package/dist-types/src/layouts/atlas/atlasHeroLayout.d.ts.map +1 -1
  36. package/dist-types/src/layouts/atlas/atlasNotFoundLayout.d.ts.map +1 -1
  37. package/dist-types/src/layouts/layout-helper.d.ts +3 -3
  38. package/dist-types/src/layouts/layout-helper.d.ts.map +1 -1
  39. package/dist-types/src/markdownHook.d.ts.map +1 -1
  40. package/dist-types/src/menu.d.ts +8 -3
  41. package/dist-types/src/menu.d.ts.map +1 -1
  42. package/dist-types/src/menus/MainMenu.d.ts.map +1 -1
  43. package/dist-types/src/{development-page-module-loader.d.ts → page-module-loader.d.ts} +7 -2
  44. package/dist-types/src/page-module-loader.d.ts.map +1 -0
  45. package/dist-types/src/page-runtime.d.ts +11 -0
  46. package/dist-types/src/page-runtime.d.ts.map +1 -1
  47. package/dist-types/src/pages.d.ts.map +1 -1
  48. package/dist-types/src/route-pattern.d.ts +10 -0
  49. package/dist-types/src/route-pattern.d.ts.map +1 -0
  50. package/dist-types/src/siteDiscoverability.d.ts +4 -0
  51. package/dist-types/src/siteDiscoverability.d.ts.map +1 -1
  52. package/dist-types/src/socialPreviewImages.d.ts +2 -1
  53. package/dist-types/src/socialPreviewImages.d.ts.map +1 -1
  54. package/dist-types/src/static-params.d.ts +33 -0
  55. package/dist-types/src/static-params.d.ts.map +1 -0
  56. package/dist-types/src/transform.d.ts +4 -0
  57. package/dist-types/src/transform.d.ts.map +1 -1
  58. package/dist-types/src/wds-plugin.d.ts.map +1 -1
  59. package/exports/layouts/atlasBlog.js +11 -0
  60. package/exports/types/rocket.test.ts +26 -0
  61. package/exports/types/rocket.ts +78 -2
  62. package/exports/types.ts +7 -0
  63. package/package.json +16 -32
  64. package/src/PageData.js +12 -2
  65. package/src/RocketJsDemo.js +1 -0
  66. package/src/SocialPreviewPlayground.js +26 -20
  67. package/src/asyncMessage.js +8 -2
  68. package/src/cli/RocketBuild.js +110 -13
  69. package/src/cli/RocketCli.js +11 -1
  70. package/src/cli/RocketInit.js +64 -12
  71. package/src/cli/RocketStart.js +60 -16
  72. package/src/cli/cli.js +10 -1
  73. package/src/component-hydration.js +23 -3
  74. package/src/components/RocketDrawer.js +44 -10
  75. package/src/components/RocketSocialLink.js +26 -2
  76. package/src/config.js +33 -4
  77. package/src/feeds.js +212 -0
  78. package/src/hydration/hydrationLoader.js +80 -48
  79. package/src/icons.js +5 -0
  80. package/src/layouts/atlas/atlasBlog.css +143 -0
  81. package/src/layouts/atlas/atlasBlogLayout.js +234 -0
  82. package/src/layouts/atlas/atlasDocLayout.js +10 -1
  83. package/src/layouts/atlas/atlasHeroLayout.js +10 -6
  84. package/src/layouts/atlas/atlasNotFoundLayout.js +12 -22
  85. package/src/layouts/layout-helper.js +5 -5
  86. package/src/layouts/layout.js +2 -2
  87. package/src/loaded-page-module.js +3 -1
  88. package/src/markdownCompiler.js +2 -2
  89. package/src/markdownHook.js +7 -2
  90. package/src/menu.js +25 -19
  91. package/src/menus/MainMenu.js +10 -4
  92. package/src/{static-page-module-loader.js → page-module-loader.js} +6 -1
  93. package/src/page-pagination.js +2 -2
  94. package/src/page-runtime.js +48 -3
  95. package/src/pages.js +47 -56
  96. package/src/publicAssets.js +2 -2
  97. package/src/route-pattern.js +21 -0
  98. package/src/siteDiscoverability.js +14 -1
  99. package/src/socialPreviewImages.js +80 -14
  100. package/src/standalone-demo-url.js +2 -2
  101. package/src/static-params.js +114 -0
  102. package/src/transform.js +89 -22
  103. package/src/wds-plugin.js +16 -3
  104. package/dist-types/src/development-page-module-loader.d.ts.map +0 -1
  105. package/dist-types/src/static-page-module-loader.d.ts +0 -15
  106. package/dist-types/src/static-page-module-loader.d.ts.map +0 -1
  107. package/src/development-page-module-loader.js +0 -29
@@ -3,6 +3,7 @@ import {
3
3
  DEFAULT_SOCIAL_PREVIEW_IMAGE_WIDTH,
4
4
  defaultSocialPreviewTemplate,
5
5
  } from './defaultSocialPreviewTemplate.js';
6
+ import { debounce } from './debounce.js';
6
7
 
7
8
  const DEFAULT_TITLE = 'Runtime Guide';
8
9
  const DEFAULT_DESCRIPTION = 'Learn how Acme UI components load, render, and ship with Rocket.';
@@ -81,22 +82,23 @@ export class SocialPreviewPlayground extends HTMLElement {
81
82
  this.templateFrame = null;
82
83
  /** @type {HTMLAnchorElement | null} */
83
84
  this.pngLink = null;
85
+ // Typing reloads the preview iframe and rewrites history, so batch keystrokes.
86
+ this.schedulePreviewUpdate = debounce(() => this.updatePreviewResources(), 150);
84
87
  }
85
88
 
86
89
  connectedCallback() {
87
- if (this.shadowRoot) {
88
- return;
90
+ if (!this.shadowRoot) {
91
+ this.config = this.readConfig();
92
+ this.state = this.initialState(this.config);
93
+ this.attachShadow({ mode: 'open' });
94
+ this.render();
95
+ this.connectControls();
96
+ this.updatePreviewResources();
89
97
  }
90
- this.config = this.readConfig();
91
- this.state = this.initialState(this.config);
92
- const shadowRoot = this.attachShadow({ mode: 'open' });
93
- this.render();
94
- this.connectControls();
95
- this.updatePreviewResources();
96
98
  this.resizePreviewFrame();
97
- const frame = shadowRoot.querySelector('[data-social-preview-frame-shell]');
99
+ const frame = this.shadowRoot?.querySelector('[data-social-preview-frame-shell]');
98
100
  if (frame && 'ResizeObserver' in window) {
99
- this.resizeObserver = new ResizeObserver(() => this.resizePreviewFrame());
101
+ this.resizeObserver ??= new ResizeObserver(() => this.resizePreviewFrame());
100
102
  this.resizeObserver.observe(frame);
101
103
  }
102
104
  }
@@ -160,8 +162,8 @@ export class SocialPreviewPlayground extends HTMLElement {
160
162
  const page = config.pages.find(candidate => candidate.path === config.selectedPath);
161
163
  return {
162
164
  selectedPath: page?.path ?? config.selectedPath,
163
- title: config.defaultTitle || page?.title || DEFAULT_TITLE,
164
- description: config.defaultDescription || page?.description || DEFAULT_DESCRIPTION,
165
+ title: page?.title || config.defaultTitle,
166
+ description: page?.description || config.defaultDescription,
165
167
  };
166
168
  }
167
169
 
@@ -423,11 +425,11 @@ export class SocialPreviewPlayground extends HTMLElement {
423
425
  });
424
426
  titleInput?.addEventListener('input', () => {
425
427
  this.state.title = titleInput.value;
426
- this.updatePreviewResources();
428
+ this.schedulePreviewUpdate();
427
429
  });
428
430
  descriptionInput?.addEventListener('input', () => {
429
431
  this.state.description = descriptionInput.value;
430
- this.updatePreviewResources();
432
+ this.schedulePreviewUpdate();
431
433
  });
432
434
  shadowRoot
433
435
  .querySelector('button[data-social-preview-previous-page]')
@@ -506,7 +508,11 @@ export class SocialPreviewPlayground extends HTMLElement {
506
508
  return;
507
509
  }
508
510
  if (this.config.templateUrl) {
509
- this.templateFrame.src = this.socialPreviewResourcePath(this.config.templateUrl);
511
+ const templateSrc = this.socialPreviewResourcePath(this.config.templateUrl);
512
+ // assigning an unchanged src still reloads the iframe, so compare first
513
+ if (this.templateFrame.getAttribute('src') !== templateSrc) {
514
+ this.templateFrame.src = templateSrc;
515
+ }
510
516
  } else {
511
517
  this.templateFrame.srcdoc = this.previewHtml();
512
518
  }
@@ -515,11 +521,11 @@ export class SocialPreviewPlayground extends HTMLElement {
515
521
  this.pngLink.download = this.selectedPage()?.downloadFilename ?? 'social-preview.png';
516
522
  }
517
523
  if (this.config.syncHistory && this.config.workflowUrl) {
518
- window.history.replaceState(
519
- null,
520
- '',
521
- this.socialPreviewResourcePath(this.config.workflowUrl),
522
- );
524
+ const workflowPath = this.socialPreviewResourcePath(this.config.workflowUrl);
525
+ const currentPath = `${window.location.pathname}${window.location.search}`;
526
+ if (workflowPath !== currentPath) {
527
+ window.history.replaceState(null, '', workflowPath);
528
+ }
523
529
  }
524
530
  }
525
531
 
@@ -3,6 +3,11 @@
3
3
 
4
4
  /**
5
5
  * Turns a MessagePort into an AsyncPort, which can wait for responses.
6
+ *
7
+ * Do not use sendAndWait from inside module customization hooks: the main
8
+ * thread may be blocked in Atomics.wait (e.g. during a nested
9
+ * module.register() call) and unable to reply, which deadlocks the hook.
10
+ *
6
11
  * @param {MessagePort} port
7
12
  * @returns {AsyncPort}
8
13
  */
@@ -13,9 +18,10 @@ export function makeAsyncPort(port) {
13
18
  p._wait.resolve(message);
14
19
  p._wait = Promise.withResolvers();
15
20
  });
16
- p.sendAndWait = async message => {
21
+ p.sendAndWait = message => {
22
+ const wait = p._wait;
17
23
  p.postMessage(message);
18
- return p._wait;
24
+ return wait.promise;
19
25
  };
20
26
  return p;
21
27
  }
@@ -6,8 +6,10 @@ import { build } from 'vite';
6
6
  import path from 'node:path';
7
7
  import { PageRuntime } from '../page-runtime.js';
8
8
  import { createIconAssetStore, rocketIconRuntimeOutputs } from '../icons.js';
9
- import { paginatedArchivePaths } from '../page-pagination.js';
10
- import { createStaticPageModuleLoader } from '../static-page-module-loader.js';
9
+ import { hasPagePagination, paginatedArchivePaths } from '../page-pagination.js';
10
+ import { hasPageFeed, pageFeedPath } from '../feeds.js';
11
+ import { hasStaticParams, staticParamsPagePaths } from '../static-params.js';
12
+ import { createPageModuleLoader } from '../page-module-loader.js';
11
13
  import { writeSiteDiscoverabilityOutputs } from '../siteDiscoverability.js';
12
14
  import { normalizeDocumentPath, standaloneDemoPaths } from '../standalone-demo-url.js';
13
15
  import {
@@ -46,6 +48,7 @@ export class RocketBuild {
46
48
 
47
49
  program
48
50
  .command('build')
51
+ .description('build the static site into the output directory')
49
52
  .option('-o, --output-dir <path>', 'Path where to output built files')
50
53
  .action(async ({ outputDir }) => {
51
54
  if (outputDir) {
@@ -96,6 +99,8 @@ export class RocketBuild {
96
99
  emitRedirectFallbacks: !this.cli.config.adapter,
97
100
  });
98
101
  const outDir = this.outputDir ? path.resolve(this.outputDir) : path.resolve('dist');
102
+ // Vite's emptyOutDir force-clears any outDir, so containment must hold on every path.
103
+ assertOutputDirInsideProject(outDir);
99
104
  assertPublicAssetsOutputDir({ projectRoot: process.cwd(), outDir });
100
105
 
101
106
  try {
@@ -217,7 +222,7 @@ export function splitPages(pages) {
217
222
  export async function renderStaticPages({
218
223
  pages,
219
224
  staticPages,
220
- pageModuleLoader = createStaticPageModuleLoader(),
225
+ pageModuleLoader = createPageModuleLoader(),
221
226
  origin = 'http://localhost',
222
227
  urlLifecycle,
223
228
  siteHeadMetadata,
@@ -232,6 +237,8 @@ export async function renderStaticPages({
232
237
  assertStaticJavaScriptPagesHaveConcretePaths(staticPages);
233
238
  assertNoStandaloneDemoPathCollisions(pages, staticPages);
234
239
  assertNoPaginatedArchivePathCollisions(pages, staticPages);
240
+ assertPageFeedsAreBuildable({ pages, staticPages, siteOrigin });
241
+ assertNoStaticParamsPathCollisions(pages, staticPages);
235
242
  validateUrlLifecycleGeneratedOutputCollisions({
236
243
  redirects: urlLifecycle?.redirects,
237
244
  pages,
@@ -265,6 +272,13 @@ export async function renderStaticPages({
265
272
  });
266
273
  for (const [pagePath, page] of staticPages) {
267
274
  try {
275
+ if (hasStaticParams(page)) {
276
+ for (const paramPath of staticParamsPagePaths({ pages, page, pagePath })) {
277
+ const paramResponse = await pageRuntime.render(new Request(new URL(paramPath, origin)));
278
+ await writeStaticResponse(paramPath, paramResponse);
279
+ }
280
+ continue;
281
+ }
268
282
  const response = await pageRuntime.render(new Request(new URL(pagePath, origin)));
269
283
  await writeStaticResponse(pagePath, response);
270
284
  for (const archivePath of paginatedArchivePaths({ pages, page, pagePath })) {
@@ -275,6 +289,11 @@ export async function renderStaticPages({
275
289
  const demoResponse = await pageRuntime.render(new Request(new URL(demoPath, origin)));
276
290
  await writeStandaloneDemoResponse(demoPath, page, demoResponse);
277
291
  }
292
+ if (hasPageFeed(page)) {
293
+ const feedPath = pageFeedPath(pagePath);
294
+ const feedResponse = await pageRuntime.render(new Request(new URL(feedPath, origin)));
295
+ await writeStaticResponse(feedPath, feedResponse);
296
+ }
278
297
  } catch (error) {
279
298
  throw new Error(`Failed to render page: ${pagePath}. ${errorMessage(error)}`, {
280
299
  cause: error,
@@ -293,18 +312,55 @@ export async function renderStaticPages({
293
312
  */
294
313
  function assertStaticJavaScriptPagesHaveConcretePaths(staticPages) {
295
314
  for (const [pagePath, page] of staticPages) {
296
- if (!page.file.endsWith('.js') || !hasPathParameter(pagePath)) {
315
+ if (!page.file.endsWith('.js') || !hasPathParameter(pagePath) || hasStaticParams(page)) {
297
316
  continue;
298
317
  }
299
318
  throw new Error(
300
319
  `Static JavaScript Page ${pagePath} (${page.file}) cannot be rendered as one static ` +
301
- `output document because its path is parameterized. Parameterized JavaScript Pages need ` +
302
- `request-time rendering until Rocket has an API for enumerating static params. Use ` +
303
- `render: 'server' for this Page today.`,
320
+ `output document because its path is parameterized. Export static params to enumerate ` +
321
+ `its output documents, or use render: 'server' for request-time rendering.`,
304
322
  );
305
323
  }
306
324
  }
307
325
 
326
+ /**
327
+ * @param {import('@rocket/js/types.js').PageRegistry} pages
328
+ * @param {import('@rocket/js/types.js').PageRegistry} staticPages
329
+ */
330
+ function assertNoStaticParamsPathCollisions(pages, staticPages) {
331
+ const configuredPages = configuredPageOutputPaths(pages);
332
+ const staticParamsPages = new Map();
333
+ for (const [pagePath, page] of staticPages) {
334
+ if (!hasStaticParams(page)) {
335
+ continue;
336
+ }
337
+ if (hasPagePagination(page)) {
338
+ throw new Error(
339
+ `Static JavaScript Page ${pagePath} (${page.file}) cannot combine static params with ` +
340
+ `pagination. Paginate a concrete archive Page instead.`,
341
+ );
342
+ }
343
+ for (const paramPath of staticParamsPagePaths({ pages, page, pagePath })) {
344
+ const outputPath = normalizeDocumentPath(paramPath);
345
+ const collision = configuredPages.get(outputPath);
346
+ if (collision) {
347
+ throw new Error(
348
+ `Static params output path ${paramPath} for page ${page.file} collides with ` +
349
+ `configured Page ${collision.page.file} at ${collision.path}`,
350
+ );
351
+ }
352
+ const paramCollision = staticParamsPages.get(outputPath);
353
+ if (paramCollision) {
354
+ throw new Error(
355
+ `Static params output path ${paramPath} for page ${page.file} collides with ` +
356
+ `static params output path ${paramCollision.path} for page ${paramCollision.page.file}`,
357
+ );
358
+ }
359
+ staticParamsPages.set(outputPath, { path: paramPath, page });
360
+ }
361
+ }
362
+ }
363
+
308
364
  /**
309
365
  * @param {string} pagePath
310
366
  */
@@ -312,6 +368,40 @@ function hasPathParameter(pagePath) {
312
368
  return /(^|\/):[^/]+/.test(pagePath);
313
369
  }
314
370
 
371
+ /**
372
+ * @param {{
373
+ * pages: import('@rocket/js/types.js').PageRegistry;
374
+ * staticPages: import('@rocket/js/types.js').PageRegistry;
375
+ * siteOrigin?: string;
376
+ * }} options
377
+ */
378
+ function assertPageFeedsAreBuildable({ pages, staticPages, siteOrigin }) {
379
+ for (const [pagePath, page] of staticPages) {
380
+ if (!hasPageFeed(page)) {
381
+ continue;
382
+ }
383
+ if (hasPathParameter(pagePath)) {
384
+ throw new Error(
385
+ `Page Feed for ${page.file} cannot use the parameterized path ${pagePath}. ` +
386
+ `Page Feeds need a concrete owning Page path.`,
387
+ );
388
+ }
389
+ if (typeof siteOrigin !== 'string' || siteOrigin.trim() === '') {
390
+ throw new Error(
391
+ `Page Feed ${pageFeedPath(pagePath)} for ${page.file} requires a Site Origin for ` +
392
+ `absolute feed URLs. Add siteOrigin: 'https://example.com' to rocket-config.js.`,
393
+ );
394
+ }
395
+ const collision = pages.get(pageFeedPath(pagePath));
396
+ if (collision) {
397
+ throw new Error(
398
+ `Page Feed ${pageFeedPath(pagePath)} for ${page.file} collides with configured ` +
399
+ `Page ${collision.file}`,
400
+ );
401
+ }
402
+ }
403
+ }
404
+
315
405
  /**
316
406
  * @param {import('@rocket/js/types.js').PageRegistry} pages
317
407
  * @param {import('@rocket/js/types.js').PageRegistry} staticPages
@@ -429,23 +519,30 @@ function writeIconAssetOutputs(iconAssetStore) {
429
519
  /**
430
520
  * @param {string} outDir
431
521
  */
432
- function clearExistingOutputDir(outDir) {
433
- if (!existsSync(outDir)) {
434
- return;
435
- }
522
+ function assertOutputDirInsideProject(outDir) {
436
523
  const projectRoot = path.resolve(process.cwd());
437
524
  const relativeOutDir = path.relative(projectRoot, outDir);
438
525
  if (
439
526
  relativeOutDir === '' ||
440
- relativeOutDir.startsWith(`..${path.sep}`) ||
441
527
  relativeOutDir === '..' ||
528
+ relativeOutDir.startsWith(`..${path.sep}`) ||
442
529
  path.isAbsolute(relativeOutDir)
443
530
  ) {
444
531
  throw new Error(
445
532
  `Invalid build output directory ${outDir}. ` +
446
- `Rocket can only clear an existing output directory inside the project root.`,
533
+ `Rocket only writes build output inside the project root.`,
447
534
  );
448
535
  }
536
+ }
537
+
538
+ /**
539
+ * @param {string} outDir
540
+ */
541
+ function clearExistingOutputDir(outDir) {
542
+ if (!existsSync(outDir)) {
543
+ return;
544
+ }
545
+ assertOutputDirInsideProject(outDir);
449
546
  rmSync(outDir, { recursive: true });
450
547
  }
451
548
 
@@ -1,9 +1,14 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { Command } from 'commander';
2
3
  import { readConfig } from '../config.js';
3
4
  import { RocketBuild } from './RocketBuild.js';
4
5
  import { RocketInit } from './RocketInit.js';
5
6
  import { RocketStart } from './RocketStart.js';
6
7
 
8
+ const packageJson = JSON.parse(
9
+ readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
10
+ );
11
+
7
12
  export class RocketCli {
8
13
  /** @type {string[]} */
9
14
  argv;
@@ -21,7 +26,12 @@ export class RocketCli {
21
26
  this.argv = argv;
22
27
 
23
28
  this.program = new Command();
24
- this.program.allowUnknownOption(true).option('-c, --config-file <path>', 'path to config file');
29
+ this.program
30
+ .name('rocket')
31
+ .description('HTML-first static-site metaframework for content sites and Web Component docs')
32
+ .version(packageJson.version)
33
+ .allowUnknownOption(true)
34
+ .option('-c, --config-file <path>', 'path to config file');
25
35
  this.program.parseOptions(this.argv);
26
36
 
27
37
  this.program.allowUnknownOption(false);
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  const ROCKET_CONFIG_PATH = 'rocket-config.js';
6
6
  const INDEX_PAGE_PATH = 'docs/pages/index.rocket.md';
7
7
  const SHARED_DATA_PATH = 'docs/pages/sharedData.js';
8
+ const THEME_CSS_PATH = 'public/rocket-theme.css';
8
9
  const DOCS_PAGE_PATH = 'docs/pages/docs.rocket.md';
9
10
  const JAVASCRIPT_DEMO_PAGE_PATH = 'docs/pages/javascript-demo.rocket.md';
10
11
  const REQUEST_DEMO_PAGE_PATH = 'docs/pages/request-demo.rocket.md';
@@ -13,6 +14,7 @@ const ROCKET_AGENT_SKILL_PATH = '.agents/skills/rocket/SKILL.md';
13
14
  const EXISTING_ROCKET_PAGE_NOISE_THRESHOLD = 3;
14
15
  const STARTER_PAGE_FILES = [
15
16
  SHARED_DATA_PATH,
17
+ THEME_CSS_PATH,
16
18
  INDEX_PAGE_PATH,
17
19
  DOCS_PAGE_PATH,
18
20
  JAVASCRIPT_DEMO_PAGE_PATH,
@@ -28,6 +30,8 @@ export default {
28
30
 
29
31
  const sharedDataSource = `import { resolve } from '@rocket/js/resolve.js';
30
32
 
33
+ const themeStylesheets = ['/rocket-theme.css'];
34
+
31
35
  export const headerData = {
32
36
  logo: [
33
37
  resolve('@rocket/js/docs/assets/rocket-logo-light.svg', import.meta),
@@ -47,6 +51,7 @@ export const footerData = [];
47
51
  export const heroData = {
48
52
  headerData,
49
53
  footerData,
54
+ stylesheets: themeStylesheets,
50
55
  heroMainData: {
51
56
  logoNoText: resolve('@rocket/js/docs/assets/rocket-logo-light.svg', import.meta),
52
57
  eyebrow: 'ROCKET STARTER',
@@ -102,10 +107,29 @@ export const heroData = {
102
107
  export const docData = {
103
108
  headerData,
104
109
  footerData,
110
+ stylesheets: themeStylesheets,
105
111
  navigationIconServerBudget: 35,
106
112
  };
107
113
  `;
108
114
 
115
+ const themeCssSource = `:root {
116
+ --rocket-theme-primary: #e10d14;
117
+ --rocket-theme-primary-dark: #b90f12;
118
+ --rocket-theme-link: #0366d6;
119
+ }
120
+
121
+ .atlas-page,
122
+ .atlas-header,
123
+ .atlas-navigation,
124
+ .atlas-toc,
125
+ .home-main,
126
+ rocket-header {
127
+ --primary-color: var(--rocket-theme-primary);
128
+ --primary-color-dark: var(--rocket-theme-primary-dark);
129
+ --link-color: var(--rocket-theme-link);
130
+ }
131
+ `;
132
+
109
133
  const indexPageSource = `\`\`\`js server
110
134
  export const config = {
111
135
  path: '/',
@@ -265,6 +289,7 @@ export default function siteStatusPage() {
265
289
 
266
290
  const STARTER_PAGE_SOURCES = new Map([
267
291
  [SHARED_DATA_PATH, sharedDataSource],
292
+ [THEME_CSS_PATH, themeCssSource],
268
293
  [INDEX_PAGE_PATH, indexPageSource],
269
294
  [DOCS_PAGE_PATH, docsPageSource],
270
295
  [JAVASCRIPT_DEMO_PAGE_PATH, javascriptDemoPageSource],
@@ -289,10 +314,15 @@ description: Use when editing Rocket Pages, config, layouts, component reference
289
314
  - Markdown using Rocket custom elements needs a \`components\` export; use Atlas component maps or \`rocketDemoComponents\`.
290
315
  - Add \`menu.iconName\` to Atlas docs navigation Pages so the left navigation has icons.
291
316
  - Direct layout re-exports are supported when no local wrapper function is needed.
317
+ - For Atlas theming, use shared layout data with \`stylesheets\` and centralized CSS variables instead of per-Page style injection.
318
+ - To add a general Page, create \`docs/pages/name.rocket.md\`, set \`config.path\`, \`metadata\`, \`menu.iconName\`, and use the shared docs layout.
292
319
  - Custom layouts rendering \`rocket-icon\` need \`addBootstrapIconLibrary(pageData)\` before \`document()\`.
293
320
  - Static JavaScript Pages render once per concrete path; query/header/cookie/live-data output needs \`render: 'server'\`.
294
321
  - Static Request Demos should target concrete non-query URLs.
295
322
  - After adding a \`js demo\`, verify the parent Page and Standalone Demo URL \`/page/_demo/demoName/\`.
323
+ - If \`rocket init\` fails because package.json has \`"type": "commonjs"\`, change it to \`"type": "module"\` or rerun \`npx rocket init --yes\`.
324
+ - If dev server watchers fail with \`EMFILE\`, run \`npm start -- --no-watch --no-open\` and use \`Ctrl+R\` for manual restarts.
325
+ - When smoke-testing Pages with curl, send \`Accept: text/html\`: \`curl -H 'Accept: text/html' http://localhost:8888/path\`.
296
326
  - Keep \`npm run build\` passing; record Rocket package issues separately from local workarounds.
297
327
  `;
298
328
 
@@ -304,17 +334,19 @@ export class RocketInit {
304
334
  program
305
335
  .command('init')
306
336
  .description('create a Rocket docs starter')
307
- .action(() => {
308
- const result = this.init();
337
+ .option('-y, --yes', 'update an explicit CommonJS package.json type to module')
338
+ .action(options => {
339
+ const result = this.init({ yes: options.yes });
309
340
  reportInitResult(result);
310
341
  });
311
342
  }
312
343
 
313
344
  /**
345
+ * @param {RocketInitOptions} [options]
314
346
  * @returns {RocketInitResult}
315
347
  */
316
- init() {
317
- const packageJsonUpdate = preparePackageJsonUpdate('package.json');
348
+ init(options = {}) {
349
+ const packageJsonUpdate = preparePackageJsonUpdate('package.json', options);
318
350
  /** @type {RocketInitResult} */
319
351
  const result = {
320
352
  created: [],
@@ -343,6 +375,12 @@ export class RocketInit {
343
375
  }
344
376
  }
345
377
 
378
+ /**
379
+ * @typedef {{
380
+ * yes?: boolean;
381
+ * }} RocketInitOptions
382
+ */
383
+
346
384
  /**
347
385
  * @typedef {{
348
386
  * created: string[];
@@ -359,6 +397,7 @@ export class RocketInit {
359
397
 
360
398
  /**
361
399
  * @param {string} filePath
400
+ * @param {RocketInitOptions} options
362
401
  * @returns {{
363
402
  * changed: boolean;
364
403
  * packageJson: Record<string, any>;
@@ -367,7 +406,7 @@ export class RocketInit {
367
406
  * nextSteps: string[];
368
407
  * } | undefined}
369
408
  */
370
- function preparePackageJsonUpdate(filePath) {
409
+ function preparePackageJsonUpdate(filePath, options) {
371
410
  if (!existsSync(filePath)) {
372
411
  return undefined;
373
412
  }
@@ -379,19 +418,19 @@ function preparePackageJsonUpdate(filePath) {
379
418
  if (!isPlainRecord(packageJson)) {
380
419
  throw new Error('package.json must contain a JSON object');
381
420
  }
382
- if (packageJson.type === 'commonjs') {
383
- throw new Error(
384
- `Rocket init expects an ESM project. Found package.json type "commonjs". ` +
385
- `Change package.json to "type": "module" before running rocket init.`,
386
- );
387
- }
388
421
 
389
422
  /** @type {string[]} */
390
423
  const updated = [];
391
424
  /** @type {string[]} */
392
425
  const skipped = [];
393
426
 
394
- if (!hasOwn(packageJson, 'type')) {
427
+ if (packageJson.type === 'commonjs') {
428
+ if (!options.yes) {
429
+ throw commonJsProjectError();
430
+ }
431
+ packageJson.type = 'module';
432
+ updated.push('type');
433
+ } else if (!hasOwn(packageJson, 'type')) {
395
434
  packageJson.type = 'module';
396
435
  updated.push('type');
397
436
  } else if (packageJson.type === 'module') {
@@ -434,6 +473,19 @@ function preparePackageJsonUpdate(filePath) {
434
473
  };
435
474
  }
436
475
 
476
+ function commonJsProjectError() {
477
+ return new Error(
478
+ `Rocket init expects an ESM project. Found package.json type "commonjs".\n\n` +
479
+ `Update package.json before running rocket init:\n` +
480
+ `- "type": "commonjs",\n` +
481
+ `+ "type": "module",\n\n` +
482
+ `Then rerun:\n` +
483
+ ` npx rocket init\n\n` +
484
+ `Or let Rocket apply that package.json change:\n` +
485
+ ` npx rocket init --yes`,
486
+ );
487
+ }
488
+
437
489
  /**
438
490
  * @param {Record<string, any>} scripts
439
491
  * @param {{
@@ -8,6 +8,9 @@ export class RocketStart {
8
8
  /** @type {RocketStartOptions} */
9
9
  startOptions = {};
10
10
 
11
+ /** @type {import('child_process').ChildProcess | undefined} */
12
+ server = undefined;
13
+
11
14
  /**
12
15
  * @param {import('commander').Command} program
13
16
  * @param {import('./RocketCli.js').RocketCli} cli
@@ -17,6 +20,7 @@ export class RocketStart {
17
20
 
18
21
  program
19
22
  .command('start')
23
+ .description('start the development server')
20
24
  .option('-p, --port <port>', 'port for the development server')
21
25
  .option('--no-open', 'do not open the browser')
22
26
  .option('--no-watch', 'disable automatic file watching and reloads')
@@ -32,7 +36,7 @@ export class RocketStart {
32
36
  */
33
37
  spawnServer(options = {}) {
34
38
  process.stdin.setRawMode?.(true);
35
- return spawn(
39
+ const server = spawn(
36
40
  'node',
37
41
  startServerArgs({
38
42
  configFilePath: this.cli?.configFilePath,
@@ -43,13 +47,47 @@ export class RocketStart {
43
47
  cwd: process.cwd(),
44
48
  },
45
49
  );
50
+ server.on('exit', (code, signal) => {
51
+ // ignore exits of already-replaced servers during a restart
52
+ if (this.server !== server) {
53
+ return;
54
+ }
55
+ this.server = undefined;
56
+ if (this.startOptions.watch !== false) {
57
+ console.log(
58
+ `Rocket dev server stopped (${signal ?? `code ${code}`}). ` +
59
+ 'Waiting for changes - press Ctrl+R to restart or Ctrl+C to quit.',
60
+ );
61
+ } else {
62
+ this.shutdown(typeof code === 'number' ? code : 1);
63
+ }
64
+ });
65
+ return server;
46
66
  }
47
67
 
48
68
  restartServer() {
49
- this.server?.kill();
50
- this.server = this.spawnServer(this.startOptions);
69
+ const oldServer = this.server;
70
+ this.server = undefined;
51
71
  console.clear();
52
72
  console.log('Restarting rocket server...');
73
+ if (oldServer && oldServer.exitCode === null && oldServer.signalCode === null) {
74
+ // wait for the old process to release its port before respawning
75
+ oldServer.once('exit', () => {
76
+ this.server = this.spawnServer(this.startOptions);
77
+ });
78
+ oldServer.kill();
79
+ } else {
80
+ this.server = this.spawnServer(this.startOptions);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * @param {number} code
86
+ */
87
+ shutdown(code) {
88
+ process.stdin.setRawMode?.(false);
89
+ this.server?.kill();
90
+ process.exit(code);
53
91
  }
54
92
 
55
93
  /**
@@ -71,25 +109,31 @@ export class RocketStart {
71
109
  }, 100),
72
110
  );
73
111
 
74
- // watch src
75
- watch(
76
- import.meta.dirname,
77
- { recursive: true },
78
- debounce(async (_, filename) => {
79
- if (!filename) {
80
- return;
81
- }
82
- this.restartServer();
83
- }, 100),
84
- );
112
+ // watch the framework source when running from a Rocket checkout
113
+ const frameworkSrcDir = path.join(import.meta.dirname, '..');
114
+ if (!frameworkSrcDir.includes('node_modules') && frameworkSrcDir.startsWith(process.cwd())) {
115
+ watch(
116
+ frameworkSrcDir,
117
+ { recursive: true },
118
+ debounce(async (_, filename) => {
119
+ if (!filename) {
120
+ return;
121
+ }
122
+ this.restartServer();
123
+ }, 100),
124
+ );
125
+ }
126
+ }
127
+
128
+ for (const signal of ['SIGINT', 'SIGTERM']) {
129
+ process.on(signal, () => this.shutdown(0));
85
130
  }
86
131
 
87
132
  process.stdin.on('data', data => {
88
133
  const char = data.toString();
89
134
  if (char === '\u0003' || char === '\u0004') {
90
135
  // Ctrl+C or Ctrl+D
91
- this.server?.kill();
92
- process.exit(0);
136
+ this.shutdown(0);
93
137
  } else if (char === '\u0012') {
94
138
  // Ctrl+R
95
139
  this.restartServer();
package/src/cli/cli.js CHANGED
@@ -1,7 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ /* eslint-disable no-console */
2
3
 
3
4
  import { RocketCli } from './RocketCli.js';
4
5
 
5
6
  const cli = new RocketCli();
6
7
 
7
- await cli.start();
8
+ try {
9
+ await cli.start();
10
+ } catch (error) {
11
+ console.error(error instanceof Error ? error.message : error);
12
+ if (process.env.ROCKET_DEBUG) {
13
+ console.error(error);
14
+ }
15
+ process.exit(1);
16
+ }