diff --git a/.github/workflows/assembler-preview.yml b/.github/workflows/assembler-preview.yml index a7b9695b0c..79ca0d5e5c 100644 --- a/.github/workflows/assembler-preview.yml +++ b/.github/workflows/assembler-preview.yml @@ -57,7 +57,7 @@ jobs: } - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: ref: ${{ steps.pr-details.outputs.result || github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/build-changelog-scrubber-lambda.yml b/.github/workflows/build-changelog-scrubber-lambda.yml index 6265d75ce4..220f5a65c3 100644 --- a/.github/workflows/build-changelog-scrubber-lambda.yml +++ b/.github/workflows/build-changelog-scrubber-lambda.yml @@ -22,7 +22,7 @@ jobs: env: BINARY_PATH: .artifacts/docs-lambda-changelog-scrubber/release_linux-x64/bootstrap steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: ref: ${{ inputs.ref }} persist-credentials: false diff --git a/.github/workflows/build-link-index-updater-lambda.yml b/.github/workflows/build-link-index-updater-lambda.yml index e2008b0a85..a577c6f948 100644 --- a/.github/workflows/build-link-index-updater-lambda.yml +++ b/.github/workflows/build-link-index-updater-lambda.yml @@ -22,7 +22,7 @@ jobs: env: BINARY_PATH: .artifacts/docs-lambda-index-publisher/release_linux-x64/bootstrap steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: ref: ${{ inputs.ref }} persist-credentials: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abc7e57fd4..e3a5893012 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: validate-assembler: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false @@ -42,7 +42,7 @@ jobs: env: MSBuildNoWarn: IDE0032 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false @@ -77,7 +77,7 @@ jobs: run: working-directory: src/Elastic.Documentation.Site steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false @@ -127,7 +127,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false @@ -148,7 +148,7 @@ jobs: - macos-latest - windows-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false - name: 'Windows only, set TEMP to the same drive' @@ -201,7 +201,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/create-major-tag.yml b/.github/workflows/create-major-tag.yml index 0269fe9b79..343101b569 100644 --- a/.github/workflows/create-major-tag.yml +++ b/.github/workflows/create-major-tag.yml @@ -15,7 +15,7 @@ jobs: create-major-tag: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: true - name: Get major version diff --git a/.github/workflows/docs-preview-local.yml b/.github/workflows/docs-preview-local.yml index cdc931cbb8..6268aca031 100644 --- a/.github/workflows/docs-preview-local.yml +++ b/.github/workflows/docs-preview-local.yml @@ -93,7 +93,7 @@ jobs: steps: - name: Checkout if: github.event_name == 'push' - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} persist-credentials: false @@ -211,7 +211,7 @@ jobs: if: > env.MATCH == 'true' && needs.check.outputs.any_modified != 'false' - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} persist-credentials: false diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml index 0ae71ac315..4b1e805a51 100644 --- a/.github/workflows/license.yml +++ b/.github/workflows/license.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 56244eeb4f..b170088ae6 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -27,7 +27,7 @@ jobs: steps: - id: repo-basename run: 'echo "value=`basename ${{ github.repository }}`" >> $GITHUB_OUTPUT' - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false - name: Setup Pages @@ -56,7 +56,7 @@ jobs: major-version: ${{ steps.bootstrap.outputs.major-version }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c23904ed27..ade452057f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: major-version: ${{ steps.bootstrap.outputs.major-version }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: ref: ${{ needs.release-drafter.outputs.tag_name }} persist-credentials: false @@ -188,7 +188,7 @@ jobs: major-version: ${{ steps.bootstrap.outputs.major-version }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: ref: ${{ needs.release-drafter.outputs.tag_name }} persist-credentials: false diff --git a/.github/workflows/required-labels.yml b/.github/workflows/required-labels.yml index 13c0bb56c3..0d84967ead 100644 --- a/.github/workflows/required-labels.yml +++ b/.github/workflows/required-labels.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false - name: Wait for PR to be ready (if just opened) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index a2b5b3ae74..473793ac8c 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -26,13 +26,13 @@ jobs: landing-page-path-output: "" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false - name: Bootstrap Action Workspace uses: ./.github/actions/bootstrap - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: repository: ${{ matrix.repository }} path: test-repo diff --git a/.github/workflows/updatecli.yml b/.github/workflows/updatecli.yml index e4b286bcb0..d0ca2b9cd5 100644 --- a/.github/workflows/updatecli.yml +++ b/.github/workflows/updatecli.yml @@ -16,7 +16,7 @@ jobs: bump: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 6d35621861..10c743a3ce 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -16,7 +16,7 @@ jobs: contents: read steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: persist-credentials: false diff --git a/Directory.Packages.props b/Directory.Packages.props index a00ac8f6df..61f9616c6c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,9 +35,9 @@ - + - + diff --git a/config/versions.yml b/config/versions.yml index 7c878335b9..580a6be895 100644 --- a/config/versions.yml +++ b/config/versions.yml @@ -167,7 +167,7 @@ versioning_systems: current: 9.4.0 elasticsearch-client-ruby: base: 9.0 - current: 9.4.0 + current: 9.4.1 elasticsearch-client-rust: base: 9.0 current: 9.1.0-alpha.1 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/cli/cli-reference-how-to.md b/docs/cli/cli-reference-how-to.md index adfa46bbb9..df0cb0aa5e 100644 --- a/docs/cli/cli-reference-how-to.md +++ b/docs/cli/cli-reference-how-to.md @@ -62,6 +62,16 @@ toc: folder: cli-reference ``` +Use `title:` to customize the generated CLI root page title, and `navigation_title:` to customize the sidebar and breadcrumb label without changing generated command examples: + +```yaml +toc: + - cli: cli-schema.json + folder: cli-reference + title: Elastic CLI reference + navigation_title: CLI reference +``` + Use `children:` to prepend hand-written pages — installation guides, conceptual overviews, or quick-start tutorials — before the auto-generated reference. All schema-generated pages follow the listed children: ```yaml @@ -101,4 +111,6 @@ Your CLI reference section is live. As your CLI evolves, regenerate the schema a |---|---| | `cli: ` | Path to the schema JSON, relative to `docset.yml` | | `folder: ` | Supplemental docs folder; also sets the URL prefix | +| `title: ` | Optional generated CLI root page title | +| `navigation_title: <title>` | Optional generated CLI root navigation label | | `children:` | Regular toc items prepended before generated pages | diff --git a/docs/cli/cli-supplemental-docs.md b/docs/cli/cli-supplemental-docs.md index 14f6bbe849..f2c2cf7f2d 100644 --- a/docs/cli/cli-supplemental-docs.md +++ b/docs/cli/cli-supplemental-docs.md @@ -10,6 +10,8 @@ Supplemental files let you enrich any auto-generated CLI reference page with con **Validation is strict.** Any supplemental file whose name does not match a known namespace or command produces a build error, so renamed or removed commands can never leave orphaned docs behind silently. +**Frontmatter is preserved as metadata.** Add YAML frontmatter to set page metadata such as `description`, `applies_to`, or `navigation_title`. It is passed through to the generated page and is not rendered as supplemental description text. + ## File naming Two naming styles are supported and can coexist in the same folder. @@ -48,6 +50,24 @@ cli/ The heading structure of a supplemental file controls what it contributes to the generated page. +### Frontmatter + +Use frontmatter for page metadata: + +```markdown +--- +description: Use the Elastic CLI to call Elasticsearch REST APIs from the command line. +applies_to: + stack: preview +--- + +## Description + +The `elastic stack es` command group exposes Elasticsearch REST APIs as CLI commands. +``` + +The metadata remains metadata. The generated page uses the `## Description` section, or the schema description if the file only contains frontmatter. + ### No headings A file with no `##` headings replaces the auto-generated description entirely: diff --git a/docs/syntax/applies.md b/docs/syntax/applies.md index e5c4120f0b..1c5895607e 100644 --- a/docs/syntax/applies.md +++ b/docs/syntax/applies.md @@ -1,17 +1,29 @@ # Applies to -Starting with Elastic Stack 9.0, ECE 4.0, and ECK 3.0, documentation follows a [cumulative approach](https://www.elastic.co/docs/contribute-docs/how-to/cumulative-docs): instead of creating separate pages for each product and release, we update a single page with product- and version-specific details over time. +Elastic documentation on [elastic.co/docs](https://www.elastic.co/docs) follows a [cumulative](https://www.elastic.co/docs/contribute-docs/how-to/cumulative-docs) model. We don't maintain versioned branches or separate copies of a page for each release. Instead, every page covers all supported versions, and writers use `applies_to` metadata to tag content with the versions, products, deployment models, and lifecycle states it applies to. -To support this, source files use a tagging system to indicate: +:::{tip} +You can use the [applies-to-tagging](https://github.com/elastic/elastic-docs-skills/tree/main/skills/authoring/applies-to-tagging) skill in Claude Code to validate and generate `applies_to` tags. [**Learn more**](https://github.com/elastic/elastic-docs-skills/tree/main#elastic-docs-skills) +::: -* Which Elastic products and deployment models the content applies to. -* When a feature changes state relative to the base version. +## Quick reference -This is what the `applies_to` metadata is for. It can be used at the [page](#page-level), [section](#section-level), or [inline](#inline-level) level to specify applicability with precision. +When documenting a new feature or behavior, answer these two questions: -:::{note} -For detailed guidance, refer to [Write cumulative documentation](https://www.elastic.co/docs/contribute-docs/how-to/cumulative-docs). -::: +1. **Where should I add `applies_to` metadata?** Find where your changes sit on the page and match to a type below. +2. **What keys and values do I use?** Refer to the [key-value reference](#key-value-reference) for the full list of valid keys, lifecycles, and version formats. + +### Where should I put the `applies_to` metadata? + +| When | Use | +|------|-----| +| You're creating a new page | [Frontmatter](#page-level) | +| You're adding a new section to an existing page | [Section-level annotation](#section-level) | +| You're adding a single new item to a list or definition term | [Inline annotation](#inline-level) | +| You're marking a single item as a technical preview | [Preview shorthand](#inline-level) | +| You need to show entirely different content for each variant, not just tag the same content | [Versioned tabs](applies-switch.md) | +| You're adding a version-specific note, tip, or warning | [Admonition annotation](admonitions.md) | +| You're adding a version-specific dropdown | [Dropdown annotation](dropdowns.md) | ## Syntax reference 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 `<storybook-story>` 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<string, IFileInfo>? OpenApiSpecifications { get; } + public string? StorybookRegistry { get; } + /// <summary> /// Resolved API configurations with template and specification file information. /// </summary> @@ -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/CliReference/CliReferenceRef.cs b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs index f67f023a2c..e41c661ec6 100644 --- a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs +++ b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs @@ -14,6 +14,8 @@ namespace Elastic.Documentation.Configuration.Toc.CliReference; public record CliReferenceRef( string SchemaPath, string? SupplementalFolder, + string? Title, + string? NavigationTitle, string PathRelativeToDocumentationSet, string PathRelativeToContainer, string Context, diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 120a839607..f82f871504 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) @@ -545,7 +548,7 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol ? ResolveTableOfContents(collector, cliRef.Children, baseDirectory, fileSystem, fullVirtualRoot, containerPath, context) : []; - return new CliReferenceRef(schemaFullPath, cliRef.SupplementalFolder, fullVirtualRoot, pathRelativeToContainer, context, resolvedChildren); + return new CliReferenceRef(schemaFullPath, cliRef.SupplementalFolder, cliRef.Title, cliRef.NavigationTitle, fullVirtualRoot, pathRelativeToContainer, context, resolvedChildren); } /// <summary> @@ -740,6 +743,13 @@ public class DocumentationSetFeatures public bool? DisableGithubEditLink { get; set; } } +[YamlSerializable] +public class DocumentationSetStorybook +{ + [YamlMember(Alias = "registry")] + public string? Registry { get; set; } +} + /// <summary> /// Codex-specific metadata. Only contains <c>group</c> for navigation grouping in a codex environment. /// </summary> diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs index 664c18085b..baee79ca4d 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs @@ -117,7 +117,9 @@ public class TocItemYamlConverter : IYamlTypeConverter if (dictionary.TryGetValue("cli", out var cliSchemaPath) && cliSchemaPath is string cliSchema) { var supplementalFolder = dictionary.TryGetValue("folder", out var f) && f is string fStr ? fStr : null; - return new CliReferenceRef(cliSchema, supplementalFolder, cliSchema, cliSchema, placeholderContext, children); + var title = dictionary.TryGetValue("title", out var t) && t is string titleStr ? titleStr : null; + var navigationTitle = dictionary.TryGetValue("navigation_title", out var nt) && nt is string navigationTitleStr ? navigationTitleStr : null; + return new CliReferenceRef(cliSchema, supplementalFolder, title, navigationTitle, cliSchema, cliSchema, placeholderContext, children); } // Check for folder+file combination (e.g., folder: getting-started, file: getting-started.md) 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 <storybook-story> injects into + // <head>; 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<void> +type UnmountFn = (container: HTMLElement) => void +type KibanaPublicPath = Record<string, string> + +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<string, Promise<StoryEntry>>() +const bootstrapCache = new Map<string, Promise<void>>() +const scriptCache = new Map<string, Promise<void>>() +const styleCache = new Map<string, Promise<void>>() + +const loadEntry = (url: string): Promise<StoryEntry> => { + 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<StoryEntry> + 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<void> => { + if (!styleCache.has(url)) { + styleCache.set( + url, + new Promise((resolve, reject) => { + const existing = Array.from( + document.querySelectorAll<HTMLLinkElement>( + '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<void> => { + if (!scriptCache.has(url)) { + scriptCache.set( + url, + new Promise((resolve, reject) => { + const existing = Array.from( + document.querySelectorAll<HTMLScriptElement>( + '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.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json index 4b2f71b026..25611a4723 100644 --- a/src/Elastic.Documentation.Site/package-lock.json +++ b/src/Elastic.Documentation.Site/package-lock.json @@ -18,8 +18,8 @@ "@opentelemetry/context-zone": "^2.7.1", "@opentelemetry/core": "^2.7.1", "@opentelemetry/exporter-logs-otlp-http": "^0.215.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.215.0", - "@opentelemetry/instrumentation": "^0.217.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", + "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/instrumentation-fetch": "^0.215.0", "@opentelemetry/otlp-exporter-base": "^0.215.0", "@opentelemetry/resources": "^2.7.1", @@ -30,7 +30,7 @@ "@tanstack/react-query": "^5.100.9", "@theletterf/beautiful-mermaid": "0.1.5", "@uidotdev/usehooks": "2.4.1", - "dompurify": "^3.4.2", + "dompurify": "3.4.2", "highlight.js": "11.11.1", "htmx-ext-head-support": "2.0.5", "htmx-ext-preload": "2.1.2", @@ -45,7 +45,7 @@ "ua-parser-js": "2.0.9", "uuid": "14.0.0", "zod": "4.4.3", - "zustand": "5.0.12" + "zustand": "5.0.13" }, "devDependencies": { "@babel/core": "7.28.4", @@ -83,7 +83,7 @@ "svgo": "^4.0.1", "text-diff": "1.0.1", "typescript": "^5.9.3", - "typescript-eslint": "8.59.3", + "typescript-eslint": "8.59.4", "wait-on": "9.0.5" } }, @@ -4821,16 +4821,16 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.215.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.215.0.tgz", - "integrity": "sha512-k4J9ISeGpb0Bm/wCrlcrbroMFTkiWMrdhNxQGrlktxLy127Yzd4/7nrTawn5d/ApktYTknvdixsE6++34Qfi1w==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.218.0.tgz", + "integrity": "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.0", - "@opentelemetry/otlp-exporter-base": "0.215.0", - "@opentelemetry/otlp-transformer": "0.215.0", - "@opentelemetry/resources": "2.7.0", - "@opentelemetry/sdk-trace-base": "2.7.0" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4839,28 +4839,84 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", - "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", + "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-transformer": "0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", + "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", + "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.0.tgz", - "integrity": "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==", + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4871,12 +4927,12 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.217.0.tgz", - "integrity": "sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.218.0.tgz", + "integrity": "sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.217.0", + "@opentelemetry/api-logs": "0.218.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, @@ -4949,18 +5005,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz", - "integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.215.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.215.0.tgz", @@ -24727,17 +24771,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", - "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/type-utils": "8.59.3", - "@typescript-eslint/utils": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -24750,7 +24794,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -24766,16 +24810,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", - "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "engines": { @@ -24791,14 +24835,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", - "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.3", - "@typescript-eslint/types": "^8.59.3", + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "engines": { @@ -24813,14 +24857,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -24831,9 +24875,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", - "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", "dev": true, "license": "MIT", "engines": { @@ -24848,15 +24892,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", - "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -24873,9 +24917,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", - "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", "dev": true, "license": "MIT", "engines": { @@ -24887,16 +24931,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", - "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.3", - "@typescript-eslint/tsconfig-utils": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -24954,9 +24998,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", "dev": true, "license": "ISC", "bin": { @@ -24967,16 +25011,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", - "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3" + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -24991,13 +25035,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", - "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -29901,9 +29945,9 @@ } }, "node_modules/joi": { - "version": "18.1.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", - "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -33592,16 +33636,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", - "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.3", - "@typescript-eslint/parser": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3" + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -34442,9 +34486,9 @@ "license": "MIT" }, "node_modules/zustand": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", - "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/src/Elastic.Documentation.Site/package.json b/src/Elastic.Documentation.Site/package.json index a1a421ea63..a4e31a52c1 100644 --- a/src/Elastic.Documentation.Site/package.json +++ b/src/Elastic.Documentation.Site/package.json @@ -91,7 +91,7 @@ "svgo": "^4.0.1", "text-diff": "1.0.1", "typescript": "^5.9.3", - "typescript-eslint": "8.59.3", + "typescript-eslint": "8.59.4", "wait-on": "9.0.5" }, "browserslist": [ @@ -108,8 +108,8 @@ "@opentelemetry/context-zone": "^2.7.1", "@opentelemetry/core": "^2.7.1", "@opentelemetry/exporter-logs-otlp-http": "^0.215.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.215.0", - "@opentelemetry/instrumentation": "^0.217.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", + "@opentelemetry/instrumentation": "^0.218.0", "@opentelemetry/instrumentation-fetch": "^0.215.0", "@opentelemetry/otlp-exporter-base": "^0.215.0", "@opentelemetry/resources": "^2.7.1", @@ -135,6 +135,6 @@ "ua-parser-js": "2.0.9", "uuid": "14.0.0", "zod": "4.4.3", - "zustand": "5.0.12" + "zustand": "5.0.13" } } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs index d4a6168141..d260b30f8b 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs @@ -10,10 +10,12 @@ namespace Elastic.Markdown.Extensions.CliReference; internal static partial class CliMarkdownGenerator { - public static string RootPage(CliSchema schema, CliSupplementalDoc? supplemental) + public static string RootPage(CliSchema schema, CliSupplementalDoc? supplemental, string? title = null) { var sb = new StringBuilder(); - _ = sb.AppendLine($"# {schema.Name}"); + AppendFrontMatter(sb, supplemental); + var pageTitle = string.IsNullOrWhiteSpace(title) ? schema.Name : title.Trim(); + _ = sb.AppendLine($"# {pageTitle}"); _ = sb.AppendLine(); var description = supplemental?.Description ?? schema.Description?.Trim(); @@ -44,7 +46,7 @@ public static string RootPage(CliSchema schema, CliSupplementalDoc? supplemental _ = sb.AppendLine("## Namespaces"); _ = sb.AppendLine(); foreach (var ns in schema.Namespaces) - AppendPageCard(sb, ns.Segment, $"./{ns.Segment}/index.md", ns.Summary); + AppendPageCard(sb, ns.Segment, $"./{ns.Segment}", ns.Summary); } if (schema.Environment?.Variables is { Count: > 0 } envVars) @@ -108,6 +110,7 @@ public static string NamespacePage( List<CliShortcutSchema>? shortcuts = null) { var sb = new StringBuilder(); + AppendFrontMatter(sb, supplemental); var heading = fullPath is { Length: > 0 } ? string.Join(" ", fullPath) : ns.Segment; _ = sb.AppendLine($"# {heading} <span class=\"cli-badge-ns\">cli namespace</span>"); _ = sb.AppendLine(); @@ -126,7 +129,7 @@ public static string NamespacePage( { var depth = fullPath?.Length ?? 1; var upPrefix = string.Concat(Enumerable.Repeat("../", depth)); - var links = nsAliases.Select(a => $"[`{binaryName ?? a} {a}`]({upPrefix}{a}/index.md)"); + var links = nsAliases.Select(a => $"[`{binaryName ?? a} {a}`]({upPrefix}{a})"); _ = sb.AppendLine($"Also accessible as {string.Join(", ", links)}."); _ = sb.AppendLine(); } @@ -156,7 +159,7 @@ public static string NamespacePage( _ = sb.AppendLine("## Sub-namespaces"); _ = sb.AppendLine(); foreach (var sub in subNamespaces) - AppendPageCard(sb, sub.Segment, $"./{sub.Segment}/index.md", sub.Summary); + AppendPageCard(sb, sub.Segment, $"./{sub.Segment}", sub.Summary); } var options = ns.Options ?? []; @@ -198,6 +201,7 @@ public static string CommandPage( List<CliShortcutSchema>? shortcuts = null) { var sb = new StringBuilder(); + AppendFrontMatter(sb, supplemental); var heading = fullPath is { Length: > 0 } ? string.Join(" ", fullPath) : cmd.Name; _ = sb.AppendLine($"# {heading} <span class=\"cli-badge-cmd\">cli command</span>"); _ = sb.AppendLine(); @@ -322,6 +326,15 @@ public static string CommandPage( return sb.ToString(); } + private static void AppendFrontMatter(StringBuilder sb, CliSupplementalDoc? supplemental) + { + if (string.IsNullOrWhiteSpace(supplemental?.FrontMatter)) + return; + + _ = sb.AppendLine(supplemental.FrontMatter); + _ = sb.AppendLine(); + } + private static void AppendCommandModifiers(StringBuilder sb, CliCommandSchema cmd) { if (cmd.Deprecated is not null) diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs index 3bc9e2e010..9204df6927 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs @@ -24,7 +24,11 @@ internal sealed record CliEntityInfo( /// <summary>Ancestor namespace options ordered from closest to furthest (direct parent first).</summary> IReadOnlyList<(string Segment, List<CliParamSchema>? Options)>? AncestorNamespaceOptions = null, /// <summary>Relative path from this file to the alias target — set for CliShortcutSchema entities only.</summary> - string? AliasCanonicalRelativePath = null + string? AliasCanonicalRelativePath = null, + /// <summary>Display title for the generated CLI root page.</summary> + string? Title = null, + /// <summary>Navigation title for the generated CLI root page.</summary> + string? NavigationTitle = null ); public class CliReferenceDocsBuilderExtension(BuildContext build) : IDocsBuilderExtension @@ -113,7 +117,7 @@ private void EnsureSyntheticFilesBuilt() private MarkdownFile? CreateCliFileFromInfo(IFileInfo sourceFile, MarkdownParser markdownParser, CliEntityInfo info) => info.Entity switch { - CliSchema schema => new CliRootFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, schema, info.SupplementalDoc), + CliSchema schema => new CliRootFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, schema, info.SupplementalDoc, info.Title, info.NavigationTitle), CliNamespaceSchema ns => new CliNamespaceFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, ns, info.SupplementalDoc, info.FullPath ?? [ns.Segment], info.Schema.Name, info.Schema.ReservedMetaCommands, info.Schema.Shortcuts), CliCommandSchema cmd => new CliCommandFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, cmd, info.SupplementalDoc, info.FullPath ?? [cmd.Name], info.Schema.Name, info.Schema.ReservedMetaCommands, info.AncestorNamespaceOptions, info.Schema.GlobalOptions, info.Schema.Shortcuts), CliShortcutSchema shortcut => new CliAliasFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, shortcut, info.Schema.Name, info.AliasCanonicalRelativePath ?? "../"), @@ -186,7 +190,7 @@ private List<IFileInfo> BuildSyntheticFiles() var rootSupplemental = FindSupplemental(supplementalDirPath, [], isNamespace: true, matched); var rootSyntheticPath = SyntheticPath(Build.DocumentationSourceDirectory.FullName, virtualRoot, [], isNamespace: true); var rootFileInfo = Build.ReadFileSystem.FileInfo.New(rootSyntheticPath); - var rootInfo = new CliEntityInfo(schema, schema, rootSupplemental, rootFileInfo); + var rootInfo = new CliEntityInfo(schema, schema, rootSupplemental, rootFileInfo, Title: cliRef.Title, NavigationTitle: cliRef.NavigationTitle); _syntheticFiles![rootSyntheticPath] = rootInfo; if (rootSupplemental != null) _supplementalFiles![rootSupplemental.FullName] = rootInfo; diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs index 991622158d..739409b609 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs @@ -14,6 +14,8 @@ public record CliRootFile : IO.MarkdownFile { private readonly CliSchema _schema; private readonly IFileInfo? _supplementalDoc; + private readonly string _title; + private readonly string _navigationTitle; public CliRootFile( IFileInfo sourceFile, @@ -21,19 +23,23 @@ public CliRootFile( MarkdownParser parser, BuildContext build, CliSchema schema, - IFileInfo? supplementalDoc + IFileInfo? supplementalDoc, + string? title = null, + string? navigationTitle = null ) : base(sourceFile, rootPath, parser, build) { _schema = schema; _supplementalDoc = supplementalDoc; - Title = schema.Name; + _title = string.IsNullOrWhiteSpace(title) ? schema.Name : title.Trim(); + _navigationTitle = string.IsNullOrWhiteSpace(navigationTitle) ? $"{schema.Name} CLI" : navigationTitle.Trim(); + Title = _title; } - public override string NavigationTitle => $"{_schema.Name} CLI"; + public override string NavigationTitle => _navigationTitle; protected override Task<MarkdownDocument> GetMinimalParseDocumentAsync(Cancel ctx) { - Title = _schema.Name; + Title = _title; var markdown = BuildMarkdown(); return Task.FromResult(MarkdownParser.MinimalParseStringAsync(markdown, SourceFile, null)); } @@ -50,6 +56,6 @@ private string BuildMarkdown() ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) : null; var supplemental = CliSupplementalDoc.Parse(rawSupplemental); - return CliMarkdownGenerator.RootPage(_schema, supplemental); + return CliMarkdownGenerator.RootPage(_schema, supplemental, _title); } } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliSupplementalDoc.cs b/src/Elastic.Markdown/Extensions/CliReference/CliSupplementalDoc.cs index ca59d01ddc..854ceab239 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliSupplementalDoc.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliSupplementalDoc.cs @@ -7,6 +7,7 @@ namespace Elastic.Markdown.Extensions.CliReference; internal sealed partial record CliSupplementalDoc( + string? FrontMatter, string? Description, Dictionary<string, string> OptionOverrides, Dictionary<string, string> ArgumentOverrides, @@ -18,13 +19,14 @@ internal sealed partial record CliSupplementalDoc( if (raw is null) return null; - var trimmed = raw.Trim(); + var (frontMatter, rawContent) = ExtractFrontMatter(raw); + var trimmed = rawContent.Trim(); if (string.IsNullOrWhiteSpace(trimmed)) - return null; + return string.IsNullOrWhiteSpace(frontMatter) ? null : new CliSupplementalDoc(frontMatter, null, [], [], null); // Backward compat: no ## headings → entire content is description if (!trimmed.Contains("\n## ") && !trimmed.StartsWith("## ", StringComparison.Ordinal)) - return new CliSupplementalDoc(trimmed, [], [], null); + return new CliSupplementalDoc(frontMatter, trimmed, [], [], null); var sections = SplitSections(trimmed); string? description = null; @@ -55,7 +57,16 @@ internal sealed partial record CliSupplementalDoc( } var postContent = postParts.Count > 0 ? string.Join("\n\n", postParts) : null; - return new CliSupplementalDoc(description, optionOverrides, argumentOverrides, postContent); + return new CliSupplementalDoc(frontMatter, description, optionOverrides, argumentOverrides, postContent); + } + + private static (string? FrontMatter, string Content) ExtractFrontMatter(string raw) + { + var match = FrontMatterRegex().Match(raw); + if (!match.Success) + return (null, raw); + + return (match.Value.Trim(), raw[match.Length..]); } private static List<(string? heading, string body)> SplitSections(string text) @@ -124,4 +135,7 @@ private static string NormalizeKey(string raw) // Matches: `: `--flag`` or `: --flag` or `: <name>` [GeneratedRegex(@"^:\s+(`[^`]+`|--[\w-]+|<[\w-]+>)")] private static partial Regex TermLineRegex(); + + [GeneratedRegex(@"\A---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)")] + private static partial Regex FrontMatterRegex(); } diff --git a/src/Elastic.Markdown/IO/MarkdownFileFactory.cs b/src/Elastic.Markdown/IO/MarkdownFileFactory.cs index e27b89203c..cd246c804b 100644 --- a/src/Elastic.Markdown/IO/MarkdownFileFactory.cs +++ b/src/Elastic.Markdown/IO/MarkdownFileFactory.cs @@ -117,7 +117,8 @@ private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext contex if (context.Configuration.IsExcluded(relativePath)) return new ExcludedFile(file, sourceDirectory, context.Git.RepositoryName); - if (relativePath.Contains("_snippets")) + if (relativePath.Contains($"{Path.DirectorySeparatorChar}_snippets{Path.DirectorySeparatorChar}") + || relativePath.StartsWith($"_snippets{Path.DirectorySeparatorChar}")) return new SnippetFile(file, sourceDirectory, context.Git.RepositoryName); // we ignore files in folders that start with an underscore 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/Include/IncludeBlock.cs b/src/Elastic.Markdown/Myst/Directives/Include/IncludeBlock.cs index 3f5a4e81d5..9948c4e354 100644 --- a/src/Elastic.Markdown/Myst/Directives/Include/IncludeBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Include/IncludeBlock.cs @@ -93,7 +93,7 @@ private void ExtractInclusionPath(ParserContext context) if (Literal) return; - if (file.Directory != null && file.Directory.FullName.IndexOf("_snippets", StringComparison.Ordinal) < 0) + if (file.Directory != null && !file.Directory.FullName.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("_snippets")) { this.EmitError($"{{include}} only supports including snippets from `_snippet` folders. `{IncludePath}` is not a snippet"); Found = false; diff --git a/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs b/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs index 4cdc9db58f..810341d6c2 100644 --- a/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs @@ -55,6 +55,11 @@ public override void FinalizeAndValidate(ParserContext context) : relativeToSource; ResolvedUrl = "/" + withoutExtension.Replace('\\', '/'); + + // Apply URL path prefix so links work in preview/sub-path deployments (same logic as DiagnosticLinkInlineParser) + var urlPathPrefix = context.Build.UrlPathPrefix ?? string.Empty; + if (!string.IsNullOrWhiteSpace(urlPathPrefix) && !ResolvedUrl.StartsWith(urlPathPrefix, StringComparison.OrdinalIgnoreCase)) + ResolvedUrl = $"{urlPathPrefix.TrimEnd('/')}{ResolvedUrl}"; } [GeneratedRegex(@"^\[([^\]]+)\]\(([^)]+)\)$")] 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<string, JsonElement>? Build { get; init; } + + [JsonPropertyName("stories")] + public Dictionary<string, StorybookRegistryStory> 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<string> Scripts { get; init; } = []; + + [JsonPropertyName("styles")] + public IReadOnlyCollection<string> 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<Elastic.Markdown.Myst.Directives.Storybook.StorybookViewModel> +<div class="storybook-embed"> +@if (Model.HasInlineStory) +{ +<storybook-story + story-id="@Model.StoryId" + entry="@Model.InlineEntry" + bootstrap="@Model.InlineBootstrapJson"> +</storybook-story> +} +else +{ + <iframe + src="@Model.StoryUrl" + title="@Model.IframeTitle" + style="width:100%;height:@Model.HeightStyle;border:none;" + sandbox="allow-scripts allow-same-origin" + loading="lazy"></iframe> +} + @if (Model.HasBody) + { + <div class="storybook-embed-body"> + @Model.RenderBlock() + </div> + } +</div> 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("<storybook"); + renderer.Writer.Write($" title=\"{WebUtility.HtmlEncode(block.IframeTitle)}\""); + if (!string.IsNullOrWhiteSpace(block.Project)) + renderer.Writer.Write($" project=\"{WebUtility.HtmlEncode(block.Project)}\""); + if (!string.IsNullOrWhiteSpace(block.Storybook)) + renderer.Writer.Write($" storybook=\"{WebUtility.HtmlEncode(block.Storybook)}\""); + if (!string.IsNullOrWhiteSpace(block.StoryId)) + renderer.Writer.Write($" story-id=\"{WebUtility.HtmlEncode(block.StoryId)}\""); + renderer.Writer.Write($" src=\"{WebUtility.HtmlEncode(LlmRenderingHelpers.MakeAbsoluteUrl(renderer, block.StoryUrl) ?? block.StoryUrl)}\""); + renderer.Writer.WriteLine(">"); + if (block.Count > 0) + WriteChildrenWithIndentation(renderer, block, " "); + renderer.Writer.WriteLine("</storybook>"); + renderer.EnsureLine(); + } + private static void WriteChildrenWithIndentation(LlmMarkdownRenderer renderer, Block container, string indent) { // Capture output and manually add indentation diff --git a/src/Elastic.Markdown/_Layout.cshtml b/src/Elastic.Markdown/_Layout.cshtml index 88b1df2443..c590f02d7d 100644 --- a/src/Elastic.Markdown/_Layout.cshtml +++ b/src/Elastic.Markdown/_Layout.cshtml @@ -98,7 +98,7 @@ @if (Model.Features.WebsiteSearchEnabled && Model.Features.WebsiteSearchScriptUrl is not null) { <elastic-website-search - nav-mode="off" + nav-mode="on" chat-enabled="true" chat-mode="input" chat-input-container="#elastic-website-search-input-container"> diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs index ffb96d96ef..9a7d41b801 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -10,6 +10,25 @@ namespace Elastic.Documentation.Configuration.Tests; public class PhysicalDocsetTests { + [Fact] + public void CliReferenceRefReadsTitleOverrides() + { + const string yaml = """ + project: test + toc: + - cli: cli/schema.json + folder: cli + title: Elastic CLI reference + navigation_title: CLI reference + """; + + var docSet = ConfigurationFileProvider.Deserializer.Deserialize<DocumentationSetFile>(yaml); + var cliRef = docSet.TableOfContents.OfType<CliReferenceRef>().Single(); + + cliRef.Title.Should().Be("Elastic CLI reference"); + cliRef.NavigationTitle.Should().Be("CLI reference"); + } + [Fact] public void PhysicalDocsetFileCanBeDeserialized() { diff --git a/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs b/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs new file mode 100644 index 0000000000..796a3c377e --- /dev/null +++ b/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs @@ -0,0 +1,51 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Configuration.Toc.CliReference; +using Elastic.Markdown.Extensions.CliReference; + +namespace Elastic.Markdown.Tests.CliReference; + +public class CliMarkdownGeneratorTests +{ + [Fact] + public void RootPage_UsesTitleOverrideForHeading() + { + var schema = new CliSchema( + SchemaVersion: 1, + Name: "elastic", + Description: "Interact with Elastic from the command line.", + GlobalOptions: [], + RootDefault: null, + Commands: [], + Namespaces: [] + ); + + var markdown = CliMarkdownGenerator.RootPage(schema, null, "Elastic CLI reference"); + + markdown.Should().StartWith("# Elastic CLI reference"); + markdown.Should().Contain("Interact with Elastic from the command line."); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void RootPage_FallsBackToSchemaNameForBlankTitleOverride(string title) + { + var schema = new CliSchema( + SchemaVersion: 1, + Name: "elastic", + Description: "Interact with Elastic from the command line.", + GlobalOptions: [], + RootDefault: null, + Commands: [], + Namespaces: [] + ); + + var markdown = CliMarkdownGenerator.RootPage(schema, null, title); + + markdown.Should().StartWith("# elastic"); + } +} diff --git a/tests/Elastic.Markdown.Tests/CliReference/CliSupplementalDocTests.cs b/tests/Elastic.Markdown.Tests/CliReference/CliSupplementalDocTests.cs new file mode 100644 index 0000000000..5de73f634a --- /dev/null +++ b/tests/Elastic.Markdown.Tests/CliReference/CliSupplementalDocTests.cs @@ -0,0 +1,70 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Configuration.Toc.CliReference; +using Elastic.Markdown.Extensions.CliReference; + +namespace Elastic.Markdown.Tests.CliReference; + +public class CliSupplementalDocTests +{ + [Fact] + public void RootPage_PreservesFrontMatterAsMetadata() + { + var schema = CreateSchema(); + const string raw = """ + --- + description: Use the Elastic CLI from the command line. + applies_to: + stack: preview + --- + """; + + var supplemental = CliSupplementalDoc.Parse(raw); + var markdown = CliMarkdownGenerator.RootPage(schema, supplemental).ReplaceLineEndings("\n"); + + var expectedStart = """ + --- + description: Use the Elastic CLI from the command line. + applies_to: + stack: preview + --- + + # elastic + """.ReplaceLineEndings("\n"); + + markdown.Should().StartWith(expectedStart); + markdown.Should().NotContain("description: Use the Elastic CLI from the command line.\n\n"); + } + + [Fact] + public void RootPage_StripsFrontMatterBeforeParsingDescription() + { + var schema = CreateSchema(); + const string raw = """ + --- + description: Metadata description. + --- + + User-facing supplemental description. + """; + + var supplemental = CliSupplementalDoc.Parse(raw); + var markdown = CliMarkdownGenerator.RootPage(schema, supplemental).ReplaceLineEndings("\n"); + + markdown.Should().Contain("\n# elastic\n\nUser-facing supplemental description.\n"); + markdown.Should().NotContain("\nMetadata description.\n"); + } + + private static CliSchema CreateSchema() => new( + SchemaVersion: 1, + Name: "elastic", + Description: "Schema description.", + GlobalOptions: [], + RootDefault: null, + Commands: [], + Namespaces: [] + ); +} 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) { } /// </summary> protected virtual IReadOnlyList<string>? 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<StorybookBlock>(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("<storybook-story"); + Html.Should().Contain("story-id=\"ai-components-aibutton--default\""); + Html.Should().Contain("entry=\"http://127.0.0.1:6007/storybook-docs/shared_ux/registry.js\""); + Html.Should().Contain("http://127.0.0.1:6007/storybook/shared_ux/kbn-ui-shared-deps-src.css"); + Html.Should().Contain("https://fonts.googleapis.com"); + Html.Should().NotContain("kibana:shared_ux:ai-components-aibutton--default"); + } +} + +public class StorybookStructuredReferenceTests(ITestOutputHelper output) : StorybookRegistryTest(output, +""" +:::{storybook} +:project: kibana +:storybook: shared_ux +:component: ai-components-aibutton +:story: default +::: +""" +) +{ + [Fact] + public void ResolvesComponentAndStory() => + 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("<iframe"); + Html.Should().Contain("src=\"http://127.0.0.1:6007/storybook/shared_ux/iframe.html?id=components-callout--info-storybook&viewMode=story\""); + } +} + +public class StorybookBodyTests(ITestOutputHelper output) : StorybookRegistryTest(output, +""" +:::{storybook} +:id: kibana:shared_ux:components-callout--info +Supporting details for this story. +::: +""" +) +{ + [Fact] + public void RendersBodyContent() => + 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<StorybookBlock>(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..b98472d614 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<string, string>? globalVariables = null, - IReadOnlyList<string>? products = null) + IReadOnlyList<string>? products = null, + string? extraYaml = null) { // language=yaml var yaml = new StringWriter(); @@ -34,7 +35,7 @@ public static void GenerateDocSetYaml( .EnumerateFiles(root.FullName, "*.md", SearchOption.AllDirectories); foreach (var markdownFile in markdownFiles) { - if (markdownFile.Contains("_snippet")) + if (markdownFile.Contains($"{Path.DirectorySeparatorChar}_snippets{Path.DirectorySeparatorChar}")) continue; var relative = fileSystem.Path.GetRelativePath(root.FullName, markdownFile); yaml.WriteLine($" - file: {relative}"); @@ -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/Elastic.Markdown.Tests/OutputDirectoryTests.cs b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs index 82a69f20a5..d6dfa1295c 100644 --- a/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs @@ -44,6 +44,35 @@ public async Task CreatesDefaultOutputDirectory() fileSystem.Directory.Exists(".artifacts").Should().BeTrue(); } + [Fact] + public void FilesWithSnippetsInNameNotTreatedAsSnippets() + { + var logger = new TestLoggerFactory(output); + var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData> + { + { "docs/docset.yml", + //language=yaml + new MockFileData(""" +project: test +toc: +- file: index.md +- file: top_snippets.md +""") }, + { "docs/index.md", new MockFileData("# Test") }, + { "docs/top_snippets.md", new MockFileData("# Top Snippets") } + }, new MockFileSystemOptions + { + CurrentDirectory = Paths.WorkingDirectoryRoot.FullName + }); + var collector = new TestDiagnosticsCollector(output); + var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); + var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var linkResolver = new TestCrossLinkResolver(); + var set = new DocumentationSet(context, logger, linkResolver); + + set.MarkdownFiles.Should().Contain(f => f.RelativePath.EndsWith("top_snippets.md")); + } + [Theory] [MemberData(nameof(ValidFileNames))] public void OutputFileValidationValidNames(string fileName) 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} +::: +""" + + [<Fact>] + 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 +::: +""" + + [<Fact>] + 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 @@ <Compile Include="Blocks\Lists.fs"/> <Compile Include="Blocks\Admonitions.fs"/> <Compile Include="Blocks\AgentSkill.fs"/> + <Compile Include="Blocks\Storybook.fs"/> <Compile Include="Blocks\ImageBlocks.fs" /> <Compile Include="Applicability\AppliesToFrontMatter.fs" /> <Compile Include="Applicability\AppliesToDirective.fs" />