Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions code/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@
"resolve": "^1.22.11",
"resolve.exports": "^2.0.3",
"sirv": "^2.0.4",
"sitemap": "^9.0.0",
"slash": "^5.0.0",
"source-map": "^0.7.4",
"store2": "^2.14.2",
Expand Down
17 changes: 17 additions & 0 deletions code/core/src/bin/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ command('build')
.option('--docs', 'Build a documentation-only site using addon-docs')
.option('--test', 'Build stories optimized for testing purposes.')
.option('--preview-only', 'Use the preview without the manager UI')
.option(
'--site-url <string>',
'The URL where the built site will be deployed, for sitemap generation'
)
Comment on lines +156 to +159
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sidnioulz, minor item here. Don't forget to add the appropriate documentation to reflect this CLI flag, to avoid potential questions/issues, and ensure parity between what's available and what's documented.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course 👍

I will copy my TODO list for this feature into the description to dispel any concern :)

.action(async (options) => {
const { env } = process;
env.NODE_ENV = env.NODE_ENV || 'production';
Expand All @@ -169,6 +173,19 @@ command('build')
staticDir: 'SBCONFIG_STATIC_DIR',
outputDir: 'SBCONFIG_OUTPUT_DIR',
configDir: 'SBCONFIG_CONFIG_DIR',
siteUrl: [
// Our own environment variable naming convention
'SBCONFIG_SITE_URL',
// Netlify: https://docs.netlify.com/build/configure-builds/environment-variables/
'URL',
// Vercel: https://vercel.com/docs/environment-variables/system-environment-variables
'VERCEL_PROJECT_PRODUCTION_URL',
// Cloudflare Pages: https://developers.cloudflare.com/pages/configuration/build-configuration/
'CF_PAGES_URL',
// Render: https://render.com/docs/environment-variables
'RENDER_EXTERNAL_URL',
// GH Pages, AWS Amplify, Azure Static Web Apps, Heroku do not expose an env var.
],
});

await build({
Expand Down
1 change: 1 addition & 0 deletions code/core/src/cli/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const build = async (cliOptions: any) => {
...cliOptions,
configDir: cliOptions.configDir || './.storybook',
outputDir: cliOptions.outputDir || './storybook-static',
siteUrl: cliOptions.siteUrl || undefined,
ignorePreview: !!cliOptions.previewUrl && !cliOptions.forceBuildPreview,
configType: 'PRODUCTION',
cache,
Expand Down
14 changes: 10 additions & 4 deletions code/core/src/common/utils/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,18 @@ export function parseList(str: string): string[] {
.filter((item) => item.length > 0);
}

export function getEnvConfig(program: Record<string, any>, configEnv: Record<string, any>): void {
export function getEnvConfig(
program: Record<string, unknown>,
configEnv: Record<string, string | string[]>
): void {
Object.keys(configEnv).forEach((fieldName) => {
const envVarName = configEnv[fieldName];
const envVarValue = process.env[envVarName];
const envVarNames = Array.isArray(configEnv[fieldName])
? configEnv[fieldName]
: [configEnv[fieldName]];

const envVarValue = envVarNames.find((envVarName) => process.env[envVarName]);
if (envVarValue) {
program[fieldName] = envVarValue;
program[fieldName] = process.env[envVarValue];
}
});
}
Expand Down
15 changes: 13 additions & 2 deletions code/core/src/core-server/build-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ import { copyAllStaticFilesRelativeToMain } from './utils/copy-all-static-files'
import { getBuilders } from './utils/get-builders';
import { extractStorybookMetadata } from './utils/metadata';
import { outputStats } from './utils/output-stats';
import { extractStoriesJson } from './utils/stories-json';
import { extractSitemap, extractStoriesJson } from './utils/stories-json';
import { summarizeIndex } from './utils/summarizeIndex';

export type BuildStaticStandaloneOptions = CLIOptions &
LoadOptions &
BuilderOptions & { outputDir: string };
BuilderOptions & {
outputDir: string;
siteUrl?: string;
};

export async function buildStaticStandalone(options: BuildStaticStandaloneOptions) {
options.configType = 'PRODUCTION';
Expand Down Expand Up @@ -163,6 +166,14 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption
)
);

effects.push(
extractSitemap(
join(options.outputDir, 'sitemap.xml'),
initializedStoryIndexGenerator as Promise<StoryIndexGenerator>,
options
)
);

if (features?.experimentalComponentsManifest) {
const componentManifestGenerator = await presets.apply(
'experimental_componentManifestGenerator'
Expand Down
4 changes: 4 additions & 0 deletions code/core/src/core-server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { openInBrowser } from './utils/open-browser/open-in-browser';
import { getServerAddresses } from './utils/server-address';
import { getServer } from './utils/server-init';
import { useStatics } from './utils/server-statics';
import { useSitemap } from './utils/stories-json';
import { summarizeIndex } from './utils/summarizeIndex';

export async function storybookDevServer(options: Options) {
Expand Down Expand Up @@ -138,6 +139,9 @@ export async function storybookDevServer(options: Options) {
throw indexError;
}

// StoryIndexGenerator should now be guaranteed to be defined as `indexError` if not set.
useSitemap(app, initializedStoryIndexGenerator as Promise<StoryIndexGenerator>, options);

const features = await options.presets.apply('features');
if (features?.experimentalComponentsManifest) {
app.use('/manifests/components.json', async (req, res) => {
Expand Down
1 change: 1 addition & 0 deletions code/core/src/core-server/utils/getStoryIndexGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export async function getStoryIndexGenerator(
serverChannel,
workingDir,
configDir,
options,
});

return initializedStoryIndexGenerator;
Expand Down
171 changes: 166 additions & 5 deletions code/core/src/core-server/utils/stories-json.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { join } from 'node:path';
import { gunzipSync } from 'node:zlib';

import { beforeEach, describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -79,6 +80,13 @@
on: vi.fn(),
} as any;

const defaultOptions = {
host: 'localhost',
port: 6006,
https: false,
initialPath: '',
} as any;

beforeEach(async () => {
use.mockClear();
end.mockClear();
Expand All @@ -101,10 +109,12 @@
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: defaultOptions,
});
console.timeEnd('useStoriesJson');

expect(use).toHaveBeenCalledTimes(1);
// /index.json and /sitemap.xml
expect(use).toHaveBeenCalledTimes(2);

Check failure on line 117 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > JSON endpoint > scans and extracts index

AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times ❯ src/core-server/utils/stories-json.test.ts:117:19
const route = use.mock.calls[0][1];

console.time('route');
Expand Down Expand Up @@ -477,9 +487,10 @@
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: defaultOptions,
});

expect(use).toHaveBeenCalledTimes(1);
expect(use).toHaveBeenCalledTimes(2);

Check failure on line 493 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > JSON endpoint > can handle simultaneous access

AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times ❯ src/core-server/utils/stories-json.test.ts:493:19
const route = use.mock.calls[0][1];

const firstPromise = route(request, response);
Expand Down Expand Up @@ -509,9 +520,10 @@
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: defaultOptions,
});

expect(use).toHaveBeenCalledTimes(1);
expect(use).toHaveBeenCalledTimes(2);

Check failure on line 526 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > SSE endpoint > sends invalidate events

AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times ❯ src/core-server/utils/stories-json.test.ts:526:19
const route = use.mock.calls[0][1];

await route(request, response);
Expand Down Expand Up @@ -543,9 +555,10 @@
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: defaultOptions,
});

expect(use).toHaveBeenCalledTimes(1);
expect(use).toHaveBeenCalledTimes(2);

Check failure on line 561 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > SSE endpoint > only sends one invalidation when multiple event listeners are listening

AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times ❯ src/core-server/utils/stories-json.test.ts:561:19
const route = use.mock.calls[0][1];

// Don't wait for the first request here before starting the second
Expand Down Expand Up @@ -586,9 +599,10 @@
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: defaultOptions,
});

expect(use).toHaveBeenCalledTimes(1);
expect(use).toHaveBeenCalledTimes(2);

Check failure on line 605 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > SSE endpoint > debounces invalidation events

AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times ❯ src/core-server/utils/stories-json.test.ts:605:19
const route = use.mock.calls[0][1];

await route(request, response);
Expand Down Expand Up @@ -621,4 +635,151 @@
expect(mockServerChannel.emit).toHaveBeenCalledTimes(2);
});
});

describe('Sitemap endpoint', () => {
beforeEach(() => {
use.mockClear();
end.mockClear();
});

it('generates sitemap with http://localhost', async () => {
const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
useStoriesJson({
app,
serverChannel: mockServerChannel,
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: defaultOptions,
});

expect(use).toHaveBeenCalledTimes(2);

Check failure on line 656 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > Sitemap endpoint > generates sitemap with http://localhost

AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times ❯ src/core-server/utils/stories-json.test.ts:656:19
const sitemapRoute = use.mock.calls[1][1];

await sitemapRoute(request, response);

expect(end).toHaveBeenCalledTimes(1);
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'application/xml');
expect(response.setHeader).toHaveBeenCalledWith('Content-Encoding', 'gzip');

const gzippedBuffer = end.mock.calls[0][0];
const decompressed = gunzipSync(gzippedBuffer).toString('utf-8');

expect(decompressed).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(decompressed).toContain('<urlset');
expect(decompressed).toContain('http://localhost:6006');
expect(decompressed).toContain('/?path=/docs/a--metaof');
expect(decompressed).toContain('/?path=/story/a--story-one');
expect(decompressed).toContain('/?path=/settings/about');
expect(decompressed).toContain('/?path=/settings/whats-new');
expect(decompressed).toContain('/?path=/settings/guide');
expect(decompressed).toContain('/?path=/settings/shortcuts');
});

it('generates sitemap with https protocol', async () => {
const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
const httpsOptions = { ...defaultOptions, https: true };

useStoriesJson({
app,
serverChannel: mockServerChannel,
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: httpsOptions,
});

const sitemapRoute = use.mock.calls[1][1];

Check failure on line 692 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > Sitemap endpoint > generates sitemap with https protocol

TypeError: Cannot read properties of undefined (reading '1') ❯ src/core-server/utils/stories-json.test.ts:692:44
await sitemapRoute(request, response);

const gzippedBuffer = end.mock.calls[0][0];
const decompressed = gunzipSync(gzippedBuffer).toString('utf-8');

expect(decompressed).toContain('https://localhost:6006');
expect(decompressed).not.toContain('http://localhost:6006');
});

it('generates sitemap for production website (example.com)', async () => {
const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
const productionOptions = {
host: 'example.com',
port: 443,
https: true,
initialPath: '',
} as any;

useStoriesJson({
app,
serverChannel: mockServerChannel,
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: productionOptions,
});

const sitemapRoute = use.mock.calls[1][1];

Check failure on line 720 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > Sitemap endpoint > generates sitemap for production website (example.com)

TypeError: Cannot read properties of undefined (reading '1') ❯ src/core-server/utils/stories-json.test.ts:720:44
await sitemapRoute(request, response);

const gzippedBuffer = end.mock.calls[0][0];
const decompressed = gunzipSync(gzippedBuffer).toString('utf-8');

expect(decompressed).toContain('https://example.com');
expect(decompressed).toContain('/?path=/docs/a--metaof');
expect(decompressed).toContain('/?path=/story/a--story-one');
});

it('uses cached sitemap on subsequent requests', async () => {
const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
useStoriesJson({
app,
serverChannel: mockServerChannel,
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: defaultOptions,
});

const sitemapRoute = use.mock.calls[1][1];

Check failure on line 742 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > Sitemap endpoint > uses cached sitemap on subsequent requests

TypeError: Cannot read properties of undefined (reading '1') ❯ src/core-server/utils/stories-json.test.ts:742:44

// First request
await sitemapRoute(request, response);
const firstBuffer = end.mock.calls[0][0] as Buffer;

// Second request
const secondResponse = {
...response,
end: vi.fn(),
setHeader: vi.fn(),
on: vi.fn(),
};
await sitemapRoute(request, secondResponse);
const secondBuffer = secondResponse.end.mock.calls[0][0] as Buffer;

// Should return the same cached buffer
expect(firstBuffer).toBe(secondBuffer);
});

it('excludes test stories from sitemap', async () => {
const mockServerChannel = { emit: vi.fn() } as any as ServerChannel;
useStoriesJson({
app,
serverChannel: mockServerChannel,
workingDir,
normalizedStories,
initializedStoryIndexGenerator: getInitializedStoryIndexGenerator(),
options: defaultOptions,
});

const sitemapRoute = use.mock.calls[1][1];

Check failure on line 773 in code/core/src/core-server/utils/stories-json.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/core-server/utils/stories-json.test.ts > useStoriesJson > Sitemap endpoint > excludes test stories from sitemap

TypeError: Cannot read properties of undefined (reading '1') ❯ src/core-server/utils/stories-json.test.ts:773:44
await sitemapRoute(request, response);

const gzippedBuffer = end.mock.calls[0][0];
const decompressed = gunzipSync(gzippedBuffer).toString('utf-8');

// Verify test stories are not included
// Note: If test stories exist in mock data with subtype: 'test', they should not appear
expect(decompressed).toContain('/?path=/story/a--story-one');
expect(decompressed).toContain('/?path=/docs/a--metaof');
});
});
Comment on lines +639 to +784
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sitemap endpoint tests are strong overall; tighten the “excludes test stories” assertion

The new sitemap tests nicely validate hostname/protocol handling, presence of key paths, and cache reuse. However, in it('excludes test stories from sitemap') you only assert that known non-test doc/story URLs are present; you never assert that any “test” stories are actually missing, and the current mock index may not include entries with subtype: 'test'. To make this test meaningful, either (a) add a fixture entry with subtype: 'test' and assert that its /story/ URL is not present in the decompressed sitemap, or (b) rename the test to reflect that it’s only checking inclusion of specific non-test entries.

🤖 Prompt for AI Agents
In code/core/src/core-server/utils/stories-json.test.ts around lines 639 to 784,
the "excludes test stories from sitemap" test never verifies that a story with
subtype: 'test' is actually excluded; update the test to either add a mock story
entry with subtype: 'test' to normalizedStories (so it would generate a
/?path=/story/... URL if not excluded) and assert that the decompressed sitemap
does NOT contain that test-story path, or change the test name to reflect it
only asserts presence of known non-test entries; preferred fix: add a test-only
fixture story with subtype: 'test' in the normalizedStories passed into
useStoriesJson and add an
expect(decompressed).not.toContain('/?path=/story/<test-story-id>') assertion to
prove exclusion.

});
Loading
Loading