@rocket/js 0.1.2 → 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.
- package/README.md +8 -2
- package/dist-types/exports/layouts/atlasBlog.d.ts +2 -0
- package/dist-types/exports/layouts/atlasBlog.d.ts.map +1 -0
- package/dist-types/exports/types/rocket.d.ts +60 -2
- package/dist-types/exports/types/rocket.d.ts.map +1 -1
- package/dist-types/exports/types.d.ts +1 -1
- package/dist-types/exports/types.d.ts.map +1 -1
- package/dist-types/src/PageData.d.ts +3 -1
- package/dist-types/src/PageData.d.ts.map +1 -1
- package/dist-types/src/RocketJsDemo.d.ts.map +1 -1
- package/dist-types/src/SocialPreviewPlayground.d.ts +1 -0
- package/dist-types/src/SocialPreviewPlayground.d.ts.map +1 -1
- package/dist-types/src/asyncMessage.d.ts +5 -0
- package/dist-types/src/asyncMessage.d.ts.map +1 -1
- package/dist-types/src/cli/RocketBuild.d.ts.map +1 -1
- package/dist-types/src/cli/RocketCli.d.ts.map +1 -1
- package/dist-types/src/cli/RocketStart.d.ts +6 -1
- package/dist-types/src/cli/RocketStart.d.ts.map +1 -1
- package/dist-types/src/component-hydration.d.ts.map +1 -1
- package/dist-types/src/components/RocketDrawer.d.ts +8 -0
- package/dist-types/src/components/RocketDrawer.d.ts.map +1 -1
- package/dist-types/src/components/RocketSocialLink.d.ts.map +1 -1
- package/dist-types/src/config.d.ts.map +1 -1
- package/dist-types/src/feeds.d.ts +38 -0
- package/dist-types/src/feeds.d.ts.map +1 -0
- package/dist-types/src/hydration/hydrationLoader.d.ts +13 -4
- package/dist-types/src/hydration/hydrationLoader.d.ts.map +1 -1
- package/dist-types/src/icons.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts +42 -0
- package/dist-types/src/layouts/atlas/atlasBlogLayout.d.ts.map +1 -0
- package/dist-types/src/layouts/atlas/atlasDocLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasHeroLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/atlas/atlasNotFoundLayout.d.ts.map +1 -1
- package/dist-types/src/layouts/layout-helper.d.ts +3 -3
- package/dist-types/src/layouts/layout-helper.d.ts.map +1 -1
- package/dist-types/src/markdownHook.d.ts.map +1 -1
- package/dist-types/src/menu.d.ts +8 -3
- package/dist-types/src/menu.d.ts.map +1 -1
- package/dist-types/src/menus/MainMenu.d.ts.map +1 -1
- package/dist-types/src/{development-page-module-loader.d.ts → page-module-loader.d.ts} +7 -2
- package/dist-types/src/page-module-loader.d.ts.map +1 -0
- package/dist-types/src/page-runtime.d.ts +11 -0
- package/dist-types/src/page-runtime.d.ts.map +1 -1
- package/dist-types/src/pages.d.ts.map +1 -1
- package/dist-types/src/route-pattern.d.ts +10 -0
- package/dist-types/src/route-pattern.d.ts.map +1 -0
- package/dist-types/src/siteDiscoverability.d.ts +4 -0
- package/dist-types/src/siteDiscoverability.d.ts.map +1 -1
- package/dist-types/src/socialPreviewImages.d.ts +2 -1
- package/dist-types/src/socialPreviewImages.d.ts.map +1 -1
- package/dist-types/src/static-params.d.ts +33 -0
- package/dist-types/src/static-params.d.ts.map +1 -0
- package/dist-types/src/transform.d.ts +4 -0
- package/dist-types/src/transform.d.ts.map +1 -1
- package/dist-types/src/wds-plugin.d.ts.map +1 -1
- package/exports/layouts/atlasBlog.js +11 -0
- package/exports/types/rocket.test.ts +26 -0
- package/exports/types/rocket.ts +70 -2
- package/exports/types.ts +7 -0
- package/package.json +15 -31
- package/src/PageData.js +12 -2
- package/src/RocketJsDemo.js +1 -0
- package/src/SocialPreviewPlayground.js +26 -20
- package/src/asyncMessage.js +8 -2
- package/src/cli/RocketBuild.js +110 -13
- package/src/cli/RocketCli.js +11 -1
- package/src/cli/RocketStart.js +60 -16
- package/src/cli/cli.js +10 -1
- package/src/component-hydration.js +23 -3
- package/src/components/RocketDrawer.js +44 -10
- package/src/components/RocketSocialLink.js +26 -2
- package/src/config.js +33 -4
- package/src/feeds.js +212 -0
- package/src/hydration/hydrationLoader.js +80 -48
- package/src/icons.js +5 -0
- package/src/layouts/atlas/atlasBlog.css +143 -0
- package/src/layouts/atlas/atlasBlogLayout.js +234 -0
- package/src/layouts/atlas/atlasDocLayout.js +3 -2
- package/src/layouts/atlas/atlasHeroLayout.js +4 -6
- package/src/layouts/atlas/atlasNotFoundLayout.js +9 -21
- package/src/layouts/layout-helper.js +5 -5
- package/src/layouts/layout.js +2 -2
- package/src/loaded-page-module.js +3 -1
- package/src/markdownCompiler.js +2 -2
- package/src/markdownHook.js +7 -2
- package/src/menu.js +25 -19
- package/src/menus/MainMenu.js +10 -4
- package/src/{static-page-module-loader.js → page-module-loader.js} +6 -1
- package/src/page-pagination.js +2 -2
- package/src/page-runtime.js +48 -3
- package/src/pages.js +47 -56
- package/src/publicAssets.js +2 -2
- package/src/route-pattern.js +21 -0
- package/src/siteDiscoverability.js +14 -1
- package/src/socialPreviewImages.js +80 -14
- package/src/standalone-demo-url.js +2 -2
- package/src/static-params.js +114 -0
- package/src/transform.js +89 -22
- package/src/wds-plugin.js +16 -3
- package/dist-types/src/development-page-module-loader.d.ts.map +0 -1
- package/dist-types/src/static-page-module-loader.d.ts +0 -15
- package/dist-types/src/static-page-module-loader.d.ts.map +0 -1
- package/src/development-page-module-loader.js +0 -29
package/src/page-runtime.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { collectResult } from '@lit-labs/ssr/lib/render-result.js';
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
/**
|
package/src/publicAssets.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
await
|
|
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
|
-
|
|
389
|
+
capturePage.setContent(html, { waitUntil: 'load' }),
|
|
351
390
|
);
|
|
352
391
|
await runSocialPreviewCaptureStep('wait for Social Preview fonts', () =>
|
|
353
|
-
|
|
392
|
+
capturePage.evaluateHandle('document.fonts.ready'),
|
|
354
393
|
);
|
|
355
394
|
const screenshot = await runSocialPreviewCaptureStep('capture Social Preview screenshot', () =>
|
|
356
|
-
|
|
395
|
+
capturePage.screenshot({
|
|
357
396
|
type: 'png',
|
|
358
397
|
}),
|
|
359
398
|
);
|
|
360
399
|
return Buffer.from(screenshot);
|
|
361
400
|
} finally {
|
|
362
|
-
if (
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
127
|
+
const pattern = routePattern(routePath);
|
|
128
128
|
return pattern.exec(pathname, origin);
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { PageData } from './PageData.js';
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('@rocket/js/types.js').Page} Page */
|
|
4
|
+
/** @typedef {import('@rocket/js/types.js').PageRegistry} PageRegistry */
|
|
5
|
+
/** @typedef {import('@rocket/js/types.js').PageStaticParams} PageStaticParams */
|
|
6
|
+
/** @typedef {import('@rocket/js/types.js').PageStaticParamsDeclaration} PageStaticParamsDeclaration */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {Page} page
|
|
10
|
+
*/
|
|
11
|
+
export function hasStaticParams(page) {
|
|
12
|
+
return page.file.endsWith('.js') && page.module.staticParams !== undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} pagePath
|
|
17
|
+
*/
|
|
18
|
+
export function hasPathParameter(pagePath) {
|
|
19
|
+
return /(^|\/):[^/]+/.test(pagePath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Enumerates the concrete output paths of a parameterized static JavaScript
|
|
24
|
+
* Page from its `staticParams` export.
|
|
25
|
+
*
|
|
26
|
+
* @param {{
|
|
27
|
+
* pages: PageRegistry;
|
|
28
|
+
* page: Page;
|
|
29
|
+
* pagePath: string;
|
|
30
|
+
* }} options
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
export function staticParamsPagePaths({ pages, page, pagePath }) {
|
|
34
|
+
if (!hasPathParameter(pagePath)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Static params for ${page.file} require a parameterized Page path, ` +
|
|
37
|
+
`but its path ${pagePath} has no route params.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const declaration = /** @type {PageStaticParamsDeclaration} */ (page.module.staticParams);
|
|
41
|
+
const paramsList =
|
|
42
|
+
typeof declaration === 'function'
|
|
43
|
+
? declaration(new PageData(pages, page.metadata, pagePath))
|
|
44
|
+
: declaration;
|
|
45
|
+
if (!Array.isArray(paramsList)) {
|
|
46
|
+
throw new Error(`Static params for ${page.file} must be an array of route param objects.`);
|
|
47
|
+
}
|
|
48
|
+
const paths = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const params of paramsList) {
|
|
51
|
+
const concretePath = substituteRouteParams(pagePath, params, page);
|
|
52
|
+
if (seen.has(concretePath)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Static params for ${page.file} produce the duplicate output path ${concretePath}.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
seen.add(concretePath);
|
|
58
|
+
paths.push(concretePath);
|
|
59
|
+
}
|
|
60
|
+
return paths;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} pagePath
|
|
65
|
+
* @param {unknown} params
|
|
66
|
+
* @param {Page} page
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function substituteRouteParams(pagePath, params, page) {
|
|
70
|
+
if (typeof params !== 'object' || params === null || Array.isArray(params)) {
|
|
71
|
+
throw new Error(`Static params for ${page.file} must be objects with string values.`);
|
|
72
|
+
}
|
|
73
|
+
const paramValues = /** @type {Record<string, unknown>} */ (params);
|
|
74
|
+
const usedNames = new Set();
|
|
75
|
+
const segments = pagePath.split('/').map(segment => {
|
|
76
|
+
if (!segment.startsWith(':')) {
|
|
77
|
+
return segment;
|
|
78
|
+
}
|
|
79
|
+
const name = segment.slice(1);
|
|
80
|
+
usedNames.add(name);
|
|
81
|
+
return routeParamValue(name, paramValues[name], page);
|
|
82
|
+
});
|
|
83
|
+
for (const name of Object.keys(paramValues)) {
|
|
84
|
+
if (!usedNames.has(name)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Static params for ${page.file} set unknown route param "${name}" ` +
|
|
87
|
+
`for the Page path ${pagePath}.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return segments.join('/');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} name
|
|
96
|
+
* @param {unknown} value
|
|
97
|
+
* @param {Page} page
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function routeParamValue(name, value, page) {
|
|
101
|
+
if (typeof value !== 'string' || value === '') {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Static params for ${page.file} must provide a non-empty string ` +
|
|
104
|
+
`for route param "${name}".`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (encodeURIComponent(value) !== value) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Static params for ${page.file} route param "${name}" value ${JSON.stringify(value)} ` +
|
|
110
|
+
`is not a URL-safe path segment. Slugify param values before returning them.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|