Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/Ignite/Elements/Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ public struct Link: InlineElement, NavigationItem, DropdownItem {
return Markup()
}

let path = publishingContext.path(for: url)
let path = publishingContext.linkPath(for: url)
linkAttributes.append(customAttributes: .init(name: "href", value: path))
let contentHTML = content.markupString()
return Markup("<a\(linkAttributes)>\(contentHTML)</a>")
Expand Down
2 changes: 1 addition & 1 deletion Sources/Ignite/Elements/LinkGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public struct LinkGroup: HTML {
return Markup()
}

let path = publishingContext.path(for: url)
let path = publishingContext.linkPath(for: url)
linkAttributes.append(customAttributes: .init(name: "href", value: path))
let contentHTML = content.markupString()
return Markup("<a\(linkAttributes)>\(contentHTML)</a>")
Expand Down
8 changes: 8 additions & 0 deletions Sources/Ignite/Elements/PlainDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ public struct PlainDocument: Document {
var attributes = attributes
attributes.append(customAttributes: .init(name: "lang", value: language.rawValue))

let site = PublishingContext.shared.site
if let lightTheme = site.lightTheme {
attributes.append(customAttributes: .init(name: "data-light-theme", value: lightTheme.cssID))
}
if let darkTheme = site.darkTheme {
attributes.append(customAttributes: .init(name: "data-dark-theme", value: darkTheme.cssID))
}

let bodyMarkup = body.markup()
// Deferred head rendering to accommodate for context updates during body rendering
let headMarkup = head.markup()
Expand Down
2 changes: 2 additions & 0 deletions Sources/Ignite/Framework/EmptyTagPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

/// A default tag page that does nothing; used to disable tag pages entirely.
public struct EmptyTagPage: TagPage {
public init() {}

public var body: some BodyElement {
EmptyHTML()
}
Expand Down
9 changes: 8 additions & 1 deletion Sources/Ignite/Framework/FeedConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ public struct FeedConfiguration: Sendable {
/// feed readers.
var image: FeedImage?

/// An optional list of content types to include in the feed.
/// When nil, all content types are included.
var contentTypes: [String]?

/// A safe default feed configuration: 20 items, description only, path at /feed.rss.
static let `default` = FeedConfiguration(mode: .descriptionOnly, contentCount: 20, path: "/feed.rss")

Expand All @@ -68,11 +72,14 @@ public struct FeedConfiguration: Sendable {
/// - path: The path where the RSS feed should be accessible, default to /feed.rss
/// - image: An optional image used to customize your feed's
/// appearance in feed readers.
public init?(mode: ContentMode, contentCount: Int, path: String = "/feed.rss", image: FeedImage? = nil) {
/// - contentTypes: An optional list of content types to include
/// (e.g. ["letters"]). When nil, all types are included.
public init?(mode: ContentMode, contentCount: Int, path: String = "/feed.rss", image: FeedImage? = nil, contentTypes: [String]? = nil) {
guard contentCount > 0 else { return nil }
self.mode = mode
self.contentCount = contentCount
self.path = path
self.image = image
self.contentTypes = contentTypes
}
}
15 changes: 12 additions & 3 deletions Sources/Ignite/Publishing/FeedGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,23 @@ struct FeedGenerator {
return result
}

private func xmlEscape(_ string: String) -> String {
string
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}

private func generateContentXML() -> String {
content
.prefix(feedConfig.contentCount)
.map { item in
var itemXML = """
<item>\
<guid isPermaLink="true">\(item.path(in: site))</guid>\
<title>\(item.title)</title>\
<title>\(xmlEscape(item.title))</title>\
<link>\(item.path(in: site))</link>\
<description><![CDATA[\(item.description)]]></description>\
<pubDate>\(item.date.asRFC822(timeZone: site.timeZone))</pubDate>
Expand Down Expand Up @@ -86,8 +95,8 @@ struct FeedGenerator {
xmlns:atom="http://www.w3.org/2005/Atom" \
xmlns:content="http://purl.org/rss/1.0/modules/content/">\
<channel>\
<title>\(site.name)</title>\
<description>\(site.description ?? "")</description>\
<title>\(xmlEscape(site.name))</title>\
<description>\(xmlEscape(site.description ?? ""))</description>\
<link>\(site.url.absoluteString)</link>\
<atom:link
href="\(site.url.appending(path: feedConfig.path).absoluteString)"
Expand Down
52 changes: 48 additions & 4 deletions Sources/Ignite/Publishing/PublishingContext-Copying.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,45 @@
import Foundation

extension PublishingContext {
/// Recursively copies or merges contents from a source directory to a destination directory.
/// - For files: replaces existing files or creates new ones
/// - For directories: creates if needed, then recursively merges contents
/// - Parameters:
/// - source: The source URL to copy from
/// - destination: The destination URL to copy to
private func mergeContents(from source: URL, to destination: URL) throws {
let fileManager = FileManager.default

var isDirectory: ObjCBool = false
let sourceExists = fileManager.fileExists(atPath: source.path, isDirectory: &isDirectory)

guard sourceExists else { return }

if isDirectory.boolValue {
// Source is a directory - create destination directory and merge contents
try fileManager.createDirectory(at: destination, withIntermediateDirectories: true)

let contents = try fileManager.contentsOfDirectory(
at: source,
includingPropertiesForKeys: nil
)

for item in contents {
let itemName = item.lastPathComponent
try mergeContents(
from: source.appending(path: itemName),
to: destination.appending(path: itemName)
)
}
} else {
// Source is a file - remove existing and copy
if fileManager.fileExists(atPath: destination.path) {
try fileManager.removeItem(at: destination)
}
try fileManager.copyItem(at: source, to: destination)
}
}

/// Copies one file from the Ignite resources into the final build folder.
/// - Parameters resource: The resource to copy.
func copy(resource: String) {
Expand All @@ -23,13 +62,17 @@ extension PublishingContext {

do {
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
if FileManager.default.fileExists(atPath: destinationFile.path) {
try FileManager.default.removeItem(at: destinationFile)
}
try FileManager.default.copyItem(at: sourceURL, to: destinationFile)
} catch {
fatalError(.failedToCopySiteResource(resource))
}
}

/// Copies all files from the project's "Assets" directory to the build output's root directory.
/// Merges with any existing directories (e.g., css/, fonts/) that Ignite may have already created.
func copyAssets() {
guard FileManager.default.fileExists(atPath: assetsDirectory.decodedPath) else {
return
Expand All @@ -42,8 +85,8 @@ extension PublishingContext {
)

for asset in assets {
try FileManager.default.copyItem(
at: assetsDirectory.appending(path: asset.lastPathComponent),
try mergeContents(
from: assetsDirectory.appending(path: asset.lastPathComponent),
to: buildDirectory.appending(path: asset.lastPathComponent)
)
}
Expand All @@ -53,6 +96,7 @@ extension PublishingContext {
}

/// Copies custom font files from the project's "Fonts" directory to the build output's "fonts" directory.
/// Merges with any existing fonts that Ignite may have already created.
func copyFonts() {
guard FileManager.default.fileExists(atPath: fontsDirectory.decodedPath) else {
return
Expand All @@ -68,8 +112,8 @@ extension PublishingContext {
try FileManager.default.createDirectory(at: buildFontsDirectory, withIntermediateDirectories: true)

for font in fonts {
try FileManager.default.copyItem(
at: fontsDirectory.appending(path: font.lastPathComponent),
try mergeContents(
from: fontsDirectory.appending(path: font.lastPathComponent),
to: buildFontsDirectory.appending(path: font.lastPathComponent)
)
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/Ignite/Publishing/PublishingContext-Generators.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ extension PublishingContext {
public func generateFeed() {
guard let feedConfig = site.feedConfiguration else { return }

let content = allContent.sorted(
var filtered = allContent
if let types = feedConfig.contentTypes {
filtered = filtered.filter { types.contains($0.type) }
}

let content = filtered.sorted(
by: \.date,
order: .reverse
)
Expand Down
19 changes: 19 additions & 0 deletions Sources/Ignite/Publishing/PublishingContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,25 @@ final class PublishingContext {
return result
}

/// Returns a path with a trailing slash appended for local page URLs.
/// Static hosts serve directory-style pages (path/index.html) at path/,
/// so links should use the canonical form to avoid 301 redirects.
func linkPath(for url: URL) -> String {
var result = path(for: url)

let isExternal = url.scheme == "http" || url.scheme == "https" || url.scheme == "mailto"
if !isExternal,
!result.hasSuffix("/"),
!result.hasPrefix("#") {
let lastComponent = result.split(separator: "/").last.map(String.init) ?? ""
if !lastComponent.contains(".") {
result += "/"
}
}

return result
}

/// Converts a path string to a site-relative path, prepending the site's
/// subpath if needed and respecting the `useRelativePaths` setting.
/// - Parameter path: A path string, typically starting with "/" for local assets.
Expand Down
3 changes: 2 additions & 1 deletion Sources/Ignite/Publishing/SiteMapGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ struct SiteMapGenerator {

func generateSiteMap() -> String {
let locations = context.siteMap.map {
"<url><loc>\(context.site.url.absoluteString)\($0.path)</loc><priority>\($0.priority)</priority></url>"
let path = $0.path.hasSuffix("/") ? $0.path : $0.path + "/"
return "<url><loc>\(context.site.url.absoluteString)\(path)</loc><priority>\($0.priority)</priority></url>"
}.joined()

return """
Expand Down
4 changes: 2 additions & 2 deletions Tests/IgniteTesting/Elements/ArticlePreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ class ArticlePreviewTests: IgniteTestSuite {
#expect(output.contains(#"href="/articles/testing-ignite/""#))
#expect(output.contains("Testing Ignite"))
#expect(output.contains(#"class="mb-0 card-text""#))
#expect(output.contains(#"href="/tags/swift""#))
#expect(output.contains(#"href="/tags/web-dev""#))
#expect(output.contains(#"href="/tags/swift/""#))
#expect(output.contains(#"href="/tags/web-dev/""#))
#expect(output.contains(#"rel="tag""#))
#expect(output.contains(#"class="badge text-bg-primary rounded-pill""#))
#expect(output.contains(#"style="margin-top: -5px""#))
Expand Down
24 changes: 24 additions & 0 deletions Tests/IgniteTesting/Elements/HTMLDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,30 @@ class HTMLDocumentTests: IgniteTestSuite {
#expect(language == "en")
}

@Test("html tag includes data-light-theme attribute matching site light theme cssID")
func html_includes_data_light_theme_attribute() throws {
let sut = PlainDocument(head: Head(), body: Body())
let output = sut.markupString()

let lightTheme = try #require(output.htmlTagWithCloseTag("html")?.attributes
.htmlAttribute(named: "data-light-theme")
)

#expect(lightTheme == "light")
}

@Test("html tag includes data-dark-theme attribute matching site dark theme cssID")
func html_includes_data_dark_theme_attribute() throws {
let sut = PlainDocument(head: Head(), body: Body())
let output = sut.markupString()

let darkTheme = try #require(output.htmlTagWithCloseTag("html")?.attributes
.htmlAttribute(named: "data-dark-theme")
)

#expect(darkTheme == "dark")
}

@Test("lang attribute is taken from language property", arguments: Language.allCases)
func language_property_determines_lang_attribute(_ language: Language) throws {

Expand Down
10 changes: 5 additions & 5 deletions Tests/IgniteTesting/Elements/Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import Testing

let element = Link(link.description, target: link.target)
let output = element.markupString()
let expectedPath = PublishingContext.shared.path(for: URL(string: link.target)!)
let expectedPath = PublishingContext.shared.linkPath(for: URL(string: link.target)!)

#expect(output == "<a href=\"\(expectedPath)\">\(link.description)</a>")
}
Expand All @@ -34,7 +34,7 @@ import Testing
let element = Link("This is a test", target: page).linkStyle(.button)
let output = element.markupString()

let expectedPath = PublishingContext.shared.path(for: URL(string: page.path)!)
let expectedPath = PublishingContext.shared.linkPath(for: URL(string: page.path)!)

#expect(output == "<a href=\"\(expectedPath)\" class=\"btn btn-primary\">This is a test</a>")
}
Expand All @@ -49,7 +49,7 @@ import Testing
}
let output = element.markupString()

let expectedPath = PublishingContext.shared.path(for: URL(string: page.path)!)
let expectedPath = PublishingContext.shared.linkPath(for: URL(string: page.path)!)

#expect(output == "<a href=\"\(expectedPath)\" class=\"link-plain d-inline-block\">MORE <p>CONTENT</p></a>")
}
Expand All @@ -60,7 +60,7 @@ import Testing

let element = Link("Link with warning role.", target: page).role(.warning)
let output = element.markupString()
let expectedPath = PublishingContext.shared.path(for: URL(string: page.path)!)
let expectedPath = PublishingContext.shared.linkPath(for: URL(string: page.path)!)

#expect(output == "<a href=\"\(expectedPath)\" class=\"link-warning\">Link with warning role.</a>")
}
Expand All @@ -71,7 +71,7 @@ import Testing
Text("About Us")
}
let output = element.markupString()
#expect(output.contains("href=\"/about\""))
#expect(output.contains("href=\"/about/\""))
#expect(output.contains("About Us"))
#expect(output.contains("link-plain"))
}
Expand Down
6 changes: 3 additions & 3 deletions Tests/IgniteTesting/RelativePaths/RelativePathsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ struct RelativePathsTests {
let link = Link("Test", target: "/about")
let output = link.markupString()

#expect(output.contains("href=\"/about\""))
#expect(output.contains("href=\"/about/\""))
}

// MARK: - Relative Paths Behavior (useRelativePaths = true)
Expand Down Expand Up @@ -114,8 +114,8 @@ struct RelativePathsTests {
let link = Link("About", target: "/about")
let output = link.markupString()

#expect(output.contains("href=\"about\""))
#expect(!output.contains("href=\"/about\""))
#expect(output.contains("href=\"about/\""))
#expect(!output.contains("href=\"/about/\""))
}

// MARK: - Subsite Support
Expand Down
4 changes: 2 additions & 2 deletions Tests/IgniteTesting/Subsite/Subsite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class SubsiteTests: IgniteSubsiteTestSuite {
let page = TestSubsitePage()
let element = Link("This is a test", target: page).linkStyle(.button)
let output = element.markupString()
#expect(output == "<a href=\"\(page.path)\" class=\"btn btn-primary\">This is a test</a>")
#expect(output == "<a href=\"\(page.path)/\" class=\"btn btn-primary\">This is a test</a>")
}

@Test("Page Content Test")
Expand All @@ -90,6 +90,6 @@ class SubsiteTests: IgniteSubsiteTestSuite {
}
let output = element.markupString()

#expect(output == "<a href=\"\(page.path)\" class=\"link-plain d-inline-block\">MORE <p>CONTENT</p></a>")
#expect(output == "<a href=\"\(page.path)/\" class=\"link-plain d-inline-block\">MORE <p>CONTENT</p></a>")
}
}
Loading