Skip to content

Commit 278c624

Browse files
committed
CLI: Allow passing --site-url to build a sitemap
1 parent 4e78833 commit 278c624

File tree

6 files changed

+112
-38
lines changed

6 files changed

+112
-38
lines changed

code/core/src/bin/core.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ command('build')
153153
.option('--docs', 'Build a documentation-only site using addon-docs')
154154
.option('--test', 'Build stories optimized for testing purposes.')
155155
.option('--preview-only', 'Use the preview without the manager UI')
156+
.option(
157+
'--site-url <string>',
158+
'The URL where the built site will be deployed, for sitemap generation'
159+
)
156160
.action(async (options) => {
157161
const { env } = process;
158162
env.NODE_ENV = env.NODE_ENV || 'production';
@@ -169,6 +173,7 @@ command('build')
169173
staticDir: 'SBCONFIG_STATIC_DIR',
170174
outputDir: 'SBCONFIG_OUTPUT_DIR',
171175
configDir: 'SBCONFIG_CONFIG_DIR',
176+
siteUrl: 'SBCONFIG_SITE_URL',
172177
});
173178

174179
await build({

code/core/src/cli/build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const build = async (cliOptions: any) => {
99
...cliOptions,
1010
configDir: cliOptions.configDir || './.storybook',
1111
outputDir: cliOptions.outputDir || './storybook-static',
12+
siteUrl: cliOptions.siteUrl || undefined,
1213
ignorePreview: !!cliOptions.previewUrl && !cliOptions.forceBuildPreview,
1314
configType: 'PRODUCTION',
1415
cache,

code/core/src/core-server/build-static.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ import { copyAllStaticFilesRelativeToMain } from './utils/copy-all-static-files'
2525
import { getBuilders } from './utils/get-builders';
2626
import { extractStorybookMetadata } from './utils/metadata';
2727
import { outputStats } from './utils/output-stats';
28-
import { extractStoriesJson } from './utils/stories-json';
28+
import { extractSitemap, extractStoriesJson } from './utils/stories-json';
2929
import { summarizeIndex } from './utils/summarizeIndex';
3030

3131
export type BuildStaticStandaloneOptions = CLIOptions &
3232
LoadOptions &
33-
BuilderOptions & { outputDir: string };
33+
BuilderOptions & {
34+
outputDir: string;
35+
siteUrl?: string;
36+
};
3437

3538
export async function buildStaticStandalone(options: BuildStaticStandaloneOptions) {
3639
options.configType = 'PRODUCTION';
@@ -163,6 +166,14 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption
163166
)
164167
);
165168

169+
effects.push(
170+
extractSitemap(
171+
join(options.outputDir, 'sitemap.xml'),
172+
initializedStoryIndexGenerator as Promise<StoryIndexGenerator>,
173+
options
174+
)
175+
);
176+
166177
if (features?.experimentalComponentsManifest) {
167178
const componentManifestGenerator = await presets.apply(
168179
'experimental_componentManifestGenerator'

code/core/src/core-server/dev-server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { openInBrowser } from './utils/open-browser/open-in-browser';
2222
import { getServerAddresses } from './utils/server-address';
2323
import { getServer } from './utils/server-init';
2424
import { useStatics } from './utils/server-statics';
25+
import { useSitemap } from './utils/stories-json';
2526
import { summarizeIndex } from './utils/summarizeIndex';
2627

2728
export async function storybookDevServer(options: Options) {
@@ -138,6 +139,9 @@ export async function storybookDevServer(options: Options) {
138139
throw indexError;
139140
}
140141

142+
// StoryIndexGenerator should now be guaranteed to be defined as `indexError` if not set.
143+
useSitemap(app, initializedStoryIndexGenerator as Promise<StoryIndexGenerator>, options);
144+
141145
const features = await options.presets.apply('features');
142146
if (features?.experimentalComponentsManifest) {
143147
app.use('/manifests/components.json', async (req, res) => {

code/core/src/core-server/utils/stories-json.ts

Lines changed: 86 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,55 @@ import type { NormalizedStoriesSpecifier, StoryIndex } from 'storybook/internal/
99
import { debounce } from 'es-toolkit/function';
1010
import type { Polka } from 'polka';
1111
import { SitemapStream, streamToPromise } from 'sitemap';
12-
import invariant from 'tiny-invariant';
1312

13+
import { logger } from '../../node-logger';
14+
import type { BuildStaticStandaloneOptions } from '../build-static';
1415
import type { StoryIndexGenerator } from './StoryIndexGenerator';
1516
import type { ServerChannel } from './get-server-channel';
1617
import { getServerAddresses } from './server-address';
1718
import { watchStorySpecifiers } from './watch-story-specifiers';
1819
import { watchConfig } from './watchConfig';
1920

2021
export const DEBOUNCE = 100;
22+
let cachedSitemap: Buffer | undefined;
23+
24+
async function generateSitemap(
25+
initializedStoryIndexGenerator: Promise<StoryIndexGenerator>,
26+
networkAddress: string,
27+
isProduction: boolean
28+
): Promise<Buffer | undefined> {
29+
const generator = await initializedStoryIndexGenerator;
30+
const index = await generator.getIndex();
31+
32+
const smStream = new SitemapStream({ hostname: networkAddress });
33+
// const smStream = new SitemapStream();
34+
const pipeline = isProduction ? smStream : smStream.pipe(createGzip());
35+
36+
const now = new Date();
37+
38+
// static settings pages; those with backlinks to storybook.js.org are the most important.
39+
smStream.write({ url: './?path=/settings/about', lastmod: now, priority: 1 });
40+
smStream.write({ url: './?path=/settings/whats-new', lastmod: now, priority: 1 });
41+
smStream.write({ url: './?path=/settings/guide', lastmod: now, priority: 0.3 });
42+
smStream.write({ url: './?path=/settings/shortcuts', lastmod: now, priority: 0.3 });
43+
smStream.write({ url: './index.json', lastmod: now, priority: 0.3 });
44+
smStream.write({ url: './project.json', lastmod: now, priority: 0.3 });
45+
46+
// entries from story index; we prefer indexing docs over stories
47+
const entries = Object.values(index.entries || {});
48+
for (const entry of entries) {
49+
if (entry.type === 'docs') {
50+
smStream.write({ url: `./?path=/docs/${entry.id}`, lastmod: now, priority: 0.9 });
51+
} else if (entry.type === 'story' && entry.subtype !== 'test') {
52+
smStream.write({ url: `./?path=/story/${entry.id}`, lastmod: now, priority: 0.5 });
53+
}
54+
}
55+
56+
smStream.end();
57+
58+
// await and cache the gzipped buffer
59+
return streamToPromise(pipeline);
60+
}
2161

2262
export async function extractStoriesJson(
2363
outputFile: string,
@@ -29,14 +69,42 @@ export async function extractStoriesJson(
2969
await writeFile(outputFile, JSON.stringify(transform ? transform(storyIndex) : storyIndex));
3070
}
3171

72+
export async function extractSitemap(
73+
outputFile: string,
74+
initializedStoryIndexGenerator: Promise<StoryIndexGenerator>,
75+
options: BuildStaticStandaloneOptions
76+
) {
77+
const { siteUrl } = options;
78+
79+
if (options.ignorePreview) {
80+
logger.info(`Not building preview`);
81+
} else {
82+
}
83+
84+
// Can't generate a sitemap for the static build without knowing the host.
85+
if (!siteUrl) {
86+
logger.info(`Not building sitemap (\`siteUrl\` option not set).`);
87+
return;
88+
}
89+
90+
logger.info('Building sitemap..');
91+
const sitemapBuffer = await generateSitemap(
92+
initializedStoryIndexGenerator,
93+
siteUrl.endsWith('/') ? siteUrl : `${siteUrl}/`,
94+
true
95+
);
96+
if (sitemapBuffer) {
97+
await writeFile(outputFile, sitemapBuffer.toString('utf-8'));
98+
}
99+
}
100+
32101
export function useStoriesJson({
33102
app,
34103
initializedStoryIndexGenerator,
35104
workingDir = process.cwd(),
36105
configDir,
37106
serverChannel,
38107
normalizedStories,
39-
options,
40108
}: {
41109
app: Polka;
42110
initializedStoryIndexGenerator: Promise<StoryIndexGenerator>;
@@ -46,8 +114,6 @@ export function useStoriesJson({
46114
normalizedStories: NormalizedStoriesSpecifier[];
47115
options: Options;
48116
}) {
49-
let cachedSitemap: Buffer | undefined;
50-
51117
const maybeInvalidate = debounce(() => serverChannel.emit(STORY_INDEX_INVALIDATED), DEBOUNCE, {
52118
edges: ['leading', 'trailing'],
53119
});
@@ -79,7 +145,13 @@ export function useStoriesJson({
79145
res.end(err instanceof Error ? err.toString() : String(err));
80146
}
81147
});
148+
}
82149

150+
export function useSitemap(
151+
app: Polka,
152+
initializedStoryIndexGenerator: Promise<StoryIndexGenerator>,
153+
options: Options
154+
) {
83155
app.use('/sitemap.xml', async (req, res) => {
84156
try {
85157
res.setHeader('Content-Type', 'application/xml');
@@ -91,40 +163,18 @@ export function useStoriesJson({
91163
return;
92164
}
93165

94-
const generator = await initializedStoryIndexGenerator;
95-
const index = await generator.getIndex();
166+
const { port = 80, host } = options;
96167

97-
const { port, host, initialPath } = options;
98-
invariant(port, 'expected options to have a port');
99168
const proto = options.https ? 'https' : 'http';
100-
const { networkAddress } = getServerAddresses(port, host, proto, initialPath);
101-
102-
const smStream = new SitemapStream({ hostname: networkAddress });
103-
const pipeline = smStream.pipe(createGzip());
104-
105-
const now = new Date();
106-
107-
// static settings pages; those with backlinks to storybook.js.org are the most important.
108-
smStream.write({ url: '/?path=/settings/about', lastmod: now, priority: 1 });
109-
smStream.write({ url: '/?path=/settings/whats-new', lastmod: now, priority: 1 });
110-
smStream.write({ url: '/?path=/settings/guide', lastmod: now, priority: 0.3 });
111-
smStream.write({ url: '/?path=/settings/shortcuts', lastmod: now, priority: 0.3 });
112-
113-
// entries from story index; we prefer indexing docs over stories
114-
const entries = Object.values(index.entries || {});
115-
for (const entry of entries) {
116-
if (entry.type === 'docs') {
117-
smStream.write({ url: `/?path=/docs/${entry.id}`, lastmod: now, priority: 0.9 });
118-
} else if (entry.type === 'story' && entry.subtype !== 'test') {
119-
smStream.write({ url: `/?path=/story/${entry.id}`, lastmod: now, priority: 0.5 });
120-
}
121-
}
122-
123-
smStream.end();
124-
125-
// await and cache the gzipped buffer
126-
cachedSitemap = await streamToPromise(pipeline);
127-
res.end(cachedSitemap);
169+
const { networkAddress } = getServerAddresses(port, host, proto);
170+
171+
const sitemapBuffer = await generateSitemap(
172+
initializedStoryIndexGenerator,
173+
networkAddress,
174+
false
175+
);
176+
cachedSitemap = sitemapBuffer;
177+
res.end(sitemapBuffer);
128178
} catch (err) {
129179
res.statusCode = 500;
130180
res.end(err instanceof Error ? err.toString() : String(err));

code/core/src/types/modules/core-common.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,9 @@ export interface TestBuildFlags {
334334
}
335335

336336
export interface TestBuildConfig {
337+
/** URL of the deployed website (needed for sitemap computation). */
338+
siteUrl?: string;
339+
337340
test?: TestBuildFlags;
338341
}
339342

0 commit comments

Comments
 (0)