Created
December 28, 2020 07:27
-
-
Save jackpal/efe1a15e4b37b52550eef30fac129c2a to your computer and use it in GitHub Desktop.
A Swift Command Line tool to translate the posts of a simple website from Publish to Jekyll
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// ConvertBlogFromPublishToJekyll.swift | |
// Quick and dirty script to translate the posts of my personal website from the | |
// [Publish](https://github.com/JohnSundell/Publish) site generator to the | |
// [Jekyll](https://jekyllrb.com/) site generator. | |
// | |
// Created by Jack Palevich on 12/27/20. | |
// | |
import Foundation | |
/// Some regular expression helpers from around the web: | |
extension NSTextCheckingResult: Sequence { | |
public func makeIterator() -> AnyIterator<NSRange> { | |
// keep the index of the next car in the iteration | |
var nextIndex = 1 | |
// Construct a GeneratorOf<Car> instance, | |
// passing a closure that returns the next | |
// car in the iteration | |
return AnyIterator<NSRange> { | |
if nextIndex > self.numberOfRanges-1 { | |
return nil | |
} | |
let current = self.range(at: nextIndex) | |
nextIndex += 1 | |
return current | |
} | |
} | |
} | |
extension String { | |
public func everyMatch(pattern: String, options: NSRegularExpression.Options = [.anchorsMatchLines]) -> [[String]]? { | |
let regex = try? NSRegularExpression(pattern: pattern, options: options) | |
let maybeResults = regex?.matches(in: self, options: [], range: NSMakeRange(0, self.count)) | |
guard let results = maybeResults else { | |
return nil | |
} | |
var allOutputs = [[String]]() | |
for res in results where res.numberOfRanges > 0 { | |
var row = [String]() | |
for match in res where match.location != NSNotFound { | |
row.append((self as NSString).substring(with: match)) | |
} | |
let filteredRow = row.filter { $0.count > 0 } | |
if filteredRow.count > 0 { | |
allOutputs.append(filteredRow) | |
} | |
} | |
return allOutputs.count > 0 ? allOutputs : nil | |
} | |
public func firstMatching(pattern: String, options: NSRegularExpression.Options = [.anchorsMatchLines]) -> String? { | |
let regex = try? NSRegularExpression(pattern: pattern, options: options) | |
let result = regex?.firstMatch(in: self, options: [], range: NSMakeRange(0, self.count)) | |
guard let rangeCount = result?.numberOfRanges, rangeCount > 1, | |
let firstRange = result?.range(at: 1), firstRange.location != NSNotFound else { | |
return nil | |
} | |
return (self as NSString).substring(with: firstRange) | |
} | |
// Adapted from https://blog.sb1.io/a-better-find-replace-swift-interface/ | |
func replace(_ pattern: String, options: NSRegularExpression.Options = [], transformer: ([String]) -> String) -> String { | |
guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { return self } | |
let matches = regex.matches(in: self, options: NSRegularExpression.MatchingOptions(rawValue: 0), range: NSMakeRange(0, (self as NSString).length)) | |
guard matches.count > 0 else { return self } | |
var splitStart = startIndex | |
return matches.map { (match) -> (String, [String]) in | |
let range = Range(match.range, in: self)! | |
let split = String(self[splitStart ..< range.lowerBound]) | |
splitStart = range.upperBound | |
return (split, (0 ..< match.numberOfRanges) | |
.compactMap { Range(match.range(at: $0), in: self) } | |
.map { String(self[$0]) } | |
) | |
}.reduce("") { "\($0)\($1.0)\(transformer($1.1))" } + self[Range(matches.last!.range, in: self)!.upperBound ..< endIndex] | |
} | |
} | |
/// The main converter | |
struct Resource { | |
let name: String | |
let contents: Data | |
} | |
struct Post { | |
let name: String | |
let date: Date | |
let contents: String | |
let resources: [Resource] | |
} | |
struct Site { | |
let posts:[Post] | |
} | |
extension Post { | |
var newName: String { | |
let calendar = Calendar.current | |
let dateComponents = calendar.dateComponents([.year,.month,.day], from: date) | |
return String(format: "%04d-%02d-%02d", | |
dateComponents.year!, dateComponents.month!, dateComponents.day!) | |
+ "-" + name | |
} | |
var newResourcePrefix: String { | |
String(newName[..<newName.lastIndex(of: ".")!]) | |
} | |
} | |
func newResourceName(post: Post, name: String) -> String { | |
"/assets/posts/\(post.newResourcePrefix)-\(name)" | |
} | |
func newResourceName(post: Post, resource: Resource) -> String { | |
newResourceName(post: post, name: resource.name) | |
} | |
func recursiveFindFiles(root: URL)-> [URL] { | |
var files = [URL]() | |
let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants])! | |
for case let fileURL as URL in enumerator { | |
do { | |
let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey]) | |
if fileAttributes.isRegularFile! { | |
files.append(fileURL) | |
} | |
} catch { print(error, fileURL) } | |
} | |
return files | |
} | |
func contentsOfTextFile(_ url:URL)-> String { | |
try! String(contentsOf: url) | |
} | |
func contentsOfResourceFile(_ url:URL)-> Data { | |
try! Data(contentsOf: url) | |
} | |
func mapURLs(contents: String, transformer: (String)->String)-> String { | |
contents.replace( #"\[(.*)]\((.*)\)"#) { match in | |
let text = match[1].replace(#"\!\[\]\((.*)\)"#) { match in | |
"[!](\(transformer(match[1]))]" | |
} | |
let href = transformer(match[2]) | |
return "[\(text)](\(href))" | |
} | |
} | |
func splitPostContents(contents: String)->(metadata:[String:String], body:String) { | |
let parts = contents.components(separatedBy: "---\n") | |
let metadataSection = parts[1] | |
let metadataLines = metadataSection.split(separator: "\n") | |
let kv = metadataLines.map{ | |
$0.split(separator: ":", maxSplits: 1).map { | |
String($0.trimmingCharacters(in: .whitespaces)) | |
} | |
} | |
// Any lines with just one item belong to the previous line's value | |
let kvPairs = kv.reduce([(String,String)]()){ kvs, kv in | |
if kv.count == 2 { | |
return kvs + [(kv[0], kv[1])] | |
} else { | |
let last = kvs.last! | |
let updated = (last.0, last.1 + "\n" + kv[0]) | |
return kvs.dropLast() + [updated] | |
} | |
} | |
let metadata = Dictionary(uniqueKeysWithValues: kvPairs) | |
return (metadata, parts[2]) | |
} | |
func escapeYamlString(_ s: String) -> String { | |
if s.contains(": ") { | |
return "\"" + s.replacingOccurrences(of: "\"", with: "\\\"") + "\"" | |
} | |
return s | |
} | |
func joinMetadata(metadata:[String:String]) -> String { | |
metadata.keys.sorted().map{ key in | |
"\(key): \(escapeYamlString(metadata[key]!))" | |
}.joined(separator: "\n") | |
} | |
func joinPostContents(metadata:[String:String], body:String)->String { | |
"---\n" + joinMetadata(metadata: metadata) + "\n---\n" + body | |
} | |
func parseDate(_ contents: String)->Date { | |
let metadata = splitPostContents(contents:contents).metadata | |
let dateStr = metadata["date"]! | |
let dateFormatter = DateFormatter() | |
dateFormatter.locale = Locale(identifier: "en_US_POSIX") // set locale to reliable US_POSIX | |
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" | |
let date = dateFormatter.date(from:dateStr)! | |
return date | |
} | |
func resourceURLs(root:URL, postURL:URL) -> [URL] { | |
let postPathComponents = postURL.deletingPathExtension().pathComponents | |
let filePart = postPathComponents[postPathComponents.count-3..<postPathComponents.count] | |
let resourcesRoot = root.appendingPathComponent("Resources/posts", isDirectory: true) | |
let resourceDir = resourcesRoot.appendingPathComponent(filePart.joined(separator: "/"), isDirectory:true) | |
let resourceURLs = recursiveFindFiles(root: resourceDir) | |
return resourceURLs | |
} | |
func parseResources(root:URL, postURL:URL, contents: String) -> [Resource] { | |
let urls = resourceURLs(root:root, postURL:postURL) | |
let resources = urls.map {url -> Resource in | |
let name = url.pathComponents.last! | |
return Resource(name: name, contents: contentsOfResourceFile(url)) | |
} | |
return resources | |
} | |
func readOldSite(root: URL) -> Site { | |
let contentRoot = root.appendingPathComponent("Content", isDirectory: true) | |
let postsRoot = contentRoot.appendingPathComponent("posts", isDirectory: true) | |
let postURLs = recursiveFindFiles(root: postsRoot) | |
let posts = postURLs.compactMap {postURL -> Post? in | |
let name = postURL.lastPathComponent | |
if name == "index.md" { | |
return nil | |
} | |
let contents = contentsOfTextFile(postURL) | |
let date = parseDate(contents) | |
let resources = parseResources(root:root, postURL:postURL, contents: contents) | |
return Post(name: name, date: date, contents: contents, resources: resources) | |
} | |
return Site(posts: posts) | |
} | |
// Move posts from /Content/posts/YYYY/MM/Post to _posts/YYYY_MM_DD_Post | |
// -- need to get the DD from within the Post.md file | |
// | |
// Move resources from /Resources/... to assets / resources / YYYY_MM_DD_Post_... | |
// -- update links within markdown. (To images and html.) | |
func newPostContents(post: Post)-> String { | |
let postContents = splitPostContents(contents:post.contents) | |
var metadata = postContents.metadata | |
var body = postContents.body | |
body = body.replace("^\n*# (.+)\n") {title in | |
metadata["title"] = title[1] | |
return "" | |
} | |
if let tags = metadata["tags"] { | |
metadata["tags"] = tags.split(separator: ",").map{ tag in | |
tag.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: " ", with: "_").lowercased() | |
}.joined(separator:" ") | |
} | |
let newContents = joinPostContents(metadata: metadata, body: body) | |
return mapURLs(contents: newContents){ url in | |
if url.hasPrefix("http") { | |
return url | |
} | |
let newURL = newResourceName(post:post, name:url) | |
return newURL | |
} | |
} | |
func writeNewPost(root: URL, site: Site, post: Post) { | |
let newPostURL = root | |
.appendingPathComponent("_posts", isDirectory:true) | |
.appendingPathComponent(post.newName, isDirectory: false) | |
let newContents = newPostContents(post:post) | |
try! newContents.write(to: newPostURL, atomically: false, encoding: .utf8) | |
post.resources.forEach { resource in | |
let name = String(newResourceName(post:post, resource:resource).dropFirst()) | |
let newResourceURL = root.appendingPathComponent(name, isDirectory:false) | |
try! resource.contents.write(to: newResourceURL) | |
} | |
} | |
func writeNewSite(root: URL, site: Site) { | |
site.posts.forEach {post in | |
writeNewPost(root: root, site: site, post: post) | |
} | |
} | |
func convert(from: URL, to: URL){ | |
let site = readOldSite(root: from) | |
writeNewSite(root: to, site: site) | |
} | |
/// Main function. Converts the old website to the new website. | |
func convert() { | |
convert(from: URL(fileURLWithPath: "/Users/jackpal/Developer/jackpal.github.io.source/"), | |
to: URL(fileURLWithPath: "/Users/jackpal/Developer/jackpal.github.io/")) | |
} | |
/// Run the main function. | |
convert() | |
/// Tests. | |
func test() { | |
let post = """ | |
# Brick Break - a Javascript Breakout clone | |
[![](brickbreak.png)](brickbreak.html) | |
This weekend I wrote a Javascript clone of the old Atari "Breakout" game. | |
[Jack's Brick Break Breakout clone](brickbreak.html) | |
""" | |
let updated = mapURLs(contents: post){ url in | |
"aaa-" + url + "-bbb" | |
} | |
print(updated) | |
} | |
// test() | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment