Skip to content

Commit 5c16e84

Browse files
committed
Added support for using relative paths in websites, when needed.
1 parent 57dacdd commit 5c16e84

File tree

8 files changed

+397
-11
lines changed

8 files changed

+397
-11
lines changed

Sources/Ignite/Elements/Image.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ private extension Image {
199199
}
200200

201201
/// Creates a `srcset` string from image variants with their corresponding pixel density descriptors,
202-
/// e.g., `"/images/hero@2x.jpg 2x"`
202+
/// e.g., `"/images/hero@2x.jpg 2x"` or `"images/hero@2x.jpg 2x"` when `useRelativePaths` is enabled.
203203
/// - Parameter variants: An array of image variant URLs
204204
/// - Returns: An HTML attribute containing the srcset value, or nil if no valid variants exist
205205
func generateSourceSet(_ variants: [URL]) -> Attribute? {
@@ -210,7 +210,8 @@ private extension Image {
210210
let densityDescriptor = getDensityDescriptor(filename).map { " \($0)" } ?? ""
211211
let relativePath = variant.path.replacingOccurrences(of: assetsDirectory.path, with: "")
212212
let webPath = relativePath.split(separator: "/").joined(separator: "/")
213-
return "/\(webPath)\(densityDescriptor)"
213+
let resolvedPath = publishingContext.assetPath("/\(webPath)")
214+
return "\(resolvedPath)\(densityDescriptor)"
214215
}.joined(separator: ", ")
215216

216217
return sources.isEmpty ? nil : .init(name: "srcset", value: sources)

Sources/Ignite/Elements/MetaLink.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,14 @@ public struct MetaLink: HeadElement, Sendable {
9797
/// Renders this element using publishing context passed in.
9898
/// - Returns: The HTML for this element.
9999
///
100-
/// If the link `href` starts with a `\` it is an asset and requires any `subsite` prepended;
101-
/// otherwise the `href` is a URL and doesn't get `subsite` prepended
100+
/// If the link `href` starts with a `/` it is a local asset and requires any subsite
101+
/// path prepended; otherwise the `href` is treated as-is (e.g., external URLs).
102+
/// The path respects the site's `useRelativePaths` setting.
102103
public func markup() -> Markup {
103104
var attributes = attributes
104-
// char[0] of the link 'href' is '/' for an asset; not for a site URL
105-
let basePath = href.starts(with: "/") ? publishingContext.site.url.path : ""
105+
let resolvedPath = publishingContext.assetPath(href)
106106
attributes.append(customAttributes:
107-
.init(name: "href", value: "\(basePath)\(href)"),
107+
.init(name: "href", value: resolvedPath),
108108
.init(name: "rel", value: rel))
109109

110110
return Markup("<link\(attributes) />")

Sources/Ignite/Framework/Site.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ public protocol Site: Sendable {
142142
/// The path to the favicon
143143
var favicon: URL? { get }
144144

145+
/// When `true`, generates asset paths without leading slashes
146+
/// (e.g., `css/bootstrap.min.css` instead of `/css/bootstrap.min.css`).
147+
///
148+
/// Use this for single-page sites that need to work when opened directly
149+
/// from the filesystem without a web server. Defaults to `false`.
150+
var useRelativePaths: Bool { get }
151+
145152
/// An array of all the static pages you want to include in your site.
146153
@StaticPageBuilder var staticPages: [any StaticPage] { get }
147154

@@ -221,9 +228,12 @@ public extension Site {
221228
/// An empty error page by default, which triggers no error pages being generated.
222229
var errorPage: EmptyErrorPage { EmptyErrorPage() }
223230

224-
/// The default favicon being nil
231+
/// The default favicon being nil.
225232
var favicon: URL? { nil }
226233

234+
/// Default to absolute paths for all local URLs.
235+
var useRelativePaths: Bool { false }
236+
227237
/// The syntax highlighting themes from every site theme.
228238
internal var allHighlighterThemes: OrderedSet<HighlighterTheme> {
229239
var themes = OrderedSet<HighlighterTheme>()

Sources/Ignite/Publishing/PublishingContext.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,14 +169,37 @@ final class PublishingContext {
169169
/// Converts a URL to a site-relative path string.
170170
/// - Parameter url: The URL to convert.
171171
/// - Returns: A string path, either preserving remote URLs or
172-
/// making local URLs relative to the site root.
172+
/// making local URLs relative to the site root. When `site.useRelativePaths`
173+
/// is true, local paths will have their leading slash removed.
173174
func path(for url: URL) -> String {
174175
let path = url.relativeString
175-
return if url.isFileURL {
176+
var result = if url.isFileURL {
176177
site.url.appending(path: path).decodedPath
177178
} else {
178179
path
179180
}
181+
182+
if site.useRelativePaths, result.hasPrefix("/") {
183+
result = String(result.dropFirst())
184+
}
185+
186+
return result
187+
}
188+
189+
/// Converts a path string to a site-relative path, prepending the site's
190+
/// subpath if needed and respecting the `useRelativePaths` setting.
191+
/// - Parameter path: A path string, typically starting with "/" for local assets.
192+
/// - Returns: The resolved path. For subsites, includes the subsite path prefix.
193+
/// When `useRelativePaths` is true, the leading slash is removed.
194+
func assetPath(_ path: String) -> String {
195+
let basePath = path.hasPrefix("/") ? site.url.path : ""
196+
var fullPath = "\(basePath)\(path)"
197+
198+
if site.useRelativePaths, fullPath.hasPrefix("/") {
199+
fullPath = String(fullPath.dropFirst())
200+
}
201+
202+
return fullPath
180203
}
181204

182205
/// Adds a warning during a site build.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//
2+
// IgniteRelativePathsTestSuite.swift
3+
// Ignite
4+
// https://www.github.com/twostraws/Ignite
5+
// See LICENSE for license information.
6+
//
7+
8+
import Foundation
9+
@testable import Ignite
10+
11+
/// A base class for Ignite tests that use `TestRelativePathsSite` with relative paths enabled.
12+
@MainActor
13+
class IgniteRelativePathsTestSuite {
14+
let site: any Site = TestRelativePathsSite()
15+
16+
var publishingContext: PublishingContext {
17+
PublishingContext.shared
18+
}
19+
20+
/// Creates a new test instance and initializes the publishing context for `TestRelativePathsSite`.
21+
init() throws {
22+
try PublishingContext.initialize(for: TestRelativePathsSite(), from: #filePath)
23+
}
24+
}
25+
26+
/// A base class for Ignite tests using `TestRelativePathsSubsite` (relative paths with subsite).
27+
@MainActor
28+
class IgniteRelativePathsSubsiteTestSuite {
29+
let site: any Site = TestRelativePathsSubsite()
30+
31+
var publishingContext: PublishingContext {
32+
PublishingContext.shared
33+
}
34+
35+
/// Creates a new test instance and initializes the publishing context for `TestRelativePathsSubsite`.
36+
init() throws {
37+
try PublishingContext.initialize(for: TestRelativePathsSubsite(), from: #filePath)
38+
}
39+
40+
/// Resets the publishing context when the test is deallocated.
41+
deinit {
42+
Task { @MainActor in
43+
try? PublishingContext.initialize(for: TestSite(), from: #filePath)
44+
}
45+
}
46+
}

0 commit comments

Comments
 (0)