Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
A Swift Command Line tool to translate the posts of a simple website from Publish to Jekyll
//
// 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