From 76506134a80bf39cad735864e5398d700b3b1b1c Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 2 Jun 2026 20:44:38 -0400 Subject: [PATCH 1/7] Add Storybook registry directive support --- docs/_docset.yml | 4 + docs/syntax/directives.md | 3 +- docs/syntax/index.md | 4 +- docs/syntax/storybook.md | 86 ++++++ .../Builder/ConfigurationFile.cs | 5 + .../Serialization/YamlStaticContext.cs | 1 + .../Toc/DocumentationSetFile.cs | 10 + src/Elastic.Documentation.Site/Assets/main.ts | 1 + .../Assets/markdown/storybook.css | 32 ++ .../Assets/styles.css | 1 + .../StorybookStoryComponent.tsx | 263 ++++++++++++++++ .../Myst/Directives/DirectiveBlockParser.cs | 4 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 23 ++ .../Directives/Storybook/StorybookBlock.cs | 284 ++++++++++++++++++ .../Directives/Storybook/StorybookRegistry.cs | 90 ++++++ .../Directives/Storybook/StorybookView.cshtml | 25 ++ .../Storybook/StorybookViewModel.cs | 26 ++ .../LlmMarkdown/LlmBlockRenderers.cs | 21 ++ .../Directives/DirectiveBaseTests.cs | 4 +- .../Directives/StorybookTests.cs | 245 +++++++++++++++ .../MockFileSystemExtensions.cs | 6 +- tests/authoring/Blocks/Storybook.fs | 26 ++ tests/authoring/authoring.fsproj | 1 + 23 files changed, 1160 insertions(+), 5 deletions(-) create mode 100644 docs/syntax/storybook.md create mode 100644 src/Elastic.Documentation.Site/Assets/markdown/storybook.css create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx create mode 100644 src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Storybook/StorybookRegistry.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Storybook/StorybookViewModel.cs create mode 100644 tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs create mode 100644 tests/authoring/Blocks/Storybook.fs diff --git a/docs/_docset.yml b/docs/_docset.yml index 0dd5a42d69..aed40e521b 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -41,6 +41,9 @@ subs: features: primary-nav: false + +storybook: + registry: https://ci-artifacts.kibana.dev/storybooks/pr-272388/storybook-docs/docs_registry.json suppress: - AutolinkElasticCoDocs @@ -149,6 +152,7 @@ toc: - file: lists.md - file: task-lists.md - file: line_breaks.md + - file: storybook.md - file: links.md - file: list-sub-pages.md - file: page-card.md diff --git a/docs/syntax/directives.md b/docs/syntax/directives.md index b3754b6c6e..07ae223f61 100644 --- a/docs/syntax/directives.md +++ b/docs/syntax/directives.md @@ -77,7 +77,8 @@ The following directives are available: - [Math](math.md) - Mathematical expressions and equations - [Page cards](page-card.md) - Full-width clickable navigation rows - [Settings](automated_settings.md) - Configuration blocks +- [Storybook](storybook.md) - Embedded Storybook stories - [Stepper](stepper.md) - Step-by-step content - [Tabs](tabs.md) - Tabbed content organization - [Tables](tables.md) - Data tables -- [Version blocks](version-variables.md) - API version information \ No newline at end of file +- [Version blocks](version-variables.md) - API version information diff --git a/docs/syntax/index.md b/docs/syntax/index.md index de6b664fa6..c049b30a3a 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -12,7 +12,7 @@ Elastic Docs V3 uses a custom implementation of [MyST](https://mystmd.org/) (Mar If you know [Markdown](https://commonmark.org), you already know most of what you need. If not, the CommonMark project offers a [10-minute tutorial](https://commonmark.org/help/). -When you need more than basic Markdown, you can use _directives_ to add features like callouts, tabs, and diagrams. To learn how directives work in general, including how to add options and arguments and nest multiple directives, refer to [How directives work](directives.md). For a full list of available directives, refer to the sidebar. +When you need more than basic Markdown, you can use _directives_ to add features like callouts, tabs, diagrams, and embedded Storybook stories. To learn how directives work in general, including how to add options and arguments and nest multiple directives, refer to [How directives work](directives.md). For a full list of available directives, refer to the sidebar. ## GitHub Flavored Markdown support @@ -25,4 +25,4 @@ V3 supports some GitHub Flavored Markdown extensions: **Not supported:** - Automatic URL linking: https://www.elastic.co - Links must use standard Markdown syntax: [Elastic](https://www.elastic.co) -- Using a subset of HTML \ No newline at end of file +- Using a subset of HTML diff --git a/docs/syntax/storybook.md b/docs/syntax/storybook.md new file mode 100644 index 0000000000..28e5ab964a --- /dev/null +++ b/docs/syntax/storybook.md @@ -0,0 +1,86 @@ +# Storybook + +The `{storybook}` directive embeds a Storybook story from a Kibana `docs_registry.json`. The registry supplies the Storybook runtime ID, inline module entry, bootstrap assets, and iframe fallback URL. + +Configure the registry URL in `docset.yml`: + +```yaml +storybook: + registry: https://example.com/storybook-docs/docs_registry.json +``` + +This page uses the Kibana Storybook artifact registry from PR 272388 so docs-builder preview builds can exercise inline module loading from `docs-v3-preview.elastic.dev`. + +For local Kibana testing, `yarn storybook_docs shared_ux --serve` serves the registry at: + +```text +http://127.0.0.1:6007/storybook-docs/docs_registry.json +``` + +## Usage + +Use a registry ID directly: + +```markdown +:::{storybook} +:id: kibana:shared_ux:ai-components-aibutton--default +:height: 300 +:title: AI button default story +::: +``` + +:::{storybook} +:id: kibana:shared_ux:ai-components-aibutton--default +:height: 300 +:title: AI button default story +::: + +Or use structured properties: + +```markdown +:::{storybook} +:project: kibana +:storybook: shared_ux +:component: ai-components-aibutton +:story: default +::: +``` + +:::{storybook} +:project: kibana +:storybook: shared_ux +:component: ai-components-aibutton +:story: default +::: + +If a bare `:id:` is used, it must match exactly one story in the configured registry: + +```markdown +:::{storybook} +:id: ai-components-aibutton--default +::: +``` + +:::{storybook} +:id: ai-components-aibutton--default +::: + +## Properties + +| Property | Required | Description | +|---|---|---| +| `:id:` | Yes* | Full registry ID such as `kibana:shared_ux:ai-components-aibutton--default`, or a bare docs/storybook ID that matches exactly one configured story. | +| `:project:` | Yes* | Registry project prefix, for example `kibana`. Required when `:id:` is omitted. | +| `:storybook:` | Yes* | Storybook alias, for example `shared_ux`. Required when `:id:` is omitted. | +| `:component:` | No | Component ID used with `:story:` to form `{component}--{story}`. | +| `:story:` | Yes* | Story name or docs ID. Required when `:id:` is omitted. | +| `:height:` | No | Iframe fallback height in pixels. Defaults to the registry story height when present, otherwise `400`. | +| `:title:` | No | Accessible title for the iframe fallback. Defaults to `Storybook story`. | + +## Rendering + +If the registry story has `renderMode: inline` and an `inline` entry, docs-builder renders a `` element. The browser loads the registry bootstrap styles and scripts, then imports `inline.entry` and calls `mountStory(story.storybookId, container)`. + +If the story has `renderMode: iframe`, or no inline entry, docs-builder renders `iframe.url`. + +The registry, inline module, bootstrap assets, and iframe fallback can live on different paths. docs-builder uses the URLs from the registry directly; those assets must allow browser access from the docs site. diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index f20055f83b..8a94b087b7 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -59,6 +59,8 @@ public record ConfigurationFile public IReadOnlyDictionary? OpenApiSpecifications { get; } + public string? StorybookRegistry { get; } + /// /// Resolved API configurations with template and specification file information. /// @@ -245,6 +247,9 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte ApiConfigurations = apiConfigs.Count > 0 ? apiConfigs : null; } + if (docSetFile.Storybook is not null) + StorybookRegistry = docSetFile.Storybook.Registry?.Trim(); + // Process products from docset - resolve ProductLinks to Product objects if (docSetFile.Products.Count > 0) { diff --git a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs index 21cab10863..7fc6e8f614 100644 --- a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs +++ b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs @@ -39,6 +39,7 @@ namespace Elastic.Documentation.Configuration.Serialization; // Table of contents [YamlSerializable(typeof(DocumentationSetFile))] [YamlSerializable(typeof(DocumentationSetFeatures))] +[YamlSerializable(typeof(DocumentationSetStorybook))] [YamlSerializable(typeof(CodexDocSetMetadata))] [YamlSerializable(typeof(TableOfContentsFile))] [YamlSerializable(typeof(SiteNavigationFile))] diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 120a839607..5b94ab7f71 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -73,6 +73,9 @@ public class DocumentationSetFile : TableOfContentsFile [YamlMember(Alias = "branding")] public BrandingConfiguration? Branding { get; set; } + [YamlMember(Alias = "storybook")] + public DocumentationSetStorybook? Storybook { get; set; } + public static FileRef[] GetFileRefs(ITableOfContentsItem item) { if (item is FileRef fileRef) @@ -740,6 +743,13 @@ public class DocumentationSetFeatures public bool? DisableGithubEditLink { get; set; } } +[YamlSerializable] +public class DocumentationSetStorybook +{ + [YamlMember(Alias = "registry")] + public string? Registry { get; set; } +} + /// /// Codex-specific metadata. Only contains group for navigation grouping in a codex environment. /// diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index ad795a20ad..7217084af5 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -41,6 +41,7 @@ import('./web-components/VersionDropdown') import('./web-components/AppliesToPopover') import('./web-components/FullPageSearch/FullPageSearchComponent') import('./web-components/Diagnostics/DiagnosticsComponent') +import('./web-components/StorybookStory/StorybookStoryComponent') if (config.buildType === 'isolated' || config.airGapped) { import('./isolated') diff --git a/src/Elastic.Documentation.Site/Assets/markdown/storybook.css b/src/Elastic.Documentation.Site/Assets/markdown/storybook.css new file mode 100644 index 0000000000..54d318eaca --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/storybook.css @@ -0,0 +1,32 @@ +.storybook-embed { + border: 1px solid #E3E8F2; + border-radius: 6px; + margin-block: 1.5rem; + overflow: hidden; +} + +.storybook-embed iframe { + display: block; +} + +.storybook-embed storybook-story { + display: block; + padding: 24px; +} + +.storybook-embed-body { + padding: 0.75rem 1.5rem; + border-top: 1px solid #E3E8F2; + background: #F7F8FC; +} + +.storybook-embed-error { + padding: 1rem; + color: #BD271E; + font-size: 14px; +} + +.storybook-embed-error strong { + display: block; + margin-bottom: 0.25rem; +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index ebb52e75c1..d4e0b91134 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -29,6 +29,7 @@ @import './markdown/agent-skill.css'; @import './markdown/cli-modifiers.css'; @import './markdown/contributors.css'; +@import './markdown/storybook.css'; @import './api-docs.css'; @import 'tippy.js/dist/tippy.css'; diff --git a/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx b/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx new file mode 100644 index 0000000000..dc121a16f6 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx @@ -0,0 +1,263 @@ +type MountFn = (storyId: string, container: HTMLElement) => Promise +type UnmountFn = (container: HTMLElement) => void +type KibanaPublicPath = Record + +declare global { + interface Window { + __kbnPublicPath__?: KibanaPublicPath + __kbnHardenPrototypes__?: boolean + } +} + +interface StoryEntry { + mountStory: MountFn + unmountStory: UnmountFn +} + +interface StoryBootstrap { + publicPath?: string + scripts?: string[] + styles?: string[] +} + +class StorybookLoadError extends Error { + constructor( + public readonly title: string, + public readonly detail: string + ) { + super(detail) + } +} + +const entryCache = new Map>() +const bootstrapCache = new Map>() +const scriptCache = new Map>() +const styleCache = new Map>() + +const loadEntry = (url: string): Promise => { + if (!entryCache.has(url)) { + entryCache.set( + url, + import(/* @vite-ignore */ url) + .then(validateEntry) + .catch((err) => { + entryCache.delete(url) + if (err instanceof StorybookLoadError) throw err + throw new StorybookLoadError( + 'Storybook entry module could not be imported.', + `Check that the module exists and CORS allows this docs origin: ${url}` + ) + }) + ) + } + return entryCache.get(url)! +} + +const validateEntry = (entry: unknown): StoryEntry => { + const candidate = entry as Partial + if (typeof candidate.mountStory !== 'function' || typeof candidate.unmountStory !== 'function') { + throw new StorybookLoadError( + 'Storybook entry module has an unsupported contract.', + 'Expected mountStory(storyId, container) and unmountStory(container).' + ) + } + return candidate as StoryEntry +} + +const loadStylesheet = (url: string): Promise => { + if (!styleCache.has(url)) { + styleCache.set( + url, + new Promise((resolve, reject) => { + const existing = Array.from(document.querySelectorAll('link[data-storybook-style]')).find( + (link) => link.dataset.storybookStyle === url + ) + if (existing) { + resolve() + return + } + + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = url + link.dataset.storybookStyle = url + link.onload = () => resolve() + link.onerror = () => { + styleCache.delete(url) + reject(new StorybookLoadError('Storybook stylesheet could not be loaded.', `Missing or blocked stylesheet: ${url}`)) + } + document.head.appendChild(link) + }) + ) + } + return styleCache.get(url)! +} + +const loadScript = (url: string): Promise => { + if (!scriptCache.has(url)) { + scriptCache.set( + url, + new Promise((resolve, reject) => { + const existing = Array.from(document.querySelectorAll('script[data-storybook-script]')).find( + (script) => script.dataset.storybookScript === url + ) + if (existing) { + resolve() + return + } + + const script = document.createElement('script') + script.src = url + script.async = false + script.dataset.storybookScript = url + script.onload = () => resolve() + script.onerror = () => { + scriptCache.delete(url) + reject(new StorybookLoadError('Storybook bootstrap script could not be loaded.', `Missing or blocked script: ${url}`)) + } + document.head.appendChild(script) + }) + ) + } + return scriptCache.get(url)! +} + +const parseBootstrap = (value: string | null): StoryBootstrap | null => { + if (!value) return null + + try { + return JSON.parse(value) as StoryBootstrap + } catch { + throw new StorybookLoadError( + 'Storybook bootstrap data is invalid.', + 'The registry provided bootstrap data that is not valid JSON.' + ) + } +} + +const setKibanaGlobals = (publicPath?: string) => { + if (!publicPath) return + + const publicPaths = { + 'kbn-ui-shared-deps-npm': publicPath, + 'kbn-ui-shared-deps-src': publicPath, + 'kbn-monaco': publicPath, + } + + window.__kbnPublicPath__ = publicPaths + window.__kbnHardenPrototypes__ = false + + try { + if (window.top) { + window.top.__kbnPublicPath__ = publicPaths + window.top.__kbnHardenPrototypes__ = false + } + } catch { + // Cross-origin frames cannot access `top`. + } +} + +const loadBootstrap = async (bootstrap: StoryBootstrap | null) => { + if (!bootstrap) return + + setKibanaGlobals(bootstrap.publicPath) + + const cacheKey = bootstrap.publicPath || JSON.stringify(bootstrap) + if (!bootstrapCache.has(cacheKey)) { + bootstrapCache.set( + cacheKey, + (async () => { + await Promise.all((bootstrap.styles ?? []).map(loadStylesheet)) + for (const script of bootstrap.scripts ?? []) { + await loadScript(script) + } + })() + ) + } + + await bootstrapCache.get(cacheKey) +} + +class StorybookStoryElement extends HTMLElement { + private container: HTMLDivElement | null = null + private entry: StoryEntry | null = null + private mountVersion = 0 + + static get observedAttributes() { + return ['story-id', 'entry', 'bootstrap'] + } + + connectedCallback() { + if (!this.container) { + this.container = document.createElement('div') + this.appendChild(this.container) + } + this.mount() + } + + disconnectedCallback() { + this.mountVersion++ + this.unmount() + } + + attributeChangedCallback() { + if (this.container) { + this.mount() + } + } + + private unmount() { + if (this.container && this.entry) { + this.entry.unmountStory(this.container) + this.entry = null + } + } + + private async mount() { + const storyId = this.getAttribute('story-id') + const entryUrl = this.getAttribute('entry') + const currentMount = ++this.mountVersion + + if (!storyId || !entryUrl || !this.container) return + + this.unmount() + this.container.replaceChildren() + + try { + await loadBootstrap(parseBootstrap(this.getAttribute('bootstrap'))) + const entry = await loadEntry(entryUrl) + if (currentMount !== this.mountVersion || !this.container || !this.isConnected) return + + this.entry = entry + try { + await entry.mountStory(storyId, this.container) + } catch (err) { + throw new StorybookLoadError( + 'Storybook story failed while mounting.', + err instanceof Error ? err.message : String(err) + ) + } + } catch (err) { + if (currentMount === this.mountVersion && this.container) { + this.container.replaceChildren(createErrorMessage(err)) + } + } + } +} + +const createErrorMessage = (err: unknown): HTMLDivElement => { + const message = document.createElement('div') + message.className = 'storybook-embed-error' + + const title = document.createElement('strong') + title.textContent = err instanceof StorybookLoadError ? err.title : 'Storybook story failed to load.' + message.appendChild(title) + + const detail = document.createElement('div') + detail.textContent = err instanceof StorybookLoadError ? err.detail : err instanceof Error ? err.message : String(err) + message.appendChild(detail) + + return message +} + +customElements.define('storybook-story', StorybookStoryElement) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index 100c211c5e..5a0bf0dcd8 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -16,6 +16,7 @@ using Elastic.Markdown.Myst.Directives.PageCard; using Elastic.Markdown.Myst.Directives.Settings; using Elastic.Markdown.Myst.Directives.Stepper; +using Elastic.Markdown.Myst.Directives.Storybook; using Elastic.Markdown.Myst.Directives.SubPages; using Elastic.Markdown.Myst.Directives.Table; using Elastic.Markdown.Myst.Directives.Tabs; @@ -141,6 +142,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{cli-modifiers}") > 0) return new CliModifiersBlock(this, context); + if (info.IndexOf("{storybook}") > 0) + return new StorybookBlock(this, context); + foreach (var admonition in Admonitions) { if (info.IndexOf(admonition) > 0) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index bb62cf6337..efbcb96333 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -22,6 +22,7 @@ using Elastic.Markdown.Myst.Directives.PageCard; using Elastic.Markdown.Myst.Directives.Settings; using Elastic.Markdown.Myst.Directives.Stepper; +using Elastic.Markdown.Myst.Directives.Storybook; using Elastic.Markdown.Myst.Directives.SubPages; using Elastic.Markdown.Myst.Directives.Table; using Elastic.Markdown.Myst.Directives.Tabs; @@ -129,6 +130,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case CliModifiersBlock cliModifiersBlock: WriteCliModifiers(renderer, cliModifiersBlock); return; + case StorybookBlock storybookBlock: + WriteStorybook(renderer, storybookBlock); + return; default: // if (!string.IsNullOrEmpty(directiveBlock.Info) && !directiveBlock.Info.StartsWith('{')) // WriteCode(renderer, directiveBlock); @@ -302,6 +306,25 @@ private static void WriteAgentSkill(HtmlRenderer renderer, AgentSkillBlock block RenderRazorSlice(slice, renderer); } + private static void WriteStorybook(HtmlRenderer renderer, StorybookBlock block) + { + if (string.IsNullOrEmpty(block.StoryUrl)) + return; + + var slice = StorybookView.Create(new StorybookViewModel + { + DirectiveBlock = block, + StoryUrl = block.StoryUrl, + StoryId = block.StoryId ?? string.Empty, + Height = block.Height, + IframeTitle = block.IframeTitle, + HasBody = block.Count > 0, + InlineEntry = block.InlineEntry, + InlineBootstrapJson = block.InlineBootstrapJson, + }); + RenderRazorSlice(slice, renderer); + } + private static void WriteFigure(HtmlRenderer renderer, ImageBlock block) { var imageUrl = block.ImageUrl != null && diff --git a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs new file mode 100644 index 0000000000..594023b100 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs @@ -0,0 +1,284 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net; +using System.Text.Json; +using Elastic.Markdown.Diagnostics; + +namespace Elastic.Markdown.Myst.Directives.Storybook; + +public class StorybookBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) +{ + private const string SupportedRegistrySchemaVersion = "1"; + + public override string Directive => "storybook"; + + public string? Project { get; private set; } + + public string? Storybook { get; private set; } + + public string? DocsId { get; private set; } + + public string? StoryId { get; private set; } + + public string? StoryUrl { get; private set; } + + public string? InlineEntry { get; private set; } + + public string? InlineBootstrapJson { get; private set; } + + public int Height { get; private set; } = 400; + + public string IframeTitle { get; private set; } = "Storybook story"; + + public bool HasInlineStory => !string.IsNullOrWhiteSpace(InlineEntry); + + public override void FinalizeAndValidate(ParserContext context) + { + if (!string.IsNullOrWhiteSpace(Arguments)) + this.EmitWarning("storybook directive ignores positional arguments. Use properties instead."); + + var reference = ResolveReference(); + if (reference is null) + return; + + if (!TryLoadRegistry(out var registry)) + return; + + var story = FindStory(registry, reference); + if (story is null) + { + this.EmitError($"storybook registry does not contain id '{reference.RawId}'."); + return; + } + + if (string.IsNullOrWhiteSpace(story.RenderMode) || !IsSupportedRenderMode(story.RenderMode)) + { + this.EmitError($"storybook registry id '{reference.RawId}' has unsupported renderMode '{story.RenderMode}'."); + return; + } + + if (string.IsNullOrWhiteSpace(story.DocsId) || string.IsNullOrWhiteSpace(story.StorybookId)) + { + this.EmitError($"storybook registry id '{reference.RawId}' requires docsId and storybookId."); + return; + } + + if (story.Iframe is null || string.IsNullOrWhiteSpace(story.Iframe.Url)) + { + this.EmitError($"storybook registry id '{reference.RawId}' requires iframe.url."); + return; + } + + Project = reference.Project; + Storybook = reference.Storybook ?? story.Alias; + DocsId = story.DocsId; + StoryId = story.StorybookId; + StoryUrl = ResolveRegistryUrl(registry.BaseUrl, story.Iframe.Url); + Height = story.Height ?? Height; + + if (story.RenderMode.Equals("inline", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(story.Inline?.Entry)) + { + InlineEntry = ResolveRegistryUrl(registry.BaseUrl, story.Inline.Entry); + if (story.Inline.Bootstrap is not null) + InlineBootstrapJson = SerializeBootstrap(registry.BaseUrl, story.Inline.Bootstrap); + } + + var rawHeight = Prop("height"); + if (!string.IsNullOrWhiteSpace(rawHeight)) + { + if (int.TryParse(rawHeight.Trim(), out var parsedHeight) && parsedHeight > 0) + Height = parsedHeight; + else + this.EmitWarning($"storybook directive :height: must be a positive integer. Got '{rawHeight}', using default {Height}px."); + } + + var rawTitle = Prop("title"); + if (!string.IsNullOrWhiteSpace(rawTitle)) + IframeTitle = rawTitle.Trim(); + } + + private StoryReference? ResolveReference() + { + var rawId = Prop("id")?.Trim(); + var project = Prop("project")?.Trim(); + var storybook = Prop("storybook")?.Trim(); + var component = Prop("component")?.Trim(); + var story = Prop("story")?.Trim(); + + if (!string.IsNullOrWhiteSpace(rawId)) + return StoryReference.FromId(rawId, project, storybook, component, story); + + if (string.IsNullOrWhiteSpace(project)) + { + this.EmitError("storybook directive requires :id: or :project:."); + return null; + } + + if (string.IsNullOrWhiteSpace(storybook)) + { + this.EmitError("storybook directive requires :id: or :storybook:."); + return null; + } + + if (string.IsNullOrWhiteSpace(story)) + { + this.EmitError("storybook directive requires :id: or :story:."); + return null; + } + + var docsId = string.IsNullOrWhiteSpace(component) ? story : $"{component}--{story}"; + return new StoryReference(project, storybook, docsId, component, story, $"{project}:{storybook}:{docsId}"); + } + + private bool TryLoadRegistry(out StorybookRegistry registry) + { + registry = new StorybookRegistry(); + + var rawRegistry = Build.Configuration.StorybookRegistry; + if (string.IsNullOrWhiteSpace(rawRegistry)) + { + this.EmitError("storybook directive requires docset.yml storybook.registry."); + return false; + } + + try + { + var registryJson = ReadRegistry(rawRegistry); + return TryDeserializeRegistry(rawRegistry, registryJson, out registry); + } + catch (Exception e) + { + this.EmitError($"storybook registry could not be read: {rawRegistry}", e); + return false; + } + } + + private string ReadRegistry(string rawRegistry) + { + if (Uri.TryCreate(rawRegistry, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + { + using var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }; + using var http = new HttpClient(handler); + return http.GetStringAsync(uri).GetAwaiter().GetResult(); + } + + var registryPath = Path.IsPathRooted(rawRegistry) + ? rawRegistry + : Build.ReadFileSystem.Path.Combine(Build.DocumentationSourceDirectory.FullName, rawRegistry); + return Build.ReadFileSystem.File.ReadAllText(registryPath); + } + + private bool TryDeserializeRegistry(string rawRegistryPath, string registryJson, out StorybookRegistry registry) + { + registry = new StorybookRegistry(); + try + { + registry = JsonSerializer.Deserialize(registryJson, StorybookRegistryJsonContext.Default.StorybookRegistry)!; + } + catch (JsonException e) + { + this.EmitError($"storybook registry could not be parsed: {rawRegistryPath}", e); + return false; + } + + if (registry is null || registry.Stories.Count == 0) + { + this.EmitError($"storybook registry is empty: {rawRegistryPath}"); + return false; + } + + var schemaVersion = RegistrySchemaVersion(registry.SchemaVersion); + if (!schemaVersion.Equals(SupportedRegistrySchemaVersion, StringComparison.Ordinal)) + { + this.EmitError($"storybook registry schemaVersion '{schemaVersion}' is not supported. Expected '{SupportedRegistrySchemaVersion}'."); + return false; + } + + return true; + } + + private static string RegistrySchemaVersion(JsonElement schemaVersion) => + schemaVersion.ValueKind switch + { + JsonValueKind.Number => schemaVersion.GetRawText(), + JsonValueKind.String => schemaVersion.GetString() ?? string.Empty, + _ => string.Empty + }; + + private static StorybookRegistryStory? FindStory(StorybookRegistry registry, StoryReference reference) + { + if (registry.Stories.TryGetValue(reference.RawId, out var rawMatch)) + return rawMatch; + + var namespacedId = $"{reference.Project}:{reference.Storybook}:{reference.DocsId}"; + if (registry.Stories.TryGetValue(namespacedId, out var namespacedMatch)) + return namespacedMatch; + + var matches = registry.Stories + .Where(story => MatchesReferenceScope(story.Key, story.Value, reference)) + .Select(story => story.Value) + .Where(story => + story.DocsId?.Equals(reference.DocsId, StringComparison.OrdinalIgnoreCase) == true + || story.StorybookId?.Equals(reference.DocsId, StringComparison.OrdinalIgnoreCase) == true) + .ToArray(); + + return matches.Length == 1 ? matches[0] : null; + } + + private static bool MatchesReferenceScope(string registryId, StorybookRegistryStory story, StoryReference reference) + { + var parts = registryId.Split(':', 3, StringSplitOptions.TrimEntries); + if (!string.IsNullOrWhiteSpace(reference.Project) && (parts.Length != 3 || !parts[0].Equals(reference.Project, StringComparison.OrdinalIgnoreCase))) + return false; + + if (string.IsNullOrWhiteSpace(reference.Storybook)) + return true; + + var registryStorybook = parts.Length == 3 ? parts[1] : story.Alias; + return registryStorybook?.Equals(reference.Storybook, StringComparison.OrdinalIgnoreCase) == true + || story.Alias?.Equals(reference.Storybook, StringComparison.OrdinalIgnoreCase) == true; + } + + private static bool IsSupportedRenderMode(string renderMode) => + renderMode.Equals("inline", StringComparison.OrdinalIgnoreCase) || renderMode.Equals("iframe", StringComparison.OrdinalIgnoreCase); + + private static string SerializeBootstrap(string? baseUrl, StorybookRegistryBootstrap bootstrap) + { + var resolvedBootstrap = new StorybookRegistryBootstrap + { + PublicPath = ResolveRegistryUrl(baseUrl, bootstrap.PublicPath), + Scripts = bootstrap.Scripts.Select(script => ResolveRegistryUrl(baseUrl, script)!).ToArray(), + Styles = bootstrap.Styles.Select(style => ResolveRegistryUrl(baseUrl, style)!).ToArray() + }; + return JsonSerializer.Serialize(resolvedBootstrap, StorybookRegistryJsonContext.Default.StorybookRegistryBootstrap); + } + + private static string? ResolveRegistryUrl(string? baseUrl, string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + return rawUrl; + + var trimmed = rawUrl.Trim(); + if (Uri.TryCreate(trimmed, UriKind.Absolute, out _)) + return trimmed; + + if (!string.IsNullOrWhiteSpace(baseUrl) && Uri.TryCreate(baseUrl, UriKind.Absolute, out var baseUri)) + return new Uri(baseUri, trimmed).ToString(); + + return trimmed; + } + + private sealed record StoryReference(string? Project, string? Storybook, string DocsId, string? Component, string? StoryName, string RawId) + { + public static StoryReference FromId(string rawId, string? project, string? storybook, string? component, string? story) + { + var parts = rawId.Split(':', 3, StringSplitOptions.TrimEntries); + if (parts.Length == 3) + return new StoryReference(parts[0], parts[1], parts[2], component, story, rawId); + + return new StoryReference(project, storybook, rawId, component, story, rawId); + } + } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookRegistry.cs b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookRegistry.cs new file mode 100644 index 0000000000..447fbf93cd --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookRegistry.cs @@ -0,0 +1,90 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elastic.Markdown.Myst.Directives.Storybook; + +public sealed class StorybookRegistry +{ + [JsonPropertyName("schemaVersion")] + public JsonElement SchemaVersion { get; init; } + + [JsonPropertyName("producer")] + public string? Producer { get; init; } + + [JsonPropertyName("baseUrl")] + public string? BaseUrl { get; init; } + + [JsonPropertyName("build")] + public Dictionary? Build { get; init; } + + [JsonPropertyName("stories")] + public Dictionary Stories { get; init; } = new(StringComparer.Ordinal); +} + +public sealed class StorybookRegistryStory +{ + [JsonPropertyName("alias")] + public string? Alias { get; init; } + + [JsonPropertyName("docsId")] + public string? DocsId { get; init; } + + [JsonPropertyName("storybookId")] + public string? StorybookId { get; init; } + + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("height")] + public int? Height { get; init; } + + [JsonPropertyName("renderMode")] + public string? RenderMode { get; init; } + + [JsonPropertyName("inline")] + public StorybookRegistryInline? Inline { get; init; } + + [JsonPropertyName("iframe")] + public StorybookRegistryIframe? Iframe { get; init; } +} + +public sealed class StorybookRegistryIframe +{ + [JsonPropertyName("url")] + public string? Url { get; init; } +} + +public sealed class StorybookRegistryInline +{ + [JsonPropertyName("entry")] + public string? Entry { get; init; } + + [JsonPropertyName("bundleId")] + public string? BundleId { get; init; } + + [JsonPropertyName("bootstrap")] + public StorybookRegistryBootstrap? Bootstrap { get; init; } +} + +public sealed class StorybookRegistryBootstrap +{ + [JsonPropertyName("publicPath")] + public string? PublicPath { get; init; } + + [JsonPropertyName("scripts")] + public IReadOnlyCollection Scripts { get; init; } = []; + + [JsonPropertyName("styles")] + public IReadOnlyCollection Styles { get; init; } = []; +} + +[JsonSerializable(typeof(StorybookRegistry))] +[JsonSerializable(typeof(StorybookRegistryBootstrap))] +internal sealed partial class StorybookRegistryJsonContext : JsonSerializerContext; diff --git a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml new file mode 100644 index 0000000000..ca6c194783 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml @@ -0,0 +1,25 @@ +@inherits RazorSlice +
+@if (Model.HasInlineStory) +{ + + +} +else +{ + +} + @if (Model.HasBody) + { +
+ @Model.RenderBlock() +
+ } +
diff --git a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookViewModel.cs new file mode 100644 index 0000000000..f201bcf75e --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookViewModel.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Markdown.Myst.Directives.Storybook; + +public class StorybookViewModel : DirectiveViewModel +{ + public required string StoryUrl { get; init; } + + public required string StoryId { get; init; } + + public required int Height { get; init; } + + public required string IframeTitle { get; init; } + + public bool HasBody { get; init; } + + public string? InlineEntry { get; init; } + + public string? InlineBootstrapJson { get; init; } + + public bool HasInlineStory => !string.IsNullOrWhiteSpace(InlineEntry); + + public string HeightStyle => $"{Height}px"; +} diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 25002d5bed..5e6b68aa8c 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -15,6 +15,7 @@ using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Myst.Directives.Math; using Elastic.Markdown.Myst.Directives.Settings; +using Elastic.Markdown.Myst.Directives.Storybook; using Markdig.Extensions.DefinitionLists; using Markdig.Extensions.Tables; using Markdig.Extensions.Yaml; @@ -496,6 +497,9 @@ protected override void Write(LlmMarkdownRenderer renderer, DirectiveBlock obj) case AgentSkillBlock agentSkillBlock: WriteAgentSkillBlock(renderer, agentSkillBlock); return; + case StorybookBlock storybookBlock: + WriteStorybookBlock(renderer, storybookBlock); + return; } // Ensure single empty line before directive @@ -864,6 +868,23 @@ private static void WriteAgentSkillBlock(LlmMarkdownRenderer renderer, AgentSkil renderer.EnsureLine(); } + private static void WriteStorybookBlock(LlmMarkdownRenderer renderer, StorybookBlock block) + { + if (string.IsNullOrEmpty(block.StoryUrl)) + return; + + renderer.EnsureBlockSpacing(); + renderer.Writer.Write(""); + if (block.Count > 0) + WriteChildrenWithIndentation(renderer, block, " "); + renderer.Writer.WriteLine(""); + renderer.EnsureLine(); + } + private static void WriteChildrenWithIndentation(LlmMarkdownRenderer renderer, Block container, string indent) { // Capture output and manually add indentation diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index 62389d448b..48edbc964b 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -67,7 +67,7 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") var root = FileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/")); // ReSharper disable once VirtualMemberCallInConstructor - FileSystem.GenerateDocSetYaml(root, products: GetDocsetProducts()); + FileSystem.GenerateDocSetYaml(root, products: GetDocsetProducts(), extraYaml: GetDocsetExtraYaml()); Collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); @@ -87,6 +87,8 @@ protected virtual void AddToFileSystem(MockFileSystem fileSystem) { } /// protected virtual IReadOnlyList? GetDocsetProducts() => null; + protected virtual string? GetDocsetExtraYaml() => null; + public virtual async ValueTask InitializeAsync() { _ = Collector.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs b/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs new file mode 100644 index 0000000000..e534c69886 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs @@ -0,0 +1,245 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using Elastic.Documentation.Diagnostics; +using Elastic.Markdown.Myst.Directives.Storybook; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +public abstract class StorybookRegistryTest(ITestOutputHelper output, string content) : DirectiveTest(output, content) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile("docs/docs_registry.json", new MockFileData(RegistryJson)); + + protected override string? GetDocsetExtraYaml() => +""" +storybook: + registry: docs_registry.json +"""; + + private const string RegistryJson = + """ + { + "schemaVersion": 1, + "producer": "kibana-storybook", + "baseUrl": "http://127.0.0.1:6007/storybook-docs", + "build": { + "commit": "abc123", + "branch": "storybook-to-docs" + }, + "stories": { + "kibana:shared_ux:ai-components-aibutton--default": { + "alias": "shared_ux", + "docsId": "ai-components-aibutton--default", + "storybookId": "ai-components-aibutton--default", + "title": "ai-components/aibutton", + "name": "Default", + "height": 360, + "type": "story", + "renderMode": "inline", + "inline": { + "entry": "http://127.0.0.1:6007/storybook-docs/shared_ux/registry.js", + "bundleId": "shared_ux", + "bootstrap": { + "publicPath": "http://127.0.0.1:6007/storybook/shared_ux/", + "scripts": [ + "http://127.0.0.1:6007/storybook/shared_ux/kbn-ui-shared-deps-npm.dll.js", + "http://127.0.0.1:6007/storybook/shared_ux/kbn-ui-shared-deps-src.js" + ], + "styles": [ + "http://127.0.0.1:6007/storybook/shared_ux/kbn-ui-shared-deps-src.css", + "https://fonts.googleapis.com/css2?family=Inter:wght@300..700&display=swap" + ] + } + }, + "iframe": { + "url": "http://127.0.0.1:6007/storybook/shared_ux/iframe.html?id=ai-components-aibutton--default&viewMode=story" + } + }, + "kibana:shared_ux:components-callout--info": { + "alias": "shared_ux", + "docsId": "components-callout--info", + "storybookId": "components-callout--info-storybook", + "title": "components-callout", + "name": "Info", + "type": "story", + "renderMode": "iframe", + "iframe": { + "url": "http://127.0.0.1:6007/storybook/shared_ux/iframe.html?id=components-callout--info-storybook&viewMode=story" + } + } + } + } + """; +} + +public class StorybookInlineIdTests(ITestOutputHelper output) : StorybookRegistryTest(output, +""" +:::{storybook} +:id: kibana:shared_ux:ai-components-aibutton--default +:title: AI Button / Default story +::: +""" +) +{ + [Fact] + public void ResolvesStory() + { + Block!.Project.Should().Be("kibana"); + Block.Storybook.Should().Be("shared_ux"); + Block.DocsId.Should().Be("ai-components-aibutton--default"); + Block.StoryId.Should().Be("ai-components-aibutton--default"); + Block.InlineEntry.Should().Be("http://127.0.0.1:6007/storybook-docs/shared_ux/registry.js"); + Block.StoryUrl.Should().Be("http://127.0.0.1:6007/storybook/shared_ux/iframe.html?id=ai-components-aibutton--default&viewMode=story"); + Block.Height.Should().Be(360); + } + + [Fact] + public void RendersInlineStory() + { + Html.Should().Contain(" + Block!.StoryId.Should().Be("ai-components-aibutton--default"); +} + +public class StorybookStructuredReferenceWrongStorybookTests(ITestOutputHelper output) : StorybookRegistryTest(output, +""" +:::{storybook} +:project: kibana +:storybook: content_management +:story: ai-components-aibutton--default +::: +""" +) +{ + [Fact] + public void DoesNotFallbackToAnotherStorybook() => + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("does not contain id 'kibana:content_management:ai-components-aibutton--default'")); +} + +public class StorybookBareIdTests(ITestOutputHelper output) : StorybookRegistryTest(output, +""" +:::{storybook} +:id: ai-components-aibutton--default +::: +""" +) +{ + [Fact] + public void ResolvesFromConfiguredRegistry() => + Block!.StoryId.Should().Be("ai-components-aibutton--default"); +} + +public class StorybookIframeTests(ITestOutputHelper output) : StorybookRegistryTest(output, +""" +:::{storybook} +:id: kibana:shared_ux:components-callout--info +::: +""" +) +{ + [Fact] + public void RendersIframeFallback() + { + Block!.HasInlineStory.Should().BeFalse(); + Html.Should().Contain(" + Html.Should().Contain("Supporting details for this story."); +} + +public class StorybookInvalidHeightTests(ITestOutputHelper output) : StorybookRegistryTest(output, +""" +:::{storybook} +:id: kibana:shared_ux:components-callout--info +:height: tall +::: +""" +) +{ + [Fact] + public void WarnsAndFallsBackToDefaultHeight() + { + Block!.Height.Should().Be(400); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Warning + && d.Message.Contains(":height: must be a positive integer")); + Html.Should().Contain("height:400px"); + } +} + +public class StorybookMissingRegistryTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{storybook} +:id: kibana:shared_ux:ai-components-aibutton--default +::: +""" +) +{ + [Fact] + public void EmitsError() => + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("requires docset.yml storybook.registry")); +} + +public class StorybookMissingIdTests(ITestOutputHelper output) : StorybookRegistryTest(output, +""" +:::{storybook} +::: +""" +) +{ + [Fact] + public void EmitsError() => + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("requires :id: or :project:")); +} + +public class StorybookPositionalArgumentWarningTests(ITestOutputHelper output) : StorybookRegistryTest(output, +""" +:::{storybook} /storybook/ignored +:id: kibana:shared_ux:components-callout--info +::: +""" +) +{ + [Fact] + public void EmitsWarning() => + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Warning + && d.Message.Contains("ignores positional arguments")); +} diff --git a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs index 68b12f5b3b..243fa13f18 100644 --- a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs +++ b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs @@ -13,7 +13,8 @@ public static void GenerateDocSetYaml( this MockFileSystem fileSystem, IDirectoryInfo root, Dictionary? globalVariables = null, - IReadOnlyList? products = null) + IReadOnlyList? products = null, + string? extraYaml = null) { // language=yaml var yaml = new StringWriter(); @@ -47,6 +48,9 @@ public static void GenerateDocSetYaml( yaml.WriteLine($" {key}: {value}"); } + if (!string.IsNullOrWhiteSpace(extraYaml)) + yaml.WriteLine(extraYaml.Trim()); + fileSystem.AddFile(Path.Join(root.FullName, "docset.yml"), new MockFileData(yaml.ToString())); } } diff --git a/tests/authoring/Blocks/Storybook.fs b/tests/authoring/Blocks/Storybook.fs new file mode 100644 index 0000000000..2aadbe4fae --- /dev/null +++ b/tests/authoring/Blocks/Storybook.fs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information +module ``AuthoringTests``.``block elements``.``storybook elements`` + +open Xunit +open authoring + +type ``storybook missing reference`` () = + static let markdown = Setup.Markdown """ +:::{storybook} +::: +""" + + [] + let ``has error`` () = markdown |> hasError "requires :id: or :project:" + +type ``storybook missing registry`` () = + static let markdown = Setup.Markdown """ +:::{storybook} +:id: kibana:shared_ux:components-button--regular +::: +""" + + [] + let ``has error`` () = markdown |> hasError "requires docset.yml storybook.registry" diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index b71096b268..db9aafb701 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -51,6 +51,7 @@ + From 045aa38d642b9f52e4ab869e933f8ddb27a56f3e Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 2 Jun 2026 21:49:12 -0400 Subject: [PATCH 2/7] Address PR feedback for storybook directive - Escape title/src attributes in the LLM output (LlmBlockRenderers). - Key the bootstrap asset cache on publicPath + scripts + styles to avoid collisions. - Bound the registry fetch with an explicit timeout and cancellation token. - Add a least-privilege sandbox to the fallback storybook iframe. - Use [with(...)] collection-expression args and AwesomeAssertions after the main rebase. --- .../StorybookStory/StorybookStoryComponent.tsx | 6 +++++- .../Myst/Directives/Storybook/StorybookBlock.cs | 9 +++++++-- .../Myst/Directives/Storybook/StorybookRegistry.cs | 2 +- .../Myst/Directives/Storybook/StorybookView.cshtml | 1 + .../Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs | 5 +++-- .../Elastic.Markdown.Tests/Directives/StorybookTests.cs | 2 +- 6 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx b/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx index dc121a16f6..6eb6eae08f 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx @@ -162,7 +162,11 @@ const loadBootstrap = async (bootstrap: StoryBootstrap | null) => { setKibanaGlobals(bootstrap.publicPath) - const cacheKey = bootstrap.publicPath || JSON.stringify(bootstrap) + const cacheKey = JSON.stringify({ + publicPath: bootstrap.publicPath ?? '', + scripts: bootstrap.scripts ?? [], + styles: bootstrap.styles ?? [], + }) if (!bootstrapCache.has(cacheKey)) { bootstrapCache.set( cacheKey, diff --git a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs index 594023b100..95ff25bc28 100644 --- a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs @@ -12,6 +12,8 @@ public class StorybookBlock(DirectiveBlockParser parser, ParserContext context) { private const string SupportedRegistrySchemaVersion = "1"; + private static readonly TimeSpan RegistryFetchTimeout = TimeSpan.FromSeconds(30); + public override string Directive => "storybook"; public string? Project { get; private set; } @@ -159,9 +161,12 @@ private string ReadRegistry(string rawRegistry) { if (Uri.TryCreate(rawRegistry, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") { + // FinalizeAndValidate is synchronous across all directives, so the fetch is sync-over-async here. + // Bound it with an explicit timeout so an unresponsive registry host can never stall the build. using var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }; - using var http = new HttpClient(handler); - return http.GetStringAsync(uri).GetAwaiter().GetResult(); + using var http = new HttpClient(handler) { Timeout = RegistryFetchTimeout }; + using var cts = new CancellationTokenSource(RegistryFetchTimeout); + return http.GetStringAsync(uri, cts.Token).GetAwaiter().GetResult(); } var registryPath = Path.IsPathRooted(rawRegistry) diff --git a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookRegistry.cs b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookRegistry.cs index 447fbf93cd..86939547b5 100644 --- a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookRegistry.cs +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookRegistry.cs @@ -22,7 +22,7 @@ public sealed class StorybookRegistry public Dictionary? Build { get; init; } [JsonPropertyName("stories")] - public Dictionary Stories { get; init; } = new(StringComparer.Ordinal); + public Dictionary Stories { get; init; } = [with(StringComparer.Ordinal)]; } public sealed class StorybookRegistryStory diff --git a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml index ca6c194783..086e4064fc 100644 --- a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml @@ -14,6 +14,7 @@ else src="@Model.StoryUrl" title="@Model.IframeTitle" style="width:100%;height:@Model.HeightStyle;border:none;" + sandbox="allow-scripts allow-same-origin" loading="lazy"> } @if (Model.HasBody) diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 5e6b68aa8c..3527cc9096 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -874,10 +874,11 @@ private static void WriteStorybookBlock(LlmMarkdownRenderer renderer, StorybookB return; renderer.EnsureBlockSpacing(); + var src = WebUtility.HtmlEncode(LlmRenderingHelpers.MakeAbsoluteUrl(renderer, block.StoryUrl)); renderer.Writer.Write(""); if (block.Count > 0) WriteChildrenWithIndentation(renderer, block, " "); diff --git a/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs b/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs index e534c69886..2858c72780 100644 --- a/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions.TestingHelpers; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.Myst.Directives.Storybook; -using FluentAssertions; +using AwesomeAssertions; namespace Elastic.Markdown.Tests.Directives; From 5dbe10399198e09c696b7028d6f2fa7b28745677 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 2 Jun 2026 22:32:25 -0400 Subject: [PATCH 3/7] Fix CI for storybook directive - storybook.md: the PR 272388 registry now exposes the bare docs ID `ai-components-aibutton--default` under both `kibana:presentation` and `kibana:shared_ux`, so the bare-`:id:` live example resolves ambiguously and fails the doc build. Replace the live block with a note; the fully-qualified examples still render. - StorybookStoryComponent.tsx: add `export {}` so the file is a module (validates the `declare global` Window augmentation and the dynamic import from main.ts), and apply prettier formatting. - storybook.css: apply prettier formatting. - StorybookTests.cs: fix import ordering and add the `/*lang=json,strict*/` marker on the JSON fixture to satisfy `dotnet format`. --- docs/syntax/storybook.md | 4 +- .../Assets/markdown/storybook.css | 30 +- .../StorybookStoryComponent.tsx | 434 ++++++++++-------- .../Directives/StorybookTests.cs | 5 +- 4 files changed, 251 insertions(+), 222 deletions(-) diff --git a/docs/syntax/storybook.md b/docs/syntax/storybook.md index 28e5ab964a..71cf58436f 100644 --- a/docs/syntax/storybook.md +++ b/docs/syntax/storybook.md @@ -61,9 +61,7 @@ If a bare `:id:` is used, it must match exactly one story in the configured regi ::: ``` -:::{storybook} -:id: ai-components-aibutton--default -::: +In the PR 272388 registry this bare ID is ambiguous — it resolves to both `kibana:presentation:ai-components-aibutton--default` and `kibana:shared_ux:ai-components-aibutton--default` — so it is not rendered live here. Use the fully-qualified `project:storybook:docsId` form when a docs ID is not unique. ## Properties diff --git a/src/Elastic.Documentation.Site/Assets/markdown/storybook.css b/src/Elastic.Documentation.Site/Assets/markdown/storybook.css index 54d318eaca..cee94b9cc3 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/storybook.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/storybook.css @@ -1,32 +1,32 @@ .storybook-embed { - border: 1px solid #E3E8F2; - border-radius: 6px; - margin-block: 1.5rem; - overflow: hidden; + border: 1px solid #e3e8f2; + border-radius: 6px; + margin-block: 1.5rem; + overflow: hidden; } .storybook-embed iframe { - display: block; + display: block; } .storybook-embed storybook-story { - display: block; - padding: 24px; + display: block; + padding: 24px; } .storybook-embed-body { - padding: 0.75rem 1.5rem; - border-top: 1px solid #E3E8F2; - background: #F7F8FC; + padding: 0.75rem 1.5rem; + border-top: 1px solid #e3e8f2; + background: #f7f8fc; } .storybook-embed-error { - padding: 1rem; - color: #BD271E; - font-size: 14px; + padding: 1rem; + color: #bd271e; + font-size: 14px; } .storybook-embed-error strong { - display: block; - margin-bottom: 0.25rem; + display: block; + margin-bottom: 0.25rem; } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx b/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx index 6eb6eae08f..8487a7e59d 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx @@ -1,32 +1,34 @@ +export {} + type MountFn = (storyId: string, container: HTMLElement) => Promise type UnmountFn = (container: HTMLElement) => void type KibanaPublicPath = Record declare global { - interface Window { - __kbnPublicPath__?: KibanaPublicPath - __kbnHardenPrototypes__?: boolean - } + interface Window { + __kbnPublicPath__?: KibanaPublicPath + __kbnHardenPrototypes__?: boolean + } } interface StoryEntry { - mountStory: MountFn - unmountStory: UnmountFn + mountStory: MountFn + unmountStory: UnmountFn } interface StoryBootstrap { - publicPath?: string - scripts?: string[] - styles?: string[] + publicPath?: string + scripts?: string[] + styles?: string[] } class StorybookLoadError extends Error { - constructor( - public readonly title: string, - public readonly detail: string - ) { - super(detail) - } + constructor( + public readonly title: string, + public readonly detail: string + ) { + super(detail) + } } const entryCache = new Map>() @@ -35,233 +37,261 @@ const scriptCache = new Map>() const styleCache = new Map>() const loadEntry = (url: string): Promise => { - if (!entryCache.has(url)) { - entryCache.set( - url, - import(/* @vite-ignore */ url) - .then(validateEntry) - .catch((err) => { - entryCache.delete(url) - if (err instanceof StorybookLoadError) throw err - throw new StorybookLoadError( - 'Storybook entry module could not be imported.', - `Check that the module exists and CORS allows this docs origin: ${url}` - ) - }) - ) - } - return entryCache.get(url)! + if (!entryCache.has(url)) { + entryCache.set( + url, + import(/* @vite-ignore */ url).then(validateEntry).catch((err) => { + entryCache.delete(url) + if (err instanceof StorybookLoadError) throw err + throw new StorybookLoadError( + 'Storybook entry module could not be imported.', + `Check that the module exists and CORS allows this docs origin: ${url}` + ) + }) + ) + } + return entryCache.get(url)! } const validateEntry = (entry: unknown): StoryEntry => { - const candidate = entry as Partial - if (typeof candidate.mountStory !== 'function' || typeof candidate.unmountStory !== 'function') { - throw new StorybookLoadError( - 'Storybook entry module has an unsupported contract.', - 'Expected mountStory(storyId, container) and unmountStory(container).' - ) - } - return candidate as StoryEntry + const candidate = entry as Partial + if ( + typeof candidate.mountStory !== 'function' || + typeof candidate.unmountStory !== 'function' + ) { + throw new StorybookLoadError( + 'Storybook entry module has an unsupported contract.', + 'Expected mountStory(storyId, container) and unmountStory(container).' + ) + } + return candidate as StoryEntry } const loadStylesheet = (url: string): Promise => { - if (!styleCache.has(url)) { - styleCache.set( - url, - new Promise((resolve, reject) => { - const existing = Array.from(document.querySelectorAll('link[data-storybook-style]')).find( - (link) => link.dataset.storybookStyle === url + if (!styleCache.has(url)) { + styleCache.set( + url, + new Promise((resolve, reject) => { + const existing = Array.from( + document.querySelectorAll( + 'link[data-storybook-style]' + ) + ).find((link) => link.dataset.storybookStyle === url) + if (existing) { + resolve() + return + } + + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = url + link.dataset.storybookStyle = url + link.onload = () => resolve() + link.onerror = () => { + styleCache.delete(url) + reject( + new StorybookLoadError( + 'Storybook stylesheet could not be loaded.', + `Missing or blocked stylesheet: ${url}` + ) + ) + } + document.head.appendChild(link) + }) ) - if (existing) { - resolve() - return - } - - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = url - link.dataset.storybookStyle = url - link.onload = () => resolve() - link.onerror = () => { - styleCache.delete(url) - reject(new StorybookLoadError('Storybook stylesheet could not be loaded.', `Missing or blocked stylesheet: ${url}`)) - } - document.head.appendChild(link) - }) - ) - } - return styleCache.get(url)! + } + return styleCache.get(url)! } const loadScript = (url: string): Promise => { - if (!scriptCache.has(url)) { - scriptCache.set( - url, - new Promise((resolve, reject) => { - const existing = Array.from(document.querySelectorAll('script[data-storybook-script]')).find( - (script) => script.dataset.storybookScript === url + if (!scriptCache.has(url)) { + scriptCache.set( + url, + new Promise((resolve, reject) => { + const existing = Array.from( + document.querySelectorAll( + 'script[data-storybook-script]' + ) + ).find((script) => script.dataset.storybookScript === url) + if (existing) { + resolve() + return + } + + const script = document.createElement('script') + script.src = url + script.async = false + script.dataset.storybookScript = url + script.onload = () => resolve() + script.onerror = () => { + scriptCache.delete(url) + reject( + new StorybookLoadError( + 'Storybook bootstrap script could not be loaded.', + `Missing or blocked script: ${url}` + ) + ) + } + document.head.appendChild(script) + }) ) - if (existing) { - resolve() - return - } - - const script = document.createElement('script') - script.src = url - script.async = false - script.dataset.storybookScript = url - script.onload = () => resolve() - script.onerror = () => { - scriptCache.delete(url) - reject(new StorybookLoadError('Storybook bootstrap script could not be loaded.', `Missing or blocked script: ${url}`)) - } - document.head.appendChild(script) - }) - ) - } - return scriptCache.get(url)! + } + return scriptCache.get(url)! } const parseBootstrap = (value: string | null): StoryBootstrap | null => { - if (!value) return null - - try { - return JSON.parse(value) as StoryBootstrap - } catch { - throw new StorybookLoadError( - 'Storybook bootstrap data is invalid.', - 'The registry provided bootstrap data that is not valid JSON.' - ) - } + if (!value) return null + + try { + return JSON.parse(value) as StoryBootstrap + } catch { + throw new StorybookLoadError( + 'Storybook bootstrap data is invalid.', + 'The registry provided bootstrap data that is not valid JSON.' + ) + } } const setKibanaGlobals = (publicPath?: string) => { - if (!publicPath) return + if (!publicPath) return - const publicPaths = { - 'kbn-ui-shared-deps-npm': publicPath, - 'kbn-ui-shared-deps-src': publicPath, - 'kbn-monaco': publicPath, - } + const publicPaths = { + 'kbn-ui-shared-deps-npm': publicPath, + 'kbn-ui-shared-deps-src': publicPath, + 'kbn-monaco': publicPath, + } - window.__kbnPublicPath__ = publicPaths - window.__kbnHardenPrototypes__ = false + window.__kbnPublicPath__ = publicPaths + window.__kbnHardenPrototypes__ = false - try { - if (window.top) { - window.top.__kbnPublicPath__ = publicPaths - window.top.__kbnHardenPrototypes__ = false + try { + if (window.top) { + window.top.__kbnPublicPath__ = publicPaths + window.top.__kbnHardenPrototypes__ = false + } + } catch { + // Cross-origin frames cannot access `top`. } - } catch { - // Cross-origin frames cannot access `top`. - } } const loadBootstrap = async (bootstrap: StoryBootstrap | null) => { - if (!bootstrap) return - - setKibanaGlobals(bootstrap.publicPath) - - const cacheKey = JSON.stringify({ - publicPath: bootstrap.publicPath ?? '', - scripts: bootstrap.scripts ?? [], - styles: bootstrap.styles ?? [], - }) - if (!bootstrapCache.has(cacheKey)) { - bootstrapCache.set( - cacheKey, - (async () => { - await Promise.all((bootstrap.styles ?? []).map(loadStylesheet)) - for (const script of bootstrap.scripts ?? []) { - await loadScript(script) - } - })() - ) - } + if (!bootstrap) return + + setKibanaGlobals(bootstrap.publicPath) + + const cacheKey = JSON.stringify({ + publicPath: bootstrap.publicPath ?? '', + scripts: bootstrap.scripts ?? [], + styles: bootstrap.styles ?? [], + }) + if (!bootstrapCache.has(cacheKey)) { + bootstrapCache.set( + cacheKey, + (async () => { + await Promise.all((bootstrap.styles ?? []).map(loadStylesheet)) + for (const script of bootstrap.scripts ?? []) { + await loadScript(script) + } + })() + ) + } - await bootstrapCache.get(cacheKey) + await bootstrapCache.get(cacheKey) } class StorybookStoryElement extends HTMLElement { - private container: HTMLDivElement | null = null - private entry: StoryEntry | null = null - private mountVersion = 0 - - static get observedAttributes() { - return ['story-id', 'entry', 'bootstrap'] - } - - connectedCallback() { - if (!this.container) { - this.container = document.createElement('div') - this.appendChild(this.container) - } - this.mount() - } - - disconnectedCallback() { - this.mountVersion++ - this.unmount() - } + private container: HTMLDivElement | null = null + private entry: StoryEntry | null = null + private mountVersion = 0 - attributeChangedCallback() { - if (this.container) { - this.mount() + static get observedAttributes() { + return ['story-id', 'entry', 'bootstrap'] } - } - private unmount() { - if (this.container && this.entry) { - this.entry.unmountStory(this.container) - this.entry = null + connectedCallback() { + if (!this.container) { + this.container = document.createElement('div') + this.appendChild(this.container) + } + this.mount() } - } - private async mount() { - const storyId = this.getAttribute('story-id') - const entryUrl = this.getAttribute('entry') - const currentMount = ++this.mountVersion + disconnectedCallback() { + this.mountVersion++ + this.unmount() + } - if (!storyId || !entryUrl || !this.container) return + attributeChangedCallback() { + if (this.container) { + this.mount() + } + } - this.unmount() - this.container.replaceChildren() + private unmount() { + if (this.container && this.entry) { + this.entry.unmountStory(this.container) + this.entry = null + } + } - try { - await loadBootstrap(parseBootstrap(this.getAttribute('bootstrap'))) - const entry = await loadEntry(entryUrl) - if (currentMount !== this.mountVersion || !this.container || !this.isConnected) return - - this.entry = entry - try { - await entry.mountStory(storyId, this.container) - } catch (err) { - throw new StorybookLoadError( - 'Storybook story failed while mounting.', - err instanceof Error ? err.message : String(err) - ) - } - } catch (err) { - if (currentMount === this.mountVersion && this.container) { - this.container.replaceChildren(createErrorMessage(err)) - } + private async mount() { + const storyId = this.getAttribute('story-id') + const entryUrl = this.getAttribute('entry') + const currentMount = ++this.mountVersion + + if (!storyId || !entryUrl || !this.container) return + + this.unmount() + this.container.replaceChildren() + + try { + await loadBootstrap(parseBootstrap(this.getAttribute('bootstrap'))) + const entry = await loadEntry(entryUrl) + if ( + currentMount !== this.mountVersion || + !this.container || + !this.isConnected + ) + return + + this.entry = entry + try { + await entry.mountStory(storyId, this.container) + } catch (err) { + throw new StorybookLoadError( + 'Storybook story failed while mounting.', + err instanceof Error ? err.message : String(err) + ) + } + } catch (err) { + if (currentMount === this.mountVersion && this.container) { + this.container.replaceChildren(createErrorMessage(err)) + } + } } - } } const createErrorMessage = (err: unknown): HTMLDivElement => { - const message = document.createElement('div') - message.className = 'storybook-embed-error' - - const title = document.createElement('strong') - title.textContent = err instanceof StorybookLoadError ? err.title : 'Storybook story failed to load.' - message.appendChild(title) - - const detail = document.createElement('div') - detail.textContent = err instanceof StorybookLoadError ? err.detail : err instanceof Error ? err.message : String(err) - message.appendChild(detail) - - return message + const message = document.createElement('div') + message.className = 'storybook-embed-error' + + const title = document.createElement('strong') + title.textContent = + err instanceof StorybookLoadError + ? err.title + : 'Storybook story failed to load.' + message.appendChild(title) + + const detail = document.createElement('div') + detail.textContent = + err instanceof StorybookLoadError + ? err.detail + : err instanceof Error + ? err.message + : String(err) + message.appendChild(detail) + + return message } customElements.define('storybook-story', StorybookStoryElement) diff --git a/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs b/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs index 2858c72780..9180a132a2 100644 --- a/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs @@ -3,9 +3,9 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.Myst.Directives.Storybook; -using AwesomeAssertions; namespace Elastic.Markdown.Tests.Directives; @@ -21,7 +21,8 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) => """; private const string RegistryJson = - """ + /*lang=json,strict*/ + """ { "schemaVersion": 1, "producer": "kibana-storybook", From 30cf479df115abc22056bf53dd5445ea67d9b73a Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Wed, 3 Jun 2026 11:42:46 -0400 Subject: [PATCH 4/7] Reuse a shared HttpClient for storybook registry fetch Replace the per-directive HttpClient/handler with a process-wide `static readonly HttpClient` so registry fetches pool connections instead of churning sockets. Uses `SocketsHttpHandler` with a 5-minute `PooledConnectionLifetime` to bound DNS staleness under the long-lived serve/watch process. Per-request timeout and cancellation are unchanged. --- .../Myst/Directives/Storybook/StorybookBlock.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs index 95ff25bc28..3b37474c8c 100644 --- a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs @@ -14,6 +14,16 @@ public class StorybookBlock(DirectiveBlockParser parser, ParserContext context) private static readonly TimeSpan RegistryFetchTimeout = TimeSpan.FromSeconds(30); + // Shared across all storybook directives to pool connections; PooledConnectionLifetime bounds DNS staleness in long-lived serve/watch runs. + private static readonly HttpClient RegistryHttpClient = new( + new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + } + ) + { Timeout = RegistryFetchTimeout }; + public override string Directive => "storybook"; public string? Project { get; private set; } @@ -163,10 +173,8 @@ private string ReadRegistry(string rawRegistry) { // FinalizeAndValidate is synchronous across all directives, so the fetch is sync-over-async here. // Bound it with an explicit timeout so an unresponsive registry host can never stall the build. - using var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }; - using var http = new HttpClient(handler) { Timeout = RegistryFetchTimeout }; using var cts = new CancellationTokenSource(RegistryFetchTimeout); - return http.GetStringAsync(uri, cts.Token).GetAwaiter().GetResult(); + return RegistryHttpClient.GetStringAsync(uri, cts.Token).GetAwaiter().GetResult(); } var registryPath = Path.IsPathRooted(rawRegistry) From 08e4383ba12bbcde02f014aaaa5aae9a959ba236 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:20:49 +0000 Subject: [PATCH 5/7] Improve LLM storybook output: replace src/height with project/storybook/story-id attributes --- .../Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 3527cc9096..64a700f1f3 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -874,11 +874,14 @@ private static void WriteStorybookBlock(LlmMarkdownRenderer renderer, StorybookB return; renderer.EnsureBlockSpacing(); - var src = WebUtility.HtmlEncode(LlmRenderingHelpers.MakeAbsoluteUrl(renderer, block.StoryUrl)); renderer.Writer.Write(""); if (block.Count > 0) WriteChildrenWithIndentation(renderer, block, " "); From ca02c1196591d77c664b9c8ad6abd38648e923f2 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Wed, 3 Jun 2026 13:32:31 -0400 Subject: [PATCH 6/7] Fix empty storybook embeds on htmx navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit injects its Kibana bootstrap