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..71cf58436f --- /dev/null +++ b/docs/syntax/storybook.md @@ -0,0 +1,84 @@ +# 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 +::: +``` + +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 + +| 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..4f6a935bb6 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') @@ -134,8 +135,19 @@ document.addEventListener('htmx:load', function () { document.addEventListener( 'htmx:removingHeadElement', function (event: HtmxEvent) { - const tagName = event.detail.headElement.tagName - if (tagName === 'STYLE') { + const headElement = event.detail.headElement + if (headElement.tagName === 'STYLE') { + event.preventDefault() + return + } + // Keep the Storybook bootstrap assets that injects into + // ; htmx would otherwise strip them on navigation, which both breaks an + // in-flight first load (the stylesheet's onload never fires) and leaves the + // module-level load caches pointing at elements that no longer exist. + if ( + headElement.dataset?.storybookScript !== undefined || + headElement.dataset?.storybookStyle !== undefined + ) { event.preventDefault() } } 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..cee94b9cc3 --- /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..8487a7e59d --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/StorybookStory/StorybookStoryComponent.tsx @@ -0,0 +1,297 @@ +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 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 = 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) +} + +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..3b37474c8c --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs @@ -0,0 +1,297 @@ +// 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"; + + 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; } + + 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") + { + // 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 cts = new CancellationTokenSource(RegistryFetchTimeout); + return RegistryHttpClient.GetStringAsync(uri, cts.Token).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..86939547b5 --- /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; } = [with(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..086e4064fc --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookView.cshtml @@ -0,0 +1,26 @@ +@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..7290376891 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,28 @@ 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..9180a132a2 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs @@ -0,0 +1,246 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Diagnostics; +using Elastic.Markdown.Myst.Directives.Storybook; + +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 = + /*lang=json,strict*/ + """ + { + "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 @@ +