Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
31 changes: 30 additions & 1 deletion code/core/src/core-server/dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { logConfig } from 'storybook/internal/common';
import { logger } from 'storybook/internal/node-logger';
import { MissingBuilderError } from 'storybook/internal/server-errors';
import type { Options } from 'storybook/internal/types';
import type { ComponentsManifest, Options } from 'storybook/internal/types';
import { type ComponentManifestGenerator } from 'storybook/internal/types';

import compression from '@polka/compression';
import polka from 'polka';
import invariant from 'tiny-invariant';

import { telemetry } from '../telemetry';
import { renderManifestComponentsPage } from './manifest';
import { type StoryIndexGenerator } from './utils/StoryIndexGenerator';
import { doTelemetry } from './utils/doTelemetry';
import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders';
Expand Down Expand Up @@ -165,6 +166,34 @@ export async function storybookDevServer(options: Options) {
return;
}
});

app.get('/manifests/components.html', async (req, res) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be nice to also support '/manifests/components', without the html extension. just call get() two times with the same handler.

try {
const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply(
'experimental_componentManifestGenerator'
);
const indexGenerator = await initializedStoryIndexGenerator;

if (!componentManifestGenerator || !indexGenerator) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<pre>No component manifest generator configured.</pre>`);
return;
}

const manifest = (await componentManifestGenerator(
indexGenerator as unknown as import('storybook/internal/core-server').StoryIndexGenerator
)) as ComponentsManifest;

res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderManifestComponentsPage(manifest));
} catch (e) {
// logger?.error?.(e instanceof Error ? e : String(e));
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<pre>${e instanceof Error ? e.toString() : String(e)}</pre>`);
}
Comment on lines +191 to +195
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 | 🟠 Major

Escape HTML in error responses to avoid XSS; log the error.

Interpolating raw error text into HTML is unsafe. Also, keep server logs. Two options:

  • Safer HTML: escape the string and keep text/html.
  • Or return text/plain without HTML.

Suggested patch (HTML + escape + logging):

-      } catch (e) {
-        // logger?.error?.(e instanceof Error ? e : String(e));
-        res.statusCode = 500;
-        res.setHeader('Content-Type', 'text/html; charset=utf-8');
-        res.end(`<pre>${e instanceof Error ? e.toString() : String(e)}</pre>`);
-      }
+      } catch (e) {
+        logger.error(e instanceof Error ? e : String(e));
+        const esc = (s: unknown) =>
+          String(s ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c] as string));
+        res.statusCode = 500;
+        res.setHeader('Content-Type', 'text/html; charset=utf-8');
+        const msg = e instanceof Error ? e.toString() : String(e);
+        res.end(`<pre><code>${esc(msg)}</code></pre>`);
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// logger?.error?.(e instanceof Error ? e : String(e));
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<pre>${e instanceof Error ? e.toString() : String(e)}</pre>`);
}
} catch (e) {
logger.error(e instanceof Error ? e : String(e));
const esc = (s: unknown) =>
String(s ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c] as string));
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
const msg = e instanceof Error ? e.toString() : String(e);
res.end(`<pre><code>${esc(msg)}</code></pre>`);
}
🤖 Prompt for AI Agents
In code/core/src/core-server/dev-server.ts around lines 191 to 195, the error
response currently interpolates raw error text into HTML and doesn't log the
error; replace that with a safe approach: log the error via the server logger
(e.g., logger?.error(e)) and then either (a) escape the error string for HTML
(implement or use an escape utility to replace &,<,>,",') and return as
text/html with the escaped content, or (b) simpler—set Content-Type to
text/plain; charset=utf-8 and return the raw string there; ensure res.statusCode
remains 500 and use e instanceof Error ? e.toString() : String(e) as the source
for logging/escaping.

});
}
// Now the preview has successfully started, we can count this as a 'dev' event.
doTelemetry(app, core, initializedStoryIndexGenerator as Promise<StoryIndexGenerator>, options);
Expand Down
327 changes: 327 additions & 0 deletions code/core/src/core-server/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
import { dedent } from 'ts-dedent';

import type { ComponentManifest, ComponentsManifest } from '../types';

// AI generated manifests/components.html page
// Only HTML/CSS no JS
export function renderManifestComponentsPage(manifest: ComponentsManifest) {
const entries = Object.entries(manifest?.components ?? {}).sort((a, b) =>
(a[1].name || a[0]).localeCompare(b[1].name || b[0])
);

const analyses = entries.map(([, c]) => analyzeComponent(c));
const totals = {
components: entries.length,
componentsWithError: analyses.filter((a) => a.hasComponentError).length,
componentsWithWarnings: analyses.filter((a) => a.hasWarns).length,
examples: analyses.reduce((sum, a) => sum + a.totalExamples, 0),
exampleErrors: analyses.reduce((sum, a) => sum + a.exampleErrors, 0),
};

// Top filters (clickable), no <b> tags; 1px active ring lives in CSS via :target
const allPill = `<a class="filter-pill all" data-k="all" href="#filter-all">All</a>`;
const compErrorsPill =
totals.componentsWithError > 0
? `<a class="filter-pill err" data-k="errors" href="#filter-errors">${totals.componentsWithError}/${totals.components} component ${plural(totals.componentsWithError, 'error')}</a>`
: `<span class="filter-pill ok" aria-disabled="true">${totals.components} components ok</span>`;
const compWarningsPill =
totals.componentsWithWarnings > 0
? `<a class="filter-pill warn" data-k="warnings" href="#filter-warnings">${totals.componentsWithWarnings}/${totals.components} component ${plural(totals.componentsWithWarnings, 'warning')}</a>`
: '';
const examplesPill =
totals.exampleErrors > 0
? `<a class="filter-pill err" data-k="example-errors" href="#filter-example-errors">${totals.exampleErrors}/${totals.examples} example errors</a>`
: `<span class="filter-pill ok" aria-disabled="true">${totals.examples} examples ok</span>`;

const grid = entries.map(([key, c], idx) => renderComponentCard(key, c, idx)).join('');

return dedent`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Components Manifest</title>
<style>
:root{
--bg:#0b0c10; --panel:#121318; --muted:#9aa0a6; --fg:#e8eaed;
--ok:#22c55e; --warn:#b08900; --err:#c62828;
--ok-bg:#0c1a13; --warn-bg:#1a1608; --err-bg:#1a0e0e;
--chip:#1f2330; --border:#2b2f3a; --link:#8ab4f8;
--active-ring:1px; /* 1px active ring for pills and toggles */
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,"Helvetica Neue",Arial,"Noto Sans"}
.wrap{max-width:1100px;margin:0 auto;padding:16px 20px}
header{position:sticky;top:0;backdrop-filter:blur(6px);background:color-mix(in srgb,var(--bg) 84%, transparent);border-bottom:1px solid var(--border);z-index:10}
h1{font-size:20px;margin:0 0 6px}
.summary{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
/* Top filter pills */
.filter-pill{
display:inline-flex;align-items:center;gap:6px;
padding:6px 12px;border:1px solid var(--border);border-radius:999px;
background:var(--panel);text-decoration:none;cursor:pointer;user-select:none;color:var(--fg);
}
.filter-pill.ok{color:#b9f6ca;border-color:color-mix(in srgb,var(--ok) 55%, var(--border));background:color-mix(in srgb,var(--ok) 18%, #000)}
.filter-pill.warn{color:#ffd666;border-color:color-mix(in srgb,var(--warn) 55%, var(--border));background:var(--warn-bg)}
.filter-pill.err{color:#ff9aa0;border-color:color-mix(in srgb,var(--err) 55%, var(--border));background:var(--err-bg)}
.filter-pill.all{color:#d7dbe0;border-color:var(--border);background:var(--panel)}
.filter-pill[aria-disabled="true"]{cursor:default;text-decoration:none}
.filter-pill:focus,.filter-pill:active{outline:none;box-shadow:none}
/* Selected top pill ring via :target */
#filter-all:target ~ header .filter-pill[data-k="all"],
#filter-errors:target ~ header .filter-pill[data-k="errors"],
#filter-warnings:target ~ header .filter-pill[data-k="warnings"],
#filter-example-errors:target ~ header .filter-pill[data-k="example-errors"]{
box-shadow:0 0 0 var(--active-ring) currentColor;border-color:currentColor;
}
/* Hidden targets for filtering */
#filter-all,#filter-errors,#filter-warnings,#filter-example-errors{display:none}
main{padding:24px 0 40px}
.grid{display:grid;grid-template-columns:1fr;gap:14px} /* one card per row */
.card{border:1px solid var(--border);background:var(--panel);border-radius:14px;padding:14px;display:flex;flex-direction:column;gap:10px}
.head{display:flex;flex-direction:column;gap:8px}
.title{display:flex;align-items:center;justify-content:space-between;gap:10px}
.title h2{font-size:16px;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.meta{font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.kv{display:flex;flex-wrap:wrap;gap:6px}
.chip{font-size:12px;padding:4px 8px;border-radius:999px;background:var(--chip);border:1px solid var(--border)}
.hint{color:var(--muted);font-size:12px}
.badges{display:flex;gap:8px;flex-wrap:wrap}
/* Per-card badges: labels become toggles when clickable */
.badge{font-size:12px;padding:3px 8px;border-radius:999px;border:1px solid var(--border);background:var(--chip);color:#d7dbe0}
.badge.ok{color:#b9f6ca;border-color:color-mix(in srgb,var(--ok) 55%, var(--border))}
.badge.warn{color:#ffd666;border-color:color-mix(in srgb,var(--warn) 55%, var(--border))}
.badge.err{color:#ff9aa0;border-color:color-mix(in srgb,var(--err) 55%, var(--border))}
.as-toggle{cursor:pointer}
/* 1px ring on active toggle */
.tg-err:checked + label.as-toggle,
.tg-warn:checked + label.as-toggle,
.tg-ex:checked + label.as-toggle { box-shadow:0 0 0 var(--active-ring) currentColor; border-color:currentColor; }
/* Panels: hidden by default, shown when respective toggle checked */
.panels{display:grid;gap:10px}
.panel{display:none}
.tg-err:checked ~ .panels .panel-err{display:grid}
.tg-warn:checked ~ .panels .panel-warn{display:grid; gap: 8px}
.tg-ex:checked ~ .panels .panel-ex{display:grid}
/* Colored notes for component error + warnings */
.note{padding:12px;border:1px solid var(--border);border-radius:10px}
.note.err{border-color:color-mix(in srgb,var(--err) 55%, var(--border));background:var(--err-bg);color:#ffd1d4}
.note.warn{border-color:color-mix(in srgb,var(--warn) 55%, var(--border));background:var(--warn-bg);color:#ffe9a6}
.note-title{font-weight:600;margin-bottom:6px}
.note-body{white-space:normal}
/* Example error cards */
.ex{padding:10px;border:1px solid var(--border);border-radius:10px;background:#0f131b}
.ex.err{border-color:color-mix(in srgb,var(--err) 55%, var(--border))}
.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.dot-ok{background:var(--ok)}
.dot-err{background:var(--err)}
.ex-name{font-weight:600}
/* CSS-only filtering of cards via top pills */
#filter-errors:target ~ main .card:not(.has-error):not(.has-example-error){display:none}
#filter-warnings:target ~ main .card:not(.has-warn){display:none}
#filter-example-errors:target ~ main .card:not(.has-example-error){display:none}
#filter-all:target ~ main .card{display:block}
/* When a toggle is checked, show the corresponding panel */
.card > .tg-err:checked ~ .panels .panel-err { display: grid; }
.card > .tg-warn:checked ~ .panels .panel-warn { display: grid; }
.card > .tg-ex:checked ~ .panels .panel-ex { display: grid; }
/* Optional: a subtle 1px ring on the active badge, using :has() if available */
@supports selector(.card:has(.tg-err:checked)) {
.card:has(.tg-err:checked) label[for$="-err"],
.card:has(.tg-warn:checked) label[for$="-warn"],
.card:has(.tg-ex:checked) label[for$="-ex"] {
box-shadow: 0 0 0 1px currentColor;
border-color: currentColor;
}
}
</style>
</head>
<body>
<!-- Hidden targets for the top-level filters -->
<span id="filter-all"></span>
<span id="filter-errors"></span>
<span id="filter-warnings"></span>
<span id="filter-example-errors"></span>
<header>
<div class="wrap">
<h1>Components Manifest</h1>
<div class="summary">
${allPill}
${compErrorsPill}
${compWarningsPill}
${examplesPill}
</div>
</div>
</header>
<main>
<div class="wrap">
<div class="grid" role="list">
${grid || `<div class="card"><div class="head"><div class="hint">No components.</div></div></div>`}
</div>
</div>
</main>
</body>
</html>`;
}

const esc = (s: unknown) =>
String(s ?? '').replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string
);
const plural = (n: number, one: string, many = `${one}s`) => (n === 1 ? one : many);

function analyzeComponent(c: ComponentManifest) {
const hasComponentError = !!c.error;
const warns: string[] = [];

if (!c.description?.trim()) {
warns.push('No description found. Write a jsdoc comment such as /** Component description */.');
}

if (!c.import?.trim()) {
warns.push(
`Specify an @import jsdoc tag on your component such as @import import { ${c.name} } from 'my-design-system';`
);
}

const totalExamples = c.examples?.length ?? 0;
const exampleErrors = (c.examples ?? []).filter((e) => !!e?.error).length;
const exampleOk = totalExamples - exampleErrors;

const hasAnyError = hasComponentError || exampleErrors > 0; // for status dot (red if any errors)

return {
hasComponentError,
hasAnyError,
hasWarns: warns.length > 0,
warns,
totalExamples,
exampleErrors,
exampleOk,
};
}

function note(title: string, bodyHTML: string, kind: 'warn' | 'err') {
return dedent`
<div class="note ${kind}">
<div class="note-title">${esc(title)}</div>
<div class="note-body">${bodyHTML}</div>
</div>`;
}

function renderComponentCard(key: string, c: ComponentManifest, i: number) {
const a = analyzeComponent(c);
const statusDot = a.hasAnyError ? 'dot-err' : 'dot-ok';
const errorExamples = (c.examples ?? []).filter((ex) => !!ex?.error);

const slug = `c-${i}-${(c.id || key)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')}`;
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

Potential ID collision risk in slug generation.

The slug generation normalizes component IDs to create unique HTML element IDs. While the index prefix (c-${i}-) reduces collision risk, two components with names that normalize identically (e.g., "MyComponent" and "My-Component") could still produce colliding IDs if they appear at the same index in different renders.

Consider incorporating more uniqueness into the slug:

-  const slug = `c-${i}-${(c.id || key)
+  const slug = `c-${i}-${(c.id || key).replace(/\//g, '-')}`.replace(/[^a-z0-9-]/gi, '-').replace(/-+/g, '-')
     .toLowerCase()
     .replace(/[^a-z0-9]+/g, '-')
     .replace(/^-+|-+$/g, '')}`;

Or use a hash of the full key to guarantee uniqueness:

+  // At top: import { createHash } from 'crypto';
-  const slug = `c-${i}-${(c.id || key)
+  const slug = `c-${createHash('md5').update(key).digest('hex').slice(0, 8)}-${(c.id || c.name || key)
     .toLowerCase()
     .replace(/[^a-z0-9]+/g, '-')
     .replace(/^-+|-+$/g, '')}`;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In code/core/src/core-server/manifest.ts around lines 233 to 236, the slug
generation can produce collisions when different keys normalize to the same
string; change the slug to include a short deterministic unique suffix (for
example a 6–8 character hex hash of the full key or id) appended after the
normalized string (still lowercased and sanitized), so the slug becomes
c-{i}-{normalized}-{hash}; compute the hash from the original key/id (not the
normalized value) to ensure uniqueness across similar names and keep the
sanitization step for the final slug output.


const componentErrorBadge = a.hasComponentError
? `<label for="${slug}-err" class="badge err as-toggle">component error</label>`
: '';

const warningsBadge = a.hasWarns
? `<label for="${slug}-warn" class="badge warn as-toggle">${a.warns.length} ${plural(a.warns.length, 'warning')}</label>`
: '';

const examplesBadge =
a.exampleErrors > 0
? `<label for="${slug}-ex" class="badge err as-toggle">${a.exampleErrors}/${a.totalExamples} example errors</label>`
: `<span class="badge ok">${a.totalExamples} examples ok</span>`;

const tags =
c.jsDocTags && typeof c.jsDocTags === 'object'
? Object.entries(c.jsDocTags)
.flatMap(([k, v]) =>
(Array.isArray(v) ? v : [v]).map(
(val) => `<span class="chip">${esc(k)}: ${esc(val)}</span>`
)
)
.join('')
: '';

return dedent`
<article class="card ${a.hasComponentError ? 'has-error' : 'no-error'} ${a.hasWarns ? 'has-warn' : 'no-warn'} ${a.exampleErrors ? 'has-example-error' : 'no-example-error'}" role="listitem" aria-label="${esc(c.name || key)}">
<div class="head">
<div class="title">
<h2><span class="status-dot ${statusDot}"></span> ${esc(c.name || key)}</h2>
<div class="badges">
${componentErrorBadge}
${warningsBadge}
${examplesBadge}
</div>
</div>
<div class="meta" title="${esc(c.path)}">${esc(c.id)} · ${esc(c.path)}</div>
${c.summary ? `<div>${esc(c.summary)}</div>` : ''}
${c.description ? `<div class="hint">${esc(c.description)}</div>` : ''}
${tags ? `<div class="kv">${tags}</div>` : ''}
</div>
<!-- ⬇️ Hidden toggles must be siblings BEFORE .panels -->
${a.hasComponentError ? `<input id="${slug}-err" class="tg tg-err" type="checkbox" hidden />` : ''}
${a.hasWarns ? `<input id="${slug}-warn" class="tg tg-warn" type="checkbox" hidden />` : ''}
${a.exampleErrors > 0 ? `<input id="${slug}-ex" class="tg tg-ex" type="checkbox" hidden />` : ''}
<div class="panels">
${
a.hasComponentError
? `
<div class="panel panel-err">
${note('Component error', `<pre><code>${esc(c.error?.message || 'Unknown error')}</code></pre>`, 'err')}
</div>`
: ''
}
${
a.hasWarns
? `
<div class="panel panel-warn">
${a.warns.map((w) => note('Warning', esc(w), 'warn')).join('')}
</div>`
: ''
}
${
a.exampleErrors > 0
? `
<div class="panel panel-ex">
${errorExamples
.map(
(ex, j) => `
<div class="ex err">
<div class="row">
<span class="status-dot dot-err"></span>
<span class="ex-name">${esc(ex?.name ?? `Example ${j + 1}`)}</span>
<span class="badge err">example error</span>
</div>
${ex?.snippet ? `<pre><code>${esc(ex.snippet)}</code></pre>` : ''}
${ex?.error?.message ? `<pre><code>${esc(ex.error.message)}</code></pre>` : ''}
</div>
`
)
.join('')}
</div>`
: ''
}
</div>
</article>`;
}
6 changes: 4 additions & 2 deletions code/core/src/core-server/utils/StoryIndexGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,15 @@ export class StoryIndexGenerator {
absolutePath: Path,
matchPath: TsconfigPaths.MatchPath | undefined
) {
const extensions = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.mts'];
let rawPath = rawComponentPath;
if (matchPath) {
rawPath = matchPath(rawPath) ?? rawPath;
rawPath = matchPath(rawPath, undefined, undefined, extensions) ?? rawPath;
}

const absoluteComponentPath = resolve(dirname(absolutePath), rawPath);
const existing = ['', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.mts']
const existing = [...extensions, '']
.flatMap((it) => (it === '' ? [it] : ['/index' + it, it]))
.map((ext) => `${absoluteComponentPath}${ext}`)
.find((candidate) => existsSync(candidate));
if (existing) {
Expand Down
Loading
Loading