Skip to content

Commit 0e22b61

Browse files
committed
Add Index and Project
1 parent a82a147 commit 0e22b61

38 files changed

+2386
-206
lines changed

Package.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ let package = Package(
2929
.target(name: "Generator")
3030
],
3131
exclude: [
32-
"Styles/global.css"
32+
"Styles/global.css",
33+
"Contents"
3334
],
3435
swiftSettings: [
3536
.enableUpcomingFeature("StrictConcurrency")

Public/app/daysquare/Icon.svg

Lines changed: 5 additions & 0 deletions
Loading

Public/portfolio.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"github": "jihoonahn",
3+
"projects": [
4+
{"title": "DaySquare", "description": "하루를 위한 앱", "image": "/app/daysquare/Icon.svg", "url": "/projects/daysquare", "group": "App"},
5+
{"title": "Layout", "repo": "pelagornis/swift-layout", "group": "Opensource"},
6+
{"title": "RefineUI System Icons", "repo": "pelagornis/refineui-system-icons", "group": "Opensource"},
7+
{"title": "Rex", "repo": "pelagornis/swift-rex", "group": "Opensource"},
8+
{"title": "Network", "repo": "pelagornis/swift-network", "group": "Opensource"},
9+
{"title": "DesignTuist", "repo": "jihoonme/designtuist", "group": "Opensource"},
10+
{"title": "TuistUI", "repo": "jihoonme/tuistui", "group": "Opensource"},
11+
{"title": "File", "repo": "pelagornis/swift-file", "group": "Opensource"},
12+
{"title": "Command", "repo": "pelagornis/swift-command", "group": "Opensource"},
13+
{"title": "Let's Swift 2023", "description": "LetSwift 컨퍼런스 발표", "date": "2023-12-13", "url": "https://www.youtube.com/watch?v=sh66E-wYOA4", "group": "Speaker"},
14+
{"title": "IGA iOS 2023", "description": "iOS Meets ML 발표", "date": "2023-07-08", "url": "https://www.youtube.com/watch?v=ugNe2yFBRDM", "group": "Speaker"},
15+
{"title": "제 4회 와글와글 iOS", "description": "와글와글 iOS 발표", "date": "2023-02-05", "url": "https://www.youtube.com/watch?v=rDlcnt31re0", "group": "Speaker"}
16+
]
17+
}

Public/scripts/project-mockup.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
document.addEventListener('DOMContentLoaded', function() {
2+
var root = document.querySelector('.project-mockup-main');
3+
if (!root) return;
4+
var mockup = document.getElementById('project-device-mockup');
5+
var row = root.querySelector('.project-mockup-device-row');
6+
var tabs = root.querySelectorAll('.project-mockup-tab');
7+
var typeEl = document.getElementById('project-mockup-type');
8+
9+
if (mockup && row && tabs.length) {
10+
tabs.forEach(function(tab) {
11+
tab.addEventListener('click', function() {
12+
var platform = tab.getAttribute('data-platform');
13+
var subtitle = tab.getAttribute('data-subtitle');
14+
if (!platform) return;
15+
tabs.forEach(function(t) { t.classList.remove('is-active'); });
16+
tab.classList.add('is-active');
17+
mockup.setAttribute('data-platform', platform);
18+
mockup.className = 'device-mockup device-mockup--' + platform;
19+
row.setAttribute('data-current-platform', platform);
20+
if (typeEl && subtitle) typeEl.textContent = subtitle;
21+
});
22+
});
23+
}
24+
25+
var viewports = root.querySelectorAll('.device-carousel-viewport');
26+
viewports.forEach(function(viewport) {
27+
var platform = viewport.getAttribute('data-platform');
28+
if (!platform) return;
29+
var dots = root.querySelectorAll('.device-carousel-dots[data-platform="' + platform + '"] .device-carousel-dot');
30+
if (dots.length === 0) return;
31+
32+
function slideWidth() {
33+
var slide = viewport.querySelector('.device-carousel-slide');
34+
return (slide && slide.offsetWidth) ? slide.offsetWidth : viewport.offsetWidth;
35+
}
36+
function updateDotsFromScroll() {
37+
var w = slideWidth();
38+
if (w <= 0) return;
39+
var i = Math.round(viewport.scrollLeft / w);
40+
i = Math.max(0, Math.min(i, dots.length - 1));
41+
for (var j = 0; j < dots.length; j++) dots[j].classList.toggle('is-active', j === i);
42+
}
43+
function goTo(i) {
44+
var w = slideWidth();
45+
viewport.scrollLeft = i * w;
46+
for (var j = 0; j < dots.length; j++) dots[j].classList.toggle('is-active', j === i);
47+
}
48+
for (var i = 0; i < dots.length; i++) {
49+
(function(idx) { dots[idx].addEventListener('click', function() { goTo(idx); }); })(i);
50+
}
51+
viewport.addEventListener('scroll', updateDotsFromScroll);
52+
viewport.addEventListener('touchmove', updateDotsFromScroll);
53+
try { viewport.addEventListener('scrollend', updateDotsFromScroll); } catch (e) {}
54+
setTimeout(updateDotsFromScroll, 150);
55+
requestAnimationFrame(updateDotsFromScroll);
56+
});
57+
58+
var codeBlocks = root.querySelectorAll('.project-mockup-content pre');
59+
codeBlocks.forEach(function(pre) {
60+
var btn = document.createElement('button');
61+
btn.textContent = 'Copy';
62+
btn.className = 'copy-code-button';
63+
btn.addEventListener('click', function() {
64+
var code = pre.querySelector('code');
65+
var text = code ? code.textContent : pre.textContent;
66+
navigator.clipboard.writeText(text).then(function() {
67+
btn.textContent = 'Copied!';
68+
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
69+
});
70+
});
71+
pre.appendChild(btn);
72+
});
73+
});

Sources/Generator/Content.swift

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ public struct Content {
5959
}
6060
}
6161

62+
/// 하위 폴더(예: Contents/Posts)의 .md도 모두 한 리스트로 평탄하게 반환합니다.
63+
/// 예: Contents/*.md + Contents/Posts/*.md → 모두 /posts/{slug}/ 로 노출
6264
private func generatePages(from basePath: String, relativePath: String) throws -> [Page] {
6365
let fullPath = basePath.isEmpty ? relativePath : (relativePath.isEmpty ? basePath : "\(basePath)/\(relativePath)")
6466

@@ -79,14 +81,8 @@ public struct Content {
7981
fileManager.fileExists(atPath: itemPath, isDirectory: &isDirectory)
8082

8183
if isDirectory.boolValue {
82-
let subPages = try generatePages(from: fullPath, relativePath: item)
83-
if !subPages.isEmpty {
84-
pages.append(Page(
85-
name: item.capitalized,
86-
path: item,
87-
children: subPages
88-
))
89-
}
84+
let subPages = try generatePages(from: basePath, relativePath: relativePath.isEmpty ? item : "\(relativePath)/\(item)")
85+
pages.append(contentsOf: subPages)
9086
} else if item.hasSuffix(".md") {
9187
let filePath = Path(itemPath)
9288
let file = try File(path: filePath)
@@ -97,7 +93,7 @@ public struct Content {
9793
}
9894

9995
let post = try Post.from(file: item, content: content)
100-
let postHTML = layout(post, posts) // 이제 모든 포스트가 로드된 상태
96+
let postHTML = layout(post, posts)
10197

10298
pages.append(Page(
10399
name: post.metadata.title,

Sources/Generator/Generator.swift

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ public final class Generator: Sendable {
5050
try setupDistFolder()
5151
try buildTailwindCSS()
5252
try copyPublicFiles()
53+
try enrichPortfolioWithGitHub()
5354
try generatePages()
5455
try generateRSSFeed()
5556
try generateSitemap()
57+
try generatePostsJSON()
5658

5759
logger.info("✅ Website generated successfully!")
5860
}
@@ -115,8 +117,60 @@ public final class Generator: Sendable {
115117

116118
logger.info("✅ Copied \(copiedCount) file(s) from Public folder")
117119
}
118-
119-
120+
121+
private func enrichPortfolioWithGitHub() throws {
122+
let path = "\(distPath)/portfolio.json"
123+
let fileManager = FileManager.default
124+
guard fileManager.fileExists(atPath: path) else {
125+
logger.info("ℹ️ portfolio.json not found, skipping GitHub enrich")
126+
return
127+
}
128+
let data = try Data(contentsOf: Foundation.URL(fileURLWithPath: path))
129+
guard var top = try JSONSerialization.jsonObject(with: data) as? [String: Any],
130+
var projects = top["projects"] as? [[String: Any]] else {
131+
logger.info("ℹ️ portfolio.json invalid structure, skipping GitHub enrich")
132+
return
133+
}
134+
for i in projects.indices {
135+
guard (projects[i]["group"] as? String) == "Opensource",
136+
let repoRaw = projects[i]["repo"] as? String,
137+
!repoRaw.isEmpty else { continue }
138+
let repo = repoRaw.trimmingCharacters(in: .whitespacesAndNewlines)
139+
guard let url = Foundation.URL(string: "https://api.github.com/repos/\(repo)") else { continue }
140+
var req = URLRequest(url: url)
141+
req.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept")
142+
let (fetchData, _, _) = Self.syncDataTask(with: req)
143+
guard let fetchData = fetchData,
144+
let repoInfo = try? JSONSerialization.jsonObject(with: fetchData) as? [String: Any] else {
145+
logger.warning(" ⚠️ GitHub API failed for \(repo)")
146+
continue
147+
}
148+
if let stars = repoInfo["stargazers_count"] as? Int {
149+
projects[i]["stars"] = stars
150+
}
151+
if let desc = repoInfo["description"] as? String {
152+
projects[i]["description"] = desc
153+
}
154+
logger.info(" ✓ Enriched: \(repo)")
155+
}
156+
top["projects"] = projects
157+
let outData = try JSONSerialization.data(withJSONObject: top)
158+
try outData.write(to: Foundation.URL(fileURLWithPath: path))
159+
logger.info("✅ Portfolio enriched with GitHub stars/description")
160+
}
161+
162+
private static func syncDataTask(with request: URLRequest) -> (Data?, URLResponse?, Error?) {
163+
final class Box { var value: (Data?, URLResponse?, Error?) = (nil, nil, nil) }
164+
let box = Box()
165+
let semaphore = DispatchSemaphore(value: 0)
166+
URLSession.shared.dataTask(with: request) { data, response, error in
167+
box.value = (data, response, error)
168+
semaphore.signal()
169+
}.resume()
170+
semaphore.wait()
171+
return box.value
172+
}
173+
120174
private func generatePages() throws {
121175
logger.info("Generating static pages...")
122176

@@ -172,6 +226,35 @@ public final class Generator: Sendable {
172226
logger.info("✅ Sitemap generation complete")
173227
}
174228

229+
private func generatePostsJSON() throws {
230+
struct PostListItem: Encodable {
231+
let title: String
232+
let slug: String
233+
let date: String
234+
let description: String?
235+
let image: String?
236+
let tags: [String]
237+
}
238+
let formatter = ISO8601DateFormatter()
239+
formatter.formatOptions = [.withInternetDateTime]
240+
let items = posts.sorted { $0.metadata.date > $1.metadata.date }.map { post in
241+
PostListItem(
242+
title: post.metadata.title,
243+
slug: post.slug,
244+
date: formatter.string(from: post.metadata.date),
245+
description: post.metadata.description,
246+
image: post.metadata.image,
247+
tags: post.metadata.tags
248+
)
249+
}
250+
let data = try JSONEncoder().encode(items)
251+
guard let jsonString = String(data: data, encoding: .utf8) else { return }
252+
let jsonPath = Path("\(distPath)/posts.json")
253+
let file = try File(path: jsonPath)
254+
try file.write(jsonString)
255+
logger.info(" ✓ Generated: posts.json -> \(distPath)/posts.json")
256+
}
257+
175258
private func generatePage(_ page: Page, basePath: String) throws {
176259
let fullPath = "\(basePath)/\(page.path)"
177260

Sources/Generator/Post.swift

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,36 @@ public struct PostMetadata: Sendable {
77
public let tags: [String]
88
public let image: String?
99
public let description: String?
10-
11-
public init(title: String, date: Date, tags: [String], image: String? = nil, description: String? = nil) {
10+
/// 프로젝트 스크린 갤러리용 이미지 URL 배열 (캐러셀 표시, 플랫폼별 없을 때 fallback)
11+
public let screens: [String]
12+
/// 플랫폼별 스크린: screenshot/ios, screenshot/ipad, screenshot/macos 폴더 기준
13+
public let screensIos: [String]
14+
public let screensIpad: [String]
15+
public let screensMacos: [String]
16+
/// 프로젝트 지원 플랫폼 (iOS, iPad, macOS) - 목업 탭 표시용
17+
public let platforms: [String]
18+
19+
public init(title: String, date: Date, tags: [String], image: String? = nil, description: String? = nil, screens: [String] = [], screensIos: [String] = [], screensIpad: [String] = [], screensMacos: [String] = [], platforms: [String] = ["iOS"]) {
1220
self.title = title
1321
self.date = date
1422
self.tags = tags
1523
self.image = image
1624
self.description = description
25+
self.screens = screens
26+
self.screensIos = screensIos
27+
self.screensIpad = screensIpad
28+
self.screensMacos = screensMacos
29+
self.platforms = platforms.isEmpty ? ["iOS"] : platforms
30+
}
31+
32+
/// 플랫폼 키(ios/ipad/mac)에 해당하는 스크린 목록. 없으면 screens 사용
33+
public func screens(forPlatformKey key: String) -> [String] {
34+
switch key {
35+
case "ios": return screensIos.isEmpty ? screens : screensIos
36+
case "ipad": return screensIpad.isEmpty ? screens : screensIpad
37+
case "mac": return screensMacos.isEmpty ? screens : screensMacos
38+
default: return screens
39+
}
1740
}
1841
}
1942

@@ -32,7 +55,7 @@ public struct Post: Sendable {
3255

3356
public static func from(file: String, content: String) throws -> Post {
3457
// 새로운 Markdown Parser 사용
35-
var parser = MarkdownParser()
58+
let parser = MarkdownParser()
3659
let markdown = parser.parse(content)
3760

3861
// slug 생성
@@ -46,8 +69,13 @@ public struct Post: Sendable {
4669
title: markdown.metadata["title"] ?? "Untitled",
4770
date: parseDate(from: markdown.metadata["date"]) ?? Date(),
4871
tags: parseTags(from: markdown.metadata["tags"]),
49-
image: parseImage(from: markdown.metadata["image"], slug: slug),
50-
description: markdown.metadata["description"]
72+
image: parseImage(from: markdown.metadata["image"]),
73+
description: markdown.metadata["description"],
74+
screens: parseScreens(from: markdown.metadata["screens"]),
75+
screensIos: parseScreens(from: markdown.metadata["screens_ios"]),
76+
screensIpad: parseScreens(from: markdown.metadata["screens_ipad"]),
77+
screensMacos: parseScreens(from: markdown.metadata["screens_macos"]),
78+
platforms: parsePlatforms(from: markdown.metadata["platforms"])
5179
)
5280

5381
return Post(
@@ -94,24 +122,38 @@ public struct Post: Sendable {
94122
return cleanedValue.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
95123
}
96124

97-
// 이미지 파싱 헬퍼 함수
98-
private static func parseImage(from imageString: String?, slug: String) -> String? {
125+
// 이미지 파싱: Contents 프론트매터에 적은 경로를 그대로 사용 (Public 기준 루트 상대 경로)
126+
private static func parseImage(from imageString: String?) -> String? {
99127
guard let imageString = imageString else { return nil }
100128

101-
let trimmedImage = imageString.trimmingCharacters(in: .whitespacesAndNewlines)
129+
let trimmed = imageString.trimmingCharacters(in: .whitespacesAndNewlines)
130+
guard !trimmed.isEmpty else { return nil }
102131

103-
// 외부 URL인 경우 (https:// 또는 http://로 시작)
104-
if trimmedImage.hasPrefix("https://") || trimmedImage.hasPrefix("http://") {
105-
return trimmedImage
132+
// 외부 URL 또는 이미 / 로 시작하는 절대 경로 → 그대로 사용
133+
if trimmed.hasPrefix("https://") || trimmed.hasPrefix("http://") || trimmed.hasPrefix("/") {
134+
return trimmed
106135
}
107-
108-
// 로컬 경로인 경우 /thumbnail/ 경로로 변환
109-
if trimmedImage.hasPrefix("/") {
110-
// 이미 절대 경로인 경우 그대로 사용
111-
return trimmedImage
112-
} else {
113-
// 상대 경로인 경우 /thumbnail/ 경로로 변환
114-
return "/thumbnail/\(trimmedImage)"
136+
// 상대 경로 → Public 루트 기준으로 / 경로 (예: thumbnail/foo.svg → /thumbnail/foo.svg, app/daysquare/Icon.svg → /app/daysquare/Icon.svg)
137+
return "/" + trimmed
138+
}
139+
140+
/// screens: "url1, url2, url3" 형태 파싱, 각 경로는 parseImage 규칙으로 정규화
141+
private static func parseScreens(from screensString: String?) -> [String] {
142+
guard let s = screensString else { return [] }
143+
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
144+
guard !trimmed.isEmpty else { return [] }
145+
return trimmed.components(separatedBy: ",").compactMap { part in
146+
let p = part.trimmingCharacters(in: .whitespaces)
147+
return parseImage(from: p.isEmpty ? nil : p)
115148
}
116149
}
150+
151+
/// platforms: "iOS, iPad, macOS" 형태 파싱
152+
private static func parsePlatforms(from platformsString: String?) -> [String] {
153+
guard let s = platformsString else { return ["iOS"] }
154+
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
155+
guard !trimmed.isEmpty else { return ["iOS"] }
156+
let list = trimmed.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
157+
return list.isEmpty ? ["iOS"] : list
158+
}
117159
}

Sources/Website/Components/Global/Header.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ struct Header: Component {
88
ComponentGroup(members: navigation.map { item in
99
Tooltip(text: item.name, position: .bottom) {
1010
Link(url: item.href) {
11-
item.icon
11+
navIcon(for: item.href)
1212
}
1313
.class("flex items-center justify-center hover:bg-neutral-800 hover:text-neutral-400 rounded-full transition-colors duration-200 text-neutral-600 w-12 h-12")
1414
}
@@ -38,3 +38,13 @@ struct Header: Component {
3838
.class("fixed top-8 left-0 right-0 flex justify-between items-center px-6 max-w-2xl mx-auto z-99")
3939
}
4040
}
41+
42+
private func navIcon(for href: String) -> Component {
43+
switch href {
44+
case "/": return HomeIcon()
45+
case "/posts": return PostIcon()
46+
case "/projects": return PostIcon()
47+
case "/about": return PersonIcon()
48+
default: return PostIcon()
49+
}
50+
}

0 commit comments

Comments
 (0)