@@ -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 + 본문으로 파싱
6296Future <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 추출
102155String ? _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