From 054113bda019244207f5f9fea63e841942998c34 Mon Sep 17 00:00:00 2001 From: John Estropia Date: Thu, 14 Mar 2024 14:59:10 +0900 Subject: [PATCH] Support StorybookPreview macro in Preview declarations --- Development/Demo/MyBook.swift | 19 + Sources/StorybookKit/StorybookKit.swift | 18 + .../StorybookMacrosPlugin.swift | 3 +- .../StorybookPageMacro.swift | 4 +- .../StorybookPreviewMacro.swift | 106 ++++++ .../StorybookPreviewTests.swift | 332 ++++++++++++++++++ 6 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 Sources/StorybookMacrosPlugin/StorybookPreviewMacro.swift create mode 100644 Sources/StorybookMacrosTests/StorybookPreviewTests.swift diff --git a/Development/Demo/MyBook.swift b/Development/Demo/MyBook.swift index 5b67cf6..a461f14 100644 --- a/Development/Demo/MyBook.swift +++ b/Development/Demo/MyBook.swift @@ -190,6 +190,25 @@ let myBook = Book.init( } } + +#Preview("Some title") { + #StorybookPreview { + BookPreview { _ in + let label = UILabel() + label.text = "UILabel 1" + return label + } + } +} + +#Preview("Some title 2") { + #StorybookPreview { + BookPreview { _ in + MyLabel(title: "MyLabel 2") + } + } +} + #StorybookPage { BookPreview { _ in MyLabel(title: "Test") diff --git a/Sources/StorybookKit/StorybookKit.swift b/Sources/StorybookKit/StorybookKit.swift index d568d31..d2082c7 100644 --- a/Sources/StorybookKit/StorybookKit.swift +++ b/Sources/StorybookKit/StorybookKit.swift @@ -40,3 +40,21 @@ public macro StorybookPage( module: "StorybookMacrosPlugin", type: "StorybookPageMacro" ) + +@freestanding(expression) +public macro StorybookPreview( + title: String, + @ViewBuilder contents: @escaping () -> any View +) -> AnyView = #externalMacro( + module: "StorybookMacrosPlugin", + type: "StorybookPreviewMacro" +) + +@freestanding(expression) +public macro StorybookPreview( + target: Target.Type = Target.self, + @ViewBuilder contents: @escaping () -> any View +) -> AnyView = #externalMacro( + module: "StorybookMacrosPlugin", + type: "StorybookPreviewMacro" +) diff --git a/Sources/StorybookMacrosPlugin/StorybookMacrosPlugin.swift b/Sources/StorybookMacrosPlugin/StorybookMacrosPlugin.swift index 9c38192..ae99d81 100644 --- a/Sources/StorybookMacrosPlugin/StorybookMacrosPlugin.swift +++ b/Sources/StorybookMacrosPlugin/StorybookMacrosPlugin.swift @@ -30,6 +30,7 @@ struct StorybookMacrosPlugin: CompilerPlugin { // MARK: CompilerPlugin let providingMacros: [Macro.Type] = [ - StorybookPageMacro.self + StorybookPageMacro.self, + StorybookPreviewMacro.self ] } diff --git a/Sources/StorybookMacrosPlugin/StorybookPageMacro.swift b/Sources/StorybookMacrosPlugin/StorybookPageMacro.swift index b815e71..78584a5 100644 --- a/Sources/StorybookMacrosPlugin/StorybookPageMacro.swift +++ b/Sources/StorybookMacrosPlugin/StorybookPageMacro.swift @@ -58,9 +58,11 @@ public struct StorybookPageMacro: DeclarationMacro { """ ) ] - } + + // MARK: Private + private static func parseArguments( from node: some FreestandingMacroExpansionSyntax ) throws -> ( diff --git a/Sources/StorybookMacrosPlugin/StorybookPreviewMacro.swift b/Sources/StorybookMacrosPlugin/StorybookPreviewMacro.swift new file mode 100644 index 0000000..cab24ef --- /dev/null +++ b/Sources/StorybookMacrosPlugin/StorybookPreviewMacro.swift @@ -0,0 +1,106 @@ +// +// Copyright (c) 2024 Eureka, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Foundation +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +// MARK: - StorybookPreviewMacro + +public struct StorybookPreviewMacro: ExpressionMacro { + + // MARK: Internal + + /// Should match `Book._magicSubstring` + static let _magicSubstring: String = "__🤖🛠️_StorybookMagic_" + + // MARK: ExpressionMacro + + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) throws -> ExprSyntax { + let (title, closure) = try self.parseArguments(from: node) + let enumName = context.makeUniqueName( + self._magicSubstring + ) + return .init( + stringLiteral: """ + { + enum \(enumName): BookProvider { + static var bookBody: BookPage { + .init( + title: \(title), + destination: \(closure) + ) + } + } + return \(enumName).bookBody.destination + }() + """ + ) + } + + + // MARK: Private + + private static func parseArguments( + from node: some FreestandingMacroExpansionSyntax + ) throws -> ( + title: ExprSyntax, + closure: ClosureExprSyntax + ) { + var argumentsIterator = node.argumentList.makeIterator() + var title: ExprSyntax? = (node.genericArgumentClause?.arguments.first?.argument) + .flatMap { genericType -> TypeSyntaxProtocol? in + genericType.as(IdentifierTypeSyntax.self) + ?? genericType.as(MemberTypeSyntax.self) + } + .map({ .init(stringLiteral: "_typeName(\($0).self)") }) + + var closure: ClosureExprSyntax? = node.trailingClosure + while let argument = argumentsIterator.next()? + .as(LabeledExprSyntax.self) { + + switch argument.label?.text { + + case "title"?: + title = argument.expression + + case "target"?: + title = .init(stringLiteral: "_typeName(\(argument))") + + case "contents"?: + closure = argument.expression + .as(ClosureExprSyntax.self) + + default: + fatalError() + } + } + return ( + title: title!, + closure: closure! + ) + } +} diff --git a/Sources/StorybookMacrosTests/StorybookPreviewTests.swift b/Sources/StorybookMacrosTests/StorybookPreviewTests.swift new file mode 100644 index 0000000..c95f7b1 --- /dev/null +++ b/Sources/StorybookMacrosTests/StorybookPreviewTests.swift @@ -0,0 +1,332 @@ +// +// Copyright (c) 2024 Eureka, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import MacroTesting +import XCTest +import StorybookMacrosPlugin + +final class StorybookPreviewTests: XCTestCase { + + override func invokeTest() { + withMacroTesting( + isRecording: false, + macros: ["StorybookPreview": StorybookPreviewMacro.self] + ) { + super.invokeTest() + } + } + + func testUnlabeledTitleUnlabeledContent() { + assertMacro { + """ + #Preview("Some title") { + #StorybookPreview { + VStack { + Text("test") + } + .tint(.accentColor) + } + } + """ + } expansion: { + """ + #Preview("Some title") { + { + enum __macro_local_20__🤖🛠️_StorybookMagic_fMu_: BookProvider { + static var bookBody: BookPage { + .init( + title: _typeName(UIView.self), + destination: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + } + return __macro_local_20__🤖🛠️_StorybookMagic_fMu_.bookBody.destination + }() + } + """ + } + + assertMacro { + """ + #Preview { + #StorybookPreview(title: "Path1.Path2.Title") { + VStack { + Text("test") + } + .tint(.accentColor) + } + } + """ + } expansion: { + """ + #Preview { + { + enum __macro_local_20__🤖🛠️_StorybookMagic_fMu_: BookProvider { + static var bookBody: BookPage { + .init( + title: "Path1.Path2.Title", + destination: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + } + return __macro_local_20__🤖🛠️_StorybookMagic_fMu_.bookBody.destination + }() + } + """ + } + } + + func testLabeledTitleUnlabeledContent() { + assertMacro { + """ + enum Namespace1 { enum Namespace2 { enum Namespace3 { class TestableView: UIView {} } } } + #Preview { + #StorybookPreview(target: Namespace1.Namespace2.Namespace3.TestableView.self) { + VStack { + Text("test") + } + .tint(.accentColor) + } + } + """ + } expansion: { + """ + enum Namespace1 { enum Namespace2 { enum Namespace3 { class TestableView: UIView {} } } } + #Preview { + { + enum __macro_local_20__🤖🛠️_StorybookMagic_fMu_: BookProvider { + static var bookBody: BookPage { + .init( + title: _typeName(target: Namespace1.Namespace2.Namespace3.TestableView.self), + destination: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + } + return __macro_local_20__🤖🛠️_StorybookMagic_fMu_.bookBody.destination + }() + } + """ + } + + assertMacro { + """ + #Preview { + #StorybookPreview(title: "Path1.Path2.Title") { + VStack { + Text("test") + } + .tint(.accentColor) + } + } + """ + } expansion: { + """ + #Preview { + { + enum __macro_local_20__🤖🛠️_StorybookMagic_fMu_: BookProvider { + static var bookBody: BookPage { + .init( + title: "Path1.Path2.Title", + destination: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + } + return __macro_local_20__🤖🛠️_StorybookMagic_fMu_.bookBody.destination + }() + } + """ + } + } + + func testUnlabeledTitleLabeledContent() { + assertMacro { + """ + #Preview { + #StorybookPreview( + contents: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + """ + } expansion: { + """ + #Preview { + { + enum __macro_local_20__🤖🛠️_StorybookMagic_fMu_: BookProvider { + static var bookBody: BookPage { + .init( + title: _typeName(UIView.self), + destination: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + } + return __macro_local_20__🤖🛠️_StorybookMagic_fMu_.bookBody.destination + }() + } + """ + } + + assertMacro { + """ + #Preview { + #StorybookPreview( + title: "Path1.Path2.Title", + contents: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + """ + } expansion: { + """ + #Preview { + { + enum __macro_local_20__🤖🛠️_StorybookMagic_fMu_: BookProvider { + static var bookBody: BookPage { + .init( + title: "Path1.Path2.Title", + destination: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + } + return __macro_local_20__🤖🛠️_StorybookMagic_fMu_.bookBody.destination + }() + } + """ + } + } + + func testLabeledTitleLabeledContent() { + assertMacro { + """ + enum Namespace1 { enum Namespace2 { enum Namespace3 { class TestableView: UIView {} } } } + #Preview { + #StorybookPreview( + target: Namespace1.Namespace2.Namespace3.TestableView.self, + contents: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + """ + } expansion: { + """ + enum Namespace1 { enum Namespace2 { enum Namespace3 { class TestableView: UIView {} } } } + #Preview { + { + enum __macro_local_20__🤖🛠️_StorybookMagic_fMu_: BookProvider { + static var bookBody: BookPage { + .init( + title: _typeName( + target: Namespace1.Namespace2.Namespace3.TestableView.self,), + destination: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + } + return __macro_local_20__🤖🛠️_StorybookMagic_fMu_.bookBody.destination + }() + } + """ + } + + assertMacro { + """ + #Preview { + #StorybookPreview( + title: "Path1.Path2.Title", + contents: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + """ + } expansion: { + """ + #Preview { + { + enum __macro_local_20__🤖🛠️_StorybookMagic_fMu_: BookProvider { + static var bookBody: BookPage { + .init( + title: "Path1.Path2.Title", + destination: { + VStack { + Text("test") + } + .tint(.accentColor) + } + ) + } + } + return __macro_local_20__🤖🛠️_StorybookMagic_fMu_.bookBody.destination + }() + } + """ + } + } +}