diff --git a/Sources/Publish/Internal/HTMLGenerator.swift b/Sources/Publish/Internal/HTMLGenerator.swift index 411bf428..8683c722 100644 --- a/Sources/Publish/Internal/HTMLGenerator.swift +++ b/Sources/Publish/Internal/HTMLGenerator.swift @@ -15,32 +15,32 @@ internal struct HTMLGenerator { let context: PublishingContext func generate() async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { try await copyThemeResources() } - group.addTask { try generateIndexHTML() } - group.addTask { try await generateSectionHTML() } - group.addTask { try await generatePageHTML() } - group.addTask { try await generateTagHTMLIfNeeded() } - - // Throw any errors generated by the above set of operations: - for try await _ in group {} + try await withThrowingTaskGroup(of: (substep: String, paths: [Path]).self) { group in + group.addTask { (substep: "Copy theme resources", paths: try await copyThemeResources()) } + group.addTask { (substep: "Generate index", paths: try generateIndexHTML()) } + group.addTask { (substep: "Generate sections", paths: try await generateSectionHTML()) } + group.addTask { (substep: "Generate pages", paths: try await generatePageHTML()) } + group.addTask { (substep: "Generate tags", paths: try await generateTagHTMLIfNeeded()) } + + try await validate(group) } } } private extension HTMLGenerator { - func copyThemeResources() async throws { + func copyThemeResources() async throws -> [Path] { guard !theme.resourcePaths.isEmpty else { - return + return [] } let creationFile = try File(path: theme.creationPath.string) let packageFolder = try creationFile.resolveSwiftPackageFolder() - try await theme.resourcePaths.concurrentForEach { path in + return try await theme.resourcePaths.concurrentMap { path -> Path in do { let file = try packageFolder.file(at: path.string) try context.copyFileToOutput(file, targetFolderPath: nil) + return path } catch { throw PublishingError( path: path, @@ -51,22 +51,27 @@ private extension HTMLGenerator { } } - func generateIndexHTML() throws { + func generateIndexHTML() throws -> [Path] { let html = try theme.makeIndexHTML(context.index, context) - let indexFile = try context.createOutputFile(at: "index.html") + let path = Path("index.html") + let indexFile = try context.createOutputFile(at: path) try indexFile.write(html.render(indentedBy: indentation)) + return [path] } - func generateSectionHTML() async throws { - try await context.sections.concurrentForEach { section in - try outputHTML( + func generateSectionHTML() async throws -> [Path] { + try await context.sections.concurrentFlatMap { section -> [Path] in + var allPaths = [Path]() + + let sectionPath = try outputHTML( for: section, indentedBy: indentation, using: theme.makeSectionHTML, fileMode: .foldersAndIndexFiles ) - - try await section.items.concurrentForEach { item in + allPaths.append(sectionPath) + + let sectionItemPaths = try await section.items.concurrentMap { item -> Path in try outputHTML( for: item, indentedBy: indentation, @@ -74,11 +79,14 @@ private extension HTMLGenerator { fileMode: fileMode ) } + + allPaths.append(contentsOf: sectionItemPaths) + return allPaths } } - func generatePageHTML() async throws { - try await context.pages.values.concurrentForEach { page in + func generatePageHTML() async throws -> [Path] { + try await context.pages.values.concurrentMap { page -> Path in try outputHTML( for: page, indentedBy: indentation, @@ -88,9 +96,9 @@ private extension HTMLGenerator { } } - func generateTagHTMLIfNeeded() async throws { + func generateTagHTMLIfNeeded() async throws -> [Path] { guard let config = context.site.tagHTMLConfig else { - return + return [] } let listPage = TagListPage( @@ -99,13 +107,15 @@ private extension HTMLGenerator { content: config.listContent ?? .init() ) + var allPaths = [Path]() if let listHTML = try theme.makeTagListHTML(listPage, context) { let listPath = Path("\(config.basePath)/index.html") let listFile = try context.createOutputFile(at: listPath) try listFile.write(listHTML.render(indentedBy: indentation)) + allPaths.append(listPath) } - try await context.allTags.concurrentForEach { tag in + let tagPaths: [Path] = try await context.allTags.concurrentCompactMap { tag -> Path? in let detailsPath = context.site.path(for: tag) let detailsContent = config.detailsContentResolver(tag) @@ -116,16 +126,35 @@ private extension HTMLGenerator { ) guard let detailsHTML = try theme.makeTagDetailsHTML(detailsPage, context) else { - return + return nil } - try outputHTML( + return try outputHTML( for: detailsPage, indentedBy: indentation, using: { _, _ in detailsHTML }, fileMode: fileMode ) } + + allPaths.append(contentsOf: tagPaths) + return allPaths + } + + func validate(_ group: ThrowingTaskGroup<(substep: String, paths: [Path]), Error>) async throws { + var pathSubsteps = [Path: [String]]() + for try await substepAndPaths in group { + for path in substepAndPaths.paths { + if let previousSubsteps = pathSubsteps[path] { + let substeps = previousSubsteps.appending(substepAndPaths.substep) + throw PublishingError( + path: path, + infoMessage: "Path conflict in substeps: \(substeps)" + ) + } + pathSubsteps[path, default: []].append(substepAndPaths.substep) + } + } } func outputHTML( @@ -133,11 +162,12 @@ private extension HTMLGenerator { indentedBy indentation: Indentation.Kind?, using generator: (T, PublishingContext) throws -> HTML, fileMode: HTMLFileMode - ) throws { + ) throws -> Path { let html = try generator(location, context) let path = filePath(for: location, fileMode: fileMode) let file = try context.createOutputFile(at: path) try file.write(html.render(indentedBy: indentation)) + return path } func filePath(for location: Location, fileMode: HTMLFileMode) -> Path { diff --git a/Tests/PublishTests/Tests/HTMLGenerationTests.swift b/Tests/PublishTests/Tests/HTMLGenerationTests.swift index 2afca997..6065ea09 100644 --- a/Tests/PublishTests/Tests/HTMLGenerationTests.swift +++ b/Tests/PublishTests/Tests/HTMLGenerationTests.swift @@ -282,6 +282,31 @@ final class HTMLGenerationTests: PublishTestCase { ) } + func testGeneratingConflictingFilesThrowsError() throws { + let folder = try Folder.createTemporary() + + var thrownError: PublishingError? + do { + try publishWebsite( + in: folder, + using: [ + .addMarkdownFiles(), + .generateHTML(withTheme: .foundation) + ], + content: [ + // This file has the same name as the `WebsiteStub.SectionID.one` case, which + // causes multiple outputs at the same location. + "one.md": "# One content", + ] + ) + } catch { + thrownError = error as? PublishingError + } + + let path = try require(thrownError?.path) + XCTAssertEqual(path, "one/index.html") + } + func testFoundationTheme() throws { let folder = try Folder.createTemporary()