diff --git a/.github/actions/aws-auth/action.yml b/.github/actions/aws-auth/action.yml new file mode 100644 index 00000000..c384b672 --- /dev/null +++ b/.github/actions/aws-auth/action.yml @@ -0,0 +1,43 @@ +name: AWS Auth + +description: | + This is an opinionated action to authenticate with AWS. + It will generate a role ARN based on the repository name and the AWS account ID. + +inputs: + aws_account_id: + description: 'The AWS account ID to generate the role ARN for' + required: true + default: '197730964718' # elastic-web + aws_region: + description: 'The AWS region to use' + required: false + default: 'us-east-1' + aws_role_name_prefix: + description: 'The prefix for the role name' + required: false + default: 'elastic-docs-v3-preview-' + +runs: + using: composite + steps: + - name: Generate AWS Role ARN + id: role_arn + shell: python + env: + AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} + ROLE_NAME_PREFIX: ${{ inputs.aws_role_name_prefix }} + run: | + import hashlib + import os + prefix = os.environ["ROLE_NAME_PREFIX"] + m = hashlib.sha256() + m.update(os.environ["GITHUB_REPOSITORY"].encode('utf-8')) + hash = m.hexdigest()[:64-len(prefix)] + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"result=arn:aws:iam::{os.environ["AWS_ACCOUNT_ID"]}:role/{prefix}{hash}") + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + role-to-assume: ${{ steps.role_arn.outputs.result }} + aws-region: ${{ inputs.aws_region }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dd6d7906..efe76602 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -6,6 +6,9 @@ on: permissions: contents: read packages: read + id-token: write + pull-requests: write + deployments: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -32,8 +35,14 @@ jobs: - name: Publish AOT run: ./build.sh publishbinaries - - # we run our artifact directly please use the prebuild - # elastic/docs-builder@main GitHub Action for all other repositories! - - name: Build documentation - run: .artifacts/publish/docs-builder/release/docs-builder --strict + + - uses: actions/upload-artifact@v4 + with: + name: docs-builder-binary + path: .artifacts/publish/docs-builder/release/docs-builder + if-no-files-found: error + retention-days: 1 + + preview: + needs: build + uses: ./.github/workflows/preview.yml diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml new file mode 100644 index 00000000..63295f8f --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,49 @@ +name: preview-cleanup + +on: + pull_request_target: + types: [closed] + +permissions: + deployments: write + id-token: write + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/aws-auth + - name: Delete s3 objects + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + aws s3 rm "s3://elastic-docs-v3-website-preview/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" --recursive + + - name: Delete GitHub environment + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const deployments = await github.rest.repos.listDeployments({ + owner, + repo, + environment: `preview-${context.issue.number}` + }); + for (const deployment of deployments.data) { + await github.rest.repos.createDeploymentStatus({ + owner, + repo, + deployment_id: deployment.id, + state: 'inactive', + description: 'Marking deployment as inactive' + }); + await github.rest.repos.deleteDeployment({ + owner, + repo, + deployment_id: deployment.id + }); + } + + + diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..1fcb79f2 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,93 @@ +name: preview + +on: + workflow_call: ~ + +permissions: + id-token: write + pull-requests: write + deployments: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Create Deployment + uses: actions/github-script@v7 + id: deployment + with: + result-encoding: string + script: | + const { owner, repo } = context.repo; + const deployment = await github.rest.repos.createDeployment({ + issue_number: context.issue.number, + owner, + repo, + ref: context.payload.pull_request.head.ref, + environment: `preview-${context.issue.number}`, + description: `Preview deployment for PR ${context.issue.number}`, + auto_merge: false, + required_contexts: [], + }) + await github.rest.repos.createDeploymentStatus({ + deployment_id: deployment.data.id, + owner, + repo, + state: "in_progress", + description: "Deployment created", + log_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}?pr=${context.issue.number}`, + }) + return deployment.data.id + + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: docs-builder-binary + + # we run our artifact directly please use the prebuild + # elastic/docs-builder@main GitHub Action for all other repositories! + - name: Build documentation + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + chmod +x ./docs-builder + ./docs-builder --strict --path-prefix "/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + + - uses: ./.github/actions/aws-auth + + - name: Upload to S3 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + aws s3 sync .artifacts/docs/html "s3://elastic-docs-v3-website-preview/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" --delete + aws cloudfront create-invalidation --distribution-id EKT7LT5PM8RKS --paths "/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}/*" + + - name: Update deployment status + uses: actions/github-script@v7 + if: steps.deployment.outputs.result + with: + script: | + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: ${{ steps.deployment.outputs.result }}, + state: "success", + description: "Deployment completed", + environment_url: `https://docs-v3-preview.elastic.dev/${context.repo.owner}/${context.repo.repo}/pull/${context.issue.number}`, + log_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}?pr=${context.issue.number}`, + }) + + - name: Update Deployment Status on Failure + if: failure() && steps.deployment.outputs.result + uses: actions/github-script@v7 + with: + script: | + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: ${{ steps.deployment.outputs.result }}, + state: "failure", + description: "Deployment failed", + log_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}?pr=${context.issue.number}`, + }) diff --git a/docs/syntax/links.md b/docs/syntax/links.md index 1c8dc27e..610654fd 100644 --- a/docs/syntax/links.md +++ b/docs/syntax/links.md @@ -91,4 +91,15 @@ Do note that these inline anchors will be normalized. ## This Is A Header [What about this for an anchor!] ``` -Will result in the anchor `what-about-this-for-an-anchor`. \ No newline at end of file +Will result in the anchor `what-about-this-for-an-anchor`. + + +## Inline anchors + +Docsbuilder temporary supports the abbility to create a linkable anchor anywhere on any document. + +```markdown +This is text and $$$this-is-an-inline-anchor$$$ +``` + +This feature exists to aid with migration however is scheduled for removal and new content should **NOT** utilize this feature. \ No newline at end of file diff --git a/src/Elastic.Markdown/Helpers/SlugExtensions.cs b/src/Elastic.Markdown/Helpers/SlugExtensions.cs new file mode 100644 index 00000000..dd1c0e20 --- /dev/null +++ b/src/Elastic.Markdown/Helpers/SlugExtensions.cs @@ -0,0 +1,16 @@ +// 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 Slugify; + +namespace Elastic.Markdown.Helpers; + +public static class SlugExtensions +{ + private static readonly SlugHelper _slugHelper = new(); + + + public static string Slugify(this string? text) => _slugHelper.GenerateSlug(text); + +} diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index f697caaa..230fcbed 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -8,18 +8,17 @@ using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.FrontMatter; +using Elastic.Markdown.Myst.InlineParsers; using Elastic.Markdown.Slices; using Markdig; using Markdig.Extensions.Yaml; using Markdig.Syntax; -using Slugify; namespace Elastic.Markdown.IO; public record MarkdownFile : DocumentationFile { - private readonly SlugHelper _slugHelper = new(); private string? _navigationTitle; public MarkdownFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext context) @@ -162,8 +161,6 @@ private void ReadDocumentInstructions(MarkdownDocument document) Collector.EmitWarning(FilePath, "Document has no title, using file name as title."); } - - var contents = document .Where(block => block is HeadingBlock { Level: >= 2 }) .Cast() @@ -171,7 +168,7 @@ private void ReadDocumentInstructions(MarkdownDocument document) .Select(h => new PageTocItem { Heading = h.Item1!.StripMarkdown(), - Slug = _slugHelper.GenerateSlug(h.Item2 ?? h.Item1) + Slug = (h.Item2 ?? h.Item1).Slugify() }) .ToList(); _tableOfContent.Clear(); @@ -181,8 +178,10 @@ private void ReadDocumentInstructions(MarkdownDocument document) var labels = document.Descendants() .Select(b => b.CrossReferenceName) .Where(l => !string.IsNullOrWhiteSpace(l)) - .Select(_slugHelper.GenerateSlug) + .Select(s => s.Slugify()) + .Concat(document.Descendants().Select(a => a.Anchor)) .ToArray(); + foreach (var label in labels) { if (!string.IsNullOrEmpty(label)) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs index 45a696db..e7527843 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs @@ -51,6 +51,10 @@ public override bool Close(BlockProcessor processor, Block block) var newSlice = new StringSlice(header.ToString()); headerBlock.Lines.Lines[0] = new StringLine(ref newSlice); + + if (header.IndexOf('$') >= 0) + anchor = HeadingAnchorParser.MatchAnchor().Replace(anchor.ToString(), ""); + headerBlock.SetData("anchor", anchor.ToString()); headerBlock.SetData("header", header.ToString()); return base.Close(processor, block); @@ -67,4 +71,7 @@ public static partial class HeadingAnchorParser [GeneratedRegex(@"(?:\[[^[]+\])\s*$", RegexOptions.IgnoreCase, "en-US")] public static partial Regex MatchAnchor(); + + [GeneratedRegex(@"\$\$\$[^\$]+\$\$\$", RegexOptions.IgnoreCase, "en-US")] + public static partial Regex InlineAnchors(); } diff --git a/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs new file mode 100644 index 00000000..c96052ed --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs @@ -0,0 +1,81 @@ +// 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 Elastic.Markdown.Helpers; +using Markdig; +using Markdig.Extensions.SmartyPants; +using Markdig.Helpers; +using Markdig.Parsers; +using Markdig.Parsers.Inlines; +using Markdig.Renderers; +using Markdig.Renderers.Html; +using Markdig.Renderers.Html.Inlines; +using Markdig.Syntax.Inlines; + +namespace Elastic.Markdown.Myst.InlineParsers; + +public static class InlineAnchorBuilderExtensions +{ + public static MarkdownPipelineBuilder UseInlineAnchors(this MarkdownPipelineBuilder pipeline) + { + pipeline.Extensions.AddIfNotAlready(); + return pipeline; + } +} + +public class InlineAnchorBuilderExtension : IMarkdownExtension +{ + public void Setup(MarkdownPipelineBuilder pipeline) => + pipeline.InlineParsers.InsertAfter(new InlineAnchorParser()); + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) => + renderer.ObjectRenderers.InsertAfter(new InlineAnchorRenderer()); +} + +public class InlineAnchorParser : InlineParser +{ + public InlineAnchorParser() + { + OpeningCharacters = ['$']; + } + + public override bool Match(InlineProcessor processor, ref StringSlice slice) + { + var startPosition = processor.GetSourcePosition(slice.Start, out var line, out var column); + var c = slice.CurrentChar; + + var span = slice.AsSpan(); + if (!span.StartsWith("$$$")) + return false; + + var closingStart = span[3..].IndexOf('$'); + if (closingStart <= 0) + return false; + + //not ending with three dollar signs + if (!span[(closingStart + 3)..].StartsWith("$$$")) + return false; + + processor.Inline = new InlineAnchor { Anchor = span[3..(closingStart + 3)].ToString().Slugify() }; + + var sliceEnd = slice.Start + closingStart + 6; + while (slice.Start != sliceEnd) + slice.SkipChar(); + + return true; + } + + +} + +public class InlineAnchor : LeafInline +{ + public required string Anchor { get; init; } +} + +public class InlineAnchorRenderer : HtmlObjectRenderer +{ + protected override void Write(HtmlRenderer renderer, InlineAnchor obj) => + renderer.Write(""); +} diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 82f1ce00..152fb62a 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -31,6 +31,7 @@ public class MarkdownParser( public static MarkdownPipeline MinimalPipeline { get; } = new MarkdownPipelineBuilder() .UseYamlFrontMatter() + .UseInlineAnchors() .UseHeadingsWithSlugs() .UseDirectives() .Build(); @@ -38,6 +39,7 @@ public class MarkdownParser( public static MarkdownPipeline Pipeline { get; } = new MarkdownPipelineBuilder() .EnableTrackTrivia() + .UseInlineAnchors() .UsePreciseSourceLocation() .UseDiagnosticLinks() .UseHeadingsWithSlugs() diff --git a/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs b/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs index 9567281e..cd1c4a8f 100644 --- a/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs +++ b/src/Elastic.Markdown/Myst/SectionedHeadingRenderer.cs @@ -1,17 +1,16 @@ // 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 Elastic.Markdown.Helpers; +using Elastic.Markdown.Myst.InlineParsers; using Markdig.Renderers; using Markdig.Renderers.Html; using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using Slugify; namespace Elastic.Markdown.Myst; public class SectionedHeadingRenderer : HtmlObjectRenderer { - private readonly SlugHelper _slugHelper = new(); private static readonly string[] HeadingTexts = [ "h1", @@ -33,7 +32,11 @@ protected override void Write(HtmlRenderer renderer, HeadingBlock obj) var header = obj.GetData("header") as string; var anchor = obj.GetData("anchor") as string; - var slug = _slugHelper.GenerateSlug(anchor ?? header); + var slugTarget = (anchor ?? header) ?? string.Empty; + if (slugTarget.IndexOf('$') >= 0) + slugTarget = HeadingAnchorParser.InlineAnchors().Replace(slugTarget, ""); + + var slug = slugTarget.Slugify(); renderer.Write(@"
(output, + """ + this is regular text and this $$$is-an-inline-anchor$$$ and this continues to be regular text + """ +) +{ + [Fact] + public void ParsesBlock() + { + Block.Should().NotBeNull(); + Block!.Anchor.Should().Be("is-an-inline-anchor"); + } + + [Fact] + public void GeneratesAttributesInHtml() => + // language=html + Html.Should().Contain( + """

this is regular text and this and this continues to be regular text

""" + ); +} + +public class InlineAnchorAtStartTests(ITestOutputHelper output) : LeafTest(output, + """ + $$$is-an-inline-anchor$$$ and this continues to be regular text + """ +) +{ + [Fact] + public void ParsesBlock() + { + Block.Should().NotBeNull(); + Block!.Anchor.Should().Be("is-an-inline-anchor"); + } + + [Fact] + public void GeneratesAttributesInHtml() => + // language=html + Html.Should().Be( + """

and this continues to be regular text

""" + ); +} + +public class InlineAnchorAtEndTests(ITestOutputHelper output) : LeafTest(output, + """ + this is regular text and this $$$is-an-inline-anchor$$$ + """ +) +{ + [Fact] + public void ParsesBlock() + { + Block.Should().NotBeNull(); + Block!.Anchor.Should().Be("is-an-inline-anchor"); + } + + [Fact] + public void GeneratesAttributesInHtml() => + // language=html + Html.Should().Contain( + """

this is regular text and this

""" + ); +} + +public class BadStartInlineAnchorTests(ITestOutputHelper output) : BlockTest(output, + """ + this is regular text and this $$is-an-inline-anchor$$$ + """ +) +{ + [Fact] + public void GeneratesAttributesInHtml() => + // language=html + Html.Should().Contain( + """

this is regular text and this $$is-an-inline-anchor$$$

""" + ); +} + +public class BadEndInlineAnchorTests(ITestOutputHelper output) : BlockTest(output, + """ + this is regular text and this $$$is-an-inline-anchor$$ + """ +) +{ + [Fact] + public void GeneratesAttributesInHtml() => + // language=html + Html.Should().Contain( + """

this is regular text and this $$$is-an-inline-anchor$$

""" + ); +} + +public class InlineAnchorInHeading(ITestOutputHelper output) : BlockTest(output, + """ + ## Hello world $$$my-anchor$$$ + """ +) +{ + [Fact] + public void GeneratesAttributesInHtml() => + // language=html + Html.Should().Be( + """ +

Hello world +

+
+ """.TrimEnd() + ); +} + +public class ExplicitSlugInHeader(ITestOutputHelper output) : BlockTest(output, + """ + ## Hello world [#my-anchor] + """ +) +{ + [Fact] + public void GeneratesAttributesInHtml() => + // language=html + Html.Should().Be( + """ +

Hello world +

+
+ """.TrimEnd() + ); +} + + +public abstract class InlineAnchorLinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) + : InlineTest(output, +$""" +## Hello world + +A paragraph + +{content} + +$$$same-page-anchor$$$ + +""") +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + // language=markdown + var inclusion = +""" +# Special Requirements + +## Sub Requirements + +To follow this tutorial you will need to install the following components: + +## New Requirements [#new-reqs] + +These are new requirements + +With a custom anchor that exists temporarily. $$$custom-anchor$$$ +"""; + fileSystem.AddFile(@"docs/testing/req.md", inclusion); + fileSystem.AddFile(@"docs/_static/img/observability.png", new MockFileData("")); + } + +} + +public class InlineAnchorCanBeLinkedToo(ITestOutputHelper output) : InlineAnchorLinkTestBase(output, +""" +[Hello](#same-page-anchor) +""" +) +{ + [Fact] + public void GeneratesHtml() => + // language=html + Html.Should().Contain( + """

Hello

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class ExternalPageInlineAnchorCanBeLinkedToo(ITestOutputHelper output) : InlineAnchorLinkTestBase(output, +""" +[Sub Requirements](testing/req.md#custom-anchor) +""" +) +{ + [Fact] + public void GeneratesHtml() => + // language=html + Html.Should().Contain( + """

Sub Requirements

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index 1513f01e..4d794a85 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -130,7 +130,7 @@ public virtual async Task InitializeAsync() Document = await File.ParseFullAsync(default); var html = File.CreateHtml(Document).AsSpan(); - var find = "
"; + var find = "\n"; var start = html.IndexOf(find, StringComparison.Ordinal); Html = start >= 0 && !TestingFullDocument ? html[(start + find.Length)..].ToString().Trim(Environment.NewLine.ToCharArray())