@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
package/src/menu.js CHANGED
@@ -34,13 +34,14 @@ export function treeFromPages(pages) {
34
34
  continue;
35
35
  }
36
36
  const parts = getMenuParts(url, module);
37
+ if (parts.some(part => part.startsWith(':'))) {
38
+ // Dynamic paths have no single public URL, so they get no menu entry.
39
+ continue;
40
+ }
37
41
  let obj = children;
38
42
  /** @type {any} */
39
43
  let last = {};
40
44
  for (const part of parts) {
41
- if (part.startsWith(':')) {
42
- break;
43
- }
44
45
  const get = obj.find(leaf => leaf.name === part);
45
46
  if (!get) {
46
47
  /** @type {import('@rocket/js/types.js').PageTree[]} */
@@ -137,19 +138,25 @@ function sortRecursive(children) {
137
138
  }
138
139
 
139
140
  /**
141
+ * The itemFn receives the rendered child list (or lit `nothing`) and must place
142
+ * it inside the returned item so nested lists stay valid HTML.
143
+ *
140
144
  * @param {import("@rocket/js/types.js").PageTree} tree
141
145
  * @param {string} listTagName
142
- * @param {(name: string, url: string, module: import("@rocket/js/types.js").Module, linkText: string) => import("lit").TemplateResult} itemFn
146
+ * @param {(name: string, url: string, module: import("@rocket/js/types.js").Module, linkText: string, children: import("lit").TemplateResult | typeof nothing) => import("lit").TemplateResult} itemFn
147
+ * @returns {import("lit").TemplateResult}
143
148
  */
144
149
  export function treeToHtml(tree, listTagName, itemFn) {
145
150
  return html`${unsafeHTML(`<${listTagName}>`)}${join(
146
- tree.children.map(page => {
147
- let a = itemFn(page.name, page.url, page.module, page.linkText);
148
- if (page.children.length) {
149
- a = html`${a}${treeToHtml(page, listTagName, itemFn)}`;
150
- }
151
- return a;
152
- }),
151
+ tree.children.map(page =>
152
+ itemFn(
153
+ page.name,
154
+ page.url,
155
+ page.module,
156
+ page.linkText,
157
+ page.children.length ? treeToHtml(page, listTagName, itemFn) : nothing,
158
+ ),
159
+ ),
153
160
  nothing,
154
161
  )}${unsafeHTML(`</${listTagName}>`)}`;
155
162
  }
@@ -158,14 +165,13 @@ export function treeToHtml(tree, listTagName, itemFn) {
158
165
  * @param {import('@rocket/js/types.js').PageTree} pageTree
159
166
  */
160
167
  export function defaultHtmlMenu(pageTree) {
161
- return treeToHtml(
162
- pageTree,
163
- 'ul',
164
- (_name, url, _module, linkText) =>
165
- html`<li>
166
- <a href=${url}>${linkText}</a>
167
- </li>`,
168
- );
168
+ return treeToHtml(pageTree, 'ul', (_name, url, module, linkText, children) => {
169
+ const menu = module.config.menu;
170
+ const noLink = !url || menu === false || (typeof menu === 'object' && menu?.noLink);
171
+ return html`<li>
172
+ ${noLink ? html`<span>${linkText}</span>` : html`<a href=${url}>${linkText}</a>`}${children}
173
+ </li>`;
174
+ });
169
175
  }
170
176
 
171
177
  /** Runs on: import-hook */
@@ -32,15 +32,21 @@ export class MainMenu extends LitElement {
32
32
  */
33
33
  list(page, depth = 0) {
34
34
  const title = page.linkText;
35
+ const isCurrent = page.url === this.currentPath;
35
36
  /** @type {any} */
36
- const classes = { current: page.url === this.currentPath };
37
+ const classes = { current: isCurrent };
37
38
  const level = `lvl-${depth}`;
38
39
  classes[level] = true;
39
40
  return html`
40
41
  <li>
41
- <a href=${ifDefined(page.menuNoLink ? undefined : page.url)} class=${classMap(classes)}
42
- >${title}</a
43
- >
42
+ ${page.menuNoLink
43
+ ? html`<span class=${classMap(classes)}>${title}</span>`
44
+ : html`<a
45
+ href=${ifDefined(page.url)}
46
+ class=${classMap(classes)}
47
+ aria-current=${ifDefined(isCurrent ? 'page' : undefined)}
48
+ >${title}</a
49
+ >`}
44
50
  ${page.children.length > 0
45
51
  ? html`<ul>
46
52
  ${page.children.map(child => this.list(child, depth + 1))}
@@ -3,7 +3,12 @@ import { parseComponents } from './components.js';
3
3
 
4
4
  /** @typedef {import('./page-runtime.js').PageModuleLoaderOptions} PageModuleLoaderOptions */
5
5
 
6
- export function createStaticPageModuleLoader() {
6
+ /**
7
+ * The Page Module Loader used by both the development server and static builds.
8
+ * Loading goes through Rocket's import hooks, which behave the same in both
9
+ * runtime environments.
10
+ */
11
+ export function createPageModuleLoader() {
7
12
  return {
8
13
  /**
9
14
  * @param {PageModuleLoaderOptions} options
@@ -1,4 +1,4 @@
1
- import { URLPattern } from 'urlpattern-polyfill';
1
+ import { routePattern } from './route-pattern.js';
2
2
  import { PageData } from './PageData.js';
3
3
  import { normalizeDocumentPath } from './standalone-demo-url.js';
4
4
 
@@ -229,7 +229,7 @@ function parsePaginatedArchivePath(pathname) {
229
229
  * @param {string} routePath
230
230
  */
231
231
  function matchPagePath(pathname, origin, routePath) {
232
- const pattern = new URLPattern({ pathname: routePath });
232
+ const pattern = routePattern(routePath);
233
233
  return pattern.exec(pathname, origin);
234
234
  }
235
235
 
@@ -1,9 +1,10 @@
1
1
  import { collectResult } from '@lit-labs/ssr/lib/render-result.js';
2
- import { URLPattern } from 'urlpattern-polyfill';
2
+ import { routePattern } from './route-pattern.js';
3
3
  import { layout, singleDemoLayout } from './layouts/layout.js';
4
4
  import { PageData } from './PageData.js';
5
5
  import { finalizeRocketIcons } from './icons.js';
6
6
  import { matchPaginatedArchivePath, pageDataWithPagination } from './page-pagination.js';
7
+ import { createPageFeed, matchPageFeedPath } from './feeds.js';
7
8
  import { matchStandaloneDemoUrl } from './standalone-demo-url.js';
8
9
 
9
10
  /** @typedef {import('@rocket/js/types.js').PageRegistry} PageRegistry */
@@ -86,6 +87,14 @@ export class PageRuntime {
86
87
  });
87
88
  }
88
89
 
90
+ // A configured Page at the exact request path always wins over a Page Feed.
91
+ const feedMatch = this.pages.has(url.pathname)
92
+ ? null
93
+ : matchPageFeedPath(url.pathname, this.pages);
94
+ if (feedMatch) {
95
+ return this.renderPageFeed(feedMatch, url.origin);
96
+ }
97
+
89
98
  const pageMatch = findPage(url.pathname, url.origin, this.pages);
90
99
  if (!pageMatch) {
91
100
  return new Response('Page not found', { status: 404 });
@@ -134,11 +143,47 @@ export class PageRuntime {
134
143
  });
135
144
  }
136
145
 
137
- throw new Error('Unsupported Page module kind');
146
+ throw new PageRuntimeError(
147
+ 'INVALID_PAGE_MODULE',
148
+ `Unsupported Page module kind for ${pageMatch.routePath}`,
149
+ { page: pageMatch.page, routePath: pageMatch.routePath },
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Development falls back to the request origin so Page Feeds work before a
155
+ * Site Origin is configured; builds always pass the configured Site Origin.
156
+ *
157
+ * @param {{ page: Page; routePath: string }} feedMatch
158
+ * @param {string} requestOrigin
159
+ */
160
+ renderPageFeed(feedMatch, requestOrigin) {
161
+ /** @type {string} */
162
+ let feed;
163
+ try {
164
+ feed = createPageFeed({
165
+ pages: this.pages,
166
+ page: feedMatch.page,
167
+ pagePath: feedMatch.routePath,
168
+ siteOrigin: this.siteOrigin || requestOrigin,
169
+ });
170
+ } catch (error) {
171
+ throw new PageRuntimeError(
172
+ 'PAGE_RENDER_FAILED',
173
+ `Failed to render Page Feed for ${feedMatch.page.file}`,
174
+ { cause: error, page: feedMatch.page, routePath: feedMatch.routePath },
175
+ );
176
+ }
177
+ return new Response(feed, {
178
+ headers: { 'content-type': 'application/atom+xml; charset=utf-8' },
179
+ });
138
180
  }
139
181
  }
140
182
 
141
183
  /**
184
+ * Redirect sources intentionally match the request path exactly (no
185
+ * trailing-slash normalization) so authors control both URL forms explicitly.
186
+ *
142
187
  * @param {string} pathname
143
188
  * @param {RedirectConfig[] | undefined} redirects
144
189
  */
@@ -466,7 +511,7 @@ function archivePageNumber(variant) {
466
511
  * @param {string} routePath
467
512
  */
468
513
  function matchPagePath(pathname, origin, routePath) {
469
- const pattern = new URLPattern({ pathname: routePath });
514
+ const pattern = routePattern(routePath);
470
515
  return pattern.exec(pathname, origin);
471
516
  }
472
517
 
package/src/pages.js CHANGED
@@ -21,56 +21,49 @@ export async function getPages(root, include, exclude) {
21
21
  cwd: root,
22
22
  exclude: ignoreFn(exclude),
23
23
  })) {
24
- if (file.endsWith('.rocket.md')) {
25
- const module = await tryImport(`./${p.relative(process.cwd(), file)}`);
26
- const path = module.config?.path;
27
- if (!path) {
28
- throw new Error('No path found in file: ' + file);
29
- }
30
- if (pages.has(path)) {
31
- throw new Error(
32
- 'Duplicate path found: ' + path + ', used in ' + file + ' and ' + pages.get(path)?.file,
33
- );
34
- }
35
- const normalizedModule = normalizePageModule(file, module);
24
+ const kind = file.endsWith('.rocket.md')
25
+ ? 'md'
26
+ : file.endsWith('.rocket.js')
27
+ ? 'js'
28
+ : undefined;
29
+ if (!kind) {
30
+ continue;
31
+ }
32
+ const specifier = `./${p.relative(process.cwd(), p.resolve(root, file))}`;
33
+ /** @type {import('@rocket/js/types.js').Module} */
34
+ const module =
35
+ kind === 'md'
36
+ ? await tryImport(specifier)
37
+ : await import(specifier, { with: { type: 'rocketSetupJsInitial' } });
38
+ const path = module.config?.path;
39
+ if (!path) {
40
+ throw new Error('No path found in file: ' + file);
41
+ }
42
+ if (pages.has(path)) {
43
+ throw new Error(
44
+ 'Duplicate path found: ' + path + ', used in ' + file + ' and ' + pages.get(path)?.file,
45
+ );
46
+ }
47
+ const normalizedModule = normalizePageModule(file, module);
48
+ if (kind === 'md') {
36
49
  validateMarkdownPageComponents(file, normalizedModule);
37
- const linkText =
38
- (normalizedModule.config.menu === false
39
- ? undefined
40
- : normalizedModule.config.menu?.linkText) || normalizedModule._$menuLinkText$;
41
- const metadataConfig = normalizePageMetadataConfig(file, normalizedModule.config.metadata);
42
- const title = metadataConfig.title || module._$title$ || linkText || titleFromPath(path);
43
- pages.set(path, {
44
- file,
45
- module: normalizedModule,
46
- metadata: pageMetadata(title, linkText, metadataConfig),
47
- demoNames: normalizedModule._$demoNames$ || [],
48
- });
49
- } else if (file.endsWith('.rocket.js')) {
50
- /** @type {import('@rocket/js/types.js').Module} */
51
- const module = await import(`./${p.relative(process.cwd(), file)}`, {
52
- with: { type: 'rocketSetupJsInitial' },
53
- });
54
- const path = module.config?.path;
55
- if (!path) {
56
- throw new Error('No path found in file: ' + file);
57
- }
58
- if (pages.has(path)) {
59
- throw new Error(
60
- 'Duplicate path found: ' + path + ', used in ' + file + ' and ' + pages.get(path)?.file,
61
- );
62
- }
63
- const normalizedModule = normalizePageModule(file, module);
64
- const linkText =
65
- normalizedModule.config.menu === false ? undefined : normalizedModule.config.menu?.linkText;
66
- const metadataConfig = normalizePageMetadataConfig(file, normalizedModule.config.metadata);
67
- const title = metadataConfig.title || linkText || titleFromPath(path);
68
- pages.set(path, {
69
- file,
70
- module: normalizedModule,
71
- metadata: pageMetadata(title, linkText, metadataConfig),
72
- });
73
50
  }
51
+ const configLinkText =
52
+ normalizedModule.config.menu === false ? undefined : normalizedModule.config.menu?.linkText;
53
+ const linkText =
54
+ kind === 'md' ? configLinkText || normalizedModule._$menuLinkText$ : configLinkText;
55
+ const metadataConfig = normalizePageMetadataConfig(file, normalizedModule.config.metadata);
56
+ const title =
57
+ metadataConfig.title ||
58
+ (kind === 'md' ? module._$title$ : undefined) ||
59
+ linkText ||
60
+ titleFromPath(path);
61
+ pages.set(path, {
62
+ file,
63
+ module: normalizedModule,
64
+ metadata: pageMetadata(title, linkText, metadataConfig),
65
+ ...(kind === 'md' ? { demoNames: normalizedModule._$demoNames$ || [] } : {}),
66
+ });
74
67
  }
75
68
  return pages;
76
69
  }
@@ -448,10 +441,11 @@ function ignoreFn(exclude) {
448
441
  * @param {string} path
449
442
  */
450
443
  return function (path) {
444
+ if (path.includes('node_modules')) {
445
+ return true;
446
+ }
451
447
  for (const pattern of exclude) {
452
- if (path.includes('node_modules')) {
453
- return true;
454
- } else if (typeof pattern === 'string') {
448
+ if (typeof pattern === 'string') {
455
449
  if (path.includes(pattern)) return true;
456
450
  } else {
457
451
  if (pattern.test(path)) return true;
@@ -466,12 +460,9 @@ function ignoreFn(exclude) {
466
460
  * @returns {string}
467
461
  */
468
462
  function titleFromPath(path) {
469
- const parts = path.split('/');
463
+ const parts = path.split('/').filter(Boolean);
470
464
  const last = parts.findLast(part => !part.startsWith(':'));
471
- if (!last) {
472
- return parts[0] ? capitalize(parts[0].slice(1)) : 'Home';
473
- }
474
- return capitalize(last);
465
+ return last ? capitalize(last) : 'Home';
475
466
  }
476
467
 
477
468
  /**
@@ -1,6 +1,6 @@
1
1
  import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { URLPattern } from 'urlpattern-polyfill';
3
+ import { routePattern } from './route-pattern.js';
4
4
  import { paginatedArchivePaths } from './page-pagination.js';
5
5
  import { normalizeDocumentPath, standaloneDemoPaths } from './standalone-demo-url.js';
6
6
  import { needsDefaultSocialPreviewImage, publicSocialPreviewPages } from './socialPreviewImages.js';
@@ -250,7 +250,7 @@ function generatedOutputClaims({ pages, generatedPages, config, emitRedirectFall
250
250
  */
251
251
  function matchingPage(requestPath, pages) {
252
252
  for (const [routePath, page] of pages) {
253
- const pattern = new URLPattern({ pathname: routePath });
253
+ const pattern = routePattern(routePath);
254
254
  if (pattern.exec(requestPath, 'http://localhost')) {
255
255
  return { routePath, page };
256
256
  }
@@ -0,0 +1,21 @@
1
+ /** Runs on: server */
2
+ import { URLPattern } from 'urlpattern-polyfill';
3
+
4
+ /** @type {Map<string, URLPattern>} */
5
+ const patterns = new Map();
6
+
7
+ /**
8
+ * Compiling a URLPattern is comparatively expensive and route paths repeat on
9
+ * every request, so compiled patterns are cached per route path.
10
+ *
11
+ * @param {string} routePath
12
+ * @returns {URLPattern}
13
+ */
14
+ export function routePattern(routePath) {
15
+ let pattern = patterns.get(routePath);
16
+ if (!pattern) {
17
+ pattern = new URLPattern({ pathname: routePath });
18
+ patterns.set(routePath, pattern);
19
+ }
20
+ return pattern;
21
+ }
@@ -5,6 +5,7 @@ import {
5
5
  paginatedArchivePath,
6
6
  paginatedArchivePaths,
7
7
  } from './page-pagination.js';
8
+ import { hasStaticParams, staticParamsPagePaths } from './static-params.js';
8
9
 
9
10
  /**
10
11
  * @param {{
@@ -55,6 +56,10 @@ export function createSitemap({ pages, siteOrigin }) {
55
56
  }
56
57
 
57
58
  /**
59
+ * The Robots File always references the canonical Sitemap URL, even when
60
+ * Rocket does not generate `sitemap.xml` itself — this is documented behavior
61
+ * so projects can supply a sitemap through other tooling.
62
+ *
58
63
  * @param {{
59
64
  * pages?: import('@rocket/js/types.js').PageRegistry;
60
65
  * siteOrigin: string;
@@ -82,7 +87,12 @@ export function createRobotsFile({ pages = new Map(), siteOrigin }) {
82
87
  function sitemapPageLocations({ pages, siteOrigin }) {
83
88
  return Array.from(pages.entries())
84
89
  .filter(([pagePath, page]) => {
85
- return !hasPathParameter(pagePath) && page.module.config.discoverability?.sitemap !== false;
90
+ // Parameterized Pages join the Sitemap only when static params enumerate
91
+ // their public URLs.
92
+ return (
93
+ (!hasPathParameter(pagePath) || hasStaticParams(page)) &&
94
+ page.module.config.discoverability?.sitemap !== false
95
+ );
86
96
  })
87
97
  .flatMap(([pagePath, page]) => discoverabilityPagePaths({ pages, pagePath, page }))
88
98
  .map(pagePath => absolutePageUrl(siteOrigin, pagePath))
@@ -108,6 +118,9 @@ function robotsDisallowPaths({ pages }) {
108
118
  * }} options
109
119
  */
110
120
  function discoverabilityPagePaths({ pages, pagePath, page }) {
121
+ if (hasStaticParams(page)) {
122
+ return staticParamsPagePaths({ pages, page, pagePath });
123
+ }
111
124
  if (!hasPagePagination(page)) {
112
125
  return [pagePath];
113
126
  }
@@ -57,7 +57,7 @@ export async function generateStaticDefaultSocialPreviewImages({
57
57
  siteHeadMetadata,
58
58
  siteOrigin,
59
59
  cacheDirectory,
60
- captureSocialPreviewImage = captureSocialPreviewImageWithBrowser,
60
+ captureSocialPreviewImage,
61
61
  }) {
62
62
  /** @type {Map<string, DefaultSocialPreviewImageOutput>} */
63
63
  const outputs = new Map();
@@ -71,6 +71,43 @@ export async function generateStaticDefaultSocialPreviewImages({
71
71
  );
72
72
  }
73
73
 
74
+ // Launching Chromium costs seconds; share one browser across all uncached captures.
75
+ /** @type {import('puppeteer-core').Browser | undefined} */
76
+ let sharedBrowser;
77
+ /** @type {SocialPreviewCapture} */
78
+ const capture =
79
+ captureSocialPreviewImage ??
80
+ (async ({ html }) => {
81
+ sharedBrowser ??= await puppeteer.launch(await createSocialPreviewBrowserLaunchOptions());
82
+ return renderSocialPreviewImage(html, sharedBrowser);
83
+ });
84
+
85
+ try {
86
+ await generateInto({ outputs, pages, siteHeadMetadata, siteOrigin, cacheDirectory, capture });
87
+ } finally {
88
+ await sharedBrowser?.close();
89
+ }
90
+ return outputs;
91
+ }
92
+
93
+ /**
94
+ * @param {{
95
+ * outputs: Map<string, DefaultSocialPreviewImageOutput>;
96
+ * pages: import('@rocket/js/types.js').PageRegistry;
97
+ * siteHeadMetadata: import('@rocket/js/types.js').SiteHeadMetadataConfig;
98
+ * siteOrigin: string;
99
+ * cacheDirectory?: string;
100
+ * capture: SocialPreviewCapture;
101
+ * }} options
102
+ */
103
+ async function generateInto({
104
+ outputs,
105
+ pages,
106
+ siteHeadMetadata,
107
+ siteOrigin,
108
+ cacheDirectory,
109
+ capture,
110
+ }) {
74
111
  for (const [pagePath, page] of pages) {
75
112
  if (!needsDefaultSocialPreviewImage({ pagePath, page, siteHeadMetadata })) {
76
113
  continue;
@@ -101,7 +138,7 @@ export async function generateStaticDefaultSocialPreviewImages({
101
138
  }
102
139
 
103
140
  try {
104
- const data = await captureSocialPreviewImage({
141
+ const data = await capture({
105
142
  html,
106
143
  width: DEFAULT_SOCIAL_PREVIEW_IMAGE_WIDTH,
107
144
  height: DEFAULT_SOCIAL_PREVIEW_IMAGE_HEIGHT,
@@ -121,7 +158,6 @@ export async function generateStaticDefaultSocialPreviewImages({
121
158
  );
122
159
  }
123
160
  }
124
- return outputs;
125
161
  }
126
162
 
127
163
  /**
@@ -332,34 +368,39 @@ export async function captureSocialPreviewImageWithBrowser({ html }) {
332
368
 
333
369
  /**
334
370
  * @param {string} html
371
+ * @param {import('puppeteer-core').Browser} [existingBrowser] reused across captures; the caller owns closing it
335
372
  * @returns {Promise<Buffer>}
336
373
  */
337
- export async function renderSocialPreviewImage(html) {
338
- /** @type {import('puppeteer-core').Browser | undefined} */
339
- let browser;
374
+ export async function renderSocialPreviewImage(html, existingBrowser) {
375
+ const browser =
376
+ existingBrowser ?? (await puppeteer.launch(await createSocialPreviewBrowserLaunchOptions()));
377
+ /** @type {import('puppeteer-core').Page | undefined} */
378
+ let page;
340
379
  try {
341
- browser = await puppeteer.launch(await createSocialPreviewBrowserLaunchOptions());
342
- const page = await browser.newPage();
343
- await page.setViewport({
380
+ const capturePage = await browser.newPage();
381
+ page = capturePage;
382
+ await capturePage.setViewport({
344
383
  width: DEFAULT_SOCIAL_PREVIEW_IMAGE_WIDTH,
345
384
  height: DEFAULT_SOCIAL_PREVIEW_IMAGE_HEIGHT,
346
385
  deviceScaleFactor: 1,
347
386
  });
348
387
 
349
388
  await runSocialPreviewCaptureStep('set Social Preview HTML content', () =>
350
- page.setContent(html, { waitUntil: 'load' }),
389
+ capturePage.setContent(html, { waitUntil: 'load' }),
351
390
  );
352
391
  await runSocialPreviewCaptureStep('wait for Social Preview fonts', () =>
353
- page.evaluateHandle('document.fonts.ready'),
392
+ capturePage.evaluateHandle('document.fonts.ready'),
354
393
  );
355
394
  const screenshot = await runSocialPreviewCaptureStep('capture Social Preview screenshot', () =>
356
- page.screenshot({
395
+ capturePage.screenshot({
357
396
  type: 'png',
358
397
  }),
359
398
  );
360
399
  return Buffer.from(screenshot);
361
400
  } finally {
362
- if (browser) {
401
+ if (existingBrowser) {
402
+ await page?.close();
403
+ } else {
363
404
  await browser.close();
364
405
  }
365
406
  }
@@ -401,7 +442,21 @@ export async function resolveSocialPreviewBrowserExecutablePath({
401
442
  return explicitPath;
402
443
  }
403
444
  if (platform === 'linux') {
404
- return chromiumExecutablePath();
445
+ // Prefer the bundled serverless Chromium (CI/deploy targets); fall back to
446
+ // a locally installed browser on regular Linux machines.
447
+ try {
448
+ return await chromiumExecutablePath();
449
+ } catch (error) {
450
+ const localBrowserPath = findLocalBrowserExecutable({ platform, env, fileExists });
451
+ if (localBrowserPath) {
452
+ return localBrowserPath;
453
+ }
454
+ throw new Error(
455
+ `Unable to resolve a browser for Social Preview Capture. ` +
456
+ `Install Google Chrome or set PUPPETEER_EXECUTABLE_PATH to a Chromium-compatible browser.`,
457
+ { cause: error },
458
+ );
459
+ }
405
460
  }
406
461
  const localBrowserPath = findLocalBrowserExecutable({ platform, env, fileExists });
407
462
  if (localBrowserPath) {
@@ -431,6 +486,17 @@ function findLocalBrowserExecutable({ platform, env, fileExists }) {
431
486
  '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
432
487
  );
433
488
  }
489
+ if (platform === 'linux') {
490
+ paths.push(
491
+ '/usr/bin/google-chrome',
492
+ '/usr/bin/google-chrome-stable',
493
+ '/usr/bin/chromium',
494
+ '/usr/bin/chromium-browser',
495
+ '/snap/bin/chromium',
496
+ '/usr/bin/microsoft-edge',
497
+ '/usr/bin/brave-browser',
498
+ );
499
+ }
434
500
  if (platform === 'win32') {
435
501
  for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {
436
502
  if (!root) {
@@ -1,4 +1,4 @@
1
- import { URLPattern } from 'urlpattern-polyfill';
1
+ import { routePattern } from './route-pattern.js';
2
2
 
3
3
  /** @typedef {import('@rocket/js/types.js').PageRegistry} PageRegistry */
4
4
  /** @typedef {import('@rocket/js/types.js').Page} Page */
@@ -124,7 +124,7 @@ export function normalizeDocumentPath(pagePath) {
124
124
  * @param {string} routePath
125
125
  */
126
126
  function matchPagePath(pathname, origin, routePath) {
127
- const pattern = new URLPattern({ pathname: routePath });
127
+ const pattern = routePattern(routePath);
128
128
  return pattern.exec(pathname, origin);
129
129
  }
130
130