@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
@@ -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
  /**
@@ -0,0 +1,31 @@
1
+ import { resolveProjectBrowserPackage } from './project-package-resolution.js';
2
+ import { resolve } from './resolve.js';
3
+
4
+ /**
5
+ * Keeps browser packages shared between a Site Author project and a linked Rocket checkout.
6
+ * Packages absent from the project are left to normal Node resolution, which falls back to
7
+ * Rocket's dependency.
8
+ *
9
+ * @returns {import('@web/dev-server-core').Plugin}
10
+ */
11
+ export function preferProjectPackagesPlugin() {
12
+ /** @type {Map<string, string | undefined>} */
13
+ const resolvedImports = new Map();
14
+
15
+ return {
16
+ name: 'rocket-prefer-project-packages',
17
+ resolveImport({ source }) {
18
+ if (resolvedImports.has(source)) {
19
+ return resolvedImports.get(source);
20
+ }
21
+ let resolved;
22
+ try {
23
+ resolved = resolveProjectBrowserPackage(source) ? resolve(source, import.meta) : undefined;
24
+ } catch {
25
+ resolved = undefined;
26
+ }
27
+ resolvedImports.set(source, resolved);
28
+ return resolved;
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,36 @@
1
+ import { moduleResolve } from 'import-meta-resolve';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const browserImportConditions = new Set(['browser', 'import', 'module']);
6
+
7
+ /**
8
+ * Resolve a bare package import from the Site Author project using browser ESM conditions.
9
+ * Built-in modules and packages absent from the project are left to another resolver.
10
+ *
11
+ * @param {string} specifier
12
+ */
13
+ export function resolveProjectBrowserPackage(specifier) {
14
+ if (!isPackageSpecifier(specifier)) {
15
+ return undefined;
16
+ }
17
+ try {
18
+ const projectParent = pathToFileURL(path.join(process.cwd(), 'package.json'));
19
+ const resolved = moduleResolve(specifier, projectParent, browserImportConditions, false);
20
+ return resolved.protocol === 'file:' ? resolved.href : undefined;
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * @param {string} specifier
28
+ */
29
+ function isPackageSpecifier(specifier) {
30
+ return (
31
+ !specifier.startsWith('.') &&
32
+ !specifier.startsWith('/') &&
33
+ !specifier.startsWith('#') &&
34
+ !URL.canParse(specifier)
35
+ );
36
+ }
@@ -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
  }
package/src/resolve.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from 'path';
2
2
  import { fileURLToPath } from 'url';
3
+ import { resolveProjectBrowserPackage } from './project-package-resolution.js';
3
4
 
4
5
  /**
5
6
  * @param {string} url
@@ -9,7 +10,36 @@ import { fileURLToPath } from 'url';
9
10
  export function resolve(url, meta) {
10
11
  // get current directory
11
12
  const outputFilePath = process.cwd();
12
- const resolvedPath = meta.resolve(url);
13
+ const resolvedPath = resolveProjectBrowserPackage(url) ?? meta.resolve(url);
13
14
  const rel = path.relative(outputFilePath, fileURLToPath(resolvedPath));
14
- return (process.env.ENVIRONMENT === 'BUILD' ? '/../' : '/') + rel.split(path.sep).join('/');
15
+ if (process.env.ENVIRONMENT === 'BUILD') {
16
+ return `/../${toBrowserPath(rel)}`;
17
+ }
18
+ return developmentBrowserPath(rel);
19
+ }
20
+
21
+ /**
22
+ * Web Dev Server represents files outside its root with a path that records how many parent
23
+ * directories to traverse. Browsers normalize ordinary `/../` segments before making a request,
24
+ * so linked packages need this explicit form.
25
+ *
26
+ * @param {string} relativePath
27
+ */
28
+ function developmentBrowserPath(relativePath) {
29
+ const parts = relativePath.split(path.sep);
30
+ let outsideRootDepth = 0;
31
+ while (parts[outsideRootDepth] === '..') {
32
+ outsideRootDepth += 1;
33
+ }
34
+ const browserPath = parts.slice(outsideRootDepth).join('/');
35
+ return outsideRootDepth
36
+ ? `/__wds-outside-root__/${outsideRootDepth}/${browserPath}`
37
+ : `/${browserPath}`;
38
+ }
39
+
40
+ /**
41
+ * @param {string} filePath
42
+ */
43
+ function toBrowserPath(filePath) {
44
+ return filePath.split(path.sep).join('/');
15
45
  }
@@ -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) {
package/src/ssr.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /** Runs on: imported-js */
2
2
  import { render } from '@lit-labs/ssr';
3
3
  import { collectResult } from '@lit-labs/ssr/lib/render-result.js';
4
+ import { html } from 'lit';
5
+
6
+ export { html, render };
4
7
 
5
8
  /**
6
9
  * @param {string | import('lit').TemplateResult} template
@@ -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