mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-04-05 10:45:45 +00:00
Set file permissions explicitly so it works properly on Linux
This commit is contained in:
parent
56833f88e7
commit
2fe6bfc73f
14 changed files with 272 additions and 96 deletions
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// DirectoryCreating.swift
|
||||
// samhuri.net
|
||||
//
|
||||
// Created by Sami Samhuri on 2019-12-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol DirectoryCreating {
|
||||
func createDirectory(at url: URL) throws
|
||||
}
|
||||
|
||||
extension FileManager: DirectoryCreating {
|
||||
func createDirectory(at url: URL) throws {
|
||||
try createDirectory(at: url, withIntermediateDirectories: true, attributes: [.posixPermissions: FilePermissions.directoryDefault.rawValue])
|
||||
}
|
||||
}
|
||||
47
samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift
Normal file
47
samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// FilePermissions.swift
|
||||
// samhuri.net
|
||||
//
|
||||
// Created by Sami Samhuri on 2019-12-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FilePermissions: CustomStringConvertible {
|
||||
let user: Permissions
|
||||
let group: Permissions
|
||||
let other: Permissions
|
||||
|
||||
var description: String {
|
||||
[user, group, other].map { $0.description }.joined()
|
||||
}
|
||||
|
||||
static let `default`: FilePermissions = "rw-r--r--"
|
||||
static let directoryDefault: FilePermissions = "rwxr-xr-x"
|
||||
}
|
||||
|
||||
extension FilePermissions {
|
||||
init(string: String) {
|
||||
user = Permissions(string: String(string.prefix(3)))
|
||||
group = Permissions(string: String(string.dropFirst(3).prefix(3)))
|
||||
other = Permissions(string: String(string.dropFirst(6).prefix(3)))
|
||||
}
|
||||
}
|
||||
|
||||
extension FilePermissions: RawRepresentable {
|
||||
var rawValue: Int16 {
|
||||
user.rawValue << 6 | group.rawValue << 3 | other.rawValue
|
||||
}
|
||||
|
||||
init(rawValue: Int16) {
|
||||
user = Permissions(rawValue: rawValue >> 6 & 7)
|
||||
group = Permissions(rawValue: rawValue >> 3 & 7)
|
||||
other = Permissions(rawValue: rawValue >> 0 & 7)
|
||||
}
|
||||
}
|
||||
|
||||
extension FilePermissions: ExpressibleByStringLiteral {
|
||||
init(stringLiteral value: String) {
|
||||
self.init(string: value)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
//
|
||||
// FilePermissionsSetting.swift
|
||||
// samhuri.net
|
||||
//
|
||||
// Created by Sami Samhuri on 2019-12-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FilePermissionsSetting {
|
||||
func setPermissions(_ permissions: FilePermissions, ofItemAt fileURL: URL) throws
|
||||
}
|
||||
|
||||
extension FileManager: FilePermissionsSetting {
|
||||
func setPermissions(_ permissions: FilePermissions, ofItemAt fileURL: URL) throws {
|
||||
let attributes: [FileAttributeKey: Any] = [
|
||||
.posixPermissions: permissions.rawValue,
|
||||
]
|
||||
try setAttributes(attributes, ofItemAtPath: fileURL.path)
|
||||
}
|
||||
}
|
||||
35
samhuri.net/Sources/samhuri.net/Files/FileWriter.swift
Normal file
35
samhuri.net/Sources/samhuri.net/Files/FileWriter.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// FileWriter.swift
|
||||
// samhuri.net
|
||||
//
|
||||
// Created by Sami Samhuri on 2019-12-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// On Linux umask doesn't seem to be respected and files are written without
|
||||
/// group and other read permissions by default. This class explicitly sets
|
||||
/// permissions and then it works properly on macOS and Linux.
|
||||
final class FileWriter {
|
||||
typealias FileManager = DirectoryCreating & FilePermissionsSetting
|
||||
|
||||
let fileManager: FileManager
|
||||
|
||||
init(fileManager: FileManager = Foundation.FileManager.default) {
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
}
|
||||
|
||||
extension FileWriter: FileWriting {
|
||||
func write(data: Data, to fileURL: URL, permissions: FilePermissions) throws {
|
||||
try fileManager.createDirectory(at: fileURL.deletingLastPathComponent())
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
try fileManager.setPermissions(permissions, ofItemAt: fileURL)
|
||||
}
|
||||
|
||||
func write(string: String, to fileURL: URL, permissions: FilePermissions) throws {
|
||||
try fileManager.createDirectory(at: fileURL.deletingLastPathComponent())
|
||||
try string.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
try fileManager.setPermissions(permissions, ofItemAt: fileURL)
|
||||
}
|
||||
}
|
||||
26
samhuri.net/Sources/samhuri.net/Files/FileWriting.swift
Normal file
26
samhuri.net/Sources/samhuri.net/Files/FileWriting.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//
|
||||
// FileWriting.swift
|
||||
// samhuri.net
|
||||
//
|
||||
// Created by Sami Samhuri on 2019-12-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FileWriting {
|
||||
func write(data: Data, to fileURL: URL) throws
|
||||
func write(data: Data, to fileURL: URL, permissions: FilePermissions) throws
|
||||
|
||||
func write(string: String, to fileURL: URL) throws
|
||||
func write(string: String, to fileURL: URL, permissions: FilePermissions) throws
|
||||
}
|
||||
|
||||
extension FileWriting {
|
||||
func write(data: Data, to fileURL: URL) throws {
|
||||
try write(data: data, to: fileURL, permissions: .default)
|
||||
}
|
||||
|
||||
func write(string: String, to fileURL: URL) throws {
|
||||
try write(string: string, to: fileURL, permissions: .default)
|
||||
}
|
||||
}
|
||||
49
samhuri.net/Sources/samhuri.net/Files/Permissions.swift
Normal file
49
samhuri.net/Sources/samhuri.net/Files/Permissions.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//
|
||||
// Permissions.swift
|
||||
// samhuri.net
|
||||
//
|
||||
// Created by Sami Samhuri on 2019-12-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Permissions: OptionSet {
|
||||
let rawValue: Int16
|
||||
|
||||
static let execute = Permissions(rawValue: 1 << 0)
|
||||
static let write = Permissions(rawValue: 1 << 1)
|
||||
static let read = Permissions(rawValue: 1 << 2)
|
||||
|
||||
init(rawValue: Int16) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
init(string: String) {
|
||||
self.init(rawValue: 0)
|
||||
if string[string.startIndex] == "r" {
|
||||
insert(.read)
|
||||
}
|
||||
if string[string.index(string.startIndex, offsetBy: 1)] == "w" {
|
||||
insert(.write)
|
||||
}
|
||||
if string[string.index(string.startIndex, offsetBy: 2)] == "x" {
|
||||
insert(.execute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Permissions: CustomStringConvertible {
|
||||
var description: String {
|
||||
[
|
||||
contains(.read) ? "r" : "-",
|
||||
contains(.write) ? "w" : "-",
|
||||
contains(.execute) ? "x" : "-",
|
||||
].joined()
|
||||
}
|
||||
}
|
||||
|
||||
extension Permissions: ExpressibleByStringLiteral {
|
||||
init(stringLiteral value: String) {
|
||||
self.init(string: value)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,11 +10,13 @@ import Ink
|
|||
|
||||
final class MarkdownRenderer: Renderer {
|
||||
let fileManager: FileManager = .default
|
||||
let fileWriter: FileWriting
|
||||
let markdownParser = MarkdownParser()
|
||||
let pageRenderer: MarkdownPageRenderer
|
||||
|
||||
init(pageRenderer: MarkdownPageRenderer) {
|
||||
init(pageRenderer: MarkdownPageRenderer, fileWriter: FileWriting = FileWriter()) {
|
||||
self.pageRenderer = pageRenderer
|
||||
self.fileWriter = fileWriter
|
||||
}
|
||||
|
||||
func canRenderFile(named filename: String, withExtension ext: String) -> Bool {
|
||||
|
|
@ -37,8 +39,7 @@ final class MarkdownRenderer: Renderer {
|
|||
htmlPath = mdFilename.replacingOccurrences(of: ".md", with: "/index.html")
|
||||
}
|
||||
let htmlURL = targetDir.appendingPathComponent(htmlPath)
|
||||
try fileManager.createDirectory(at: htmlURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
||||
try pageHTML.write(to: htmlURL, atomically: true, encoding: .utf8)
|
||||
try fileWriter.write(string: pageHTML, to: htmlURL)
|
||||
}
|
||||
|
||||
func markdownMetadata(from url: URL) throws -> [String: String] {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,54 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
final class JSONFeedWriter {
|
||||
let fileWriter: FileWriting
|
||||
let jsonFeed: JSONFeed
|
||||
|
||||
init(jsonFeed: JSONFeed, fileWriter: FileWriting = FileWriter()) {
|
||||
self.jsonFeed = jsonFeed
|
||||
self.fileWriter = fileWriter
|
||||
}
|
||||
|
||||
func writeFeed(_ posts: [Post], for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws {
|
||||
let feed = Feed(
|
||||
title: site.title,
|
||||
home_page_url: site.url.absoluteString,
|
||||
feed_url: site.url.appendingPathComponent(jsonFeed.path).absoluteString,
|
||||
author: FeedAuthor(
|
||||
name: site.author,
|
||||
avatar: jsonFeed.avatarPath.map(site.url.appendingPathComponent)?.absoluteString,
|
||||
url: site.url.absoluteString
|
||||
),
|
||||
icon: jsonFeed.iconPath.map(site.url.appendingPathComponent)?.absoluteString,
|
||||
favicon: jsonFeed.faviconPath.map(site.url.appendingPathComponent)?.absoluteString,
|
||||
items: try posts.map { post in
|
||||
let url = site.url.appendingPathComponent(post.path)
|
||||
return FeedItem(
|
||||
title: post.isLink ? "→ \(post.title)" : post.title,
|
||||
date_published: post.date,
|
||||
id: url.absoluteString,
|
||||
url: url.absoluteString,
|
||||
external_url: post.link?.absoluteString,
|
||||
author: FeedAuthor(name: post.author, avatar: nil, url: nil),
|
||||
content_html: try templateRenderer.renderFeedPost(post, site: site, assets: .none()),
|
||||
tags: post.tags
|
||||
)
|
||||
}
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
#if os(Linux)
|
||||
encoder.outputFormatting = [.prettyPrinted]
|
||||
#else
|
||||
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
|
||||
#endif
|
||||
let feedJSON = try encoder.encode(feed)
|
||||
let feedURL = targetURL.appendingPathComponent(jsonFeed.path)
|
||||
try fileWriter.write(data: feedJSON, to: feedURL)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Feed: Codable {
|
||||
let version = "https://jsonfeed.org/version/1"
|
||||
let title: String
|
||||
|
|
@ -34,49 +82,3 @@ private struct FeedItem: Codable {
|
|||
let content_html: String
|
||||
let tags: [String]
|
||||
}
|
||||
|
||||
final class JSONFeedWriter {
|
||||
let fileManager: FileManager
|
||||
let jsonFeed: JSONFeed
|
||||
|
||||
init(fileManager: FileManager = .default, feed: JSONFeed) {
|
||||
self.fileManager = fileManager
|
||||
self.jsonFeed = feed
|
||||
}
|
||||
|
||||
func writeFeed(_ posts: [Post], for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws {
|
||||
let items: [FeedItem] = try posts.map { post in
|
||||
let url = site.url.appendingPathComponent(post.path)
|
||||
return FeedItem(
|
||||
title: post.isLink ? "→ \(post.title)" : post.title,
|
||||
date_published: post.date,
|
||||
id: url.absoluteString,
|
||||
url: url.absoluteString,
|
||||
external_url: post.link?.absoluteString,
|
||||
author: FeedAuthor(name: post.author, avatar: nil, url: nil),
|
||||
content_html: try templateRenderer.renderFeedPost(post, site: site, assets: .none()),
|
||||
tags: post.tags
|
||||
)
|
||||
}
|
||||
let avatar = jsonFeed.avatarPath.map(site.url.appendingPathComponent)
|
||||
let feed: Feed = Feed(
|
||||
title: site.title,
|
||||
home_page_url: site.url.absoluteString,
|
||||
feed_url: site.url.appendingPathComponent(jsonFeed.path).absoluteString,
|
||||
author: FeedAuthor(name: site.author, avatar: avatar?.absoluteString, url: site.url.absoluteString),
|
||||
icon: jsonFeed.iconPath.map(site.url.appendingPathComponent)?.absoluteString,
|
||||
favicon: jsonFeed.faviconPath.map(site.url.appendingPathComponent)?.absoluteString,
|
||||
items: items
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
#if os(Linux)
|
||||
encoder.outputFormatting = [.prettyPrinted]
|
||||
#else
|
||||
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
|
||||
#endif
|
||||
let feedJSON = try encoder.encode(feed)
|
||||
let feedURL = targetURL.appendingPathComponent(jsonFeed.path)
|
||||
try feedJSON.write(to: feedURL, options: [.atomic])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,18 +8,18 @@
|
|||
import Foundation
|
||||
|
||||
final class RSSFeedWriter {
|
||||
let fileManager: FileManager
|
||||
let feed: RSSFeed
|
||||
let fileWriter: FileWriting
|
||||
let rssFeed: RSSFeed
|
||||
|
||||
init(fileManager: FileManager = .default, feed: RSSFeed) {
|
||||
self.fileManager = fileManager
|
||||
self.feed = feed
|
||||
init(rssFeed: RSSFeed, fileWriter: FileWriting = FileWriter()) {
|
||||
self.rssFeed = rssFeed
|
||||
self.fileWriter = fileWriter
|
||||
}
|
||||
|
||||
func writeFeed(_ posts: [Post], for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws {
|
||||
let feedURL = site.url.appendingPathComponent(feed.path)
|
||||
let feedURL = site.url.appendingPathComponent(rssFeed.path)
|
||||
let feedXML = try templateRenderer.renderRSSFeed(posts: posts, feedURL: feedURL, site: site, assets: .none())
|
||||
let feedFileURL = targetURL.appendingPathComponent(feed.path)
|
||||
try feedXML.write(to: feedFileURL, atomically: true, encoding: .utf8)
|
||||
let feedFileURL = targetURL.appendingPathComponent(rssFeed.path)
|
||||
try fileWriter.write(string: feedXML, to: feedFileURL)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
//
|
||||
// XMLEscape.swift
|
||||
// samhuri.net
|
||||
//
|
||||
// Created by Sami Samhuri on 2019-12-12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
@available(*, deprecated)
|
||||
func escapedForXML() -> String {
|
||||
replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,11 @@
|
|||
import Foundation
|
||||
|
||||
final class PostWriter {
|
||||
let fileManager: FileManager
|
||||
let fileWriter: FileWriting
|
||||
let outputPath: String
|
||||
|
||||
init(fileManager: FileManager = .default, outputPath: String = "posts") {
|
||||
self.fileManager = fileManager
|
||||
init(outputPath: String = "posts", fileWriter: FileWriting = FileWriter()) {
|
||||
self.fileWriter = fileWriter
|
||||
self.outputPath = outputPath
|
||||
}
|
||||
}
|
||||
|
|
@ -26,8 +26,7 @@ extension PostWriter {
|
|||
let postURL = targetURL
|
||||
.appendingPathComponent(outputPath)
|
||||
.appendingPathComponent(filePath(date: post.date, slug: post.slug))
|
||||
try fileManager.createDirectory(at: postURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
||||
try postHTML.write(to: postURL, atomically: true, encoding: .utf8)
|
||||
try fileWriter.write(string: postHTML, to: postURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,8 +41,7 @@ extension PostWriter {
|
|||
func writeRecentPosts(_ recentPosts: [Post], for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws {
|
||||
let recentPostsHTML = try templateRenderer.renderRecentPosts(recentPosts, site: site, assets: .none())
|
||||
let fileURL = targetURL.appendingPathComponent("index.html")
|
||||
try fileManager.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
||||
try recentPostsHTML.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
try fileWriter.write(string: recentPostsHTML, to: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,8 +51,7 @@ extension PostWriter {
|
|||
func writeArchive(posts: PostsByYear, for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws {
|
||||
let archiveHTML = try templateRenderer.renderArchive(postsByYear: posts, site: site, assets: .none())
|
||||
let archiveURL = targetURL.appendingPathComponent(outputPath).appendingPathComponent("index.html")
|
||||
try fileManager.createDirectory(at: archiveURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
||||
try archiveHTML.write(to: archiveURL, atomically: true, encoding: .utf8)
|
||||
try fileWriter.write(string: archiveHTML, to: archiveURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,8 +63,7 @@ extension PostWriter {
|
|||
let yearDir = targetURL.appendingPathComponent(yearPosts.path)
|
||||
let yearHTML = try templateRenderer.renderYearPosts(yearPosts, site: site, assets: .none())
|
||||
let yearURL = yearDir.appendingPathComponent("index.html")
|
||||
try fileManager.createDirectory(at: yearDir, withIntermediateDirectories: true, attributes: nil)
|
||||
try yearHTML.write(to: yearURL, atomically: true, encoding: .utf8)
|
||||
try fileWriter.write(string: yearHTML, to: yearURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -81,8 +77,7 @@ extension PostWriter {
|
|||
let monthDir = targetURL.appendingPathComponent(monthPosts.path)
|
||||
let monthHTML = try templateRenderer.renderMonthPosts(monthPosts, site: site, assets: .none())
|
||||
let monthURL = monthDir.appendingPathComponent("index.html")
|
||||
try fileManager.createDirectory(at: monthDir, withIntermediateDirectories: true, attributes: nil)
|
||||
try monthHTML.write(to: monthURL, atomically: true, encoding: .utf8)
|
||||
try fileWriter.write(string: monthHTML, to: monthURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ final class PostsPluginBuilder {
|
|||
|
||||
let jsonFeedWriter: JSONFeedWriter?
|
||||
if let jsonFeed = jsonFeed {
|
||||
jsonFeedWriter = JSONFeedWriter(feed: jsonFeed)
|
||||
jsonFeedWriter = JSONFeedWriter(jsonFeed: jsonFeed)
|
||||
}
|
||||
else {
|
||||
jsonFeedWriter = nil
|
||||
|
|
@ -67,7 +67,7 @@ final class PostsPluginBuilder {
|
|||
|
||||
let rssFeedWriter: RSSFeedWriter?
|
||||
if let rssFeed = rssFeed {
|
||||
rssFeedWriter = RSSFeedWriter(feed: rssFeed)
|
||||
rssFeedWriter = RSSFeedWriter(rssFeed: rssFeed)
|
||||
}
|
||||
else {
|
||||
rssFeedWriter = nil
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ struct PartialProject {
|
|||
}
|
||||
|
||||
final class ProjectsPlugin: Plugin {
|
||||
let fileManager: FileManager = .default
|
||||
let fileWriter: FileWriting
|
||||
let outputPath: String
|
||||
let partialProjects: [PartialProject]
|
||||
let templateRenderer: ProjectsTemplateRenderer
|
||||
|
|
@ -26,12 +26,14 @@ final class ProjectsPlugin: Plugin {
|
|||
projects: [PartialProject],
|
||||
templateRenderer: ProjectsTemplateRenderer,
|
||||
projectAssets: TemplateAssets,
|
||||
outputPath: String? = nil
|
||||
outputPath: String? = nil,
|
||||
fileWriter: FileWriting = FileWriter()
|
||||
) {
|
||||
self.partialProjects = projects
|
||||
self.templateRenderer = templateRenderer
|
||||
self.projectAssets = projectAssets
|
||||
self.outputPath = outputPath ?? "projects"
|
||||
self.fileWriter = fileWriter
|
||||
}
|
||||
|
||||
// MARK: - Plugin methods
|
||||
|
|
@ -53,16 +55,14 @@ final class ProjectsPlugin: Plugin {
|
|||
}
|
||||
|
||||
let projectsDir = targetURL.appendingPathComponent(outputPath)
|
||||
try fileManager.createDirectory(at: projectsDir, withIntermediateDirectories: true, attributes: nil)
|
||||
let projectsURL = projectsDir.appendingPathComponent("index.html")
|
||||
let projectsHTML = try templateRenderer.renderProjects(projects, site: site, assets: .none())
|
||||
try projectsHTML.write(to: projectsURL, atomically: true, encoding: .utf8)
|
||||
try fileWriter.write(string: projectsHTML, to: projectsURL)
|
||||
|
||||
for project in projects {
|
||||
let projectURL = projectsDir.appendingPathComponent("\(project.title)/index.html")
|
||||
let projectHTML = try templateRenderer.renderProject(project, site: site, assets: projectAssets)
|
||||
try fileManager.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
||||
try projectHTML.write(to: projectURL, atomically: true, encoding: .utf8)
|
||||
try fileWriter.write(string: projectHTML, to: projectURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue