Skip to content

Commit 73e7976

Browse files
committed
feat: add FlatFileRedirectContentService for backward compatibility with flat-file URLs
- Introduced `FlatFileRedirectContentService` to generate `filename.html` redirect files for folder-based outputs. - Added comprehensive tests to validate redirect generation, collision handling, and deduplication logic. - Updated `ContentEngineExtensions` and examples to register the new service.
1 parent 030717c commit 73e7976

4 files changed

Lines changed: 532 additions & 0 deletions

File tree

examples/Spectre.Console/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
ContentRootPath = "Content",
1919
CanonicalBaseUrl = "https://phil-scott-78.github.io/MyLittleContentEngine/",
2020
})
21+
.WithFlatFileRedirects()
2122
// Console documentation service
2223
.WithMarkdownContentService(_ => new MarkdownContentOptions<SpectreConsoleFrontMatter>()
2324
{

src/MyLittleContentEngine/ContentEngineExtensions.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,21 @@ public static IConfiguredContentEngineServiceCollection WithConnectedRoslynSolut
405405
return services;
406406
}
407407

408+
/// <summary>
409+
/// Adds a <see cref="Services.Content.FlatFileRedirectContentService"/> that generates
410+
/// <c>filename.html</c> redirect files for pages using folder-based output.
411+
/// This provides backward compatibility for sites that migrated from flat-file to
412+
/// folder-based URL output (e.g., <c>about.html</c> redirects to <c>about/</c>).
413+
/// </summary>
414+
/// <param name="services">The configured service collection.</param>
415+
/// <returns>The updated service collection for method chaining.</returns>
416+
public static IConfiguredContentEngineServiceCollection WithFlatFileRedirects(
417+
this IConfiguredContentEngineServiceCollection services)
418+
{
419+
services.AddTransient<IContentService, FlatFileRedirectContentService>();
420+
return services;
421+
}
422+
408423
/// <summary>
409424
/// Adds an ApiReferenceContentService to the application's service collection for generating API documentation.
410425
/// </summary>
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using System.Collections.Immutable;
2+
using System.Text;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using MyLittleContentEngine.Models;
5+
using MyLittleContentEngine.Services.Content.TableOfContents;
6+
7+
namespace MyLittleContentEngine.Services.Content;
8+
9+
/// <summary>
10+
/// Optional content service that generates <c>filename.html</c> redirect files for pages
11+
/// using folder-based output (<c>folder/index.html</c>). This provides backward compatibility
12+
/// for sites that migrated from flat-file to folder-based URL output.
13+
/// </summary>
14+
/// <remarks>
15+
/// <para>
16+
/// For each page whose output file ends with <c>index.html</c> (e.g., <c>about/index.html</c>),
17+
/// this service creates a sibling <c>about.html</c> file containing a meta-refresh redirect
18+
/// to the canonical folder URL (<c>about/</c>).
19+
/// </para>
20+
/// <para>
21+
/// Uses <see cref="IServiceProvider"/> for lazy resolution of content services to avoid a
22+
/// circular dependency, following the same pattern as
23+
/// <see cref="Spa.SpaNavigationContentService"/>.
24+
/// </para>
25+
/// </remarks>
26+
internal class FlatFileRedirectContentService(IServiceProvider serviceProvider) : IContentService
27+
{
28+
private const string IndexHtml = "index.html";
29+
30+
/// <inheritdoc />
31+
public int SearchPriority => 0;
32+
33+
/// <inheritdoc />
34+
public Task<ImmutableList<PageToGenerate>> GetPagesToGenerateAsync() =>
35+
Task.FromResult(ImmutableList<PageToGenerate>.Empty);
36+
37+
/// <inheritdoc />
38+
public Task<ImmutableList<ContentTocItem>> GetContentTocEntriesAsync() =>
39+
Task.FromResult(ImmutableList<ContentTocItem>.Empty);
40+
41+
/// <inheritdoc />
42+
public Task<ImmutableList<ContentToCopy>> GetContentToCopyAsync() =>
43+
Task.FromResult(ImmutableList<ContentToCopy>.Empty);
44+
45+
/// <inheritdoc />
46+
public Task<ImmutableList<CrossReference>> GetCrossReferencesAsync() =>
47+
Task.FromResult(ImmutableList<CrossReference>.Empty);
48+
49+
/// <inheritdoc />
50+
public async Task<ImmutableList<ContentToCreate>> GetContentToCreateAsync()
51+
{
52+
var contentServices = serviceProvider.GetServices<IContentService>();
53+
var allPages = new List<PageToGenerate>();
54+
var existingFlatFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
55+
56+
foreach (var service in contentServices)
57+
{
58+
if (service is FlatFileRedirectContentService) continue;
59+
60+
var pages = await service.GetPagesToGenerateAsync();
61+
foreach (var page in pages)
62+
{
63+
allPages.Add(page);
64+
65+
// Track non-folder output files so we don't generate colliding redirects
66+
if (!IsFolderBasedOutput(page.OutputFile))
67+
{
68+
existingFlatFiles.Add(NormalizePath(page.OutputFile.Value));
69+
}
70+
}
71+
}
72+
73+
var builder = ImmutableList.CreateBuilder<ContentToCreate>();
74+
var emittedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
75+
76+
foreach (var page in allPages)
77+
{
78+
if (!IsFolderBasedOutput(page.OutputFile)) continue;
79+
80+
var flatFilePath = GetFlatFilePath(page.OutputFile);
81+
if (flatFilePath is null) continue;
82+
83+
var normalizedFlatPath = NormalizePath(flatFilePath);
84+
85+
// Skip if another service already produces this file, or we already emitted it
86+
if (existingFlatFiles.Contains(normalizedFlatPath)) continue;
87+
if (!emittedPaths.Add(normalizedFlatPath)) continue;
88+
89+
var redirectUrl = GetRelativeRedirectUrl(page.OutputFile);
90+
var html = RedirectHelper.GetRedirectHtml(redirectUrl);
91+
var bytes = Encoding.UTF8.GetBytes(html);
92+
93+
builder.Add(new ContentToCreate(new FilePath(flatFilePath), bytes));
94+
}
95+
96+
return builder.ToImmutable();
97+
}
98+
99+
/// <summary>
100+
/// Determines whether an output file uses the folder-based pattern (ends with <c>index.html</c>).
101+
/// </summary>
102+
internal static bool IsFolderBasedOutput(FilePath outputFile)
103+
{
104+
var normalized = NormalizePath(outputFile.Value);
105+
return normalized.EndsWith($"/{IndexHtml}", StringComparison.OrdinalIgnoreCase)
106+
|| normalized.Equals(IndexHtml, StringComparison.OrdinalIgnoreCase);
107+
}
108+
109+
/// <summary>
110+
/// Converts a folder-based output path to a flat file path.
111+
/// Returns <c>null</c> for the root <c>index.html</c>.
112+
/// </summary>
113+
/// <example>
114+
/// <c>about/index.html</c> becomes <c>about.html</c>;
115+
/// <c>docs/intro/index.html</c> becomes <c>docs/intro.html</c>.
116+
/// </example>
117+
internal static string? GetFlatFilePath(FilePath outputFile)
118+
{
119+
var normalized = NormalizePath(outputFile.Value);
120+
121+
// Root index.html has no flat-file equivalent
122+
if (normalized.Equals(IndexHtml, StringComparison.OrdinalIgnoreCase))
123+
return null;
124+
125+
// Strip /index.html suffix and append .html
126+
var folder = normalized[..^(IndexHtml.Length + 1)]; // remove "/index.html"
127+
return $"{folder}.html";
128+
}
129+
130+
/// <summary>
131+
/// Computes the relative redirect URL for a folder-based output file.
132+
/// Uses the last path segment with a trailing slash so the redirect works
133+
/// regardless of the site's base URL.
134+
/// </summary>
135+
/// <example>
136+
/// For <c>about/index.html</c>, returns <c>about/</c>.
137+
/// For <c>docs/intro/index.html</c>, returns <c>intro/</c>.
138+
/// </example>
139+
internal static string GetRelativeRedirectUrl(FilePath outputFile)
140+
{
141+
var normalized = NormalizePath(outputFile.Value);
142+
143+
// Strip /index.html to get the folder path
144+
var folder = normalized[..^(IndexHtml.Length + 1)];
145+
146+
// Take the last segment as the relative redirect target
147+
var lastSlash = folder.LastIndexOf('/');
148+
var segment = lastSlash >= 0 ? folder[(lastSlash + 1)..] : folder;
149+
150+
return $"{segment}/";
151+
}
152+
153+
private static string NormalizePath(string path) =>
154+
path.Replace('\\', '/').TrimStart('/');
155+
}

0 commit comments

Comments
 (0)