@rocket/js 0.1.2 → 0.2.1

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 (117) hide show
  1. package/README.md +8 -2
  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/ssr.d.ts +1 -1
  5. package/dist-types/exports/types/rocket.d.ts +60 -2
  6. package/dist-types/exports/types/rocket.d.ts.map +1 -1
  7. package/dist-types/exports/types.d.ts +1 -1
  8. package/dist-types/exports/types.d.ts.map +1 -1
  9. package/dist-types/src/PageData.d.ts +3 -1
  10. package/dist-types/src/PageData.d.ts.map +1 -1
  11. package/dist-types/src/RocketJsDemo.d.ts.map +1 -1
  12. package/dist-types/src/SocialPreviewPlayground.d.ts +1 -0
  13. package/dist-types/src/SocialPreviewPlayground.d.ts.map +1 -1
  14. package/dist-types/src/asyncMessage.d.ts +5 -0
  15. package/dist-types/src/asyncMessage.d.ts.map +1 -1
  16. package/dist-types/src/cli/RocketBuild.d.ts.map +1 -1
  17. package/dist-types/src/cli/RocketCli.d.ts.map +1 -1
  18. package/dist-types/src/cli/RocketStart.d.ts +6 -1
  19. package/dist-types/src/cli/RocketStart.d.ts.map +1 -1
  20. package/dist-types/src/component-hydration.d.ts.map +1 -1
  21. package/dist-types/src/components/RocketDrawer.d.ts +8 -0
  22. package/dist-types/src/components/RocketDrawer.d.ts.map +1 -1
  23. package/dist-types/src/components/RocketSocialLink.d.ts.map +1 -1
  24. package/dist-types/src/config.d.ts.map +1 -1
  25. package/dist-types/src/feeds.d.ts +38 -0
  26. package/dist-types/src/feeds.d.ts.map +1 -0
  27. package/dist-types/src/hydration/hydrationLoader.d.ts +13 -4
  28. package/dist-types/src/hydration/hydrationLoader.d.ts.map +1 -1
  29. package/dist-types/src/icons.d.ts.map +1 -1
  30. package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts +42 -0
  31. package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts.map +1 -0
  32. package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts.map +1 -1
  33. package/dist-types/src/layouts/atlas/atlasHeroLayout.d.ts.map +1 -1
  34. package/dist-types/src/layouts/atlas/atlasNotFoundLayout.d.ts.map +1 -1
  35. package/dist-types/src/layouts/layout-helper.d.ts +3 -3
  36. package/dist-types/src/layouts/layout-helper.d.ts.map +1 -1
  37. package/dist-types/src/markdownHook.d.ts.map +1 -1
  38. package/dist-types/src/menu.d.ts +8 -3
  39. package/dist-types/src/menu.d.ts.map +1 -1
  40. package/dist-types/src/menus/MainMenu.d.ts.map +1 -1
  41. package/dist-types/src/{development-page-module-loader.d.ts → page-module-loader.d.ts} +7 -2
  42. package/dist-types/src/page-module-loader.d.ts.map +1 -0
  43. package/dist-types/src/page-runtime.d.ts +11 -0
  44. package/dist-types/src/page-runtime.d.ts.map +1 -1
  45. package/dist-types/src/pages.d.ts.map +1 -1
  46. package/dist-types/src/prefer-project-packages-plugin.d.ts +9 -0
  47. package/dist-types/src/prefer-project-packages-plugin.d.ts.map +1 -0
  48. package/dist-types/src/project-package-resolution.d.ts +8 -0
  49. package/dist-types/src/project-package-resolution.d.ts.map +1 -0
  50. package/dist-types/src/resolve.d.ts.map +1 -1
  51. package/dist-types/src/route-pattern.d.ts +10 -0
  52. package/dist-types/src/route-pattern.d.ts.map +1 -0
  53. package/dist-types/src/siteDiscoverability.d.ts +4 -0
  54. package/dist-types/src/siteDiscoverability.d.ts.map +1 -1
  55. package/dist-types/src/socialPreviewImages.d.ts +2 -1
  56. package/dist-types/src/socialPreviewImages.d.ts.map +1 -1
  57. package/dist-types/src/ssr.d.ts +3 -0
  58. package/dist-types/src/ssr.d.ts.map +1 -1
  59. package/dist-types/src/static-params.d.ts +33 -0
  60. package/dist-types/src/static-params.d.ts.map +1 -0
  61. package/dist-types/src/transform.d.ts +4 -0
  62. package/dist-types/src/transform.d.ts.map +1 -1
  63. package/dist-types/src/wds-plugin.d.ts.map +1 -1
  64. package/exports/layouts/atlasBlog.js +11 -0
  65. package/exports/ssr.js +1 -1
  66. package/exports/types/rocket.test.ts +26 -0
  67. package/exports/types/rocket.ts +70 -2
  68. package/exports/types.ts +7 -0
  69. package/package.json +16 -31
  70. package/src/PageData.js +12 -2
  71. package/src/RocketJsDemo.js +1 -0
  72. package/src/SocialPreviewPlayground.js +26 -20
  73. package/src/asyncMessage.js +8 -2
  74. package/src/cli/RocketBuild.js +110 -13
  75. package/src/cli/RocketCli.js +11 -1
  76. package/src/cli/RocketStart.js +60 -16
  77. package/src/cli/cli.js +10 -1
  78. package/src/component-hydration.js +23 -3
  79. package/src/components/RocketDrawer.js +44 -10
  80. package/src/components/RocketSocialLink.js +26 -2
  81. package/src/config.js +33 -4
  82. package/src/feeds.js +212 -0
  83. package/src/hydration/hydrationLoader.js +80 -48
  84. package/src/icons.js +10 -1
  85. package/src/layouts/atlas/atlasBlog.css +143 -0
  86. package/src/layouts/atlas/atlasBlogLayout.js +234 -0
  87. package/src/layouts/atlas/atlasDocLayout.js +3 -2
  88. package/src/layouts/atlas/atlasHeroLayout.js +4 -6
  89. package/src/layouts/atlas/atlasNotFoundLayout.js +9 -21
  90. package/src/layouts/layout-helper.js +5 -5
  91. package/src/layouts/layout.js +2 -2
  92. package/src/loaded-page-module.js +3 -1
  93. package/src/main.js +2 -0
  94. package/src/markdownCompiler.js +2 -2
  95. package/src/markdownHook.js +7 -2
  96. package/src/menu.js +25 -19
  97. package/src/menus/MainMenu.js +10 -4
  98. package/src/{static-page-module-loader.js → page-module-loader.js} +6 -1
  99. package/src/page-pagination.js +2 -2
  100. package/src/page-runtime.js +48 -3
  101. package/src/pages.js +47 -56
  102. package/src/prefer-project-packages-plugin.js +31 -0
  103. package/src/project-package-resolution.js +36 -0
  104. package/src/publicAssets.js +2 -2
  105. package/src/resolve.js +32 -2
  106. package/src/route-pattern.js +21 -0
  107. package/src/siteDiscoverability.js +14 -1
  108. package/src/socialPreviewImages.js +80 -14
  109. package/src/ssr.js +3 -0
  110. package/src/standalone-demo-url.js +2 -2
  111. package/src/static-params.js +114 -0
  112. package/src/transform.js +93 -26
  113. package/src/wds-plugin.js +16 -3
  114. package/dist-types/src/development-page-module-loader.d.ts.map +0 -1
  115. package/dist-types/src/static-page-module-loader.d.ts +0 -15
  116. package/dist-types/src/static-page-module-loader.d.ts.map +0 -1
  117. 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);
@@ -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
+ }
@@ -30,7 +30,7 @@ export function createComponentHydration({
30
30
  serverOnlyComponents.push({ name, file, className, module });
31
31
  } else if (loading === 'client') {
32
32
  clientComponents.push({ name, file, className });
33
- } else if (loading.startsWith('hydrate:')) {
33
+ } else if (isHydrateLoading(loading)) {
34
34
  const module = await loadComponentModule(file);
35
35
  hydratedComponents.push({
36
36
  name,
@@ -39,13 +39,21 @@ export function createComponentHydration({
39
39
  module,
40
40
  strategy: loading.slice('hydrate:'.length),
41
41
  });
42
+ } else {
43
+ throw new Error(
44
+ `Registered Component "${name}" has an invalid loading value ${JSON.stringify(
45
+ loading,
46
+ )}. Use 'server', 'client', or 'hydrate:<strategy>'.`,
47
+ );
42
48
  }
43
49
  }
44
50
 
45
51
  const registry = customElementsRegistry || customElements;
46
- registry.__definitions.clear();
52
+ // The @lit-labs/ssr-dom-shim registry keeps definitions between renders;
53
+ // spec-compliant registries have no such internals to reset.
54
+ registry.__definitions?.clear();
47
55
  // @ts-ignore
48
- registry.__reverseDefinitions.clear();
56
+ registry.__reverseDefinitions?.clear();
49
57
  for (const component of serverOnlyComponents.concat(hydratedComponents)) {
50
58
  if (!component.module[component.className]) {
51
59
  throw new Error(
@@ -84,3 +92,15 @@ export function createComponentHydration({
84
92
  return clientCode;
85
93
  };
86
94
  }
95
+
96
+ /**
97
+ * @param {unknown} loading
98
+ * @returns {loading is string}
99
+ */
100
+ function isHydrateLoading(loading) {
101
+ return (
102
+ typeof loading === 'string' &&
103
+ loading.startsWith('hydrate:') &&
104
+ loading.length > 'hydrate:'.length
105
+ );
106
+ }
@@ -13,13 +13,47 @@ export class RocketDrawer extends LitElement {
13
13
  connectedCallback() {
14
14
  super.connectedCallback();
15
15
  this._onDocClick = this._onDocClick.bind(this);
16
+ this._onKeyDown = this._onKeyDown.bind(this);
16
17
  document.addEventListener('click', this._onDocClick, { capture: true });
18
+ document.addEventListener('keydown', this._onKeyDown);
17
19
  }
18
20
  disconnectedCallback() {
19
21
  document.removeEventListener('click', this._onDocClick, { capture: true });
22
+ document.removeEventListener('keydown', this._onKeyDown);
20
23
  super.disconnectedCallback();
21
24
  }
22
25
 
26
+ /**
27
+ * @param {KeyboardEvent} ev
28
+ */
29
+ _onKeyDown(ev) {
30
+ if (ev.key === 'Escape' && this.open) {
31
+ this.close();
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {Map<string, unknown>} changedProperties
37
+ */
38
+ updated(changedProperties) {
39
+ // skip the initial render so the drawer never steals focus on page load
40
+ if (!changedProperties.has('open') || changedProperties.get('open') === undefined) {
41
+ return;
42
+ }
43
+ if (this.open) {
44
+ this.shadowRoot?.getElementById('content')?.focus();
45
+ } else {
46
+ const invokerSlot = /** @type {HTMLSlotElement | null} */ (
47
+ this.shadowRoot?.querySelector('slot[name="invoker"]') ?? null
48
+ );
49
+ const assigned = /** @type {HTMLElement | undefined} */ (invokerSlot?.assignedElements()[0]);
50
+ const fallback = /** @type {HTMLElement | null} */ (
51
+ this.shadowRoot?.querySelector('.burger-menu') ?? null
52
+ );
53
+ (assigned ?? fallback)?.focus();
54
+ }
55
+ }
56
+
23
57
  render() {
24
58
  return html`
25
59
  <slot name="invoker">
@@ -36,7 +70,7 @@ export class RocketDrawer extends LitElement {
36
70
  </slot>
37
71
 
38
72
  <dialog ?open=${this.open} id="rd-dialog">
39
- <div id="content">
73
+ <div id="content" tabindex="-1">
40
74
  <slot></slot>
41
75
  </div>
42
76
  </dialog>
@@ -149,23 +183,23 @@ export class RocketDrawer extends LitElement {
149
183
  overflow: hidden;
150
184
  opacity: 0;
151
185
  pointer-events: none;
186
+ /* visibility keeps the closed drawer out of tab order and the a11y tree */
187
+ visibility: hidden;
152
188
  transition:
153
189
  width 0.35s var(--rd-ease-3),
154
- opacity 0.35s var(--rd-ease-3);
190
+ opacity 0.35s var(--rd-ease-3),
191
+ visibility 0s var(--rd-ease-3) 0.35s;
155
192
  will-change: width;
156
193
  }
157
194
  dialog[open] {
158
195
  width: var(--rd-target-width);
159
196
  opacity: 1;
160
197
  pointer-events: auto;
161
- }
162
- dialog::backdrop {
163
- background: rgba(0, 0, 0, 0.4);
164
- transition: opacity 0.35s var(--rd-ease-3);
165
- opacity: 0;
166
- }
167
- dialog[open]::backdrop {
168
- opacity: 1;
198
+ visibility: visible;
199
+ transition:
200
+ width 0.35s var(--rd-ease-3),
201
+ opacity 0.35s var(--rd-ease-3),
202
+ visibility 0s;
169
203
  }
170
204
 
171
205
  #content {
@@ -2,14 +2,38 @@ import { readFileSync } from 'fs';
2
2
  import { LitElement, html, css } from 'lit';
3
3
  import { unsafeHTML } from 'lit/directives/unsafe-html.js';
4
4
 
5
+ const SUPPORTED_BRANDS = new Set([
6
+ 'discord',
7
+ 'github',
8
+ 'gitlab',
9
+ 'license',
10
+ 'npm',
11
+ 'slack',
12
+ 'telegram',
13
+ 'twitter',
14
+ ]);
15
+
16
+ /** @type {Map<string, string>} */
17
+ const iconCache = new Map();
18
+
5
19
  /**
6
20
  * @param {string} brandName
7
21
  * @returns {import('lit/directive.js').DirectiveResult}
8
22
  */
9
23
  function getIcon(brandName) {
10
24
  const brand = brandName.toLowerCase();
11
- const fileContent = readFileSync(new URL(`./assets/${brand}.svg`, import.meta.url), 'utf8');
12
- return unsafeHTML(fileContent.toString());
25
+ if (!SUPPORTED_BRANDS.has(brand)) {
26
+ throw new Error(
27
+ `rocket-social-link does not support name="${brandName}". ` +
28
+ `Supported names: ${[...SUPPORTED_BRANDS].join(', ')}.`,
29
+ );
30
+ }
31
+ let fileContent = iconCache.get(brand);
32
+ if (fileContent === undefined) {
33
+ fileContent = readFileSync(new URL(`./assets/${brand}.svg`, import.meta.url), 'utf8');
34
+ iconCache.set(brand, fileContent);
35
+ }
36
+ return unsafeHTML(fileContent);
13
37
  }
14
38
 
15
39
  export class RocketSocialLink extends LitElement {