` 태그를 담당합니다. Blog 라는 Text를 누르면 기존 타 사이트와 마찬가지로 index 페이지로 이동하도록 만들었고
354 |
355 | `Section` 부분은 List를 사용해서 저희가 `main.swift` 에 등록한 Section들을 모두 보여주게 만들었습니다.
356 |
357 | 그리고 기존 디자인에 맞춰서 코드를 넣어주고 `styles.css` 에서 style 관련 css 코드를 넣어주었습니다.
358 |
359 | 그렇게 하면 이렇게 디자인 했던 것처럼 결과 값을 받아보실 수 있습니다.
360 |
361 |
362 |
363 |
364 | 그 다음은 Footer 입니다.
365 |
366 |
367 |
368 | 굉장하게 간단한 Footer 입니다.
369 |
370 | Text로 구성하고 싶고, `Copyright Link` 부분을 누르면 저의 깃허브로 이동하도록 만들고 싶네요.
371 |
372 | ```swift
373 | struct FooterComponent: Component {
374 | var body: Component {
375 | Footer {
376 | // code
377 | }
378 | .class("site-footer")
379 | }
380 | }
381 | ```
382 |
383 | Component를 선언후에 Footer를 넣어주고
384 |
385 | ```swift
386 | struct FooterComponent: Component {
387 | var body: Component {
388 | Footer {
389 | Paragraph("Made with Publish")
390 | Paragraph {
391 | Text("Copyright © ")
392 | Link("Jihoonahn", url: "https://github.com/jihoonahn")
393 | }
394 | .class("copyright")
395 | }
396 | .class("site-footer")
397 | }
398 | }
399 | ```
400 | 이렇게 간단하게 구축했습니다.
401 |
402 | `Paragraph` 가 HTML에서는 `` 태그의 역할을 하고, 저희는 copyright 부분중에 Jihoonahn 이라고 적힌 부분을 눌렀을 때 깃허브로 이동시키고 싶으니 이렇게 `Paragraph` 내부에 `Text` 와 `Link` 를 넣어줍니다.
403 |
404 | 이렇게 되면 `"Copyright © "` 부분을 눌렀을 때는 링크로 이동하지 않지만, `Jihoonahn` 을 눌렀을때만 이동을 하게 됩니다.
405 |
406 | 마찬가지로 기존 디자인에 맞춰서 코드를 넣어주고 `styles.css` 에서 style 관련 css 코드를 넣어주었습니다.
407 |
408 |
409 |
410 | 간단하게 Header 와 Footer Component를 만들었고 한번 이제 페이지를 구축해볼까요?
411 |
412 | ### Index
413 |
414 | ```swift
415 | func makeIndexHTML(for index: Index, context: PublishingContext) throws -> HTML {
416 | HTML(
417 | .lang(context.site.language),
418 | .head(for: index, on: context.site, stylesheetPaths: []),
419 | .body {
420 | // Index Page
421 | }
422 | )
423 | }
424 | ```
425 |
426 | 기존의 `HTMLFactory` 에서 index부분 부터 한번 작업을 해보도록하겠습니다.
427 |
428 | Index의 Page부분을 만들기 위해서 `Pages` 폴더에서 `IndexPage.swift` 파일을 만들었습니다.
429 |
430 | ```swift
431 | struct IndexPage: Component {
432 | var context: PublishingContext
433 |
434 | var body: Component {
435 | ComponentGroup {
436 | HeaderComponent(context: context)
437 | FooterComponent()
438 | }
439 | }
440 | }
441 | ```
442 |
443 | 여러개의 Component가 동시에 들어가기 때문에 `ComponentGroup` 로 묶어 두고 기존에 만들어둔 Header와 Footer을 넣어둡니다.
444 |
445 | 그리고 `Layouts` 폴더에 `PostsLayout.swift` 란 파일을 만들어서
446 |
447 | ```swift
448 | struct PostsLayout: Component
449 | ```
450 |
451 | `PostsLayout` 이라는 Component를 만들고
452 |
453 | 다시 IndexPage로 돌아가서
454 |
455 | ```swift
456 | struct IndexPage: Component {
457 | var context: PublishingContext
458 |
459 | var body: Component {
460 | ComponentGroup {
461 | HeaderComponent(context: context)
462 | PostsLayout()
463 | FooterComponent()
464 | }
465 | }
466 | }
467 | ```
468 | 이런식으로 `PostsLayout` 도 추가로 넣어둡니다.
469 |
470 | 그리고 `IndexPage` 를 `HTMLFactory` 의 `makeIndexHTML` method에 넣어줍니다.
471 |
472 | ```swift
473 | func makeIndexHTML(for index: Index, context: PublishingContext) throws -> HTML {
474 | HTML(
475 | .lang(context.site.language),
476 | .head(for: index, on: context.site),
477 | .body {
478 | IndexPage(context: context)
479 | }
480 | )
481 | }
482 | ```
483 |
484 | 그럼 이제 Index 부분을 완성하기 위해서 `PostsLayout` 부분을 채워볼까요?
485 |
486 | Posts 같은 경우
487 |
488 |
489 |
490 | 이 부분이 반복됩니다. 전체가 링크로 감싸져 있는 형태이죠.
491 |
492 | 시작하기 전에 Publish에서 확장이 되어 있지 않는 Component가 몇가지 있습니다. 그중에 PostsLayout에서 사용하고 싶은 `section` 도 아직은 존재하지 않죠
493 |
494 | 그러므로 만약 없는 Component들은 어떻게 해야하는지 보도록 하겠습니다.
495 | `Utils/Plot/ElementDefinitions.swift` 파일을 보시면 됩니다.
496 |
497 | `Publish` 의 HTML 부분을 담당하는 `Plot` 에서 `Section` 이 `Node`에서는 `Component` 타입으로 존재하지 않는것이기 때문에
498 |
499 | ```swift
500 | extension ElementDefinitions {
501 | enum Section: ElementDefinition { public static var wrapper = Node.section }
502 | }
503 |
504 | typealias Section = ElementComponent
505 | ```
506 | 이런 방식으로 기존의 Plot의 `ElementDefinitions` 부분을 참고하여 확장하시면 됩니다.
507 |
508 | 이제 `PostsLayout` 을 제작해보겠습니다.
509 |
510 | ```swift
511 | let items: [Item]
512 | ```
513 |
514 | - `items` 는 Post들을 가져오는 역할을 합니다.
515 |
516 | 이 프로퍼티를 추가해주고 `IndexPage.swift` 를 수정해줍니다.
517 |
518 | ```swift
519 | struct IndexPage: Component {
520 | var context: PublishingContext
521 |
522 | var body: Component {
523 | ComponentGroup {
524 | HeaderComponent(context: context)
525 | PostsLayout(items: context.allItems(sortedBy: \.date))
526 | FooterComponent()
527 | }
528 | }
529 | }
530 | ```
531 |
532 | 이런 식으로 Context의 모든 Item을 들고오면서 날짜 순서대로 정렬이 되도록 해뒀습니다.
533 |
534 | 다시 `PostsLayout` 부분으로 돌아가서
535 |
536 | ```swift
537 | struct PostsLayout: Component {
538 | let items: [Item]
539 |
540 | var body: Component {
541 | Section {
542 | Div {
543 | List(items) { item in
544 | Article {
545 | Link(url: item.path.absoluteString) {
546 | Paragraph(item.tags.map{ $0.string }.joined(separator: ", "))
547 | .class("posts-tag")
548 | H3(item.title)
549 | .class("posts-title")
550 | Paragraph(item.description)
551 | .class("posts-description")
552 | }
553 | .class("posts-link")
554 | }
555 | .class("posts-article")
556 | }
557 | .class("posts-list")
558 | }
559 | .class("site-posts-inner")
560 | }
561 | .class("site-posts")
562 | }
563 | }
564 | ```
565 |
566 | `items` 값을 `List()`로 보여줄 수 있도록 제작하고 `styles.css` 에 css 코드를 넣어주면 됩니다.
567 |
568 |
569 |
570 | 실행을 해보면 위 Index 디자인한것과 같은 결과물을 얻을 수 있습니다.
571 |
572 | 그 다음으로는 Section 부분을 처리해보겠습니다.
573 |
574 | ### Section
575 |
576 | ```swift
577 | func makeSectionHTML(for section: Publish.Section, context: Publish.PublishingContext) throws -> HTML {
578 | HTML(
579 | .lang(context.site.language),
580 | .head(for: section, on: context.site),
581 | .body {
582 | // Section Code
583 | }
584 | )
585 | }
586 | ```
587 |
588 | `HTMLFactory` 부분에서 `makeSectionHTML` 메서드에 Section 코드를 추가하면 됩니다.
589 |
590 |
591 | ```swift
592 | struct SectionPage: Component
593 | ```
594 |
595 | `Pages/Section/SectionPage.swift` 파일에 SectionPage라는 Component를 만들고
596 |
597 | ```swift
598 | var section: Publish.Section
599 | var context: PublishingContext
600 | ```
601 |
602 | Example의 `section` 과 `Context` 를 가져와 줍니다.
603 |
604 | ```swift
605 | struct SectionPage: Component {
606 | var section: Publish.Section
607 | var context: PublishingContext
608 |
609 | var body: Component {
610 | switch section.path.string {
611 | case Example.SectionID.blog.rawValue:
612 | return IndexPage(context: context)
613 | case Example.SectionID.about.rawValue:
614 | // About Page
615 | default: return Div()
616 | }
617 | }
618 | }
619 | ```
620 |
621 | 이렇게 section의 path가 `blog`이면 `IndexPage`를 보여주게 하고 `about` 이라면 `AboutPage`를 보여주게 만듭니다.
622 |
623 | ```swift
624 | struct AboutPage: Component {
625 | var context: PublishingContext
626 |
627 | var body: Component {
628 | ComponentGroup {
629 | HeaderComponent(context: context)
630 | Div {
631 | Image("/images/AboutPageImage.svg")
632 | H2("Publish Example")
633 | Paragraph("Jihoonahn’s Blog Example")
634 | }
635 | .class("site-about")
636 | FooterComponent()
637 | }
638 | }
639 | }
640 | ```
641 |
642 | 그리고 `SectionPage`에 넣어둘 `AboutPage`가 필요하기 때문에 간단하게 제작하고,
643 |
644 | ```swift
645 | struct SectionPage: Component {
646 | var section: Publish.Section
647 | var context: PublishingContext
648 |
649 | var body: Component {
650 | switch section.path.string {
651 | case Example.SectionID.blog.rawValue:
652 | return IndexPage(context: context)
653 | case Example.SectionID.about.rawValue:
654 | return AboutPage(context: context)
655 | default: return Div()
656 | }
657 | }
658 | }
659 | ```
660 | 이렇게 넣어두면
661 |
662 |
663 |
664 | 이렇게 Header에 있는 `About` Section을 누르게 되면 `AboutPage` 로 이동이 되게 만들 수 있습니다.
665 |
666 | ### Post
667 |
668 | 그 다음으로는 Index에서 Post중 하나를 눌렀을 때 그 Post의 내용을 볼 수 있도록 만들겠습니다.
669 |
670 | ```swift
671 | func makeItemHTML(for item: Item, context: PublishingContext) throws -> HTML {
672 | HTML(
673 | .lang(context.site.language),
674 | .head(for: item, on: context.site),
675 | .body {
676 | /// Post
677 | }
678 | )
679 | }
680 | ```
681 |
682 | 이곳에 `post` 들의 item을 가져올 수 있습니다.
683 |
684 | ```swift
685 | struct PostLayout: Component
686 | ```
687 |
688 | `Layouts/PostLayout.swift` 에서 위 Index와 비슷하게 `PostLayout` 이라는 Component를 만들어주겠습니다.
689 |
690 | 그리고 `PostLayout` 부분에서 Item 내용을 가져오기 위해서
691 |
692 | ```swift
693 | var item: Item
694 | var context: PublishingContext
695 | ```
696 | - `item` 프로퍼티를 통해서 가져올 수 있도록 하였습니다.
697 | - `context`는 tag의 url을 가져오기 위해서 사용하였습니다.
698 |
699 |
700 | ```swift
701 | struct PostLayout: Component {
702 | var item: Item
703 | var context: PublishingContext
704 |
705 | var body: Component {
706 | Section {
707 | Article {
708 | Div {
709 | for tag in item.tags {
710 | Link(tag.string, url: context.site.url(for: tag))
711 | .class("post-tag")
712 | }
713 | H2(item.title)
714 | .class("post-title")
715 | Paragraph(DateFormatter.time.string(from: item.date))
716 | .class("post-date")
717 | }
718 | .class("site-post-header")
719 | Div {
720 | Div {
721 | Node.contentBody(item.body)
722 | }
723 | }
724 | .class("site-post-content")
725 | }
726 | .class("site-post-article")
727 | }
728 | .class("site-post")
729 | }
730 | }
731 | ```
732 |
733 | 디자인 대로 구축한 `PostLayout` 코드입니다.
734 |
735 | ```swift
736 | for tag in item.tags {
737 | Link(tag.string, url: context.site.url(for: tag))
738 | .class("post-tag")
739 | }
740 | ```
741 | item에 있는 tag들을 가져와서 눌렀을때 Tag에 관련된 Post를 찾을 수 있는 페이지로 이동시킵니다.
742 |
743 | ```swift
744 | Node.contentBody(item.body)
745 | ```
746 | 그리고 저희가 `Content` 파일에 markdown으로 추가한 내용을 볼 수 있도록 `Node` 의 `contentBody` 메서드를 사용해주시면 됩니다.
747 |
748 | ```swift
749 | struct PostPage: Component {
750 | var item: Item
751 | var context: PublishingContext
752 |
753 | var body: Component {
754 | ComponentGroup {
755 | HeaderComponent(context: context)
756 | PostLayout(item: item, context: context)
757 | FooterComponent()
758 | }
759 | }
760 | }
761 | ```
762 |
763 | 그리고 `PostLayout`을 만들었으니, `PostPage` 에 `PostLayout`을 가져와주시면 됩니다.
764 |
765 | ```swift
766 | func makeItemHTML(for item: Item, context: PublishingContext) throws -> HTML {
767 | HTML(
768 | .lang(context.site.language),
769 | .head(for: item, on: context.site),
770 | .body {
771 | PostPage(item: item, context: context)
772 | }
773 | )
774 | }
775 | ```
776 |
777 | 다시 `HTMLFactory` 부분으로 돌아가서 `PostPage` 부분을 body에 넣어주시면
778 |
779 |
780 |
781 | 이렇게 Markdown에 있는 Post가 잘 작동하는 것을 확인할 수 있습니다.
782 |
783 |
784 | ### Page
785 |
786 | `makePageHTML` 부분은 특별하게 예시에서는 사용하고 있지 않기 때문에
787 | ```swift
788 | func makePageHTML(for page: Page, context: PublishingContext) throws -> HTML {
789 | HTML(
790 | .lang(context.site.language),
791 | .head(for: page, on: context.site),
792 | .body {
793 | page.body
794 | }
795 | )
796 | }
797 | ```
798 |
799 | `page.body` 만 보이게 해뒀습니다.
800 |
801 | ### Tag List
802 |
803 | 그 다음은 `TagList` 입니다. Post에 있는 모든 Tag들을 한번에 가져와서 볼 수 있게 하는 역할을 합니다.
804 |
805 | ```swift
806 | func makeTagListHTML(for page: Publish.TagListPage, context: PublishingContext) throws -> HTML? {
807 | HTML(
808 | .lang(context.site.language),
809 | .head(for: page, on: context.site),
810 | .body {
811 | // TagList Code
812 | }
813 | )
814 | }
815 | ```
816 | `HTMLFactory` 부분에서 `makeTagListHTML` 에서 처리할 수 있습니다.
817 |
818 |
819 | ```swift
820 | struct TagListPage: Component
821 | ```
822 |
823 | 먼저 `TagListPage` 에서 마찬가지로 Component를 생성하고
824 |
825 | ```swift
826 | let tags: Set
827 | ```
828 |
829 | `TagListPage`에서는 `Publish.TagListPage`에 있는 모든 태그를 들고 올 수 있게 `Set` 타입을 사용합니다.
830 |
831 | ```swift
832 | struct TagListPage: Component {
833 | let tags: [Tag]
834 | let context: PublishingContext
835 |
836 | var body: Component {
837 | ComponentGroup {
838 | HeaderComponent(context: context)
839 | List(tags) { tag in
840 | ListItem {
841 | Link(tag.string, url: context.site.url(for: tag))
842 | }
843 | .class("site-tag")
844 | }
845 | .class("site-tagList")
846 | FooterComponent()
847 | }
848 | }
849 | }
850 | ```
851 | 그리고 `tags` 를 List에 넣어서 보여주고 `styles.css` 에서 간단한 css 코드만 추가해주었습니다.
852 |
853 | ```swift
854 | func makeTagListHTML(for page: Publish.TagListPage, context: PublishingContext) throws -> HTML? {
855 | HTML(
856 | .lang(context.site.language),
857 | .head(for: page, on: context.site),
858 | .body {
859 | TagListPage(tags: page.tags, context: context)
860 | }
861 | )
862 | }
863 | ```
864 |
865 | 다시 `HTMLFactory` 코드로 돌아와서 `TagListPage`를 넣어줍니다.
866 |
867 |
868 |
869 | 이렇게 디자인과 같은 TagList 페이지를 얻을 수 있습니다.
870 |
871 | ### TagDetail
872 |
873 | 마지막 `TagDetail` 부분입니다. 지정된 tag를 눌렀을 때 이 tag를 가지고 있는 post를 보여주는 역할을 합니다.
874 |
875 | ```swift
876 | func makeTagDetailsHTML(for page: Publish.TagDetailsPage, context: PublishingContext) throws -> HTML? {
877 | HTML(
878 | .lang(context.site.language),
879 | .head(for: page, on: context.site),
880 | .body {
881 | // Tag Details Code
882 | }
883 | )
884 | }
885 | ```
886 |
887 | `HTMLFactory`에서 마지막 하나 남은 메서드인 `makeTagDetailsHTML` 에서 작업을 하실 수 있습니다.
888 |
889 |
890 | ```swift
891 | struct TagDetailPage: Component
892 | ```
893 |
894 | `TagDetailPage`라는 Component를 선언하고
895 |
896 | ```swift
897 | let items: [Item]
898 | let selectedTag: Tag
899 | ```
900 |
901 | - `items`: Tag에 포함된 post들을 가져옵니다.
902 | - `selectedTag`: 선택된 Tag의 정보를 알려줍니다.
903 |
904 | 위의 프로퍼티들을 추가해주시고
905 |
906 | ```swift
907 | struct TagDetailsPage: Component {
908 | let items: [Item]
909 | let context: PublishingContext
910 | let selectedTag: Tag
911 |
912 | var body: Component {
913 | ComponentGroup {
914 | HeaderComponent(context: context)
915 | Div {
916 | H2(selectedTag.string)
917 | PostsLayout(items: items)
918 | }
919 | .class("site-tagDetails")
920 | FooterComponent()
921 | }
922 | }
923 | }
924 | ```
925 |
926 | 기존의 `PostsLayout` 을 가져오는 방식으로 간단하게 처리하고
927 |
928 | ```swift
929 | func makeTagDetailsHTML(for page: Publish.TagDetailsPage, context: PublishingContext) throws -> HTML? {
930 | HTML(
931 | .lang(context.site.language),
932 | .head(for: page, on: context.site),
933 | .body {
934 | TagDetailsPage(
935 | items: context.items(
936 | taggedWith: page.tag,
937 | sortedBy: \.date
938 | ),
939 | context: context,
940 | selectedTag: page.tag
941 | )
942 | }
943 | )
944 | }
945 | ```
946 |
947 | 이렇게 `HTMLFactory`의 `makeTagDetailsHTML`에 넣어주시면 되는데,
948 |
949 | ```swift
950 | items: context.items(
951 | taggedWith: page.tag,
952 | sortedBy: \.date
953 | ),
954 | ```
955 | `items`를 가져오는 부분을 보면 context의 items를 가져오는데 `page.tag`가 포함되어 있는 `item` 만 가져오게 해준다라고 생각하시면 될것 같습니다.
956 |
957 |
958 |
959 | 이런식으로 Tag를 눌렀을때 잘 조회가 되는것을 확인할 수 있습니다.
960 | 이렇게 해서 저희는 Publish로 웹사이트 하나를 뚝딱 만들어 봤습니다.
961 |
962 | 위에서 진행한 내용은 [예시코드](https://github.com/Jihoonahn/Blog-Document/tree/main/Publish/part2) 를 확인할 수 있습니다.
963 |
964 | ---
965 |
966 | 이번 글에서는 Publish에서 HTML을 작성하는 방법과, 직접 예제를 만들어보며 Publish를 이용해서 실질적인 웹사이트를 만들어 봤습니다.
967 |
968 | 다음글에서는 Publish로 만든 웹사이트를 배포하는 법에 대해서 작성할 예정입니다.
969 |
--------------------------------------------------------------------------------
/Content/blog/publish-part-3.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Publish 사용하기 part 3
3 | date: 2023-11-18 15:48
4 | tags: Swift, Publish, Web, Theory
5 | description: Swift publish 배포하기
6 | postImage: https://github.com/jihoonahn/blog/assets/68891494/96d08e16-276c-4ef0-8091-69add54284ef
7 | ---
8 |
9 | ## Publish 배포 준비하기
10 | 일단 배포 준비를 하기 위해서는 publish pipeline에서 수정할 부분이 있습니다.
11 |
12 | ```swift
13 | // This will generate your website using the built-in Foundation theme:
14 | try Example().publish(using: [
15 | .optional(.copyResources()),
16 | .addMarkdownFiles(),
17 | .generateHTML(withTheme: .publish),
18 | .generateRSSFeed(including: [.blog]),
19 | .generateSiteMap(),
20 | /// Deploy 관련
21 | .deploy(using: ///배포 방식 )
22 | ])
23 | ```
24 | 기존에 만든 pipeline에서 `deploy(using:)` 메서드를 추가해줍니다.
25 |
26 |
27 | ```swift
28 | static func gitHub(
29 | _ repository: String,
30 | branch: String = "master",
31 | useSSH: Bool = true
32 | ) -> DeploymentMethod
33 | ```
34 | - repository: 프로젝트 Repository
35 | - branch: 배포할 Branch
36 | - useSSH: SSH 키를 사용하는지 여부
37 |
38 |
39 | ```swift
40 | .deploy(using: .git("git@ios.github.com:jihoonahn/ExamplePublish", branch: "gh-pages"))
41 | ```
42 |
43 | 저는 ssh키를 여러개를 사용하는 관계로 그냥 git으로 작성 하였습니다.
44 |
45 | 그리고 Website 부분에서
46 |
47 | ```swift
48 | struct Example: Website {
49 | enum SectionID: String, WebsiteSectionID {
50 | case blog
51 | case about
52 | var name: String {
53 | switch self {
54 | case .blog: return "Blog"
55 | case .about: return "About"
56 | }
57 | }
58 | }
59 |
60 | struct ItemMetadata: WebsiteItemMetadata {
61 | // Add any site-specific metadata that you want to use here.
62 | }
63 |
64 | // Update these properties to configure your website:
65 | var url = URL(string: /* URL 값 입력 */ )!
66 | var name = "Example"
67 | var description = "A description of Part"
68 | var language: Language { .english }
69 | var imagePath: Path? { nil }
70 | }
71 | ```
72 |
73 | URL 값을 입력해줍니다.
74 |
75 | ```swift
76 | var url = URL(string: "https://jihoonahn.github.io/")!
77 | ```
78 |
79 | Publish CLI를 보게 되면
80 |
81 | ```
82 | Publish Command Line Interface
83 | ------------------------------
84 | Interact with the Publish static site generator from
85 | the command line, to create new websites, or to generate
86 | and deploy existing ones.
87 |
88 | Available commands:
89 |
90 | - new: Set up a new website in the current folder.
91 | - generate: Generate the website in the current folder.
92 | - run: Generate and run a localhost server on default port 8000
93 | for the website in the current folder. Use the "-p"
94 | or "--port" option for customizing the default port.
95 | - deploy: Generate and deploy the website in the current
96 | folder, according to its deployment method.
97 | ```
98 |
99 | 이런식으로 deploy 관련 커맨드가 있습니다.
100 |
101 | ```swift
102 | $ publish generate
103 | $ publish deploy
104 | ```
105 |
106 | 이렇게 명령어를 순차적으로 작성하게 되면,
107 |
108 |
109 |
110 | 이렇게 Deploy가 완료가 됬다는 내용과 함께
111 |
112 |
113 | - ❌ 포스트 올라간 후에 삭제될 Repository입니다.
114 |
115 | 이렇게 gh-pages에 잘 배포가 되는것을 확인 할 수 있습니다.
116 |
117 |
118 | 위에서 진행한 내용은 [예시코드](https://github.com/Jihoonahn/Blog-Document/tree/main/Publish/part3) 를 확인할 수 있습니다.
119 |
120 | ---
121 |
122 | 이번 글에서는 Publish에서 Deploy 하는 방법에 대해서 알아보면서,
123 | 총 publish를 사용하는 방법에 대해서 3개로 파트를 나눠서 제작을 하였습니다.
124 |
125 | 여러분도 한번 Publish로 자기만의 blog를 만들어 보는것은 어떤가요?
126 |
--------------------------------------------------------------------------------
/Content/blog/scade-introduce.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Scade 소개
3 | date: 2023-03-17 21:24
4 | tags: Swift, Cross Platform, Scade
5 | description: Swift로 크로스플랫폼 만드는 방법을 아시나요?
6 | postImage: https://github.com/jihoonahn/blog/assets/68891494/1f836083-815e-4bc1-b92e-89cf2251a221
7 | ---
8 |
9 | ## What is Scade?
10 |
11 | Swift로 iOS 와 Android 개발을 동시에 할 수 있는 크로스 플랫폼입니다.
12 |
13 |
14 |
15 | [scade.io](https://www.scade.io)
16 |
17 | 전용 툴을 제공하며, 위 [링크](https://www.scade.io/download/)에서 다운 받을 수 있다.
18 | Scade의 [공식문서](https://docs.scade.io/docs) 입니다.
19 |
20 | ---
21 |
22 | ### 어떻게 동작되는 걸까?
23 |
24 | Swift코드를 네이티브 iOS와 Android를 바이너리로 컴파일하여, 앱을 빌드합니다.
25 | 현재 기준 Scade는 Swift 5.4를 지원합니다.
26 |
27 |
28 | 위 링크를 통해 전용 툴을 다운로드 받았다면
29 |
30 |
31 | 이러한 어플리케이션을 확인할 수 있을겁니다.
32 |
33 | 그리고 Xcode와 AndroidStudio 설치까지 마치셨다면, [공식문서](https://docs.scade.io/docs/getting-started)를 보고 세팅해주시면 됩니다.
34 |
35 | **중요**
36 |
37 |
38 | 이 부분의 세팅할 때 주의하시는 것이 좋습니다.
39 |
40 | ### 프로젝트 생성하기
41 |
42 | IDE 내부에서 FILE| Name | New Project 로 프로젝트를 생성해줍니다.
43 |
44 |
45 |
46 |
47 | Scade IDE에서 프로젝트를 생성해주면 됩니다.
48 |
49 |
50 |
51 | Scade같은 경우 3가지 종류의 로 빌드가 가능합니다.
52 | 자체 시뮬레이터인 Scade Simulator, iOS의 Simulator, Android Emulator
53 |
54 | 뷰 같은 경우 .page 파일에서 스토리보드와 비슷하게 되어 있는것을 확인 할 수 있고,
55 |
56 |
57 |
58 | 우측 + 버튼을 눌러서 Component를 가져올 수 있습니다.
59 |
60 |
61 |
62 | 원하는 컴포넌트를 Drag & Drop 해주면 됩니다. (Storyboard와 같은 느낌이죠?)
63 |
64 |
65 |
66 | Scade IDE 우측에 있는 옵션들을 수정하여, Component를 설정 할 수도 있습니다.
67 |
68 | ### 실행
69 | 한번 Android와 iOS에서 잘 돌아가는지 확인해 보겠습니다.
70 |
71 |
72 |
73 | 좌 iOS Simulator, 우 Android Emulator
74 |
75 | 같은 UI로 잘 돌아가는 것을 확인 할 수 있습니다.
76 |
77 | ### Scade를 사용해보고 느낀점
78 | 현재 꾸준히 개발되고 있지만 현재는 Beta 버전이고, 현재 공개된 [Scade Platform Github](https://github.com/scade-platform)는 이곳입니다.
79 | 아쉽게도 Scade SDK는 오픈소스는 아니기 때문에 뭔가 아쉽다 라는 느낌을 받긴 했지만, Swift로 iOS, Android CrossPlatform 개발이 된다는 점에서 신기한 느낌을 받고, IDE에서 Storyboard와 같은 기능을 지원하는것도 신기했습니다.
80 |
81 | 아직 부족한 부분은 분명 있지만 현재 베타버전임을 감안하고, 몇년에 걸쳐서 개발이 되는것을 보아,
82 | 추후 정식 출시날도 기다려집니다.
83 |
84 | 제가 개인적으로 느낀 점은, 그저 신기하다에 그치지 않고 Scade는 생각보다 놀라운 도구 였습니다.
85 |
86 | 저의 주 언어인 Swift를 가지고 Android 와 iOS를 동시 개발 가능하게 해주기 때문에, 저에게는 큰 흥미를 주었던 것 같습니다.
87 |
88 | 현재 Scade의 미래가 어떻게 될지 모르지만 Scade는 Beta에서만 끝나지 않고, 늦어도 좋으니 정식 출시까지 했으면 좋겠다는 생각이 들었습니다. (추후 Apple에서 비슷한 걸 제공해도 좋고요 ㅎㅎ)
89 |
90 | ---
91 |
92 |
93 |
94 | 이번에는 Scade에 대해서 소개하는 글이기 때문에, 간단하게 소개했기 때문에 여기에서 글을 끝내고
95 | 나중에 Scade를 더 깊게 사용해보며, 글을 적도록 하겠습니다.
96 |
--------------------------------------------------------------------------------
/Content/blog/swiftui-need-mvvm.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: SwiftUI에 MVVM이 필요할까요?
3 | date: 2022-9-21 12:00
4 | tags: Swift, Architecture, SwiftUI, Debate
5 | description: 요즘 이슈가 되고 있는 내용으로, 과연 SwiftUI에는 MVVM이 필요한지에 대한 저의 주관적인 생각을 담은 글입니다.
6 | postImage: https://github.com/jihoonahn/blog/assets/68891494/c8929a18-22ca-4ac8-9f73-82aab7ade960
7 | ---
8 |
9 | ## SwiftUI MVVM issue?
10 |
11 |
12 |
13 | [Stop using MVVM for SwiftUI (apple developer forms)](https://developer.apple.com/forums/thread/699003)
14 | [Stop using MVVM with SwiftUI (medium post)](https://medium.com/@karamage/stop-using-mvvm-with-swiftui-2c46eb2cc8dc)
15 |
16 |
17 | 위 글을 보면 SwiftUI에서 MVVM 사용을 멈추자는 의견을 제시하고 있습니다.
18 |
19 | “SwiftUI에서 MVVM 사용 중지”라는 강력한 주제로 저의 관심을 끌었습니다.
20 |
21 | 글은 꽤나 논리적인 글이라고 생각이 되었습니다. SwiftUI내에서 MVVM의 사용을 의심하지 않았던 저에게는 진짜 많은 생각을 하게 만들었었습니다.
22 |
23 |
24 |
25 | [Is MVVM an anti-pattern in SwiftUI?](https://www.reddit.com/r/swift/comments/m60pv7/is_mvvm_an_antipattern_in_swiftui/)
26 |
27 | Reddit에서도 issue가 된 내용입니다.
28 |
29 |
30 |
31 | ### 여기서부터는 저의 생각이 들어갔습니다.
32 |
33 | SwiftUI는? 선언형 뷰 프로그래밍 방식입니다.
34 |
35 | **선언형 UI에서는 ViewModel은 필요할까** 라는 주제의 여러 글들을 보고 따로 공부와 여러가지 생각을 했습니다.
36 |
37 | 옛날에는 “MVVM이 무조건 좋다” 라는 인식이 존재했습니다. 그런데 SwiftUI로 개발을 하면서 억지로 ViewModel을 만드는 상황이 발생하고 있습니다.
38 |
39 | ViewModel은 비즈니스 로직을 분리하는 목적으로 사용할 수 있기 때문에 ViewModel이 완전히 나쁘다 라고 하기는 어려울것 같습니다.
40 |
41 | 하지만 저는 SwiftUI의 View는 이미 View + ViewModel라고 생각하기 때문에 저는 불필요하다고 생각합니다.
42 |
43 |
44 |
45 |
46 | ### SwiftUI에서의 View는 이미 View+ViewModel 입니다.
47 |
48 | ```swift
49 | import SwiftUI
50 |
51 | struct Content: View {
52 | @State var model = Themes.listModel
53 |
54 | var body: some View {
55 | List(model.items, action: model.selectItem) { item in
56 | Image(item.image)
57 | VStack(alignment: .leading) {
58 | Text(item.title)
59 | Text(item.subtitle)
60 | .color(.gray)
61 | }
62 | }
63 | }
64 | }
65 | ```
66 | medium 블로그 글의 예시를 가져왔습니다.
67 |
68 | 예시처럼 SwiftUI의 View는 원래부터 데이터 바인딩 기능을 포함하고 있기 때문에, 모델 값을 View에 직접 Reactive하게 반영 할 수 있습니다.
69 |
70 | ViewModel은 원래 상태를 View에 Binding하여 Reactive에 반영하기 위한 목적으로 도입되었습니다.
71 |
72 | 하지만 위 예시처럼 선언적 UI에는 해당 기능이 포함되어 있으므로 ViewModel은 필요하지 않다고 생각합니다.
73 |
74 |
75 |
76 | ### 우리가 왜 MVVM이 무조건 좋다고 생각했을까요?
77 |
78 | 이것은 기존 사용했던 UIKit을 보고 알 수 있었습니다.
79 |
80 | 기존 코드에서는 rx를 통해 데이터 바인딩을 해주는 코드를 사용했습니다. (RxSwift를 사용했을 때)
81 | 흔하게 알고 있는 ViewModel을 통해서 뷰와 데이터 바인딩을 해주는 MVVM 구조입니다.
82 |
83 | ViewModel의 가장 중요한 역할은 데이터 바인딩입니다. 모델과 뷰 사이에 양방향 통신을 해주면서 바인딩을 시켜줍니다.
84 |
85 | **하지만 SwiftUI에서는 View에서 다 해줄 수 있기 때문에 필요가 없다는 생각이 됩니다.**
86 |
87 |
88 | ### SwiftUI에 MVVM을 사용하는 것은 복잡도를 올리게 됩니다.
89 |
90 | SwiftUI에서 MVVM을 사용하게 되면, ViewModel이라는 레이어가 추가되기 때문에 복잡도가 증가합니다.
91 |
92 | 또한 Data Flow는 ViewModel이 View와 Model의 중간 레이어와 함께 배치되어서 양방향으로 동작하게 됩니다.
93 |
94 |
95 |
96 |
97 |
98 | [Apple Document](https://developer.apple.com/documentation/swiftui/model-data)
99 |
100 | **선언형 UI를 사용하는 환경에서는 단방향 데이터 플로우 구조를 지향합니다.**
101 |
102 | 현재 많은 개발자들이 아키텍처 패턴으로 MVVM을 사용합니다.
103 |
104 | 많은 자료들이 SwiftUI + MVVM을 사용하는 방법에 대해서 설명을 하고 있기도 합니다.
105 |
106 |
107 |
108 | ## 이미 Vue나 React, Flutter 모두 MVVM을 사용하고 있지 않습니다.
109 |
110 | 세가지의 모두 공통점으로 선언형 UI를 사용한다는 것을 알 수 있습니다.
111 |
112 | 그러면 MVVM 말고 SwiftUI에서 무엇을 사용해야 할까요?
113 |
114 | ### 그럼 뭘 사용하라는 건가
115 |
116 | ViewModel을 사용하지 않는다면 비즈니스 로직과 UI 로직을 어떻게 어디서 분리해야 할까요?
117 |
118 | [Realm](https://www.youtube.com/watch?v=mTv96vqTDhc&t=756s)에서는 MVI 접근 방식을 지향한다고 합니다.
119 |
120 | 3가지 방법을 생각해볼 수 있습니다.
121 |
122 | 1. Model에서 이를 구현한다. (MV)
123 | 2. MVI (Model-View-Intent)
124 | 3. Flux 개념의 Store로 분리한다.
125 |
126 | 첫번째 방법은 선언적 UI에 어울리는 단방향 플로우의 장점을 챙겨가지 못하기 때문에 적합하지 않고,
127 |
128 | 그렇기 때문에 MVI 와 Flux 및 Store/Provider 패턴이 적합하다고 생각합니다.
129 |
130 | ---
131 |
132 | 저는 이 논쟁에 대해 저의 생각을 답글에 달았습니다.
133 |
134 |
135 |
136 |
--------------------------------------------------------------------------------
/Content/blog/tuist-xcodebeta.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Xcode-Beta에서 Tuist 사용기
3 | date: 2023-6-10 00:03
4 | tags: Swift, Tuist, Beta
5 | description: Xcode Beta에서 tuist edit 명령어에서 에러나는 부분을 해결하는 방법에 대한 포스트입니다.
6 | postImage: https://github.com/jihoonahn/blog/assets/68891494/0e65fb0b-e71a-4253-91e0-98067fc522f1
7 | ---
8 |
9 | ## Beta를 사용했을때
10 |
11 | 최근에 WWDC23이 공개되었습니다. macOS에서 발표된 내용을 보고 macOS 14와 Xcode 15의 변화에 대해서 보기 위해서, 업데이트를 했습니다.
12 |
13 |
14 |
15 | 그렇게 봉인된.. Xcode..
16 |
17 | macOS 14에서는 기존 Xcode 14.3.1(글 작성 기준)를 사용하지 못하게 됩니다.
18 | 그래서 Xcode 15를 설치해야합니다.
19 |
20 | [Xcode 설치 링크](https://xcodereleases.com/)
21 |
22 |
23 | ## 어떤 문제가 있었나..
24 |
25 | 그렇게, Xcode 15를 설치하고 Tuist를 실행 했을 때, 이런 문제가 있더군요.
26 | Tuist에서 `tuist edit` 명령어를 실행했을 때,
27 |
28 |
29 |
30 | 이런 식으로 실행이 안되는 문제가 있었습니다.
31 | 이유는.. 위에서 빌드업 했지만, 문제는 [Tuist Command Service](https://github.com/tuist/tuist/blob/main/Sources/TuistKit/Services/EditService.swift) 부분에 있었습니다.
32 |
33 |
34 | ## 문제는 어디서?
35 |
36 | ```swift
37 | try opener.open(path: workspacePath, application: selectedXcode.path, wait: true)
38 | ```
39 | [tuist > Sources > TuistKit > Services > EditService.swift](https://github.com/tuist/tuist/blob/main/Sources/TuistKit/Services/EditService.swift)
40 | 의 78번째 줄
41 |
42 | 위 코드 부분에서 에러가 발생합니다.
43 |
44 | Xcode 앱을 실행시키는 코드이고, 현재 Xcode는 위 그림처럼 봉인(?)당했기 때문에 Xcode앱을 열 수 없는 것입니다.
45 |
46 | 나머지 명령어에서는 문제가 없었지만, `tuist edit` 명령어에서만 문제가 생기더라고요.
47 | 이 문제에 대한 해결 방법은 없을까요?
48 |
49 |
50 | ## 해결 방법
51 |
52 | ### 1. Tuist 명령어만으로 해결하는 방법
53 |
54 |
55 |
56 | `tuist edit -h`를 실행시켜 명령어를 찾아봅시다.
57 |
58 | Tuist 공식 깃허브 코드에서는 permanent가 true면, Xcode앱을 실행시키지 않습니다.
59 |
60 | ```swift
61 | let workspacePath = try projectEditor.edit(
62 | at: path,
63 | in: path,
64 | onlyCurrentDirectory: onlyCurrentDirectory,
65 | plugins: plugins
66 | )
67 | logger.notice("Xcode project generated at \(workspacePath.pathString)", metadata: .success)
68 | ```
69 |
70 | 그렇기 떄문에, `tuist edit --permanent` 명령어를 실행하면?
71 |
72 |
73 |
74 | 에러가 발생하여 동작이 실패하지 않고, 아래 처럼 프로젝트와 워크스페이스 파일이 생성됩니다.
75 |
76 |
77 |
78 | 이런식으로 진행이 됬다면, Manifests.xcworkspace 파일을 눌러서, tuist 프로젝트를 수정할 수 있습니다.
79 |
80 | ### 2. xcode-select의 path를 변경하는 방법
81 |
82 |
83 |
84 |
85 | 이 방법은 [baekteun](https://github.com/baekteun) 이라는 후배가 영감을 준 방법입니다.
86 |
87 |
88 | 터미널에서
89 |
90 | ```bash
91 | sudo xcode-select -s /Applications/Xcode-beta.app/Contents/Developer
92 | ```
93 |
94 | 이렇게 xcode-select에서 path를 변경해 줍니다.
95 |
96 | 그 이후 다시 `tuist edit` 명령어를 실행하면 됩니다.
97 |
98 |
99 |
100 | 그렇게 되면 정상적으로 `tuist edit` 명령어가 작동합니다.
101 |
102 |
103 | ## 후기
104 |
105 | 처음에 `tuist edit` 명령어가 작동하지 않아서 tuist의 명령어 코드를 보다가 첫번째 방법은 발견하게 되었고, 두번째 방법은 위에서 말했듯 후배에게 영감을 받아서 얻은 방법입니다.
106 |
107 | Tuist에서 `Sources/TuistSupport/Xcode/XcodeController.swift` 부분을 보게 되면, `xcode-select -p` 를 통해서 Xcode의 developer 파일 경로를 받아오는 방식입니다.
108 | ```swift
109 | /// Returns the selected Xcode. It uses xcode-select to determine
110 | /// the Xcode that is selected in the environment.
111 | ///
112 | /// - Returns: Selected Xcode.
113 | /// - Throws: An error if it can't be obtained.
114 | public func selected() throws -> Xcode? {
115 | // Return cached value if available
116 | if let cached = selectedXcode {
117 | return cached
118 | }
119 |
120 | // e.g. /Applications/Xcode.app/Contents/Developer
121 | guard let path = try? System.shared.capture(["xcode-select", "-p"]).spm_chomp() else {
122 | return nil
123 | }
124 |
125 | let xcode = try Xcode.read(path: try AbsolutePath(validating: path).parentDirectory.parentDirectory)
126 | selectedXcode = xcode
127 | return xcode
128 | }
129 | ```
130 |
131 | 근데 처음에 시도할때는 `xcode-select` 명령어의 option에서 path를 따로 바꿀 수 있다는 사실을 망각하고 있었기 때문에, 다양한 방식을 찾은 거 같습니다.
132 |
133 | 어쨋든 Xcode-beta 또는 다른 버전의 Xcode 앱을 설치하고 `tuist edit` 명령어가 작동하지 않아서 당황하신 분들을 위해서 이 글을 적었습니다.
134 |
135 | 이 방식 외 더 좋은 방식에 대해서 알고 계신 분이 있다면, blog 댓글에 알려주세요.
136 |
137 |
--------------------------------------------------------------------------------
/Content/blog/tuistui.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: TuistUI
3 | date: 2025-04-08 23:29
4 | tags: Swift, Tuist, Plugin, Tuistui
5 | description: Tuist를 SwiftUI 처럼 사용할 수 있는 DSL Plugin인 제가 만든 TuistUI를 소개합니다.
6 | postImage: https://github.com/user-attachments/assets/0751f541-5b07-451b-ac85-85f6a7d5561a
7 | ---
8 |
9 | # TuistUI
10 |
11 | 잠시 군대 복무 때문에 블로그 작성과 활동에 제약(개발 환경 등등)이 생겨서 글을 작성하지 못한 상태였으나, 이제 곧 전역을 준비하며.. 다시 시작 해볼려고 합니다. 이번에는 제가 애정있는 프로젝트이자, 군대에서도 유지보수를 했었고, 외출/외박때도 열심히 기능을 생각하던 애착 프로젝트를 소개할려고 합니다.
12 |
13 | > TuistUI는 Tuist를 SwiftUI처럼 가독성 있게 사용하기 위해 만든 Tuist DSL Plugin입니다.
14 |
15 | TuistUI를 사용하기 위해서는 `Config.swift` 에 아래 코드를 추가하면 됩니다.
16 |
17 | ```swift
18 | import ProjectDescription
19 |
20 | let config = Config(
21 | plugins: [
22 | .git(url: "https://github.com/jihoonme/tuistui", tag: "vTAG")
23 | ]
24 | )
25 | ```
26 |
27 | ## 플러그인 개발 동기
28 |
29 | 아이디어는 SwiftUI를 보면서 하게 되었습니다.
30 |
31 | 기존의 Tuist 같은 경우는 `Project` 생성과 `Workspace` 생성 부분이 마치 `Package.swift` 와 비슷하다고 생각이 들었습니다. 하지만 그 스타일에서 벗어나 SwiftUI처럼 한눈에 직관적으로 보이는 느낌으로 만들고 싶었습니다.
32 |
33 | ```swift
34 | struct BaseView: View {
35 | var body: some View {
36 | VStack {
37 | Text("Hello World")
38 | }
39 | }
40 | }
41 | ```
42 |
43 | 관심있게 보던 프로젝트인 [`Tokamak`](https://github.com/TokamakUI/Tokamak) 프로젝트와 [`web`](https://github.com/swifweb/web) 그리고 [`Plot`](https://github.com/JohnSundell/Plot) 프로젝트를 참고해서 TuistUI의 전체적인 틀을 잡게 되었습니다.
44 |
45 | # 어떤 기능을 가지고 있는가
46 |
47 | ## Module 기능
48 |
49 | `TuistUI`는 Project든 Workspace든 `Module` 이라는 프로토콜을 상속 받는것으로 시작이 됩니다.
50 |
51 | ```swift
52 | struct BaseFeature: Module {}
53 | ```
54 |
55 | Module을 상속을 받게 되면 SwiftUI 처럼
56 |
57 | ```swift
58 | struct BaseFeature: Module {
59 | var body: some Module {}
60 | }
61 | ```
62 |
63 | body 값을 받을수 있는 프로퍼티가 생성이 됩니다.
64 |
65 | 만약에 Project를 고르고 싶다면
66 |
67 | ```swift
68 | struct BaseFeature: Module {
69 | var body: some Module {
70 | Project {}
71 | }
72 | }
73 | ```
74 |
75 | `Module`을 상속 받고 있는 `Project` 를 body 프로퍼티 안에 적어두면 됩니다.
76 |
77 | 그 후에 설정할 값들을 넣어주고
78 |
79 | ```swift
80 | struct BaseFeature: Module {
81 | var body: some Module {
82 | Project {
83 | /// Target Code
84 | }
85 | .organizationName("")
86 | .package {
87 | /// Package Code
88 | }
89 | }
90 | }
91 | ```
92 |
93 | 기존 Tuist 의 `name:` 부분은
94 |
95 | ```swift
96 | let project = Project(
97 | name: "BaseFeature"
98 | ...
99 | )
100 | ```
101 |
102 | 구조체명을 자동적으로 가져오게 설정해뒀습니다.
103 |
104 | `Project.swift` 파일에 아래 코드만 추가해주시면 됩니다.
105 |
106 | ```swift
107 | let project = BaseFeature().module()
108 | ```
109 |
110 | 그리고 `Workspace` 같은 경우도 똑같이 생성이 됩니다.
111 |
112 | Module 안에 Workspace에 필요한 값을 넣어주고
113 |
114 | ```swift
115 | struct TuistApp: Module {
116 | var body: some Module {
117 | Workspace {
118 | Path.relativeToRoot("Projects/App")
119 | }
120 | .scheme {
121 | /// Scheme Code
122 | }
123 | }
124 | }
125 | ```
126 |
127 | `Workspace.swift` 파일 안에 위 코드만 추가하면 됩니다.
128 |
129 | ```swift
130 | let workspace = TuistApp().module()
131 | ```
132 |
133 | ## Constant 값 관리
134 |
135 | SwiftUI에서 `@EnvironmentObject` 와 `ObservableObject` 부분을 보고 공통된 Constant를 관리해줄수 있는 공간이 있다면 괜찮을거 같다는 생각을 가지게 되어서 만들게 되었습니다.
136 |
137 | `ModuleObject`라는 프로토콜을 만들었고, 그곳 내부에서 Module 에서 사용되는 값들을 전체적으로 관리할수 있도록 구성했습니다.
138 |
139 | ```swift
140 | struct AppEnvironment: ModuleObject {
141 | static let organizationName: String = "jihoonme"
142 | static let destinations: Destinations = .iOS
143 | static let deploymentTargets: DeploymentTargets = .iOS("15.0")
144 | }
145 | ```
146 |
147 | 그리고 SwiftUI에서 `@EnvironmentObject` 가 존재하듯 `@Constant` 라는 propertywrapper를 Module내부에 추가해주시면 값들을 편리하게 사용할 수 있습니다.
148 |
149 | ```swift
150 | struct BaseProject: Module {
151 | @Constant var env = AppEnvironment()
152 |
153 | var body: Module {
154 | Project {
155 | // Target
156 | }
157 | .organizationName(env.organizationName)
158 | }
159 | }
160 | ```
161 |
162 | ## Configuration 기능
163 |
164 | 마지막으로 고민했던 부분은 Configuration관련 내용입니다. TuistUI를 확장하며, Configuration 기능에 좀 `TCA` 처럼 정규화된 코드를 작성하면 이 플러그인만 가져오면 관리를 편하게 할 수 있겠다고 생각하게 되었습니다.
165 |
166 | 그래서 `TCA` 의 `Reducer` 스타일을 살짝 빌려서
167 |
168 | ```swift
169 | struct AppConfiguration: XCConfig {
170 |
171 | enum XCConfigTarget: String, XCConfigTargetType {
172 | case baseProject
173 |
174 | var path: Path {
175 | switch self {
176 | case .baseProject:
177 | return .relativeToRoot("XCConfig/baseProject")
178 | }
179 | }
180 | }
181 |
182 | var body: some XCConfigOf {
183 | Configure ({
184 | switch $0 {
185 | case .baseProject:
186 | return [
187 | // Write Configuration Method
188 | ]
189 | }
190 | })
191 | }
192 | }
193 | ```
194 |
195 | `XCConfigTarget`을 지정하고 body 프로퍼티에서 Configuration Method를 관리해줄수 있도록 만들었습니다.
196 |
197 | ```swift
198 | var body: some XCConfigOf {
199 | Configure ({
200 | switch $0 {
201 | case .A:
202 | return [
203 | .debug(into: $0, name: .dev)
204 | .release(into: $0, name: .prod)
205 | ]
206 | }
207 | })
208 | }
209 | ```
210 |
211 | 이렇게 추가하고
212 |
213 | ```swift
214 | struct BaseProject: Module {
215 | let config = AppConfiguration()
216 |
217 | var body: Module {
218 | Project {
219 | // Target
220 | }
221 | .settings(
222 | .settings(
223 | configurations: config.configure(into: .baseProject)
224 | )
225 | )
226 | }
227 | }
228 | ```
229 |
230 | settings 부분에서 configurations를 추가하면 보기 좋게 관리 할수 있도록 만들었습니다.
231 |
232 | ## 고민했던 부분
233 |
234 | 플러그인 개발을 진행하면서 이런 부분은 고민을 많이 했습니다.
235 |
236 | - Templates 제공 때 어떻게 제공하는게 좋을지
237 | - Moduler Architecture 부분까지 제공을 할지
238 |
239 | 위 두가지 고민이 저에게 가장 큰 고민이였습니다.
240 |
241 | ### Templates 제공 때 어떻게 제공하는게 좋을지
242 |
243 | 이 부분은 생각보다 고민시간이 많이 걸렸습니다. 기존 대부분의 Templates 처럼 그냥 `Project.swift` 랑 `Workspace.swift` 파일을 생성하게 만들려고 했지만, `ProjectDescriptionHelpers` 부분에 초점을 두게 되었습니다.
244 |
245 | 실질적으로 `Module` 부분은 Project를 Description 해주는 부분이라고 생각이 들었습니다.
246 |
247 | ```swift
248 | struct BaseFeature: Module {
249 | var body: some Module {
250 | Project {
251 | /// Target Code
252 | }
253 | .organizationName("")
254 | .package {
255 | /// Package Code
256 | }
257 | }
258 | }
259 | ```
260 |
261 | 사실상 Project는 `Project.swift` 라는 부분에서 하는 역할은
262 |
263 | ```swift
264 | let project = BaseFeature().module()
265 | ```
266 |
267 | 생성에 관련된 코드는 이 부분이 하고 있기 때문에 프로젝트 생성을 했을때 `Project.swift` 보다는 `ProjectDescriptionHelper` 에 생성되는게 더 의미가 맞겠다라고 생각을 해서
268 |
269 | Project Template를 생성해주면
270 |
271 | ```
272 | .
273 | ├── Projects
274 | │ └── App
275 | │ └── Project.swift //<- Project.swift file Generate
276 | ├── Tuist
277 | │ ├── ProjectDescriptionHelpers
278 | │ └── Projects
279 | │ └── DemoProject.swift //<- DemoProject.swift file Generate
280 | │ ├── Dependencies.swift
281 | │ ├── Config.swift
282 | │ └── Package.swift
283 | └── [README.md](http://readme.md/)
284 | ```
285 |
286 | 이렇게 생성이 되며
287 |
288 | Workspace도 마찬가지로 Template를 생성해주면
289 |
290 | ```
291 | .
292 | ├── Projects
293 | │ └── App
294 | │ └── Workspace.swift //<- Workspace.swift file Generate
295 | ├── Tuist
296 | │ ├── ProjectDescriptionHelpers
297 | │ └── Workspace
298 | │ └── DemoApp.swift //<- DemoApp.swift file Generate
299 | │ ├── Dependencies.swift
300 | │ ├── Config.swift
301 | │ └── Package.swift
302 | └── README.md
303 | ```
304 |
305 | 이렇게 생성되게 만들어뒀습니다.
306 |
307 | ### Moduler Architecture 부분까지 제공을 할지
308 |
309 | 이부분은 기존에 또다른 플러그인인 [microfeature](https://github.com/jihoonahn/microfeature) 플러그인을 만들어보며 또 다른 플러그인으로 제공을 하게되면
310 |
311 | microfeature plugin 코드중
312 |
313 | ```swift
314 | struct ExampleModule: Module {
315 | var body: some Module {
316 | Project {
317 | Microfeature {
318 | Example(name: typeName)
319 | Interface(name: typeName)
320 | Sources(name: typeName)
321 | Testing(name: typeName)
322 | UnitTests(name: typeName)
323 | UITests(name: typeName)
324 | }
325 | }
326 | }
327 | }
328 | ```
329 |
330 | 이렇게 Microfeature라는 Method로 감싸야되는 상황이 나오게 됬습니다.
331 |
332 | ```swift
333 | public func Microfeature(@TargetBuilder content: () -> [Target]) -> [Target] {
334 | return content()
335 | }
336 | ```
337 |
338 | Microfeature 플러그인을 제작할때 TuistUI를 가져올수 없기 때문에 발생하는 사소한 부분이였기 때문에 플러그인으로 가져오는건 비 효율적이라고 생각이 들었고,
339 |
340 | **TuistUI 내부에서 Moduler Architecture를 넣어둔다면?**
341 |
342 | 이부분은 또 다른 모듈을 관리하는 Architecture가 나올수 있다는 것을 가정하에 제외하게 되었습니다. (TuistUI가 Modular Architecture 하나로만 제한되는 상황을 방지하기 위함입니다.)
343 |
344 | ## 향후 개발 계획
345 |
346 | 현재는 넘어가서 전역 후 [DesignTuist](https://github.com/jihoonme/designtuist) 에서 TuistUI 플러그인을 사용하며, 필요한 부분과 개선할 부분을 찾아볼 생각이고
347 |
348 | 추가적으로 이런 부분 추가하면 좋겠다 생각되시는 부분은 [issue](https://github.com/jihoonme/tuistui/issues)로 남겨주시면 열심히 추가해보겠습니다!
349 |
350 | [https://github.com/jihoonme/tuistui](https://github.com/jihoonme/tuistui)
351 |
--------------------------------------------------------------------------------
/Content/blog/universal-framework.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Universal Framework
3 | date: 2023-4-2 18:30
4 | tags: Swift, Framework
5 | description: Universal Framework에 대한 공부
6 | postImage: https://github.com/jihoonahn/blog/assets/68891494/0467f741-a648-48b0-bbb5-1daa24fd9280
7 | ---
8 |
9 | ## Universal Framework (범용 프레임워크)
10 | 디바이스와 시뮬레이터에서 사용가능하도록 범용적으로 프레임워크를 만드는 것입니다.
11 | Device에서의 OS, SimulatorOS 둘 모두에 적용하기 위해서는 Valid Architecture가 모두 존재해야합니다.
12 | iPhone OS에서의 CPU와, macOS에서의 경우 시뮬레이터의 구동을 위해서는 macOS의 CPU가 구현되어 맞춰줘야 합니다.
13 |
14 | 이러한 문제점을 해결하기 위해서 나온 것이 Universal Framework입니다.
15 |
16 | ### Universal Framework 의 장점
17 | - 코드 재사용성이 올라간다.
18 | - 코드 숨기가 쉬워진다.
19 | - 코드 모듈화에 이점을 갖는다.
20 | - 쉬운 통합이 가능하다.
21 | - 쉽게 배포할 수 있다.
22 |
23 | ### 사용법
24 |
25 | Target 아래쪽에 있는 + 버튼을 누릅니다. 그 후 Other -> Aggregate를 추가합니다. (Framework와 XCFramework 둘다 생성해줍니다.)
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | ## Add Script
35 | 각각의 Aggregate에 Script를 추가해줍니다.
36 |
37 |
38 |
39 |
40 |
41 |
42 | #### XCFramework
43 | ```shell
44 | # Build Device and Simulator versions
45 | xcodebuild archive -scheme "${PROJECT_NAME}" -archivePath "${BUILD_DIR}/iphoneos.xcarchive" -sdk iphoneos SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES
46 | xcodebuild archive -scheme "${PROJECT_NAME}" -archivePath "${BUILD_DIR}/iphonesimulator.xcarchive" -sdk iphonesimulator SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES
47 |
48 | xcodebuild -create-xcframework \
49 | -framework "${BUILD_DIR}/iphoneos.xcarchive/Products/Library/Frameworks/"${PROJECT_NAME}".framework" \
50 | -framework "${BUILD_DIR}/iphonesimulator.xcarchive/Products/Library/Frameworks/"${PROJECT_NAME}".framework" \
51 | -output "${BUILD_DIR}/"${PROJECT_NAME}".xcframework"
52 |
53 |
54 | # Copy the xcframework to the project directory
55 | cp -R "${BUILD_DIR}/"${PROJECT_NAME}".xcframework" "${PROJECT_DIR}"
56 |
57 | # Open the project directory in Finder
58 | open "${PROJECT_DIR}"
59 | ```
60 |
61 | #### Framework
62 | ```
63 | UNIVERSAL_OUTPUTFOLDER=${BUILD_DIR}/${CONFIGURATION}-Universal
64 |
65 | # Make sure the output directory exists
66 | mkdir -p "${UNIVERSAL_OUTPUTFOLDER}"
67 |
68 | # Build Device and Simulator versions
69 | xcodebuild -target "${PROJECT_NAME}" BITCODE_GENERATION_MODE=bitcode OTHER_CFLAGS="-fembed-bitcode" ONLY_ACTIVE_ARCH=NO -configuration ${CONFIGURATION} -sdk iphonesimulator BUILD_DIR="${BUILD_DIR}" BUILD_ROOT="${BUILD_ROOT}" clean build
70 | xcodebuild -target "${PROJECT_NAME}" BITCODE_GENERATION_MODE=bitcode OTHER_CFLAGS="-fembed-bitcode" ONLY_ACTIVE_ARCH=NO -configuration ${CONFIGURATION} -sdk iphoneos BUILD_DIR="${BUILD_DIR}" BUILD_ROOT="${BUILD_ROOT}" clean build
71 |
72 |
73 | # Copy the framework structure (from iphoneos build) to the universal folder
74 | cp -R "${BUILD_DIR}/${CONFIGURATION}-iphoneos/${PROJECT_NAME}.framework" "${UNIVERSAL_OUTPUTFOLDER}/"
75 |
76 | # Copy Swift modules from iphonesimulator build (if it exists) to the copied framework directory
77 | cp -R "${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${PROJECT_NAME}.framework/Modules/${PROJECT_NAME}.swiftmodule/." "${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework/Modules/${PROJECT_NAME}.swiftmodule"
78 |
79 | # Create universal binary file using lipo and place the combined executable in the copied framework directory
80 | lipo -create "${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${PROJECT_NAME}.framework/${PROJECT_NAME}" "${BUILD_DIR}/${CONFIGURATION}-iphoneos/${PROJECT_NAME}.framework/${PROJECT_NAME}" -output "${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework/${PROJECT_NAME}"
81 |
82 | # Copy the framework to the project directory
83 | cp -R "${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework" "${PROJECT_DIR}"
84 |
85 | # Open the project directory in Finder
86 | open "${PROJECT_DIR}"
87 | ```
88 |
89 |
90 | ## Run
91 |
92 |
93 |
94 | 각각 원하는 Aggregate Scheme를 선택하고 빌드하면 됩니다.
95 |
96 |
97 |
98 |
99 |
100 | 좌 Framework Aggregate로 빌드 했을 때, 우 XCFramework Aggregate로 빌드 했을 때
101 |
102 |
103 | ## Reference
104 |
105 | - [tistory](https://magicmon.tistory.com/225)
106 | - [medium](https://medium.com/macoclock/swift-universal-framework-3bc858224a7c)
107 | - [kstenerud/iOS-Universal-Framework](https://github.com/kstenerud/iOS-Universal-Framework)
108 |
--------------------------------------------------------------------------------
/Content/blog/what-is-swift.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Swift란?
3 | date: 2021-04-11 7:02
4 | tags: Tutorial, Swift, Theory
5 | description: Swift 언어에 대한 소개입니다.
6 | postImage: https://github.com/jihoonahn/blog/assets/68891494/dca8b4ea-ef08-47da-ac70-770cc6961726
7 | ---
8 |
9 | [Apple 공식 Swift](https://developer.apple.com/swift/)
10 |
11 | 스위프트는 iOS, macOS, watchOS, tvOS를 개발하기 위해 애플에서 제공하는 프로그래밍 언어 입니다.
12 |
13 | ### Swift의 특징
14 |
15 | 애플이 최초에 스위프트를 발표했을 때 스위프트 언어의 특성을 Safe, Modern, Powerful 이라고 발표했습니다. 그러나 오픈소스로 전환되면서 특징을 Safe, Fast, Expressive로 변경하여 발표했습니다.
16 | 더불어 애플은 ‘스위프트는 보다 직관적이고 배우기 쉬운 언어’라고 스위프트를 소개했습니다. 먼저 애플이 발표한 스위프트의 언어적 특성을 항목별로 정리해 보았습니다.
17 |
18 | ### Safe
19 |
20 | 스위프트는 안전한 프로그래밍을 지향합니다.
21 |
22 | 소프트웨어가 배포되기 전에, 즉 프로그래밍을 하는 중에 프로그래머가 저지를 수 있는 실수를 엄격한 문법을 통하여 미연에 방지하고자 노력했습니다.
23 | 때론 너무 강제적이라고 느껴질 수 있지만 문법적 제재는 실수를 줄이는 데 도움이 됩니다. 버그를 수정하거나 실수를 찾아내는 시간을 절약할 수 있습니다.
24 |
25 | 옵셔널이라는 기능을 비롯하여 guard 구문, 오류처리, 강력한 타입통제 등을 통해 스위프트는 안전한 프로그래밍을 구현하고 있습니다.
26 |
27 | ### Fast
28 |
29 | 스위프트는 C 언어를 기반으로 한 C, C++, Objective-C와 같은 프로그래밍 언어를 대체하려는 목적으로 만들어졌습니다.
30 | 아직은 부분적으로 미흡하지만 성능 또한 C 언어 수준을 목표로 개발되었습니다.
31 | 그래서 스위프트는 성능을 예측할 수 있고 일정한 수준으로 유지할 수 있는 부분에 초점을 맞춰 개발되었습니다.
32 |
33 | 실행속도의 최적화 뿐만 아니라 컴파일러의 지속된 개량을 통해 더 빠른 컴파일 성능을 구현해 나가고 있습니다.
34 |
35 | ### Expressive
36 |
37 | 스위프트는 여러 가지 프로그래밍 패러다임을 채용한 다중 패러다임 프로그래밍 언어입니다. 크게 보면 스위프트는 명령형 프로그래밍 패러다임, 객체지향 프로그래밍 패러다임, 함수형 프로그래밍 패러다임, 프로토콜 지향 프로그래밍 패러다임을 지향합니다. 정확하게는 명령형과 객체지향 프로그래밍 패러다임을 기반으로 한 함수형 프로그래밍 패러다임과 프로토콜 지향 프로그래밍 패러다임을 지향합니다. 결과적으로 스위프트에서 가장 강조하는 부분은 함수형 프로그래밍 패러다임과 프로토콜 지향 프로그래밍 패러다임입니다.
38 |
39 | 기존의 C 언어는 명령형 혹은 절자적 프로그래밍 패러다임을 채용했으며, C++, Java 등의 언어는 명령형 프로그래밍 패러다임과 객체지향 프로그래밍 패러다임을 동시에 채용한 다중 프로그래밍 패러다임 언어입니다.
40 |
41 | 최신 업데이트는 [5.7](https://github.com/apple/swift/releases/tag/swift-5.7-RELEASE)입니다.
42 |
43 | ## Xcode Start
44 |
45 | Xcode는 iOS App 개발을 위한 IDE(통합 개발 환경, Integrated Development Environment) 입니다.
46 | iOS 뿐만 아니라 macOS, iPadOS, tvOS, watchOS... 등등 다양판 플랫폼을 제공할 수 있다.
47 |
48 | [AppStore](https://apps.apple.com/kr/app/xcode/id497799835?mt=12)
49 |
50 | 또는
51 |
52 | 명령어를 통해서 설치할 수 있습니다.
53 |
54 | ```bash
55 | xcode-select --install
56 | ```
57 |
58 |
--------------------------------------------------------------------------------
/Content/blog/what-is-swiftui.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: SwiftUI 소개
3 | date: 2022-10-20 17:02
4 | tags: Tutorial, SwiftUI, Theory
5 | description: 선언형 UI로 생산성을 높여주는 SwiftUI에 대한 설명입니다.
6 | postImage: https://github.com/jihoonahn/blog/assets/68891494/7f11ad70-f1fe-4d98-b1a4-8e98a3cf1f9b
7 | ---
8 |
9 | VIDEO
10 |
11 | **본 영상은 WWDC 19이며 SwiftUI 소개되는 부분에서 시작이됩니다.**
12 |
13 | 2019년 애플의 WWDC에서 처음 소개된 SwiftUI 는 모든 애플 운영체제용 앱을 개발하는데 있어서 완전히 새로운 방법을 제공합니다.
14 |
15 | SwiftUI의 기본적인 목적은 앱 개발을 더 쉽고 폭발적인 생산성을 내면서 동시에 소프트웨어를 개발할 때 일반적으로 발생하는 버그들을 줄이는 것입니다.
16 | 또한 개발 과정에서도 앱의 라이브 프리뷰 기능을 이용하여 SwiftUI 프로젝트를 실시간으로 테스트할 수 있게 합니다.
17 |
18 |
19 | 위 이미지는 SwiftUI Project를 생성했을 때의 모습입니다.
20 |
21 |
22 | ## SwiftUI의 선언적 구문
23 |
24 | ```swift
25 | import SwiftUI
26 |
27 | struct ContentView: View {
28 | var body: some View {
29 | VStack {
30 | Text("Hello, world!")
31 | .frame(maxWidth: .infinity, maxHeight: .infinity)
32 | .foregroundColor(.white)
33 | }
34 | .padding()
35 | .background(.black)
36 | }
37 | }
38 | ```
39 |
40 | UIKit과 인터페이스 빌더를 User Interface Layout을 설계하고 필요한 동작을 구현하는 것과는 완전히 다른 방법인 선언적 구문(declairactive syntax)이 SwiftUI에 도입되었습니다.
41 | 이 과정에서 기본적으로 레이아웃에 포함될 컴포넌트들을 선언하고, 그것들이 포함될 레이아웃 메니지 종류 (VStack, HStack, Form, List 등)를 명시하고, 속성을 설정하기 위해 수정자(modifier)를 사용합니다.
42 | 이렇게 선언하고 난 후 레이아웃의 위치와 constraint그리고 렌더링 방법에 대한 모든 복잡한 세부 사항은 SwiftUI가 자동으로 처리합니다.
43 | SwiftUI 선언은 계층적으로 구조화 되어 있습니다. 따라서 복잡한 뷰를 보다 쉽게 생성할 수 있습니다.
44 |
45 |
46 | ## SwiftUI는 데이터 주도적
47 |
48 | SwiftUI 이전에는 앱 내에 있는 데이터의 현재 값을 검사하려면 그에 대한 코드를 앱에 포함 해야했습니다.
49 | 시간에 지남에 따라 데이터가 변한다면 사용자 인터페이스가 데이터의 최신 상태를 항상 반영하도록 하는 코드를 작성하거나, 데이터가 변경되었는지 주기적으로 검사하는 코드를 작성하는 것, 그리고 갱신 메뉴를 제공 해야했습니다.
50 | 이러한 데이터 소스를 앱의 여러 영역에서 사용할 경우 소스 코드의 복잡도가 증가합니다.
51 |
52 | > **SwiftUI는 앱의 데이터 모델과 사용자 인터페이스 컴포넌트, 그리고 기능을 제공하는 로직을 binding하는 여러방법으로 복잡도를 해결하게 됩니다.**
53 |
54 | 데이터 주도로 구현하면 데이터 모델은 앱의 다른 부분에서 subscibe 할 수 데이터 변수는 publish 하게 됩니다. (publisher – subsciber)
55 | 이러한 방법을 통해 데이터가 변경되었다는 사실을 모든 구독자에게 자동으로 알릴 수 있습니다.
56 | 만약 사용자 인터페이스 컴포넌트와 데이터 모델 간에 바인딩이 된다면, **추가적인 코드를 작성하지 않아도 모든 데이터의 변경 사항을 SwiftUI가 사용자 인터페이스에 자동으로 반영할 것**입니다.
57 |
58 |
59 | ## UIKit VS SwiftUI
60 |
61 |
62 |
63 | **UIKit**
64 | ```swift
65 | import UIKit
66 |
67 | final class ViewController: UIViewController {
68 |
69 | private lazy var button: UIButton = {
70 | let button = UIButton()
71 | button.setTitle("Hello UIKit", for: .normal)
72 | button.addTarget(self, action: #selector(helloUIKitButtonAction), for: .touchUpInside)
73 | return button
74 | }()
75 |
76 | override func viewDidLoad() {
77 | super.viewDidLoad()
78 |
79 | view.backgroundColor = .black
80 |
81 | view.addSubview(button)
82 |
83 | NSLayoutConstraint.activate([
84 | button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
85 | button.centerYAnchor.constraint(equalTo: self.view.centerYAnchor)
86 | ])
87 | }
88 |
89 | @objc private func helloUIKitButtonAction() {
90 | printContent("Hello UIKit!")
91 | }
92 | }
93 | ```
94 |
95 |
96 | **SwiftUI**
97 | ```swift
98 | import SwiftUI
99 |
100 | struct ContentView: View {
101 | var body: some View {
102 | VStack {
103 | Button("Hello SwiftUI") {
104 | print("Hello SwiftUI!")
105 | }
106 | }
107 | .background(.black)
108 | }
109 | }
110 | ```
111 |
112 | 같은 동작을 하는 뷰를 만들어 봤습니다.
113 |
114 | 차이점이 보이시나요? UIKit(명령형)과 SwiftUI(선언형)을 비교해볼 때
115 |
116 | UIKit에서는 Property를 선언 view에 추가하고, Layout에 제약사항을 준 후, action을 할 함수를 만들어서 button에 addTarget 해줍니다.
117 |
118 | 하지만 SwiftUI에서는 그래로 원하는 위치에 Button을 추가하고 action을 추가하면 끝납니다.
119 |
120 | 생산성 부분에서 어마어마하게 차이가 난다는 걸 볼 수 있습니다.
121 |
122 | ## UIKit과 SwiftUI를 함께 사용하는 방법
123 |
124 | 사실 UIView와 SwiftUI를 함께 사용할 수 있는 방법은 다양하게 존재합니다.
125 |
126 | SwiftUI는 빠르고 효율적인 앱 개발 환경을 제공할 뿐만 아니라 코드를 크게 변경하지 않아도 다양한 애플 플랫폼에서 동일한 앱을 사용할 수 있게 합니다.
127 |
128 | 하지만 지도 또는 웹 뷰를 통합해야 하는 특정 기능은 여전히 UIKit을 사용해야 하고, 매우 복잡한 UI 레이아웃을 설계하는 경우에 SwiftUI 레이아웃 컨테이너 뷰 사용이 만족스럽지 않을 수 있습니다.
129 |
130 | 이런 상황에서는 인터페이스 빌더를 사용을 하는 방식으로 해결할 수도 있습니다.
131 |
132 | ## 지금 SwiftUI는 어떨까?
133 |
134 | 현재까지는 시기상조라는 말도 있고, 회사에서 도입 할 것이라는 말이 있습니다.
135 | 이 부분은 사람마다 의견이 다르기 때문에 정확한 대답은 어렵지만, 개인적으로 저는 자신이 처한 상황에서 직접 고려하여 결정하는 것이 좋다고 생각합니다.
136 |
137 | SwiftUI 최소 버전은 iOS 기준 13.0이지만, 제대로 사용하려면 15.0 이상이여야 하기 때문에, 이러한 부분은 좀 많이 아쉽긴 합니다.
138 |
139 | 그리고 현재 SwiftUI는 버그도 있기도 하고, 아직 사용하기에는 불완전하다는 말에 동의는 합니다.
140 | 하지만 엄청난 생산성을 갖는다는것, 그리고 Apple이 추구하는 방향성의 UI Framework라는 것은 부정할 수 없기 때문에, iOS 개발자로 살면 언젠가는 사용해야하기 때문에 미리 공부해 보는 것도 좋다고 생각을 합니다.
141 |
--------------------------------------------------------------------------------
/Content/blog/yackety-yak-ios-4.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 와글와글 제4회 발표 회고록
3 | date: 2023-02-11 5:25
4 | tags: Recollection, Announcement
5 | description: 와글와글 iOS 제 4회 발표 회고
6 | postImage: https://github.com/jihoonahn/blog/assets/68891494/332ea2ed-6ff6-4154-b33f-abec0b26a6b5
7 | ---
8 |
9 | VIDEO
10 |
11 | 이번 제 4회 와글와글 iOS에서 블로깅하는 방법에 대해서 발표를 했고, 이에 대한 회고 입니다.
12 |
13 | ## 발표 준비
14 |
15 | 발표 전 당시 저는 Publish에 관심도가 높아졌고, 다른 사람들에게 Publish 사용을 권하고 있었을 때
16 | 리이오님이 감사하게도 발표를 제안을 주셔서 "Swift로 블로깅하기" 라는 주제로 발표를 하게 되었습니다.
17 |
18 | 저의 첫 발표였기 때문에, 어떻게 준비해야하나 부터 고민하고, 발표를 어떻게 해야는지 고민을 했던 것 같습니다.
19 |
20 | 미리 발표 자료를 만들기 위해서 Keynote내에서 어떤 목차로 진행 해야할지 부터 고민을 했습니다.
21 |
22 | ```
23 | - 다른 Swift 로 웹을 만드는 라이브러리와 비교
24 | - Publish 에 대한 소개 (소개 & 특징)
25 | - Publish 설치
26 | - Publish 시작하기
27 | - 어떤 결과물이 나올까
28 | - 글 작성 방법
29 | - 커스텀 하는 방법
30 | - Publish 로 만들어진 Web 들 예시
31 | - 한계점
32 | - 장점
33 | - 느낀점
34 | - 하고 싶은말
35 | ```
36 |
37 | 이런 목차로 진행하기로 정하고 발표 세부 내용을 작성하였습니다.
38 |
39 |
40 |
41 |
42 |
43 |
44 | 이번 발표에서는 "왜 Publish인가?" 라는 것에 대한 저의 의견이 담는 것이 중요했습니다.
45 |
46 | 또한 잘못된 내용을 전달하면 안되기 때문에, 반복적으로 검토하고 수정하기를 진행하였고, 발표 주제가 마이너한 라이브러리를 소개하기 때문에, Publish에 대한 대략적인 부분에 대한 설명부터 해야하기 때문에, 사람들에게 이해시키는 부분도 필요했고, 어떻게 사용할지에 대한 설명도 필요했습니다.
47 |
48 |
49 | 이런 부분을 생각하다보니 Keynote의 내용이 너무 많아졌고, 발표 주제에 맞는 방향인 간단하게 소개하는 방식으로 바꾸기로 결정하였습니다.
50 |
51 | 그리고 blog의 대부분 소스를 공개하지 않았기 때문에 너무 레퍼런스가 부족한 상황이였고, 처음 시작하는 사람들이 쉽게 이해할 수 있도록 레퍼런스 마련을 위해서 여러번의 고민 끝에 제가 직접 만든 블로그를 Public으로 공개하기로 결정하였습니다.
52 |
53 | ## 발표 당일
54 |
55 | 대망의 발표날이 찾아왔고, 저녁 7시 부터 발표를 시작할 준비를 하였습니다. 아침에 몇번 다시 내용을 확인하고, 발표에 대비를 하였으나, 점점 긴장을 하게되었습니다.
56 | 발표하기 10분 전에 미리 디스코드 방에 들어가서, 미리 대기를 하였습니다.
57 |
58 | ## 발표 시작
59 |
60 | 처음 진행을 할 때 8명으로 시작을 하였고, 추가적으로 3~4명 이상 진행 중에 들어오셨습니다.
61 | 발표 진행을 시작하자마자 문제가 발생했습니다.. 😓
62 | 첫 발표인 이유도 있었지만, 발표를 많이 해보지 않았기 때문에 너무 긴장해버렸습니다..
63 | 제가 너무 긴장해버린 나머지, 목소리도 잘 안나오고, 머리에서는 "아 망했다" 라는 생각이 들어서 추가적으로 더 긴장을 해버렸던 것 같습니다 ㅋㅋ..
64 |
65 | 정신 없이 발표가 진행되고, 하나의 걱정이 머리를 스쳐갔습니다.
66 |
67 | > "과연 발표 내용이 잘 전달 되었을까?"
68 |
69 | 가장 발표에서 중요한 부분이지만, 다른 분들도 Publish를 한번 씩 사용해보겠다 라는 말을 듣고, 그래도 어느정도 발표에서 말하고 싶은 부분은 전달이 됬구나 라고 생각이 되서 다행이라고 생각이 들었습니다.
70 |
71 | ## 발표가 끝나고
72 |
73 | 제가 느끼기에는 아주 긴 발표시간이 지나가고, 개인적으로 긴장해버린 것 때문에 걱정을 많이 했고, 많은 아쉬움이 남았습니다.
74 | 그래도 첫 발표를 끝냈다는 생각에 저에게 어떤 부분이 부족하고, 나중의 발표에서 어떤 부분을 주의해야할 지도 알게되는 시간이였습니다.
75 |
76 |
77 | 리이오님이 좋은 자리를 마련해주셔서, 좋은 경험이 됬습니다. (감사합니다.. 🙏)
78 |
79 | ## 느낀점
80 |
81 | 이번에 발표를 해보고, 다른 곳에서도 발표를 해보고 싶다는 생각을 하게 되었습니다.
82 | 비록 이번에는 많은 부분이 부족했지만, 내가 알고 있는 것을 다른 사람과 공유하는 것에 대한 재미를 느끼게 되었고,
83 | 현재 발표에서 부족한 부분을 해결하기 위해서 더 많은 곳에서 발표하고 싶다는 생각을 하게 되었습니다.
84 |
--------------------------------------------------------------------------------
/Content/index.md:
--------------------------------------------------------------------------------
1 | # Blog
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 안지훈
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | EXECUTABLE_NAME := jihoonahn blog
2 |
3 | install:
4 | @echo "💠 Install $(EXECUTABLE_NAME)..."
5 | brew install publish
6 |
7 | start:
8 | @echo "🚀 Start $(EXECUTABLE_NAME)..."
9 | publish run
10 |
11 | run:
12 | @echo "🍎 run $(EXECUTABLE_NAME)..."
13 | swift run Blog
14 |
15 | tailwind:
16 | @echo "👻 Start TailWindCSS inside $(EXECUTABLE_NAME)"
17 | npx tailwindcss build Sources/Styles/global.css -o Output/styles.css -c tailwind.config.js
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "codextended",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/johnsundell/codextended.git",
7 | "state" : {
8 | "revision" : "8d7c46dfc9c55240870cf5561d6cefa41e3d7105",
9 | "version" : "0.3.0"
10 | }
11 | },
12 | {
13 | "identity" : "collectionconcurrencykit",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/johnsundell/collectionConcurrencyKit.git",
16 | "state" : {
17 | "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95",
18 | "version" : "0.2.0"
19 | }
20 | },
21 | {
22 | "identity" : "files",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/johnsundell/files.git",
25 | "state" : {
26 | "revision" : "d273b5b7025d386feef79ef6bad7de762e106eaf",
27 | "version" : "4.2.0"
28 | }
29 | },
30 | {
31 | "identity" : "ink",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/johnsundell/ink.git",
34 | "state" : {
35 | "revision" : "77c3d8953374a9cf5418ef0bd7108524999de85a",
36 | "version" : "0.5.1"
37 | }
38 | },
39 | {
40 | "identity" : "plot",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/johnsundell/plot.git",
43 | "state" : {
44 | "revision" : "b358860fe565eb53e98b1f5807eb5939c8124547",
45 | "version" : "0.11.0"
46 | }
47 | },
48 | {
49 | "identity" : "publish",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/johnsundell/publish.git",
52 | "state" : {
53 | "revision" : "1c8ad00d39c985cb5d497153241a2f1b654e0d40",
54 | "version" : "0.9.0"
55 | }
56 | },
57 | {
58 | "identity" : "shellout",
59 | "kind" : "remoteSourceControl",
60 | "location" : "https://github.com/johnsundell/shellout.git",
61 | "state" : {
62 | "revision" : "e1577acf2b6e90086d01a6d5e2b8efdaae033568",
63 | "version" : "2.3.0"
64 | }
65 | },
66 | {
67 | "identity" : "splash",
68 | "kind" : "remoteSourceControl",
69 | "location" : "https://github.com/johnsundell/Splash.git",
70 | "state" : {
71 | "revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
72 | "version" : "0.16.0"
73 | }
74 | },
75 | {
76 | "identity" : "sweep",
77 | "kind" : "remoteSourceControl",
78 | "location" : "https://github.com/johnsundell/sweep.git",
79 | "state" : {
80 | "revision" : "801c2878e4c6c5baf32fe132e1f3f3af6f9fd1b0",
81 | "version" : "0.4.0"
82 | }
83 | }
84 | ],
85 | "version" : 2
86 | }
87 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.7
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "blog",
6 | platforms: [.macOS(.v13)],
7 | products: [
8 | .executable(
9 | name: "Blog",
10 | targets: ["Blog"]
11 | )
12 | ],
13 | dependencies: [
14 | .package(url: "https://github.com/JohnSundell/Publish", from: "0.9.0"),
15 | .package(url: "https://github.com/Johnsundell/Splash", from: "0.16.0")
16 | ],
17 | targets: [
18 | .executableTarget(
19 | name: "Blog",
20 | dependencies: [
21 | .product(name: "Publish", package: "publish"),
22 | .product(name: "Splash", package: "Splash")
23 | ],
24 | path: "Sources",
25 | exclude: ["Styles/global.css"]
26 | )
27 | ]
28 | )
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Jihoonahn Blog
4 |
5 | ### Description
6 | > 저만의 특색이 있는 블로그를 가지고 싶어서 만들게 된 블로그 입니다. iOS 개발자에게 친숙한 Swift 언어와, [Publish](https://github.com/JohnSundell/Publish) 를 이용해서 블로그를 만들었습니다. 꾸준히 업로드 할 예정이며, 블로그 내에서도 여러가지 기능을 추가할 예정입니다.
7 | 혹시 블로그 관련해서 궁금하신 점이 있으시면 [jihoonahn.dev@gmail.com](mailto:jihoonahn.dev@gmail.com)로 연락 주시기 바랍니다.
8 |
9 | >I made this blog because I wanted to have a blog with my own characteristics. I created a blog using Swift language and [Publish](https://github.com/JohnSundell/Publish), which are familiar to iOS developers. I'm going to upload it continuously, and I'm going to add various functions within the blog.
10 | If you have any questions about the blog, please contact [jihoonahn.dev@gmail.com](mailto:jihoonahn.dev@gmail.com).
11 |
12 | ## Install Publish
13 |
14 | Make sure you have publish installed:
15 |
16 | ```bash
17 | brew install publish
18 | ```
19 |
20 | ## Spin up local server
21 | To spin up the project locally run
22 | ```
23 | publish run
24 | ```
25 | You can now access http://localhost:8000/
26 |
27 | ## Page
28 | - [Blog](https://blog.jihoon.me/)
29 | - [About](https://blog.jihoon.me/about)
30 | #### Hidden Page
31 | - [Tags](https://blog.jihoon.me/tags/)
32 |
33 | ## Document
34 | [**blog-document**](https://github.com/jihoonahn/blog-document) is a repository of documents with example codes for your blog.
35 |
36 | ## License
37 | **Blog** is under MIT license. See the [LICENSE](LICENSE) file for more info.
38 |
--------------------------------------------------------------------------------
/Resources/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jihoonahn/blog/9eff7354d7b6c45b39db4c55df9bf7b84774c27d/Resources/favicon.ico
--------------------------------------------------------------------------------
/Resources/robots.txt :
--------------------------------------------------------------------------------
1 | User-agent: Googlebot
2 | User-agent: Yeti
3 | User-agent: Daum
4 | User-agent: Bingbot
5 | User-agent: DuckDuckBot
6 | Allow: /blog/
7 | Allow: /About/
8 | Disallow: /tags/
9 | Sitemap: https://blog.jihoon.me/sitemap.xml
10 |
--------------------------------------------------------------------------------
/Resources/static/fonts/OpenSans-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jihoonahn/blog/9eff7354d7b6c45b39db4c55df9bf7b84774c27d/Resources/static/fonts/OpenSans-Bold.ttf
--------------------------------------------------------------------------------
/Resources/static/fonts/OpenSans-ExtraBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jihoonahn/blog/9eff7354d7b6c45b39db4c55df9bf7b84774c27d/Resources/static/fonts/OpenSans-ExtraBold.ttf
--------------------------------------------------------------------------------
/Resources/static/fonts/OpenSans-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jihoonahn/blog/9eff7354d7b6c45b39db4c55df9bf7b84774c27d/Resources/static/fonts/OpenSans-Light.ttf
--------------------------------------------------------------------------------
/Resources/static/fonts/OpenSans-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jihoonahn/blog/9eff7354d7b6c45b39db4c55df9bf7b84774c27d/Resources/static/fonts/OpenSans-Medium.ttf
--------------------------------------------------------------------------------
/Resources/static/fonts/OpenSans-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jihoonahn/blog/9eff7354d7b6c45b39db4c55df9bf7b84774c27d/Resources/static/fonts/OpenSans-Regular.ttf
--------------------------------------------------------------------------------
/Resources/static/fonts/OpenSans-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jihoonahn/blog/9eff7354d7b6c45b39db4c55df9bf7b84774c27d/Resources/static/fonts/OpenSans-SemiBold.ttf
--------------------------------------------------------------------------------
/Resources/static/icons/arrow-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Resources/static/icons/arrow-right.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Resources/static/icons/copy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Resources/static/icons/envelope-solid.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Resources/static/icons/github.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Resources/static/icons/linkedin.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Resources/static/icons/rss-solid.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Resources/static/images/about.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/Resources/static/images/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Resources/static/scripts/docsearch.js:
--------------------------------------------------------------------------------
1 | docsearch({
2 | container: '#docsearch',
3 | appId: '3FO4WHQGGO',
4 | indexName: 'blog',
5 | apiKey: 'f45f471cd92d448f891637e486f50362',
6 | debug: true
7 | });
8 |
--------------------------------------------------------------------------------
/Sources/Components/Common/BasicScripts.swift:
--------------------------------------------------------------------------------
1 | import Plot
2 |
3 | struct BasicScripts: Component {
4 | var body: Component {
5 | Script(
6 | .raw("""
7 | (function(){
8 | function attachEvent(selector, event, fn) {
9 | const matches = typeof selector === 'string' ? document.querySelectorAll(selector) : selector;
10 | if (matches && matches.length) {
11 | matches.forEach((elem) => {
12 | elem.addEventListener(event, (e) => fn(e, elem), false);
13 | });
14 | }
15 | }
16 |
17 | window.onload = function() {
18 | var host = window.location.host;
19 | var pathname = window.location.pathname;
20 | let lastKnownInnerWidth = window.innerWidth;
21 | let ticking = true;
22 |
23 | attachEvent('#mobileNavButton', 'click', function() {
24 | document.body.classList.toggle('overflow-hidden');
25 | document.getElementById('blogNavScreen')?.classList.toggle('hidden');
26 | document.getElementById('mobileNavButton')?.classList.toggle('expanded');
27 | });
28 |
29 | attachEvent('.copyPost', 'click', function() {
30 | var tempInput = document.createElement("input");
31 | tempInput.value = host + pathname;
32 | document.body.appendChild(tempInput);
33 | tempInput.select();
34 | tempInput.setSelectionRange(0, 99999);
35 | document.execCommand("copy");
36 | document.body.removeChild(tempInput);
37 | });
38 |
39 | function applyHeaderStylesOnResize() {
40 | if (lastKnownInnerWidth<768) {
41 | document.body.classList.remove('overflow-hidden');
42 | document.getElementById('blogNavScreen')?.classList.add('hidden');
43 | document.getElementById('mobileNavButton')?.classList.remove('expanded');
44 | }
45 | }
46 |
47 | applyHeaderStylesOnResize();
48 | window.addEventListener('resize', applyHeaderStylesOnResize);
49 | };
50 |
51 | window.onpageshow = function() {
52 | document.body.classList.remove('overflow-hidden');
53 | document.getElementById('header nav')?.classList.add('hidden');
54 | };
55 | })();
56 | """)
57 | )
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Sources/Components/Footer.swift:
--------------------------------------------------------------------------------
1 | struct Footer: Component {
2 | let context: PublishingContext
3 |
4 | var body: Component {
5 | Plot.Footer {
6 | Div {
7 | Div {
8 | Div {
9 | Paragraph {
10 | Text("Copyright © ")
11 | Link("Jihoonahn", url: "https://github.com/jihoonahn")
12 | .class("text-stone-700")
13 | }
14 | .class("text-stone-600 my-0")
15 | }
16 | Div {
17 | Paragraph {
18 | Text("Made with Swift")
19 | }
20 | .class("text-stone-600 my-0")
21 | }
22 | }
23 | .class("text-sm mx-3 my-auto p-2")
24 | Div {
25 | List(context.site.socialMediaLinks) { socialMediaLink in
26 | ListItem {
27 | Link(url: socialMediaLink.url) {
28 | Image(socialMediaLink.icon)
29 | .class("w-full h-auto rounded-none")
30 | }
31 | }
32 | .class("text-center w-4 h-4")
33 | }
34 | .class("inline-flex gap-4 list-none")
35 | }
36 | .class("mx-2")
37 | }
38 | .class("flex flex-wrap justify-between p-1 mx-auto max-w-4xl")
39 | }
40 | .class("bg-blog-c-footer relative")
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/Components/Head.swift:
--------------------------------------------------------------------------------
1 | extension Node where Context == HTML.DocumentContext {
2 | public static func head(for page: Location, context: PublishingContext) -> Node {
3 | let site = context.site
4 | var title = page.title
5 |
6 | if title.isEmpty {
7 | title = site.name
8 | }
9 |
10 | var description = page.description
11 |
12 | if description.isEmpty {
13 | description = site.description
14 | }
15 |
16 | return .head(
17 | .encoding(.utf8),
18 | .siteName(site.name),
19 | .url(site.url(for: page)),
20 | .title(title),
21 | .description(description),
22 | .twitterCardType(page.imagePath == nil ? .summary : .summaryLargeImage),
23 | .link(
24 | .rel(.stylesheet),
25 | .href("https://cdn.jsdelivr.net/npm/@docsearch/css@3")
26 | ),
27 | .stylesheet("/styles.css"),
28 | .viewport(.accordingToDevice),
29 | .unwrap(site.favicon, { .favicon($0) }),
30 | .unwrap(Path.defaultForRSSFeed, { path in
31 | let title = "Subscribe to \(site.name)"
32 | return .rssFeedLink(path.absoluteString, title: title)
33 | }),
34 | .unwrap(page.imagePath ?? site.imagePath, { path in
35 | let url = site.url(for: path)
36 | return .socialImageLink(url)
37 | })
38 | )
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/Components/Header.swift:
--------------------------------------------------------------------------------
1 | struct Header: Component {
2 | var context: PublishingContext
3 |
4 | var body: Component {
5 | Plot.Header {
6 | Div {
7 | Div {
8 | Link(url: "/") {
9 | Image("/static/images/icon.svg")
10 | .class("max-h-11 my-0")
11 | }
12 | .class("flex items-center h-blog-nav")
13 | Div {
14 | Div {
15 | Div()
16 | .id("docsearch")
17 | .class("blogDocsearch")
18 | }
19 | .class("flex items-center pl-4")
20 | Button {
21 | Span {
22 | Span().class("transition duration-200 top-0 left-0 absolute w-full h-0.5 bg-blog-c-nav-text ease translate-x-0 translate-y-0 group-[.expanded]:rotate-45 group-[.expanded]:translate-y-0 group-[.expanded]:top-1.5")
23 | Span().class("transition duration-200 top-1.5 left-0 absolute w-full h-0.5 bg-blog-c-nav-text ease translate-x-0 translate-y-0 group-[.expanded]:opacity-0")
24 | Span().class("transition duration-200 top-3 left-0 absolute w-full h-0.5 bg-blog-c-nav-text ease translate-x-0 translate-y-0 group-[.expanded]:-rotate-45 group-[.expanded]:-translate-y-0 group-[.expanded]:top-1.5")
25 | }
26 | .class("relative h-4 w-4 overflow-hidden")
27 | }
28 | .id("mobileNavButton")
29 | .accessibilityLabel("Mobile navigation")
30 | .class("cursor-pointer flex w-10 h-blog-nav items-center justify-center group md:hidden")
31 | }
32 | .class("flex justify-end items-center grow")
33 | Navigation {
34 | List(Blog.SectionID.allCases) { sectionID in
35 | Link(
36 | context.sections[sectionID].title,
37 | url: context.sections[sectionID].path.absoluteString
38 | )
39 | .class("blogNavItem blogNavBarMenuLink block py-0 px-3 text-xs leading-calc-blog-nav")
40 | }
41 | .class("flex list-none m-0")
42 | }
43 | .id("blogNav")
44 | .class("hidden md:flex")
45 | }
46 | .class("flex justify-between mx-auto my-0 max-w-3xl")
47 | Div {
48 | Div {
49 | Navigation {
50 | List(Blog.SectionID.allCases) { sectionID in
51 | Link(
52 | context.sections[sectionID].title,
53 | url: context.sections[sectionID].path.absoluteString
54 | )
55 | .class("blogNavItem block py-3 blogNavBarMenuLink border-b border-zinc-500 border-solid")
56 | }
57 | .class("list-none m-0")
58 | }
59 | .class("inline")
60 | Div {
61 | List(context.site.socialMediaLinks) { socialMediaLink in
62 | Link(url: socialMediaLink.url) {
63 | Image(socialMediaLink.icon)
64 | .class("w-6 h-6 rounded-none")
65 | }
66 | .class("text-center w-6 h-6")
67 | }
68 | .class("flex justify-center gap-4 list-none")
69 | }
70 | .class("mt-4 w-full")
71 | }
72 | .class("my-0 mx-auto max-w-[288px] pt-6 pb-24")
73 | }
74 | .id("blogNavScreen")
75 | .class("hidden h-screen")
76 | }
77 | .class("pl-6 pr-4")
78 | Script(.src("https://cdn.jsdelivr.net/npm/@docsearch/js@3"))
79 | Script(
80 | .type("text/javascript"),
81 | .src("/static/scripts/docsearch.js")
82 | )
83 | }
84 | .id("header")
85 | .class("relative md:fixed top-0 left-0 w-full z-20 bg-blog-c-nav backdrop-saturate-125 backdrop-blur-xl")
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Sources/Components/Pagination.swift:
--------------------------------------------------------------------------------
1 | struct Pagination: Component {
2 | let activePage: Int
3 | let numberOfPages: Int
4 | let pageURL: (_ pageNumber: Int) -> String
5 | let isDemo: Bool
6 |
7 | public init(activePage: Int, numberOfPages: Int, pageURL: @escaping (Int) -> String, isDemo: Bool = false) {
8 | self.activePage = activePage
9 | self.numberOfPages = numberOfPages
10 | self.pageURL = pageURL
11 | self.isDemo = isDemo
12 | }
13 |
14 | var body: Component {
15 | Navigation {
16 | Div {
17 | Div {
18 | previewLink()
19 | }
20 | .class("blogPagination")
21 | Div {
22 | Text("\(activePage) / \(numberOfPages)")
23 | }
24 | .class("flex-1-0 text-center")
25 | Div {
26 | nextLink()
27 | }
28 | .class("blogPagination")
29 | }
30 | .class("flex relative px-4 items-center max-w-xs mx-auto my-0")
31 | }
32 | .id("pagination")
33 | .class("mt-16 mb-8")
34 | .accessibilityLabel("pagination number")
35 | }
36 |
37 | func generatePageURL(_ num: Int) -> String {
38 | if isDemo {
39 | return "#"
40 | } else {
41 | return pageURL(num)
42 | }
43 | }
44 |
45 | func previewLink() -> Component {
46 | let link: String
47 | if activePage == 1 {
48 | link = "#"
49 | } else {
50 | link = generatePageURL(activePage - 1)
51 | }
52 | let pageLink: Component = Link(url: link) {
53 | Image("/static/icons/arrow-left.svg")
54 | .class("h-4 w-4 p-0")
55 | }
56 | .class("flex h-9 w-9 items-center justify-center")
57 | return pageLink
58 | }
59 |
60 | func nextLink() -> Component {
61 | let link: String
62 | if activePage == numberOfPages {
63 | link = "#"
64 | } else {
65 | link = generatePageURL(activePage + 1)
66 | }
67 | let pageLink: Component = Link(url: link) {
68 | Image("/static/icons/arrow-right.svg")
69 | .class("h-4 w-4 p-0")
70 | }
71 | .class("flex h-9 w-9 items-center justify-center")
72 | return pageLink
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/Sources/Components/PreviewPost.swift:
--------------------------------------------------------------------------------
1 | struct PreviewPost: Component {
2 | var context: PublishingContext
3 | var item: Item
4 |
5 | var body: Component {
6 | Div {
7 | let items = context.allItems(sortedBy: \.date)
8 | let index = items.firstIndex(of: item) ?? 0
9 | let last = items.endIndex - 1
10 | Div {
11 | if index != 0 {
12 | Link(url: items[index - 1].path.absoluteString) {
13 | Div {
14 | Paragraph("Preview Post")
15 | .class("text-gray-500 m-0.5")
16 | H3(items[index-1].title)
17 | .class("text-black text-2xl font-medium m-0.5")
18 | }
19 | .class("flex-1 flex flex-col leading-none min-w-0 items-start")
20 | }
21 | .class("flex cursor-pointer rounded-[10px] bg-blog-c-preview-page w-full h-full p-4 min-h-[4.2rem] items-center")
22 | }
23 | }
24 | .class("min-w-0 flex-1 mt-4 md:mt-0")
25 | Div {
26 | if index < last {
27 | Link(url: items[index + 1].path.absoluteString) {
28 | Div {
29 | Paragraph("Next Post")
30 | .class("text-gray-500 m-0.5")
31 | H3(items[index + 1].title)
32 | .class("text-black text-2xl font-medium m-0.5")
33 | }
34 | .class("flex-1 flex flex-col leading-none min-w-0 items-end")
35 | }
36 | .class("flex cursor-pointer bg-blog-c-preview-page w-full h-full rounded-[10px] p-4 flex-row-reverse items-center min-h-[4.2rem]")
37 | }
38 | }
39 | .class("min-w-0 flex-1 mt-4 md:mt-0 md:ml-8")
40 | }
41 | .class("flex flex-col-reverse px-4 mt-11 mb-4 max-w-3xl mx-auto md:flex-row md:px-0")
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Sources/Datas/Constants.swift:
--------------------------------------------------------------------------------
1 | struct Constants {
2 | static let numberOfItemsPerIndexPage: Int = 9
3 | static let numberOfItemsPerTagsPage: Int = 9
4 | }
5 |
--------------------------------------------------------------------------------
/Sources/Datas/SocialMediaLink.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct SocialMediaLink {
4 | let title: String
5 | let url: String
6 | let icon: String
7 | }
8 |
9 | extension SocialMediaLink {
10 | static var linkedIn: SocialMediaLink {
11 | SocialMediaLink(
12 | title: "LinkedIn",
13 | url: "https://www.linkedin.com/in/ahnjihoon/",
14 | icon: "/static/icons/linkedin.svg"
15 | )
16 | }
17 | static var github: SocialMediaLink {
18 | SocialMediaLink(
19 | title: "GitHub",
20 | url: "https://github.com/jihoonahn",
21 | icon: "/static/icons/github.svg"
22 | )
23 | }
24 | static var rss: SocialMediaLink {
25 | SocialMediaLink(
26 | title: "Rss",
27 | url: "https://blog.jihoon.me/feed.rss",
28 | icon: "/static/icons/rss-solid.svg")
29 | }
30 | static var email: SocialMediaLink {
31 | SocialMediaLink(
32 | title: "Email",
33 | url: "mailto:jihoonahn.dev@gmail.com",
34 | icon: "/static/icons/envelope-solid.svg"
35 | )
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/Layouts/ArchiveLayout.swift:
--------------------------------------------------------------------------------
1 | struct ArchiveLayout: Component {
2 | let items: [Item]
3 | let context: PublishingContext
4 |
5 | var body: Component {
6 | Div {
7 | List(items) { item in
8 | Link(url: item.path.absoluteString) {
9 | Figure {
10 | Image(item.metadata.postImage)
11 | .class("m-0")
12 | }
13 | .class("my-auto border border-gray-200 rounded-lg md:rounded-2xl overflow-hidden self-start w-[30%] md:w-[350px]")
14 | Div {
15 | Paragraph(item.tags.map{ $0.string }.joined(separator: ", "))
16 | .class("text-blog-c-tag-text text-xs md:text-sm text-black m-0 break-all")
17 | H3(item.title)
18 | .class("text-black text-base md:text-heading-3 mb-2 mt-2 break-all")
19 | Time(DateFormatter.time.string(from: item.date))
20 | .class("text-sm md:text-base font-light text-blog-c-time-text")
21 | }
22 | .class("ml-3 md:ml-4 lg:ml-6")
23 | }
24 | .class("flex justify-start px-0 py-8 items-center border-t border-solid border-gray-300")
25 | }
26 | .class("m-0 list-none")
27 | }
28 | .class("border-b border-solid border-gray-300 md:w-[43rem]")
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Sources/Layouts/BaseLayout.swift:
--------------------------------------------------------------------------------
1 | struct BaseLayout: HTMLConvertable {
2 | var location: Location
3 | var context: PublishingContext
4 | var component: () -> Component
5 |
6 | public init(
7 | for location: Location,
8 | context: PublishingContext,
9 | component: @escaping () -> Component
10 | ) {
11 | self.location = location
12 | self.context = context
13 | self.component = component
14 | }
15 |
16 | func build() -> HTML {
17 | HTML(
18 | .lang(context.site.language),
19 | .head(for: location, context: context),
20 | .body {
21 | Header(context: context)
22 | Main {
23 | component()
24 | }
25 | .id("main")
26 | .class("relative z-10 flex flex-1 flex-col min-h-screen pt-3 md:pt-20 pb-14")
27 | Footer(context: context)
28 | BasicScripts()
29 | }
30 | )
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Layouts/PageLayout.swift:
--------------------------------------------------------------------------------
1 | struct PageLayout: Component {
2 | let title: String
3 | let component: () -> Component
4 |
5 | public init(
6 | title: String,
7 | component: @escaping () -> Component
8 | ) {
9 | self.title = title
10 | self.component = component
11 | }
12 |
13 | var body: Component {
14 | Section {
15 | H1(title)
16 | .id("pageTitle")
17 | .class("text-heading-2 font-semibold")
18 | component()
19 | }
20 | .class("px-4 sm:px-8 max-w-3xl mx-auto")
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/Layouts/PostLayout.swift:
--------------------------------------------------------------------------------
1 | struct PostLayout: Component {
2 | var item: Item
3 | var context: PublishingContext
4 |
5 | var body: Component {
6 | Section {
7 | Article {
8 | Div {
9 | Div {
10 | Div {
11 | Time(DateFormatter.time.string(from: item.date))
12 | .class("font-semibold text-gray-500 text-sm")
13 | }
14 | }
15 | .class("component md:w-[654px]")
16 | Div {
17 | Div {
18 | H1(item.title)
19 | .class("m-0 leading-normal font-semibold text-heading-2 md:text-heading-1")
20 | }
21 | }
22 | .class("component my-0 md:w-[654px]")
23 | Div {
24 | Div {
25 | Text(item.description)
26 | }
27 | .class("text-lg md:text-2xl mx-auto")
28 | }
29 | .class("component mt-5 mb-0 md:w-[654px]")
30 | }
31 | Figure {
32 | Div {
33 | Image(item.metadata.postImage)
34 | .class("my-0")
35 | }
36 | }
37 | .class("component rounded-xl border border-gray-200 overflow-hidden min-w-[85%] lg:min-w-[320px]")
38 | Div {
39 | Div {
40 | Node.contentBody(item.body)
41 | }
42 | }
43 | .class("mx-auto w-[85%] text-left mt-8 md:w-[700px]")
44 | Div {
45 | Div {
46 | Div {
47 | List(item.tags) { tag in
48 | ListItem {
49 | Link(tag.string, url: context.site.url(for: tag))
50 | .class("block my-1 px-1.5 py-2 bg-neutral-900 text-white rounded-lg transition duration-200 ease-in-out hover:bg-zinc-300 hover:text-black")
51 | }
52 | .class("inline-block mr-1.5")
53 | }
54 | .class("m-0")
55 | }
56 | Button {
57 | Image("/static/icons/copy.svg")
58 | .class("my-0")
59 | }
60 | .accessibilityLabel("link share")
61 | .class("copyPost m-1 w-9 h-9 p-1.5 bg-blog-c-button rounded-full hover:bg-gray-300 transition duration-200 ease-in-out")
62 | }
63 | .class("flex justify-between mt-6")
64 | }
65 | .class("mx-auto w-[85%] text-left mt-8 md:w-[700px]")
66 | PreviewPost(context: context, item: item)
67 | Script(
68 | .src("https://utteranc.es/client.js"),
69 | .repo("jihoonahn/blog"),
70 | .issue_term("pathname"),
71 | .label("comments"),
72 | .theme("github-light"),
73 | .crossorigin("anonymous"),
74 | .async()
75 | )
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/Layouts/PostsLayout.swift:
--------------------------------------------------------------------------------
1 | struct PostsLayout: Component {
2 | let items: [Item]
3 | let context: PublishingContext
4 |
5 | var body: Component {
6 | Div {
7 | List(items) { item in
8 | Article {
9 | Link(url: item.path.absoluteString) {
10 | Div {
11 | Figure {
12 | Image(item.metadata.postImage)
13 | .class("m-0 transition duration-300 ease-in-out group-hover:scale-105")
14 | }
15 | .class("h-full object-cover w-auto md:w-[43rem]")
16 | }
17 | .class("block border border-gray-200 rounded-3xl overflow-hidden")
18 | Div {
19 | Paragraph(item.tags.map{ $0.string }.joined(separator: ", "))
20 | .class("text-blog-c-tag-text text-xs md:text-sm my-1")
21 | H3(item.title)
22 | .class("text-heading-4 md:text-heading-3 font-semibold text-black my-3")
23 | Time(DateFormatter.time.string(from: item.date))
24 | .class("text-sm md:text-base font-light text-blog-c-time-text")
25 | }
26 | .class("p-4")
27 | }
28 | .class("group")
29 | }
30 | }
31 | .class("flex max-w-screen-md flex-col m-0 gap-y-4 list-none md:gap-y-6 lg:gap-y-8")
32 | }
33 | .class("flex flex-col items-center gap-20")
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/Pages/404.swift:
--------------------------------------------------------------------------------
1 | struct ErrorPage: Component {
2 | var body: Component {
3 | Section {
4 | Div {
5 | Div {
6 | H2 {
7 | Span {
8 | Text("404")
9 | }
10 | .class("text-blog-c-brand-blue")
11 | }
12 | .class("mb-8 font-bold text-9xl")
13 | Paragraph("Sorry, we couldn't find this page.")
14 | .class("text-3xl font-semibold md:text-3xl")
15 | Link(url: "/") {
16 | Text("Back to homepage")
17 | }
18 | .class("inline-flex cursor-pointer justify-center rounded-full p-5 text-gray-500 text-center border-2 ml-4")
19 | }
20 | .class("max-w-md text-center")
21 | }
22 | .class("flex items-center container flex-col justify-center mx-auto my-8 px-5")
23 | }
24 | .class("flex items-center h-full p-16")
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/Pages/About.swift:
--------------------------------------------------------------------------------
1 | struct AboutPage: Component {
2 | var body: Component {
3 | PageLayout(title: "About") {
4 | Div {
5 | Image("/static/images/about.svg")
6 | Paragraph {
7 | Text("This blog is created using ")
8 | Link("Publish", url: "https://github.com/JohnSundell/Publish")
9 | .class("text-blog-c-brand-blue")
10 | Text(" and ")
11 | Link("Tailwind CSS", url: "https://tailwindcss.com/")
12 | .class("text-blog-c-brand-blue")
13 | Text(".")
14 | }
15 | .class("text-center")
16 |
17 | Paragraph("""
18 | I am a junior developer who started iOS development in 2021. During this time, I worked with small and large teams on the project. I am interested in various architectures and technologies while developing iOS.
19 | """)
20 | .class("font-light")
21 |
22 | Paragraph {
23 | Text("You can contact me via ")
24 | Link("Email", url: "mailto:jihoonahn.dev@gmail.com")
25 | .class("text-blog-c-brand-blue")
26 | Text(" and ")
27 | Link("LinkedIn", url: "https://www.linkedin.com/in/ahnjihoon/")
28 | .class("text-blog-c-brand-blue")
29 | Text(".")
30 | }
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/Pages/HTMLFactory.swift:
--------------------------------------------------------------------------------
1 | struct BlogHTMLFactory: HTMLFactory {
2 | typealias Site = Blog
3 |
4 | @HTMLBuilder
5 | func makeIndexHTML(
6 | for index: Publish.Index,
7 | context: Publish.PublishingContext
8 | ) throws -> HTML {
9 | BaseLayout(for: index, context: context) {
10 | IndexPage(pageNumber: 1, context: context)
11 | }
12 | }
13 |
14 | @HTMLBuilder
15 | func makeSectionHTML(
16 | for section: Publish.Section,
17 | context: Publish.PublishingContext
18 | ) throws -> HTML {
19 | BaseLayout(for: section, context: context) {
20 | SectionPage(section: section, context: context)
21 | }
22 | }
23 |
24 | @HTMLBuilder
25 | func makeItemHTML(
26 | for item: Publish.Item,
27 | context: Publish.PublishingContext
28 | ) throws -> HTML {
29 | BaseLayout(for: item, context: context) {
30 | PostPage(item: item, context: context)
31 | }
32 | }
33 |
34 | @HTMLBuilder
35 | func makePageHTML(
36 | for page: Publish.Page,
37 | context: Publish.PublishingContext
38 | ) throws -> HTML {
39 | BaseLayout(for: page, context: context) {
40 | PagePage(page: page, context: context)
41 | }
42 | }
43 |
44 | @HTMLBuilder
45 | func makeTagListHTML(
46 | for page: Publish.TagListPage,
47 | context: Publish.PublishingContext
48 | ) throws -> HTML? {
49 | BaseLayout(for: page, context: context) {
50 | TagListPage(tags: page.tags, context: context)
51 | }
52 | }
53 |
54 | @HTMLBuilder
55 | func makeTagDetailsHTML(
56 | for page: Publish.TagDetailsPage,
57 | context: Publish.PublishingContext
58 | ) throws -> HTML? {
59 | BaseLayout(for: page, context: context) {
60 | TagDetailsPage(
61 | items: context.items(
62 | taggedWith: page.tag,
63 | sortedBy: \.date
64 | ),
65 | context: context,
66 | selectedTag: page.tag,
67 | pageNumber: 1
68 | )
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Sources/Pages/Index.swift:
--------------------------------------------------------------------------------
1 | struct IndexPage: Component {
2 | let pageNumber: Int
3 | let context: PublishingContext
4 |
5 | var body: Component {
6 | Section {
7 | PostsLayout(items: context.paginatedItems[pageNumber-1], context: context)
8 | Pagination(activePage: pageNumber, numberOfPages: context.paginatedItems.count) { num in
9 | context.index.paginatedPath(pageIndex: num - 1).absoluteString
10 | }
11 | }
12 | .class("px-4 sm:px-8 max-w-3xl mx-auto")
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/Pages/Page/Page.swift:
--------------------------------------------------------------------------------
1 | struct PagePage: Component {
2 | let page: Page
3 | let context: PublishingContext
4 |
5 | var body: Component {
6 | switch page.path.string {
7 | case "404":
8 | return ErrorPage()
9 | default:
10 | return page.body
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/Pages/Post/Post.swift:
--------------------------------------------------------------------------------
1 | struct PostPage: Component {
2 | var item: Item
3 | var context: PublishingContext
4 |
5 | var body: Component {
6 | PostLayout(item: item, context: context)
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Pages/Sections/Section.swift:
--------------------------------------------------------------------------------
1 | struct SectionPage: Component {
2 | var section: Publish.Section
3 | var context: PublishingContext
4 |
5 | var body: Component {
6 | switch section.path.string {
7 | case Blog.SectionID.blog.rawValue:
8 | return IndexPage(pageNumber: 1, context: context)
9 | case Blog.SectionID.about.rawValue:
10 | return AboutPage()
11 | default: return Div()
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/Pages/Tags/TagDetails.swift:
--------------------------------------------------------------------------------
1 | struct TagDetailsPage: Component {
2 | let items: [Item]
3 | let context: PublishingContext
4 | let selectedTag: Tag
5 | let pageNumber: Int
6 |
7 | var body: Component {
8 | PageLayout(title: selectedTag.string) {
9 | ComponentGroup {
10 | ArchiveLayout(
11 | items: items,
12 | context: context
13 | )
14 | if items.count > Constants.numberOfItemsPerTagsPage || pageNumber > 1 {
15 | Pagination(activePage: pageNumber, numberOfPages: context.paginatedItems(for: selectedTag).count) { num in
16 | context.site.paginatedPath(for: selectedTag, pageIndex: num - 1).absoluteString
17 | }
18 | }
19 | }
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/Pages/Tags/TagList.swift:
--------------------------------------------------------------------------------
1 | struct TagListPage: Component {
2 | let tags: Set
3 | let context: PublishingContext
4 |
5 | var body: Component {
6 | PageLayout(title: "Tag") {
7 | ComponentGroup {
8 | Paragraph("A collection of \(tags.count) tags.")
9 | .class("text-gray-700")
10 | List(tags) { tag in
11 | ListItem {
12 | Link(tag.string, url: context.site.url(for: tag))
13 | .class("p-2.5 border-2 border-stone-600 bg-white rounded-lg text-black transition duration-200 ease-in-out hover:bg-black hover:text-white")
14 | }
15 | .class("inline-block mx-1 my-3")
16 | }
17 | .class("mx-0")
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/Styles/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | html {
7 | @apply font-sans;
8 | display: block;
9 | }
10 | body {
11 | @apply bg-blog-c-bg;
12 | width: 100%;
13 | min-height: 100vh;
14 | line-height: 24px;
15 | font-size: 16px;
16 | }
17 |
18 | h1 {
19 | font-size: 2em;
20 | margin: 0.67em 0;
21 | }
22 |
23 | h2 {
24 | display: block;
25 | font-size: 1.5em;
26 | margin-block-start: 0.83em;
27 | margin-block-end: 0.83em;
28 | margin-inline-start: 0px;
29 | margin-inline-end: 0px;
30 | font-weight: bold;
31 | }
32 |
33 | h3 {
34 | display: block;
35 | font-size: 1.25em;
36 | margin-block-start: 1.45em;
37 | margin-block-end: 1.45em;
38 | margin-inline-start: 0px;
39 | margin-inline-end: 0px;
40 | font-weight: bold;
41 | }
42 |
43 | h4 {
44 | display: block;
45 | margin-block-start: 1.33em;
46 | margin-block-end: 1.33em;
47 | margin-inline-start: 0px;
48 | margin-inline-end: 0px;
49 | font-weight: bold;
50 | }
51 |
52 | ol {
53 | list-style: decimal;
54 | margin: 0 1em 0 1em;
55 | }
56 |
57 | ul {
58 | display: block;
59 | list-style-type: disc;
60 | margin-block-start: 1em;
61 | margin-block-end: 1em;
62 | margin-inline-start: 1em;
63 | margin-inline-end: 0px;
64 | }
65 |
66 | li {
67 | display: list-item;
68 | text-align: -webkit-match-parent;
69 | }
70 |
71 | img,
72 | video {
73 | max-width: 100%;
74 | height: auto;
75 | margin: 0.25rem 0;
76 | border-radius: 10px;
77 | }
78 | iframe {
79 | @apply h-52 sm:h-80 md:h-96;
80 | width: 100%;
81 | }
82 |
83 | a {
84 | color: #5364FF;
85 | }
86 |
87 | p {
88 | display: block;
89 | margin-block-start: 1em;
90 | margin-block-end: 1em;
91 | margin-inline-start: 0px;
92 | margin-inline-end: 0px;
93 | }
94 |
95 | div {
96 | display: block;
97 | }
98 |
99 | blockquote {
100 | color: #373953;
101 | border-left: 5px solid #333;
102 | padding: 0 1em;
103 | margin-top: 2em;
104 | margin-bottom: 2em;
105 | border-radius: 5px;
106 | }
107 |
108 | code {
109 | margin: 0;
110 | background-color: #6E768166;
111 | padding: 0.2em 0.4em;
112 | font-size: 95%;
113 | white-space: break-spaces;
114 | border-radius: 6px;
115 | line-break: anywhere;
116 | }
117 |
118 | pre {
119 | margin-bottom: 1.5em;
120 | background-color: rgba(34, 37, 41, 1);
121 | padding: 16px 0;
122 | border-radius: 8px;
123 | }
124 |
125 | pre code {
126 | font-family: monospace;
127 | display: block;
128 | padding: 0 20px;
129 | color: rgba(215, 215, 215, 1);
130 | background-color: rgba(34, 37, 41, 1);
131 | line-height: 1.4em;
132 | font-size: 0.95em;
133 | overflow-x: auto;
134 | white-space: pre;
135 | -webkit-overflow-scrolling: touch;
136 | }
137 |
138 | pre code .keyword {
139 | color: rgba(253, 71, 88, 1);
140 | }
141 |
142 | pre code .type {
143 | color: rgba(255, 219, 146, 1);
144 | }
145 |
146 | pre code .call {
147 | color: rgba(158, 241, 221, 1);
148 | }
149 |
150 | pre code .property {
151 | color: rgba(111, 185, 87, 1);
152 | }
153 |
154 | pre code .number {
155 | color: rgba(247, 247, 255, 1);
156 | }
157 |
158 | pre code .string {
159 | color: rgba(253, 244, 255, 1);
160 | }
161 |
162 | pre code .comment {
163 | color: rgba(107, 102, 107, 1);
164 | }
165 |
166 | pre code .dotAccess {
167 | color: rgba(158, 241, 221, 1);
168 | }
169 |
170 | pre code .preprocessing {
171 | color: #C77C49;
172 | }
173 |
174 | .blogMobileNav {
175 | height: 100vh;
176 | }
177 |
178 | .utterances {
179 | border-radius: 10px;
180 | background: #f6f8fa;
181 | max-width: 768px;
182 | }
183 |
184 | .utterances-frame {
185 | padding-left: 1rem;
186 | padding-right: 1rem;
187 | }
188 |
189 | .component {
190 | @apply text-center w-[85%] my-6 md:my-8 lg:my-11 mx-auto overflow-auto lg:w-[980px];
191 | }
192 |
193 | .blogNavBarMenuLink {
194 | @apply text-blog-c-nav-text hover:text-blog-c-brand-blue;
195 | transition: color .25s;
196 | white-space: nowrap;
197 | }
198 |
199 | .blogPagination {
200 | @apply rounded-full bg-blog-c-pagination-button hover:bg-blog-c-pagination-button-hover;
201 | transition: 250ms background-color linear,250ms color linear,250ms opacity linear;
202 | }
203 |
204 | .blogDocsearch .DocSearch-Button {
205 | background: transparent;
206 | }
207 |
208 | .blogDocsearch .DocSearch {
209 | --docsearch-primary-color: '#5364FF';
210 | --docsearch-highlight-color: '#5364FF';
211 | --docsearch-text-color: '#213547';
212 | --docsearch-muted-color: '#000000';
213 | --docsearch-searchbox-shadow: none;
214 | --docsearch-searchbox-focus-background: transparent;
215 | --docsearch-key-gradient: transparent;
216 | --docsearch-key-shadow: none;
217 | --docsearch-modal-background: '#F9F9F9';
218 | --docsearch-footer-background: '#FFFFFF';
219 | }
220 |
221 | .blogDocsearch .DocSearch-Button-Keys {
222 | display: none;
223 | }
224 |
225 | .blogDocsearch .DocSearch-Button-Placeholder {
226 | @apply text-xs hover:text-blog-c-brand-blue;
227 | font-weight: 500;
228 | padding: 0 0 0 6px;
229 | }
230 |
231 | .blogDocsearch .DocSearch-Search-Icon {
232 | @apply md:mr-1.5 md:w-3.5 md:h-3.5;
233 | top: 1px;
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/Sources/Theme.swift:
--------------------------------------------------------------------------------
1 | extension Publish.Theme where Site == Blog {
2 | static var blog: Self {
3 | Theme(htmlFactory: BlogHTMLFactory())
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/Sources/Utils/Foundation/Array.swift:
--------------------------------------------------------------------------------
1 | extension Array {
2 | func chunked(into size: Int) -> [[Element]] {
3 | return stride(from: 0, to: count, by: size).map {
4 | Array(self[$0 ..< Swift.min($0 + size, count)])
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/Utils/Foundation/DateFormatter.swift:
--------------------------------------------------------------------------------
1 | extension DateFormatter {
2 | static var time: DateFormatter = {
3 | let formatter = DateFormatter()
4 | formatter.dateStyle = .long
5 | formatter.dateFormat = "yyyy년 MM월 dd일"
6 | return formatter
7 | }()
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Utils/HTML/HTMLBuilder.swift:
--------------------------------------------------------------------------------
1 | @resultBuilder
2 | struct HTMLBuilder {
3 | @inlinable
4 | static func buildBlock(_ content: Content) -> HTML {
5 | content.build()
6 | }
7 | @inlinable
8 | static func buildOptional(_ wrapped: H?) -> HTML? {
9 | wrapped?.build()
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/Utils/HTML/HTMLConvertable.swift:
--------------------------------------------------------------------------------
1 | protocol HTMLConvertable {
2 | func build() -> HTML
3 | }
4 |
5 | extension HTML: HTMLConvertable {
6 | func build() -> HTML {
7 | return self
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Sources/Utils/Import/Import.swift:
--------------------------------------------------------------------------------
1 | @_exported import Foundation
2 | @_exported import Plot
3 | @_exported import Publish
4 | @_exported import ShellOut
5 | @_exported import Splash
6 | @_exported import Ink
7 |
--------------------------------------------------------------------------------
/Sources/Utils/Pagination/Blog+paginatedPath.swift:
--------------------------------------------------------------------------------
1 | extension Blog {
2 | func paginatedPath(for tag: Tag, pageIndex: Int) -> Path {
3 | guard pageIndex != 0 else { return self.path(for: tag) }
4 | return self.path(for: tag).appendingComponent("\(pageIndex + 1)")
5 | }
6 |
7 | func paginatedTagListPath(pageIndex: Int) -> Path {
8 | guard pageIndex != 0 else { return tagListPath }
9 | return tagListPath.appendingComponent("\(pageIndex + 1)")
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/Utils/Pagination/Index+paginatedPath.swift:
--------------------------------------------------------------------------------
1 | extension Index {
2 | func paginatedPath(pageIndex: Int) -> Path {
3 | guard pageIndex != 0 else { return path }
4 | return path.appendingComponent("\(pageIndex + 1)")
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/Utils/Pagination/PublishingContext+allPaginatedItems.swift:
--------------------------------------------------------------------------------
1 | extension PublishingContext where Site == Blog {
2 | var paginatedItems: [[Item]] {
3 | allItems(sortedBy: \.date, order: .descending).chunked(into: Constants.numberOfItemsPerIndexPage)
4 | }
5 |
6 | func paginatedItems(for tag: Tag) -> [[Item]] {
7 | items(taggedWith: tag, sortedBy: \.date, order: .descending).chunked(into: Constants.numberOfItemsPerTagsPage)
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Sources/Utils/Plot/BodyContext.swift:
--------------------------------------------------------------------------------
1 | extension Node where Context == HTML.BodyContext {
2 | static func nodeTime(_ nodes: Node...) -> Node {
3 | .element(named: "time", nodes: nodes)
4 | }
5 | static func figure(_ nodes: Node...) -> Node {
6 | .element(named: "figure", nodes: nodes)
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Utils/Plot/ElementDefinitions.swift:
--------------------------------------------------------------------------------
1 | extension ElementDefinitions {
2 | enum Main: ElementDefinition { public static var wrapper = Node.main }
3 | enum Section: ElementDefinition { public static var wrapper = Node.section }
4 | enum ASide: ElementDefinition { public static var wrapper = Node.aside }
5 | enum Figure: ElementDefinition { public static var wrapper = Node.figure }
6 | }
7 |
8 | typealias Main = ElementComponent
9 | typealias Section = ElementComponent
10 | typealias ASide = ElementComponent
11 | typealias Figure = ElementComponent
12 |
--------------------------------------------------------------------------------
/Sources/Utils/Plot/Script.swift:
--------------------------------------------------------------------------------
1 | struct Script: Component {
2 | // MARK: - Properties
3 | let nodes: [Node]
4 |
5 | // MARK: - Initalizer
6 | init(_ nodes: Node...) {
7 | self.nodes = nodes
8 | }
9 |
10 | var body: Component {
11 | Node.script(nodes.node)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/Utils/Plot/SourceContext.swift:
--------------------------------------------------------------------------------
1 | extension Node where Context: HTMLSourceContext {
2 | static func crossorigin(_ text: String) -> Node {
3 | .attribute(named: "crossorigin",value: text)
4 | }
5 | static func issue_term(_ text: String) -> Node {
6 | .attribute(named: "issue-term",value: text)
7 | }
8 | static func label(_ text: String) -> Node {
9 | .attribute(named: "label",value: text)
10 | }
11 | static func repo(_ text: String) -> Node {
12 | .attribute(named: "repo",value: text)
13 | }
14 | static func theme(_ text: String) -> Node {
15 | .attribute(named: "theme",value: text)
16 | }
17 | static func type(_ type: String) -> Node {
18 | .attribute(named: "type",value: type)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/Utils/Plot/Time.swift:
--------------------------------------------------------------------------------
1 | struct Time: Component {
2 | // MARK: - Properties
3 | let text: String
4 |
5 | // MARK: - Initalizer
6 | init(_ text: String) {
7 | self.text = text
8 | }
9 |
10 | var body: Component {
11 | Node.nodeTime(.text(text))
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/Utils/Publish/PublishingStep/404Error.swift:
--------------------------------------------------------------------------------
1 | import Publish
2 |
3 | extension PublishingStep {
4 | static func move404File() -> Self {
5 | let stepName = "Move 404 file for Blog Pages"
6 |
7 | return step(named: stepName) { context in
8 | guard let orig404Page = context.pages["404"] else {
9 | throw PublishingError(stepName: stepName,
10 | infoMessage: "Unable to find 404 page")
11 | }
12 |
13 | let orig404File = try context.outputFile(at: "\(orig404Page.path)/index.html")
14 | try orig404File.rename(to: "404")
15 |
16 | guard
17 | let orig404Folder = orig404File.parent,
18 | let outputFolder = orig404Folder.parent,
19 | let rootFolder = outputFolder.parent
20 | else {
21 | throw PublishingError(stepName: stepName,
22 | infoMessage: "Unable find root, output and 404 folders")
23 | }
24 | try context.copyFileToOutput(from: "\(orig404File.path(relativeTo: rootFolder))")
25 | try orig404Folder.delete()
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/Utils/Publish/PublishingStep/CodeBlock.swift:
--------------------------------------------------------------------------------
1 | extension PublishingStep where Site == Blog {
2 | static func codeBlock(_ classPrefix: String = "") -> Self {
3 | step(named: "CodeBlock") { step in
4 | step.markdownParser.addModifier(
5 | .codeBlock(HTMLOutputFormat(classPrefix: classPrefix))
6 | )
7 | }
8 | }
9 | }
10 |
11 | extension Modifier {
12 | static func codeBlock(_ format: HTMLOutputFormat = HTMLOutputFormat()) -> Self {
13 | let highlighter = SyntaxHighlighter(format: format)
14 |
15 | return Modifier(target: .codeBlocks) { html, markdown in
16 | var markdown = markdown.dropFirst("```".count)
17 |
18 | guard !markdown.hasPrefix("no-highlight") else {
19 | return html
20 | }
21 |
22 | markdown = markdown
23 | .drop(while: { !$0.isNewline })
24 | .dropFirst()
25 | .dropLast("\n```".count)
26 |
27 | let highlighted = highlighter.highlight(String(markdown))
28 | return "" + highlighted + "\n
"
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/Utils/Publish/PublishingStep/PaginatedPages.swift:
--------------------------------------------------------------------------------
1 | extension PublishingStep where Site == Blog {
2 | static func paginatedPages() -> Self {
3 | .group([
4 | .generatePaginatedIndexPages(),
5 | .generatePaginatedTagPages()
6 | ])
7 | }
8 |
9 | private static func generatePaginatedIndexPages() -> Self {
10 | .step(named: "Generate paginated index pages") { context in
11 | context.paginatedItems.indices.dropFirst().forEach { pageIndex in
12 | context.addPage(
13 | Page(
14 | path: context.index.paginatedPath(pageIndex: pageIndex),
15 | content: .init(title: context.index.title, description: context.index.description, body: .init(components: {
16 | IndexPage(
17 | pageNumber: pageIndex + 1,
18 | context: context
19 | )
20 | }), date: context.index.date, lastModified: context.index.lastModified, imagePath: context.index.imagePath)
21 | )
22 | )
23 | }
24 | }
25 | }
26 |
27 | private static func generatePaginatedTagPages() -> Self {
28 | .step(named: "Generate paginated tag pages") { context in
29 | context.allTags.forEach { tag in
30 | context.paginatedItems(for: tag).indices.dropFirst().forEach { pageIndex in
31 | context.addPage(
32 | Page(
33 | path: context.site.paginatedPath(for: tag, pageIndex: pageIndex),
34 | content: .init(title: "Blog Tags", description: "Tags for Jihoonahn's Blog", body: .init(components: {
35 | TagDetailsPage(
36 | items: context.paginatedItems(for: tag)[pageIndex],
37 | context: context,
38 | selectedTag: tag,
39 | pageNumber: pageIndex + 1
40 | )
41 | }))
42 | )
43 | )
44 | }
45 | }
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Sources/Utils/Publish/PublishingStep/Tailwind.swift:
--------------------------------------------------------------------------------
1 | extension PublishingStep where Site == Blog {
2 | static func tailwindcss() -> Self {
3 | .step(named: "Tailwind", body: { step in
4 | try shellOut(
5 | to: "./tailwindcss",
6 | arguments: [
7 | "-i",
8 | "./Sources/Styles/global.css",
9 | "-o",
10 | "./Output/styles.css"
11 | ]
12 | )
13 | })
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/main.swift:
--------------------------------------------------------------------------------
1 | struct Blog: Website {
2 | enum SectionID: String, WebsiteSectionID {
3 | case blog
4 | case about
5 |
6 | var name: String {
7 | switch self {
8 | case .blog: return "Blog"
9 | case .about: return "About"
10 | }
11 | }
12 | }
13 |
14 | struct ItemMetadata: WebsiteItemMetadata {
15 | var postImage: String
16 | }
17 |
18 | var url = URL(string: "https://blog.jihoon.me")!
19 | var name = "jihoon.me"
20 | var description = "This is a personal blog for iOS Developer jihoonahn."
21 | var language: Language { .korean }
22 | var imagePath: Path? { nil }
23 | var favicon: Favicon? { Favicon(path: "/favicon.ico", type: "image/x-icon") }
24 | var socialMediaLinks: [SocialMediaLink] { [.github, .linkedIn, .email, .rss] }
25 | }
26 |
27 | try Blog().publish(using: [
28 | .codeBlock(),
29 | .optional(.copyResources()),
30 | .addMarkdownFiles(),
31 | .paginatedPages(),
32 | .generateHTML(withTheme: .blog),
33 | .generateRSSFeed(including: [.blog]),
34 | .generateSiteMap(),
35 | .move404File(),
36 | .deploy(using: .gitHub("jihoonahn/blog"))
37 | ])
38 |
--------------------------------------------------------------------------------
/docsearch.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "index_name": "blog",
3 | "start_urls": [
4 | "https://blog.jihoon.me/"
5 | ],
6 | "sitemap_urls": [
7 | "https://blog.jihoon.me/sitemap.xml"
8 | ],
9 | "sitemap_alternate_links": true,
10 | "stop_urls": ["/404", "/tags/"],
11 | "selectors": {
12 | "lvl0": {
13 | "selector": "//h1[contains(@id, 'pageTitle')]",
14 | "type": "xpath",
15 | "global": true,
16 | "default_value": "Blog"
17 | },
18 | "lvl1": "article h1",
19 | "lvl2": "article h2",
20 | "lvl3": "article h3",
21 | "lvl4": "article h4",
22 | "lvl5": "article h5",
23 | "lvl6": "article h6",
24 | "text": "section p, section li, section td, section blockquote"
25 | },
26 | "strip_chars": " .,;:#"
27 | }
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@tailwindcss/typography": "^0.5.10",
4 | "tailwindcss": "^3.3.5"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./Sources/**/*.swift",
5 | "./Sources/Styles/global.css"
6 | ],
7 | theme: {
8 | fontFamily: {
9 | 'sans': ['Open Sans', 'sans-serif']
10 | },
11 | extend: {
12 | fontSize: {
13 | 'heading-1': '3rem',
14 | 'heading-2': '2rem',
15 | 'heading-3': '1.5rem',
16 | 'heading-4': '1.25rem',
17 | 'heading-5': '1rem',
18 | 'heading-6': '0.75rem'
19 | },
20 | colors: {
21 | 'blog-c-bg': '#FFFFFF',
22 | 'blog-c-nav': 'rgba(255,255,255,0.7)',
23 | 'blog-c-nav-text': '#1d1d1f',
24 | 'blog-c-tag-text': '#8e8e93',
25 | 'blog-c-time-text': '#6e6e73',
26 | 'blog-c-pagination-button': 'rgba(210,210,215,0.2)',
27 | 'blog-c-pagination-button-hover': 'rgba(210,210,215,0.5)',
28 | 'blog-c-preview-page': '#F6F8FA',
29 | 'blog-c-card': '#FFFFFF',
30 | 'blog-c-button': '#F5F5F7',
31 | 'blog-c-footer': '#F5F5F7',
32 | 'blog-c-brand-blue': '#5364FF',
33 | 'blog-c-brand-sky': '#708FFF',
34 | 'blog-c-utterances': '#F6F8FA',
35 | 'blog-c-divider': 'rgba(60, 60, 60, .12)'
36 | },
37 | height: {
38 | 'blog-nav': '55px',
39 | },
40 | lineHeight: {
41 | 'calc-blog-nav': 'calc(55px)'
42 | },
43 | spacing: {
44 | 'calc-blog-nav': 'calc(56px)'
45 | },
46 | borderRadius: {
47 | 'sm': '0.125rem',
48 | 'md': '0.375rem',
49 | 'lg': '0.75rem',
50 | 'xl': '1.5rem',
51 | },
52 | screens: {
53 | 'sm': '640px',
54 | 'md': '768px',
55 | 'lg': '1024px',
56 | 'xl': '1280px',
57 | '2xl': '1536px'
58 | },
59 | zindex: {
60 | '10': 10,
61 | '20': 20,
62 | '30': 30,
63 | '40': 40,
64 | '50': 50
65 | },
66 | flex: {
67 | '1-0': '1 0 1px'
68 | },
69 | },
70 | },
71 | plugins: [
72 | require('@tailwindcss/typography'),
73 | ],
74 | };
75 |
--------------------------------------------------------------------------------