Skip to content

[storybook] PoC of Storybook directive, for inline stories#2910

Open
clintandrewhall wants to merge 3 commits intomainfrom
poc/storybook
Open

[storybook] PoC of Storybook directive, for inline stories#2910
clintandrewhall wants to merge 3 commits intomainfrom
poc/storybook

Conversation

@clintandrewhall
Copy link
Copy Markdown

Summary

This PR is a proof-of-concept for embedding Kibana Storybook stories in docs-builder output, so teams can render interactive component examples directly inside product documentation.

Screenshot 2026-03-16 at 10 15 34 PM

The immediate problem we are trying to solve is that Storybook content today lives separately from our docs experience. We want a path where Kibana documentation can reference Storybook stories, have those stories render inside docs pages, and eventually support a workflow where Storybook-authored documentation can be previewed and published through the same docs system.

This implementation is intentionally early and exploratory. It was heavily AI-assisted to help move quickly on the shape of the integration, and I expect parts of the API and implementation to change based on review feedback, Kibana-side needs, and production deployment constraints.

Note

This PR was written with extensive assistance from GPT-5.4.

Approach

Rather than teaching docs-builder to understand Storybook MDX directly, this POC adds a first-class storybook directive to the markdown engine and treats that as the stable contract.

That means the current architecture is:

  1. Kibana-side content can be transformed into normalized markdown.
  2. docs-builder consumes that markdown via a {storybook} directive.
  3. The rendered docs page embeds the Storybook iframe using a validated URL shape.

This keeps docs-builder focused on rendering and validation, while leaving repo-specific Storybook MDX interpretation to Kibana if we choose to build that transform.

What’s Included

New storybook directive

Adds support for:

:::{storybook}
:id: components-button--primary
:title: Button / Primary story
:::

or, when needed:

:::{storybook}
:root: /storybook/kibana-eui
:id: components-button--primary
:height: 320
:title: Button / Primary story
:::

The directive renders an iframe in HTML output and a structured <storybook ...> element in LLM markdown output.

Docset-level Storybook configuration

Adds storybook config in docset.yml so authors do not need to repeat the root on every story block.

Supported settings now include:

storybook:
  root: /storybook/kibana-eui
  server_root: http://localhost:6006
  allowed_roots:
    - http://localhost:6006/storybook/kibana-eui

This supports a few use cases:

  • storybook.root
    • Default Storybook root for the docset
  • storybook.server_root
    • Optional base server URL prepended to root-relative story roots
  • storybook.allowed_roots
    • Explicit allow-list for absolute root overrides

URL generation and validation

The directive now builds the iframe URL internally as:

{root}/iframe.html?id={id}&viewMode=story

Validation ensures that:

  • root-relative paths are allowed
  • absolute roots must be explicitly configured
  • iframe.html, query strings, and fragments are rejected in :root:
  • literal / is supported as the root of the Storybook server

Documentation and testing notes

Adds and updates documentation for:

  • directive syntax
  • Kibana local testing
  • Kibana-side MDX transform expectations

This is meant to make review easier and give the team a concrete starting point for trying the flow from Kibana.

Why this shape

The main design choice in this POC is that docs-builder understands a normalized Storybook embed contract, not Storybook MDX itself.

That gives us a cleaner separation of concerns:

  • Kibana owns Storybook content, CSF metadata, package-to-root mapping, and any MDX transform
  • docs-builder owns markdown parsing, validation, rendering, and docs-site output

That feels like the right boundary for now, especially since Storybook MDX resolution is repo-specific and may vary by package, build system, or authoring conventions.

Notes / Caveats

  • This is not a final authoring API.
  • The config shape, directive syntax, and deployment assumptions may change after feedback.
  • The intended production model is that Storybook static assets are hosted on the same deployed site as the docs output, under a path like /storybook/<library>/.
  • The Kibana-side MDX transform is documented, but not implemented in this PR.
  • This should be read as a working integration spike, not a locked-in design.

Feedback I’m Looking For

  • Is the docs-builder / Kibana responsibility split the right one?
  • Is storybook.root + server_root the right docset-level model?
  • Should the final authoring flow prefer normalized markdown generation, or should we consider native MDX ingestion later?
  • Does the deployment model of static Storybook assets under the same site root match how we want to operate this long-term?
  • Is this a good start to a formal inclusion of this functionality?

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 17, 2026

🔍 Preview links for changed docs

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 1, 2026

📝 Walkthrough

Walkthrough

This pull request adds support for a {storybook} directive that embeds Storybook stories in documentation. The implementation includes configuration schema changes to DocumentationSetFile and ConfigurationFile to store Storybook roots, server roots, allowed roots, and bundle URLs; a new StorybookBlock directive parser that validates and resolves story roots and constructs iframe URLs; HTML and LLM renderers that output either iframes or web components; a custom storybook-story web component for client-side story mounting; accompanying CSS styles; and comprehensive test coverage. Documentation pages describe the directive syntax, configuration options, validation rules, and integration guidance for the Kibana repository.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch poc/storybook
  • 🛠️ Update Documentation: Commit on current branch
  • 🛠️ Update Documentation: Create PR

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx`:
- Around line 50-70: attributeChangedCallback/mount can start overlapping mounts
when attributes change rapidly; add a guard to ensure only the latest mount
proceeds. Implement a per-instance version counter (e.g., this._mountVersion) or
an AbortController token that you increment/create at the start of mount(),
capture in local scope, and check/abort before/after awaiting loadBundle and
before calling this.bundle.mountStory; ensure any previous pending mount is
considered stale (skip rendering into this.container) if its version/token
doesn't match the current one. Update attributeChangedCallback to call mount()
as before but rely on the new guard inside mount() to prevent concurrent
renders.

In `@src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs`:
- Around line 136-140: In StorybookBlock (the method that validates the :root:
Storybook URI), trim trailing slashes from rootUri.AbsolutePath (or trim rawRoot
before creating/normalizing the Uri) before checking for "/iframe.html" so
values like ".../iframe.html/" are caught; update the same check/trim logic used
around the other iframe guard (the block referenced at lines 172-176) so both
places perform the trim/normalization prior to the EndsWith/Equals checks and
then proceed with the existing normalization and error assignment.

In `@src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs`:
- Around line 794-797: The attributes written for the <storybook> element are
not escaped: sanitize/HTML-encode the attribute values before writing them
(escape block.IframeTitle and block.StoryUrl output used in the src attribute,
and any other attribute values like block.Height if it's not strictly numeric),
e.g. call an existing utility or add a small helper (use
LlmRenderingHelpers.MakeAbsoluteUrl for URL resolution then pass that result
through an HTML-attribute-encoding method) and replace direct interpolations in
the renderer.Writer.Write calls so that quotes, ampersands and angle brackets
are encoded and cannot break or inject markup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e3c4ca7c-c50c-4f00-9611-8945ddab6588

📥 Commits

Reviewing files that changed from the base of the PR and between 1797e4a and ceffd19.

📒 Files selected for processing (26)
  • docs/_docset.yml
  • docs/syntax/directives.md
  • docs/syntax/index.md
  • docs/syntax/storybook.md
  • src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs
  • src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs
  • src/Elastic.Documentation.Site/Assets/main.ts
  • src/Elastic.Documentation.Site/Assets/markdown/storybook.css
  • src/Elastic.Documentation.Site/Assets/styles.css
  • src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx
  • src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
  • src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
  • src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs
  • src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml
  • src/Elastic.Markdown/Myst/Directives/Storybook/StorybookViewModel.cs
  • src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs
  • src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs
  • storybook-kibana-local-testing.md
  • storybook-kibana-mdx-transform.md
  • tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs
  • tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs
  • tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs
  • tests/authoring/Blocks/Storybook.fs
  • tests/authoring/Framework/Setup.fs
  • tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs
  • tests/authoring/authoring.fsproj

Comment on lines +50 to +70
attributeChangedCallback() {
if (this.container) {
this.mount()
}
}

private async mount() {
const storyId = this.getAttribute('story-id')
const bundleUrl = this.getAttribute('bundle')

if (!storyId || !bundleUrl || !this.container) return

try {
this.bundle = await loadBundle(bundleUrl)
await this.bundle.mountStory(storyId, this.container)
} catch (err) {
if (this.container) {
this.container.innerHTML = `<div style="padding:1rem;color:#BD271E;font-size:14px">Failed to load story: ${err instanceof Error ? err.message : String(err)}</div>`
}
}
}
Copy link
Copy Markdown
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 race condition on rapid attribute changes.

If attributes change while mount() is still awaiting the bundle load, a second mount() can start before the first completes, potentially rendering multiple stories into the same container. For a PoC this is acceptable, but consider adding a guard (abort controller or version counter) before production use.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx`
around lines 50 - 70, attributeChangedCallback/mount can start overlapping
mounts when attributes change rapidly; add a guard to ensure only the latest
mount proceeds. Implement a per-instance version counter (e.g.,
this._mountVersion) or an AbortController token that you increment/create at the
start of mount(), capture in local scope, and check/abort before/after awaiting
loadBundle and before calling this.bundle.mountStory; ensure any previous
pending mount is considered stale (skip rendering into this.container) if its
version/token doesn't match the current one. Update attributeChangedCallback to
call mount() as before but rely on the new guard inside mount() to prevent
concurrent renders.

Comment on lines +136 to +140
if (rootUri.AbsolutePath.EndsWith("/iframe.html", StringComparison.OrdinalIgnoreCase)
|| rootUri.AbsolutePath.Equals("/iframe.html", StringComparison.OrdinalIgnoreCase))
{
validationError = $"storybook directive :root: should point to the Storybook root, not iframe.html. Got: {rawRoot}";
return false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Trim the path before the iframe.html guard.

Values ending in .../iframe.html/ slip past both checks because the trailing slash is removed after validation. They then normalize to .../iframe.html and produce .../iframe.html/iframe.html?..., which breaks the embed URL.

Proposed fix
-		if (rootUri.AbsolutePath.EndsWith("/iframe.html", StringComparison.OrdinalIgnoreCase)
-			|| rootUri.AbsolutePath.Equals("/iframe.html", StringComparison.OrdinalIgnoreCase))
+		var rootPath = rootUri.AbsolutePath.TrimEnd('/');
+		if (rootPath.EndsWith("/iframe.html", StringComparison.OrdinalIgnoreCase))
 		{
 			validationError = $"storybook directive :root: should point to the Storybook root, not iframe.html. Got: {rawRoot}";
 			return false;
 		}
@@
-		if (serverUri.AbsolutePath.EndsWith("/iframe.html", StringComparison.OrdinalIgnoreCase)
-			|| serverUri.AbsolutePath.Equals("/iframe.html", StringComparison.OrdinalIgnoreCase))
+		var serverPath = serverUri.AbsolutePath.TrimEnd('/');
+		if (serverPath.EndsWith("/iframe.html", StringComparison.OrdinalIgnoreCase))
 		{
 			validationError = $"docset.yml storybook.server_root should point to the Storybook server, not iframe.html. Got: {rawServerRoot}";
 			return false;
 		}

Also applies to: 172-176

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs` around
lines 136 - 140, In StorybookBlock (the method that validates the :root:
Storybook URI), trim trailing slashes from rootUri.AbsolutePath (or trim rawRoot
before creating/normalizing the Uri) before checking for "/iframe.html" so
values like ".../iframe.html/" are caught; update the same check/trim logic used
around the other iframe guard (the block referenced at lines 172-176) so both
places perform the trim/normalization prior to the EndsWith/Equals checks and
then proceed with the existing normalization and error assignment.

Comment on lines +794 to +797
renderer.Writer.Write("<storybook");
renderer.Writer.Write($" src=\"{LlmRenderingHelpers.MakeAbsoluteUrl(renderer, block.StoryUrl)}\"");
renderer.Writer.Write($" height=\"{block.Height}\"");
renderer.Writer.Write($" title=\"{block.IframeTitle}\"");
Copy link
Copy Markdown
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 the serialized <storybook> attributes.

title is author-controlled and is written straight into a quoted attribute here. A value containing "/</& will break the structured tag or inject extra markup into the downstream LLM payload, and src should go through the same serialization path instead of raw interpolation. Since this element is the contract consumers parse, this needs proper attribute escaping before merge.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs` around
lines 794 - 797, The attributes written for the <storybook> element are not
escaped: sanitize/HTML-encode the attribute values before writing them (escape
block.IframeTitle and block.StoryUrl output used in the src attribute, and any
other attribute values like block.Height if it's not strictly numeric), e.g.
call an existing utility or add a small helper (use
LlmRenderingHelpers.MakeAbsoluteUrl for URL resolution then pass that result
through an HTML-attribute-encoding method) and replace direct interpolations in
the renderer.Writer.Write calls so that quotes, ampersands and angle brackets
are encoded and cannot break or inject markup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant