Skip to content

Commit 67b049f

Browse files
committed
[TEST] slug 처리 개선 및 URL 하이드레이션 기능 구현
1 parent b0d1e46 commit 67b049f

File tree

2 files changed

+246
-117
lines changed

2 files changed

+246
-117
lines changed

lib/model/post_meta.dart

Lines changed: 96 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,46 +8,68 @@ class PostMeta {
88
final String category;
99
final String tag;
1010
final DateTime? date;
11+
12+
/// slug는 'raw 문자열'로 저장합니다(여기선 인코딩하지 않음).
1113
final String slug;
1214

13-
PostMeta(
14-
{required this.title,
15-
required this.category,
16-
required this.tag,
17-
this.date,
18-
required this.slug});
15+
const PostMeta({
16+
required this.title,
17+
required this.category,
18+
required this.tag,
19+
this.date,
20+
required this.slug,
21+
});
22+
23+
// 🔹 센티널(빈 메타)
24+
const PostMeta._empty()
25+
: title = '',
26+
category = 'Uncategorized',
27+
tag = '',
28+
date = null,
29+
slug = '';
30+
31+
factory PostMeta.empty() => const PostMeta._empty();
32+
33+
bool get isEmpty => title.isEmpty && slug.isEmpty;
1934

2035
factory PostMeta.fromJson(Map<String, dynamic> json) {
2136
final rawTitle = (json['title'] ?? '').toString().trim();
2237
final rawCategory = (json['category'] ?? 'Uncategorized').toString().trim();
2338
final rawTag = (json['tag'] ?? '').toString().trim();
2439
final rawDate = (json['date'] ?? '').toString().trim();
40+
2541
DateTime? dt;
2642
if (rawDate.isNotEmpty) {
27-
// ISO8601 또는 YYYY-MM-DD 가정
2843
try {
29-
dt = DateTime.parse(rawDate);
30-
} catch (_) {}
44+
dt = DateTime.parse(rawDate); // ISO8601 또는 YYYY-MM-DD 가정
45+
} catch (_) {
46+
dt = null;
47+
}
3148
}
3249

33-
final rawSlug = Uri.encodeComponent((json['slug'] ?? '')
34-
.toString()
35-
.trim()
36-
.replaceAll(RegExp(r'^-+|-+$'), ''));
50+
// ❗ 여기서 slug 인코딩하지 않습니다. (라우팅 시점에 encode)
51+
final rawSlug = (json['slug'] ?? '').toString().trim();
3752

3853
return PostMeta(
39-
title: rawTitle,
40-
category: rawCategory,
41-
tag: rawTag,
42-
date: dt,
43-
slug: rawSlug);
54+
title: rawTitle,
55+
category: rawCategory.isNotEmpty ? rawCategory : 'Uncategorized',
56+
tag: rawTag,
57+
date: dt,
58+
slug: rawSlug,
59+
);
4460
}
4561

46-
PostMeta withFallbacks({required String fallbackTitle}) => PostMeta(
62+
/// title/slug의 폴백 채우기
63+
PostMeta withFallbacks({
64+
required String fallbackTitle,
65+
required String fallbackSlug,
66+
}) =>
67+
PostMeta(
4768
title: title.isNotEmpty ? title : fallbackTitle,
4869
category: category.isNotEmpty ? category : 'Uncategorized',
4970
date: date,
50-
slug: slug,
71+
// raw slug가 비어 있으면 폴백 사용(파일명/제목 기반 slugify)
72+
slug: (slug.isNotEmpty ? slug : slugify(fallbackSlug)),
5173
tag: tag.isNotEmpty ? tag : '',
5274
);
5375
}
@@ -56,14 +78,28 @@ class MdDoc {
5678
final String path;
5779
final PostMeta meta;
5880
final String body;
59-
MdDoc({required this.path, required this.meta, required this.body});
81+
82+
const MdDoc({required this.path, required this.meta, required this.body});
83+
84+
// 🔹 센티널(빈 문서)
85+
const MdDoc._empty()
86+
: path = '',
87+
meta = const PostMeta._empty(),
88+
body = '';
89+
90+
factory MdDoc.empty() => const MdDoc._empty();
91+
92+
bool get isEmpty => path.isEmpty && meta.isEmpty && body.isEmpty;
6093
}
6194

95+
/// post/*.md를 에셋에서 읽어 front matter + 본문으로 파싱
6296
Future<List<MdDoc>> loadMarkdownDocs(BuildContext context) async {
97+
// AssetManifest.json 로드
6398
final manifestJson =
6499
await DefaultAssetBundle.of(context).loadString('AssetManifest.json');
65100
final Map<String, dynamic> manifest = json.decode(manifestJson);
66101

102+
// post/ 아래의 .md만 수집
67103
final mdPaths = manifest.keys
68104
.where((k) => k.startsWith('post/'))
69105
.where((k) => k.toLowerCase().endsWith('.md'))
@@ -73,17 +109,33 @@ Future<List<MdDoc>> loadMarkdownDocs(BuildContext context) async {
73109
final List<MdDoc> docs = [];
74110
for (final path in mdPaths) {
75111
final raw = await rootBundle.loadString(path);
112+
76113
// front matter 파싱 + 모델 매핑
77-
final doc = parseFrontmatter<PostMeta>(
114+
final parsed = parseFrontmatter<PostMeta>(
78115
content: raw,
79116
frontmatterBuilder: (map) => PostMeta.fromJson(map),
80117
);
81-
// 제목/카테고리 폴백 처리 (없을 경우 파일명 사용)
82-
final filename = path.split('/').last.replaceAll('.md', '');
83-
final meta = doc.frontmatter
84-
.withFallbacks(fallbackTitle: _firstH1(doc.body) ?? filename);
85118

86-
docs.add(MdDoc(path: path, meta: meta, body: doc.body));
119+
// 파일명(확장자 제거)
120+
final filename = path
121+
.split('/')
122+
.last
123+
.replaceAll(RegExp(r'\.md$', caseSensitive: false), '');
124+
125+
// 제목 폴백: 본문 첫 H1 > 파일명
126+
final fallbackTitle = _firstH1(parsed.body) ?? filename;
127+
128+
// slug 폴백: front matter slug 없으면 파일명/제목에서 생성
129+
final fallbackSlugCandidate = parsed.frontmatter.slug.isNotEmpty
130+
? parsed.frontmatter.slug
131+
: (filename.isNotEmpty ? filename : fallbackTitle);
132+
133+
final meta = parsed.frontmatter.withFallbacks(
134+
fallbackTitle: fallbackTitle,
135+
fallbackSlug: fallbackSlugCandidate,
136+
);
137+
138+
docs.add(MdDoc(path: path, meta: meta, body: parsed.body));
87139
}
88140

89141
// 날짜 내림차순 정렬(날짜 없는 글은 뒤로)
@@ -99,8 +151,25 @@ Future<List<MdDoc>> loadMarkdownDocs(BuildContext context) async {
99151
return docs;
100152
}
101153

154+
/// 본문에서 첫 번째 H1 추출
102155
String? _firstH1(String s) {
103156
final r = RegExp(r'^\s*#\s+(.+)$', multiLine: true);
104157
final m = r.firstMatch(s);
105158
return m?.group(1)?.trim();
106159
}
160+
161+
/// 간단 slugify: 공백→하이픈, 특수문자 정리, 양끝 하이픈 제거.
162+
/// (한글 유지: 정규식에 '가-힣' 포함)
163+
String slugify(String input) {
164+
var s = input.trim();
165+
// 이미 인코딩된 경우 원복 시도(실패 시 원본 유지)
166+
try {
167+
s = Uri.decodeComponent(s);
168+
} catch (_) {}
169+
s = s.toLowerCase();
170+
s = s.replaceAll(RegExp(r'\s+'), '-'); // 공백류 → -
171+
s = s.replaceAll(RegExp(r'[^a-z0-9\-가-힣_]'), ''); // 안전 문자만
172+
s = s.replaceAll(RegExp(r'-{2,}'), '-'); // 연속 하이픈 합치기
173+
s = s.replaceAll(RegExp(r'^-+|-+$'), ''); // 양끝 하이픈 제거
174+
return s;
175+
}

0 commit comments

Comments
 (0)