@limcpf/everything-is-a-markdown 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lim
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.
package/README.ko.md ADDED
@@ -0,0 +1,128 @@
1
+ # Everything-Is-A-Markdown (EIAM)
2
+
3
+ 언어: [English](README.md) | **한국어**
4
+
5
+ Everything-Is-A-Markdown은 로컬 Markdown 볼트를 정적 웹사이트로 빌드해, 폴더/파일 탐색 구조를 유지한 채 공개할 수 있게 해주는 CLI 도구입니다.
6
+
7
+ ## 이 앱은 무엇을 하나요
8
+
9
+ - Markdown 볼트에서 정적 문서/블로그 사이트를 생성
10
+ - `publish: true` 문서만 선택적으로 공개
11
+ - 비공개 노트와 공개 콘텐츠 분리
12
+
13
+ ## Obsidian 사용자에게 특히 잘 맞습니다
14
+
15
+ - Obsidian에서 평소처럼 작성한 뒤, 공개할 문서만 빌드할 수 있습니다.
16
+ - Obsidian 스타일 위키링크(`[[...]]`)를 지원합니다.
17
+
18
+ ## 설치
19
+
20
+ ```bash
21
+ bun install
22
+ ```
23
+
24
+ ## 사용 방법
25
+
26
+ ```bash
27
+ bun run blog [build|dev|clean] [options]
28
+ ```
29
+
30
+ 명령:
31
+
32
+ - `bun run build`: 정적 파일 빌드
33
+ - `bun run dev`: 로컬 개발 서버 실행 (기본 `http://localhost:3000`)
34
+ - `bun run clean`: `dist`와 `.cache` 삭제
35
+
36
+ 자주 쓰는 옵션:
37
+
38
+ - `--vault <path>`: Markdown 루트 디렉터리 (기본 `.`)
39
+ - `--out <path>`: 출력 디렉터리 (기본 `dist`)
40
+ - `--exclude <glob>`: 제외 패턴 추가 (반복 가능)
41
+ - `--port <n>`: 개발 서버 포트 (기본 `3000`)
42
+
43
+ 예시:
44
+
45
+ ```bash
46
+ # 샘플 볼트로 실행
47
+ bun run dev -- --vault ./test-vault --out ./dist
48
+
49
+ # 빌드
50
+ bun run build -- --vault ./test-vault --out ./dist
51
+ ```
52
+
53
+ ## Markdown Frontmatter
54
+
55
+ 공개 여부를 결정하는 핵심 필드는 `publish`입니다.
56
+
57
+ 필수:
58
+
59
+ - `publish: true`
60
+ 이 값이 `true`인 문서만 빌드 결과에 포함됩니다.
61
+
62
+ 선택:
63
+
64
+ - `draft: true`
65
+ `publish: true`여도 문서를 제외합니다.
66
+ - `title: "..."`
67
+ 문서 제목. 없으면 파일명을 사용합니다.
68
+ - `prefix: "A-01"`
69
+ 탐색기 제목 앞과 본문 메타 줄에 표시할 짧은 코드.
70
+ - `branch: dev`
71
+ 브랜치 필터 분류값.
72
+ - `description: "..."`
73
+ 요약 설명.
74
+ - `tags: ["tag1", "tag2"]`
75
+ 문자열 배열.
76
+ - `date: "YYYY-MM-DD"` 또는 `createdDate: "..."`
77
+ 생성일.
78
+ - `updatedDate: "..."` 또는 `modifiedDate: "..."` 또는 `lastModified: "..."`
79
+ 수정일.
80
+
81
+ ## Frontmatter 예시
82
+
83
+ 게시 문서:
84
+
85
+ ```md
86
+ ---
87
+ publish: true
88
+ prefix: "DEV-01"
89
+ branch: dev
90
+ title: Setup Guide
91
+ date: "2024-09-15"
92
+ updatedDate: "2024-09-20T09:30:00"
93
+ description: How to set up your development environment
94
+ tags: ["tutorial", "setup"]
95
+ ---
96
+
97
+ # Setup Guide
98
+ ```
99
+
100
+ 비공개 문서 (제외됨):
101
+
102
+ ```md
103
+ ---
104
+ publish: false
105
+ title: Internal Notes
106
+ ---
107
+ ```
108
+
109
+ 초안 문서:
110
+
111
+ ```md
112
+ ---
113
+ publish: true
114
+ draft: true
115
+ title: Work In Progress
116
+ ---
117
+ ```
118
+
119
+ ## bunx 실행 (선택)
120
+
121
+ ```bash
122
+ bunx @limcpf/everything-is-a-markdown build --vault ./vault --out ./dist
123
+ bunx @limcpf/everything-is-a-markdown dev --port 3000
124
+ ```
125
+
126
+ ## 라이선스
127
+
128
+ MIT. `LICENSE` 파일을 참고하세요.
package/README.md CHANGED
@@ -1,297 +1,128 @@
1
1
  # Everything-Is-A-Markdown (EIAM)
2
2
 
3
- 로컬 Markdown 볼트를 **파일 탐색기 UX 그대로** 웹에 공개할 수 있게 만드는 정적 블로그 빌더입니다.
4
-
5
- - 문서 저장 구조(폴더/파일)를 그대로 살려서 탐색 경험을 제공합니다.
6
- - `publish: true` 문서만 빌드해, 로컬 지식베이스와 공개 콘텐츠를 분리할 수 있습니다.
7
- - 결과물은 순수 HTML/CSS/JS 정적 파일이라 배포가 단순합니다.
8
-
9
- **왜 프로젝트인가**
10
- - 파일 트리 + 브랜치 필터 + 본문 뷰를 하나의 화면에 결합해 "문서 저장소를 읽는 경험"을 만듭니다.
11
- - 빌드 캐시(`.cache/build-index.json`)로 변경된 문서만 다시 렌더링해 반복 빌드가 빠릅니다.
12
- - Obsidian 스타일 위키링크(`[[...]]`)를 지원해 기존 작성 습관을 크게 바꾸지 않아도 됩니다.
13
- - 문서별 슬러그 고정 URL(`/path/to/doc/`)과 라우트 매핑(`manifest.json`)으로 정적 호스팅에 최적화되어 있습니다.
14
- - 반응형 사이드바, 설정 팝업, 라이트/시스템/다크 테마 전환까지 기본 제공해 바로 운영 가능한 UI를 제공합니다.
15
-
16
- **스크린샷 가이드 (원하는 이미지를 아래 설명으로 캡처해서 넣어주세요)**
17
- - (대표 화면: 좌측 파일 트리, 우측 본문, 상단 브랜치 필터 pill이 함께 보이는 데스크톱 전체 화면)
18
- - (브랜치 전환 화면: branch pill 선택 전/후로 목록이 바뀌는 상태)
19
- - (설정 팝업 화면: 메뉴 버튼 위치 토글 + Light/System/Dark 테마 세그먼트가 보이는 화면)
20
- - (다크 모드 화면: 동일 문서를 라이트/다크로 비교 가능한 화면)
21
- - (모바일 화면: 하단 Files 버튼으로 사이드바를 열었을 때 오버레이 포함 화면)
22
-
23
- **프로젝트 개요**
24
- 이 프로젝트는 로컬 Markdown 문서를 파일 트리 형태로 탐색하고, 정적 사이트로 빌드하는 File-System 스타일 블로그 생성기입니다.
25
-
26
- **주요 기능**
27
- - 파일 트리 기반 탐색 UI (폴더/파일 구조 유지)
28
- - 문서별 슬러그 고정 URL 경로 생성 (`/path/to/doc/`)
29
- - Shiki 기반 코드 하이라이팅
30
- - Obsidian 스타일 위키링크 `[[...]]` 지원
31
- - 라이트/시스템/다크 테마 전환 지원 (설정 팝업)
32
- - NEW 배지, Recent 가상 폴더, 문서 메타 표시
33
- - 증분 빌드 캐시(`.cache/build-index.json`)로 재빌드 최적화
34
-
35
- **빠른 시작**
36
- 1. 의존성 설치
3
+ Language: **English** | [한국어](README.ko.md)
4
+
5
+ Everything-Is-A-Markdown is a CLI tool that turns a local Markdown vault into a static website while keeping the folder/file navigation experience.
6
+
7
+ ## What This App Is For
8
+
9
+ - Build a static docs/blog site from your Markdown vault
10
+ - Publish only documents with `publish: true`
11
+ - Keep private notes and public content separate
12
+
13
+ ## Works Great with Obsidian
14
+
15
+ - You can keep writing in Obsidian and publish selected notes.
16
+ - Obsidian-style wikilinks (`[[...]]`) are supported.
17
+
18
+ ## Install
37
19
 
38
20
  ```bash
39
21
  bun install
40
22
  ```
41
23
 
42
- 2. 샘플 볼트로 실행
24
+ ## Usage
43
25
 
44
26
  ```bash
45
- bun run dev -- --vault ./test-vault --out ./dist
27
+ bun run blog [build|dev|clean] [options]
46
28
  ```
47
29
 
48
- 브라우저에서 `http://localhost:3000`으로 접속합니다.
30
+ Commands:
49
31
 
50
- **사용 방법**
51
- 스크립트는 `bun run`으로 실행합니다.
32
+ - `bun run build`: Build static files
33
+ - `bun run dev`: Run local dev server (default `http://localhost:3000`)
34
+ - `bun run clean`: Remove `dist` and `.cache`
52
35
 
53
- ```bash
54
- bun run blog [build|dev|clean] [options]
55
- ```
36
+ Common options:
56
37
 
57
- 기본 명령
58
- - `bun run build` : 정적 사이트 빌드
59
- - `bun run dev` : 개발 서버 실행 + 변경 감지
60
- - `bun run clean` : `dist`와 `.cache` 삭제
61
-
62
- 옵션
63
- - `--vault <path>`: 마크다운 루트 디렉터리 (기본: `.`)
64
- - `--out <path>`: 출력 디렉터리 (기본: `dist`)
65
- - `--exclude <glob>`: 제외 패턴 (반복 가능, 기본 포함: `.obsidian/**`)
66
- - `--new-within-days <n>`: NEW 배지 기준 일수 (기본: `7`)
67
- - `--recent-limit <n>`: Recent 가상 폴더 노출 개수 (기본: `5`)
68
- - `--menu-config <path>`: 상단 고정 메뉴를 JSON 파일로 임시 덮어쓰기(선택)
69
- - `--port <n>`: dev 서버 포트 (기본: `3000`)
70
-
71
- **릴리즈 단일파일 배포 (GitHub Actions)**
72
- - 워크플로우 파일: `.github/workflows/release-single-file.yml`
73
- - 동작: `bun run build` 결과인 `dist`를 단일 `.tar.gz` 파일로 묶어 Release asset으로 업로드
74
- - 자동 실행: `v*` 태그 푸시 시 실행 (예: `v0.1.0`)
75
- - 수동 실행: `Actions > Release Single File > Run workflow`에서 `tag`(필수), `asset_name`(선택) 입력
76
-
77
- 릴리즈 태그 생성 예시
78
- ```bash
79
- git tag v0.1.0
80
- git push origin v0.1.0
81
- ```
38
+ - `--vault <path>`: Markdown root directory (default `.`)
39
+ - `--out <path>`: Output directory (default `dist`)
40
+ - `--exclude <glob>`: Add exclude pattern (repeatable)
41
+ - `--port <n>`: Dev server port (default `3000`)
82
42
 
83
- **bunx 배포 (npm publish)**
84
- - 워크플로우 파일: `.github/workflows/publish-bunx.yml`
85
- - 동작: `v*` 태그 푸시 시 npm에 패키지 publish (`bun publish`)
86
- - 전제: GitHub 저장소 `Settings > Secrets and variables > Actions`에 `NPM_TOKEN` 추가
87
- - 검증: 태그 `vX.Y.Z`와 `package.json`의 `version`이 다르면 배포 실패
43
+ Examples:
88
44
 
89
- 사용자 실행 예시
90
45
  ```bash
91
- bunx @limcpf/everything-is-a-markdown build --vault ./vault --out ./dist
92
- bunx @limcpf/everything-is-a-markdown dev --port 3000
46
+ # Run with the sample vault
47
+ bun run dev -- --vault ./test-vault --out ./dist
93
48
 
94
- # 축약 실행 (bin: eiam)
95
- bunx --package @limcpf/everything-is-a-markdown eiam build --vault ./vault --out ./dist
49
+ # Build
50
+ bun run build -- --vault ./test-vault --out ./dist
96
51
  ```
97
52
 
98
- 예시
99
- ```bash
100
- # 다른 볼트 경로로 빌드
101
- bun run build -- --vault ../vault --out ./dist
53
+ ## Markdown Frontmatter
102
54
 
103
- # dev 서버 포트 변경
104
- bun run dev -- --port 4000
55
+ The key field for publishing is `publish`.
105
56
 
106
- # 제외 패턴 추가
107
- bun run build -- --exclude "private/**" --exclude "**/drafts/**"
57
+ Required:
108
58
 
109
- # 상단 고정 메뉴 임시 덮어쓰기(선택)
110
- bun run build -- --menu-config ./menu.config.json
111
- ```
59
+ - `publish: true`
60
+ Only documents with this value are included in build output.
112
61
 
113
- **설정 파일**
114
- 프로젝트 루트에 `blog.config.ts|js|mjs|cjs`를 두면 CLI 옵션과 병합됩니다.
115
-
116
- ```ts
117
- // blog.config.ts
118
- export default {
119
- vaultDir: "./vault",
120
- outDir: "dist",
121
- exclude: [".obsidian/**", "private/**"],
122
- seo: {
123
- siteUrl: "https://example.com", // origin only (http/https)
124
- pathBase: "/blog", // optional, deploy base path
125
- siteName: "Everything-Is-A-Markdown", // optional, og:site_name / WebSite.name
126
- defaultTitle: "Dev Knowledge Base", // optional, fallback title
127
- defaultDescription: "Public docs and engineering notes.", // optional, fallback description
128
- locale: "ko_KR", // optional, og:locale
129
- twitterCard: "summary_large_image", // optional: "summary" | "summary_large_image"
130
- twitterSite: "@my_team", // optional
131
- twitterCreator: "@author_handle", // optional
132
- defaultSocialImage: "/assets/social/default.png", // optional, absolute URL or /-relative
133
- defaultOgImage: "/assets/social/og.png", // optional, overrides defaultSocialImage for og:image
134
- defaultTwitterImage: "/assets/social/twitter.png", // optional, overrides defaultSocialImage for twitter:image
135
- },
136
- ui: {
137
- newWithinDays: 7,
138
- recentLimit: 5,
139
- },
140
- pinnedMenu: {
141
- label: "NOTICE",
142
- sourceDir: "Log/(Blog)/Notice",
143
- },
144
- markdown: {
145
- wikilinks: true,
146
- images: "omit-local", // "keep" | "omit-local"
147
- gfm: true,
148
- highlight: {
149
- theme: "github-dark",
150
- },
151
- },
152
- };
153
- ```
62
+ Optional:
63
+
64
+ - `draft: true`
65
+ Excludes the document even if `publish: true`.
66
+ - `title: "..."`
67
+ Display title. If missing, file name is used.
68
+ - `prefix: "A-01"`
69
+ Short code shown before the title in the explorer and meta line.
70
+ - `branch: dev`
71
+ Branch filter label.
72
+ - `description: "..."`
73
+ Short summary.
74
+ - `tags: ["tag1", "tag2"]`
75
+ String array.
76
+ - `date: "YYYY-MM-DD"` or `createdDate: "..."`
77
+ Created date.
78
+ - `updatedDate: "..."` or `modifiedDate: "..."` or `lastModified: "..."`
79
+ Updated date.
154
80
 
155
- 고정 메뉴 설정 메모
156
- - `pinnedMenu.label`: 탐색기에서 Recent 위에 표시할 이름 (미지정 시 `NOTICE`)
157
- - `pinnedMenu.sourceDir`: vault 기준 실제 물리 디렉터리 경로
158
- - `--menu-config`를 주면 `blog.config.*`의 `pinnedMenu`를 해당 실행에서만 덮어씁니다.
159
-
160
- SEO 설정 메모
161
- - `seo.siteUrl`: 필수. 절대 origin만 허용됩니다 (예: `https://example.com`, path/query/hash 불가).
162
- - `seo.pathBase`: 선택. `/blog` 같은 배포 base path를 canonical/OG/sitemap URL에 함께 붙입니다.
163
- - `seo.siteName`: 선택. `og:site_name` 및 루트 JSON-LD(WebSite.name)에 반영됩니다.
164
- - `seo.defaultTitle`: 선택. 문서 제목이 없을 때 fallback `<title>`로 사용됩니다.
165
- - `seo.defaultDescription`: 선택. 문서 설명이 없을 때 fallback description/OG/Twitter 설명으로 사용됩니다.
166
- - `seo.locale`: 선택. `og:locale` 값으로 출력됩니다 (예: `ko_KR`).
167
- - `seo.twitterCard`: 선택. `summary` 또는 `summary_large_image`.
168
- - `seo.twitterSite`, `seo.twitterCreator`: 선택. 각각 `twitter:site`, `twitter:creator`로 출력됩니다.
169
- - `seo.defaultSocialImage`: 선택. OG/Twitter 공통 기본 이미지.
170
- - `seo.defaultOgImage`, `seo.defaultTwitterImage`: 선택. 채널별 이미지 우선값(없으면 `defaultSocialImage` 사용).
171
- - `seo.siteUrl`이 없으면 `robots.txt`, `sitemap.xml`은 생성되지 않습니다.
172
-
173
- **콘텐츠 작성 규칙**
174
- - `publish: true`인 문서만 출력됩니다.
175
- - `draft: true`면 출력에서 제외됩니다.
176
- - `branch`를 지정하면 해당 브랜치 필터에서만 노출됩니다.
177
- - `branch`가 없으면 "브랜치 분류 없음"으로 간주되어 기본 브랜치에서만 노출됩니다.
178
- - 기본 브랜치 뷰는 `dev + 분류 없음`이며, 다른 브랜치는 해당 브랜치 글만 노출됩니다.
179
- - `title`이 없으면 파일명에서 자동 생성됩니다.
180
- - 생성일은 `date` 또는 `createdDate`를 사용합니다.
181
- - 수정일은 `updatedDate`(`modifiedDate`/`lastModified`도 허용)를 사용합니다.
182
- - 생성/수정일은 frontmatter에 값이 있을 때만 본문 메타에 표시됩니다.
183
- - `tags`는 문자열 배열로 작성합니다.
81
+ ## Frontmatter Examples
82
+
83
+ Published document:
184
84
 
185
85
  ```md
186
86
  ---
187
87
  publish: true
88
+ prefix: "DEV-01"
188
89
  branch: dev
189
- title: My Post
190
- date: "2024-10-24"
191
- updatedDate: "2024-10-25T14:30:00"
192
- description: Short summary
193
- tags: ["dev", "blog"]
90
+ title: Setup Guide
91
+ date: "2024-09-15"
92
+ updatedDate: "2024-09-20T09:30:00"
93
+ description: How to set up your development environment
94
+ tags: ["tutorial", "setup"]
194
95
  ---
195
96
 
196
- # Hello
197
- 본문 내용...
97
+ # Setup Guide
198
98
  ```
199
99
 
200
- **출력 구조**
201
- - `dist/manifest.json`: 트리, 문서 메타, 라우팅 정보
202
- - `dist/content/*.html`: 각 문서 본문 HTML
203
- - `dist/_app/index.html`: 앱 셸
204
- - `dist/<문서 slug 경로>/index.html`: 각 문서 경로
205
- - `dist/assets/app.js`, `dist/assets/app.css`: 런타임 UI
206
-
207
- **SEO/A11y 생성 결과**
208
- - 라우트별 HTML(`index.html`, `about/index.html`, `posts/2024/setup-guide/index.html` 등)에 route-specific `<title>`, description, canonical, Open Graph/Twitter meta가 주입됩니다.
209
- - 각 라우트에 JSON-LD(`application/ld+json`)가 포함됩니다.
210
- - `seo.siteUrl` 설정 시에만 `robots.txt`, `sitemap.xml`이 생성됩니다.
211
- - `robots.txt`는 `Sitemap: <canonical sitemap url>`을 포함합니다.
212
- - `sitemap.xml`은 `/` + 게시된 문서 라우트들을 canonical URL로 직렬화합니다 (`seo.pathBase` 반영).
213
- - 접근성 기본값:
214
- - skip link: `본문으로 건너뛰기` (`href="#viewer-panel"`)
215
- - live region: `#a11y-status` (`aria-live="polite"`, `aria-atomic="true"`)
216
- - reduced motion: `prefers-reduced-motion: reduce`에서 `.status-dot` pulse 애니메이션 비활성화
100
+ Private document (excluded):
217
101
 
218
- **검증 명령 (복붙용)**
102
+ ```md
103
+ ---
104
+ publish: false
105
+ title: Internal Notes
106
+ ---
107
+ ```
219
108
 
220
- 1) Temp CWD 기반으로 SEO OFF/ON 빌드 (repo 설정 파일 비오염)
109
+ Draft document:
221
110
 
222
- ```bash
223
- REPO="$(pwd)"
224
- TMP_NO="$(mktemp -d)"
225
- TMP_YES="$(mktemp -d)"
226
- TMP_OUT="$(mktemp -d)"
227
-
228
- # SEO OFF (blog.config.* 없음)
229
- (cd "$TMP_NO" && bun "$REPO/src/cli.ts" build --vault "$REPO/test-vault" --out "$TMP_OUT/no-seo-out")
230
-
231
- # SEO ON (temp blog.config.mjs 사용)
232
- cat > "$TMP_YES/blog.config.mjs" <<'EOF'
233
- export default {
234
- seo: {
235
- siteUrl: "https://docs.example.com",
236
- pathBase: "/kb"
237
- }
238
- };
239
- EOF
240
- (cd "$TMP_YES" && bun "$REPO/src/cli.ts" build --vault "$REPO/test-vault" --out "$TMP_OUT/with-seo-out")
111
+ ```md
112
+ ---
113
+ publish: true
114
+ draft: true
115
+ title: Work In Progress
116
+ ---
241
117
  ```
242
118
 
243
- 2) 라우트별 head/JSON-LD + robots/sitemap assert
119
+ ## bunx (Optional)
244
120
 
245
121
  ```bash
246
- bun -e 'import { existsSync, readFileSync } from "node:fs";
247
- const out = process.env.OUT;
248
- const noSeoOut = `${out}/no-seo-out`;
249
- const withSeoOut = `${out}/with-seo-out`;
250
- const pages = [
251
- ["index", `${withSeoOut}/index.html`, "https://docs.example.com/kb/"],
252
- ["about", `${withSeoOut}/about/index.html`, "https://docs.example.com/kb/about/"],
253
- ["post", `${withSeoOut}/posts/2024/setup-guide/index.html`, "https://docs.example.com/kb/posts/2024/setup-guide/"],
254
- ];
255
- for (const [name, file, canonical] of pages) {
256
- const html = readFileSync(file, "utf8");
257
- console.log(`${name}: canonical=${html.includes(`<link rel="canonical" href="${canonical}" />`)} og:url=${html.includes(`<meta property="og:url" content="${canonical}" />`)} jsonld=${/<script type="application\/ld\+json">[\s\S]*<\/script>/.test(html)}`);
258
- }
259
- console.log(`no-seo robots=${existsSync(`${noSeoOut}/robots.txt`)}`);
260
- console.log(`no-seo sitemap=${existsSync(`${noSeoOut}/sitemap.xml`)}`);
261
- console.log(`with-seo robots=${existsSync(`${withSeoOut}/robots.txt`)}`);
262
- console.log(`with-seo sitemap=${existsSync(`${withSeoOut}/sitemap.xml`)}`);
263
- ' OUT="$TMP_OUT"
122
+ bunx @limcpf/everything-is-a-markdown build --vault ./vault --out ./dist
123
+ bunx @limcpf/everything-is-a-markdown dev --port 3000
264
124
  ```
265
125
 
266
- 3) Playwright MCP 최소 키보드/포커스 + reduced motion 확인 (OpenCode)
267
-
268
- ```text
269
- static server: (cd "$TMP_OUT/with-seo-out" && python3 -m http.server 4173 --bind 127.0.0.1)
270
-
271
- browser_navigate: http://127.0.0.1:4173/
272
-
273
- browser_run_code:
274
- async (page) => {
275
- await page.keyboard.press('Tab');
276
- const skip = await page.evaluate(() => document.activeElement?.className);
277
- await page.keyboard.press('Enter');
278
- const focus = await page.evaluate(() => ({ hash: window.location.hash, id: document.activeElement?.id }));
279
- await page.emulateMedia({ reducedMotion: 'no-preference' });
280
- const normal = await page.evaluate(() => getComputedStyle(document.querySelector('.status-dot')).animationName);
281
- await page.emulateMedia({ reducedMotion: 'reduce' });
282
- const reduced = await page.evaluate(() => getComputedStyle(document.querySelector('.status-dot')).animationName);
283
- return { skip, focus, normal, reduced };
284
- }
285
- ```
126
+ ## License
286
127
 
287
- **추가로 필요한 문서 목록**
288
- - `LICENSE`: 라이선스 명시
289
- - `CHANGELOG.md`: 버전별 변경 이력
290
- - `CONTRIBUTING.md`: 개발/기여 가이드와 브랜치 규칙
291
- - `CODE_OF_CONDUCT.md`: 커뮤니티 행동 강령
292
- - `SECURITY.md`: 보안 취약점 신고 절차
293
- - `docs/CONFIG.md`: 설정 옵션 상세 레퍼런스
294
- - `docs/ARCHITECTURE.md`: 빌드 파이프라인과 런타임 구조 설명
295
- - `docs/DEPLOYMENT.md`: 정적 호스팅 배포 가이드
296
- - `docs/TROUBLESHOOTING.md`: 자주 발생하는 문제와 해결 방법
297
- - `docs/FAQ.md`: 사용자 FAQ
128
+ MIT. See `LICENSE`.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@limcpf/everything-is-a-markdown",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
+ "license": "MIT",
4
5
  "private": false,
5
6
  "type": "module",
6
7
  "bin": {
package/src/build.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  toRoute,
21
21
  } from "./utils";
22
22
 
23
- const CACHE_VERSION = 2;
23
+ const CACHE_VERSION = 3;
24
24
  const CACHE_DIR_NAME = ".cache";
25
25
  const CACHE_FILE_NAME = "build-index.json";
26
26
  const DEFAULT_BRANCH = "dev";
@@ -121,6 +121,7 @@ function normalizeCachedSourceEntry(value: unknown): CachedSourceEntry | null {
121
121
  const publish = value.publish === true;
122
122
  const draft = value.draft === true;
123
123
  const title = typeof value.title === "string" && value.title.trim().length > 0 ? value.title.trim() : undefined;
124
+ const prefix = typeof value.prefix === "string" && value.prefix.trim().length > 0 ? value.prefix.trim() : undefined;
124
125
  const date = typeof value.date === "string" && value.date.trim().length > 0 ? value.date.trim() : undefined;
125
126
  const updatedDate =
126
127
  typeof value.updatedDate === "string" && value.updatedDate.trim().length > 0 ? value.updatedDate.trim() : undefined;
@@ -142,6 +143,7 @@ function normalizeCachedSourceEntry(value: unknown): CachedSourceEntry | null {
142
143
  publish,
143
144
  draft,
144
145
  title,
146
+ prefix,
145
147
  date,
146
148
  updatedDate,
147
149
  description,
@@ -329,6 +331,25 @@ function pickDocUpdatedDate(frontmatter: Record<string, unknown>, raw: string):
329
331
  return pickFrontmatterDate(frontmatter, raw, ["updatedDate", "modifiedDate", "lastModified"]);
330
332
  }
331
333
 
334
+ function pickDocPrefix(frontmatter: Record<string, unknown>, raw: string): string | undefined {
335
+ const literal = extractFrontmatterScalar(raw, "prefix");
336
+ if (literal) {
337
+ return literal;
338
+ }
339
+
340
+ const value = frontmatter.prefix;
341
+ if (typeof value === "string") {
342
+ const trimmed = value.trim();
343
+ return trimmed.length > 0 ? trimmed : undefined;
344
+ }
345
+
346
+ if (typeof value === "number" && Number.isFinite(value)) {
347
+ return String(value);
348
+ }
349
+
350
+ return undefined;
351
+ }
352
+
332
353
  function appendRouteSuffix(route: string, suffix: string): string {
333
354
  const clean = route.replace(/^\/+/, "").replace(/\/+$/, "");
334
355
  if (!clean) {
@@ -431,6 +452,7 @@ function toCachedSourceEntry(raw: string, parsed: matter.GrayMatterFile<string>)
431
452
  publish: parsed.data.publish === true,
432
453
  draft: parsed.data.draft === true,
433
454
  title: typeof parsed.data.title === "string" && parsed.data.title.trim().length > 0 ? parsed.data.title.trim() : undefined,
455
+ prefix: pickDocPrefix(parsed.data as Record<string, unknown>, raw),
434
456
  date: pickDocDate(parsed.data as Record<string, unknown>, raw),
435
457
  updatedDate: pickDocUpdatedDate(parsed.data as Record<string, unknown>, raw),
436
458
  description: typeof parsed.data.description === "string" ? parsed.data.description.trim() || undefined : undefined,
@@ -460,6 +482,7 @@ function toDocRecord(
460
482
  contentUrl: `/content/${toContentFileName(id)}`,
461
483
  fileName,
462
484
  title: entry.title ?? makeTitleFromFileName(fileName),
485
+ prefix: entry.prefix,
463
486
  date: entry.date,
464
487
  updatedDate: entry.updatedDate,
465
488
  description: entry.description,
@@ -589,9 +612,9 @@ function fileNodeFromDoc(doc: DocRecord): FileNode {
589
612
  name: doc.fileName,
590
613
  id: doc.id,
591
614
  title: doc.title,
615
+ prefix: doc.prefix,
592
616
  route: doc.route,
593
617
  contentUrl: doc.contentUrl,
594
- mtime: doc.mtimeMs,
595
618
  isNew: doc.isNew,
596
619
  tags: doc.tags,
597
620
  description: doc.description,
@@ -633,7 +656,32 @@ function isNewByFrontmatterDate(date: string | undefined, newThreshold: number):
633
656
  }
634
657
 
635
658
  function getRecentSortEpochMs(doc: DocRecord): number {
636
- return parseDateToEpochMs(doc.date) ?? doc.mtimeMs;
659
+ return parseDateToEpochMs(doc.updatedDate) ?? parseDateToEpochMs(doc.date);
660
+ }
661
+
662
+ function compareByRecentDateThenPath(left: DocRecord, right: DocRecord): number {
663
+ const leftEpoch = getRecentSortEpochMs(left);
664
+ const rightEpoch = getRecentSortEpochMs(right);
665
+
666
+ if (leftEpoch != null && rightEpoch != null) {
667
+ const byDate = rightEpoch - leftEpoch;
668
+ if (byDate !== 0) {
669
+ return byDate;
670
+ }
671
+ } else if (leftEpoch != null && rightEpoch == null) {
672
+ return -1;
673
+ } else if (leftEpoch == null && rightEpoch != null) {
674
+ return 1;
675
+ }
676
+
677
+ return left.relNoExt.localeCompare(right.relNoExt, "ko-KR");
678
+ }
679
+
680
+ function pickHomeDoc(docs: DocRecord[]): DocRecord | null {
681
+ const inDefaultBranch = docs.filter((doc) => doc.branch == null || doc.branch === DEFAULT_BRANCH);
682
+ const candidates = inDefaultBranch.length > 0 ? inDefaultBranch : docs;
683
+ const byRoute = candidates.find((doc) => doc.route === "/index/");
684
+ return byRoute ?? candidates[0] ?? null;
637
685
  }
638
686
 
639
687
  function buildPinnedMenuFolder(docs: DocRecord[], options: BuildOptions): FolderNode | null {
@@ -697,19 +745,7 @@ function buildTree(docs: DocRecord[], options: BuildOptions): TreeNode[] {
697
745
  sortTree(root.children);
698
746
 
699
747
  const recentChildren = [...docs]
700
- .sort((left, right) => {
701
- const byDate = getRecentSortEpochMs(right) - getRecentSortEpochMs(left);
702
- if (byDate !== 0) {
703
- return byDate;
704
- }
705
-
706
- const byMtime = right.mtimeMs - left.mtimeMs;
707
- if (byMtime !== 0) {
708
- return byMtime;
709
- }
710
-
711
- return left.relNoExt.localeCompare(right.relNoExt, "ko-KR");
712
- })
748
+ .sort(compareByRecentDateThenPath)
713
749
  .slice(0, options.recentLimit)
714
750
  .map((doc) => fileNodeFromDoc(doc));
715
751
 
@@ -735,21 +771,19 @@ function buildManifest(docs: DocRecord[], tree: TreeNode[], options: BuildOption
735
771
  routeMap[doc.route] = doc.id;
736
772
  }
737
773
 
738
- const docsForManifest = [...docs]
739
- .sort((a, b) => b.mtimeMs - a.mtimeMs)
740
- .map((doc) => ({
741
- id: doc.id,
742
- route: doc.route,
743
- title: doc.title,
744
- mtime: doc.mtimeMs,
745
- date: doc.date,
746
- updatedDate: doc.updatedDate,
747
- tags: doc.tags,
748
- description: doc.description,
749
- isNew: doc.isNew,
750
- contentUrl: doc.contentUrl,
751
- branch: doc.branch,
752
- }));
774
+ const docsForManifest = docs.map((doc) => ({
775
+ id: doc.id,
776
+ route: doc.route,
777
+ title: doc.title,
778
+ prefix: doc.prefix,
779
+ date: doc.date,
780
+ updatedDate: doc.updatedDate,
781
+ tags: doc.tags,
782
+ description: doc.description,
783
+ isNew: doc.isNew,
784
+ contentUrl: doc.contentUrl,
785
+ branch: doc.branch,
786
+ }));
753
787
 
754
788
  const branchSet = new Set<string>([DEFAULT_BRANCH]);
755
789
  for (const doc of docs) {
@@ -984,6 +1018,10 @@ function normalizeTags(tags: string[]): string[] {
984
1018
  function renderInitialMeta(doc: DocRecord): string {
985
1019
  const items: string[] = [];
986
1020
 
1021
+ if (doc.prefix) {
1022
+ items.push(`<span class="meta-item meta-prefix">${escapeHtmlAttribute(doc.prefix)}</span>`);
1023
+ }
1024
+
987
1025
  const createdAt = formatMetaDateTime(doc.date);
988
1026
  if (createdAt) {
989
1027
  items.push(
@@ -991,13 +1029,6 @@ function renderInitialMeta(doc: DocRecord): string {
991
1029
  );
992
1030
  }
993
1031
 
994
- const updatedAt = formatMetaDateTime(doc.updatedDate);
995
- if (updatedAt) {
996
- items.push(
997
- `<span class="meta-item"><span class="material-symbols-outlined">schedule</span>updated ${escapeHtmlAttribute(updatedAt)}</span>`,
998
- );
999
- }
1000
-
1001
1032
  const tags = normalizeTags(doc.tags);
1002
1033
  if (tags.length > 0) {
1003
1034
  const tagsStr = tags.map((tag) => `#${escapeHtmlAttribute(tag)}`).join(" ");
@@ -1042,17 +1073,19 @@ function buildInitialView(doc: DocRecord, docs: DocRecord[], contentHtml: string
1042
1073
  async function writeShellPages(
1043
1074
  context: OutputWriteContext,
1044
1075
  docs: DocRecord[],
1076
+ manifest: Manifest,
1045
1077
  options: BuildOptions,
1046
1078
  runtimeAssets: RuntimeAssets,
1047
1079
  contentByDocId: Map<string, string>,
1048
1080
  ): Promise<void> {
1049
- const indexDoc = docs[0] ?? null;
1081
+ const indexDoc = pickHomeDoc(docs);
1050
1082
  const indexOutputPath = "index.html";
1051
1083
  const indexInitialView = indexDoc ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "") : null;
1052
1084
  const shell = renderAppShellHtml(
1053
1085
  buildShellMeta("/", null, options),
1054
1086
  buildAppShellAssetsForOutput(indexOutputPath, runtimeAssets),
1055
1087
  indexInitialView,
1088
+ manifest,
1056
1089
  );
1057
1090
  await writeOutputIfChanged(context, "_app/index.html", shell);
1058
1091
  await writeOutputIfChanged(context, indexOutputPath, shell);
@@ -1072,6 +1105,7 @@ async function writeShellPages(
1072
1105
  buildShellMeta(doc.route, doc, options),
1073
1106
  buildAppShellAssetsForOutput(routeOutputPath, runtimeAssets),
1074
1107
  initialView,
1108
+ manifest,
1075
1109
  ),
1076
1110
  );
1077
1111
  }
@@ -1256,7 +1290,7 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1256
1290
  renderedDocs += 1;
1257
1291
  }
1258
1292
 
1259
- await writeShellPages(outputContext, docs, options, runtimeAssets, contentByDocId);
1293
+ await writeShellPages(outputContext, docs, manifest, options, runtimeAssets, contentByDocId);
1260
1294
  await writeSeoArtifacts(outputContext, docs, options);
1261
1295
 
1262
1296
  await writeCache(cachePath, nextCache);
@@ -5,8 +5,8 @@
5
5
  --latte-crust: #dce0e8;
6
6
  --latte-text: #4c4f69;
7
7
  --latte-subtext0: #6c6f85;
8
- --latte-subtext1: #5c5f77;
9
- --latte-overlay0: #9ca0b0;
8
+ --latte-subtext1: #555974;
9
+ --latte-overlay0: #5f637a;
10
10
  --latte-surface0: #ccd0da;
11
11
  --latte-surface1: #bcc0cc;
12
12
  --latte-surface2: #acb0be;
@@ -30,7 +30,7 @@
30
30
  --mocha-text: #cdd6f4;
31
31
  --mocha-subtext0: #a6adc8;
32
32
  --mocha-subtext1: #bac2de;
33
- --mocha-overlay0: #6c7086;
33
+ --mocha-overlay0: #a0a7c6;
34
34
  --mocha-surface0: #313244;
35
35
  --mocha-surface1: #45475a;
36
36
  --mocha-surface2: #585b70;
@@ -121,7 +121,7 @@ body {
121
121
  body {
122
122
  background: var(--latte-base);
123
123
  color: var(--latte-text);
124
- font-family: "Inter", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", -apple-system, BlinkMacSystemFont, sans-serif;
124
+ font-family: "Pretendard Variable", "Pretendard", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", sans-serif;
125
125
  -webkit-font-smoothing: antialiased;
126
126
  overflow: hidden;
127
127
  }
@@ -192,11 +192,21 @@ a:hover {
192
192
  }
193
193
 
194
194
  .material-symbols-outlined {
195
+ font-family: "Material Symbols Outlined", "Material Icons", sans-serif;
196
+ font-style: normal;
197
+ font-weight: 400;
198
+ letter-spacing: normal;
199
+ text-transform: none;
200
+ white-space: nowrap;
201
+ word-wrap: normal;
202
+ direction: ltr;
203
+ font-feature-settings: "liga";
204
+ -webkit-font-feature-settings: "liga";
195
205
  display: inline-flex;
196
206
  align-items: center;
197
207
  justify-content: center;
198
- width: 1em;
199
- height: 1em;
208
+ min-width: 1em;
209
+ min-height: 1em;
200
210
  flex: 0 0 auto;
201
211
  font-size: 20px;
202
212
  line-height: 1;
@@ -468,6 +478,14 @@ a:hover {
468
478
  color: var(--latte-overlay0);
469
479
  }
470
480
 
481
+ .tree-prefix {
482
+ flex: 0 0 auto;
483
+ font-size: 0.68rem;
484
+ font-weight: 600;
485
+ letter-spacing: 0.02em;
486
+ color: var(--latte-overlay1);
487
+ }
488
+
471
489
  .tree-label {
472
490
  flex: 1;
473
491
  overflow: hidden;
@@ -571,7 +589,7 @@ a:hover {
571
589
  }
572
590
 
573
591
  .status-encoding {
574
- font-family: "JetBrains Mono", monospace;
592
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
575
593
  color: var(--latte-overlay0);
576
594
  }
577
595
 
@@ -752,7 +770,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
752
770
  align-items: center;
753
771
  gap: 4px;
754
772
  font-size: 0.85rem;
755
- font-family: "JetBrains Mono", monospace;
773
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
756
774
  color: var(--latte-overlay0);
757
775
  margin-bottom: 24px;
758
776
  flex-wrap: nowrap;
@@ -818,7 +836,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
818
836
  gap: 16px;
819
837
  min-height: 22px;
820
838
  font-size: 0.85rem;
821
- font-family: "JetBrains Mono", monospace;
839
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
822
840
  color: var(--latte-subtext0);
823
841
  }
824
842
 
@@ -833,6 +851,13 @@ body.mobile-toggle-left .mobile-menu-toggle {
833
851
  color: var(--latte-overlay0);
834
852
  }
835
853
 
854
+ .meta-prefix {
855
+ font-size: 0.74rem;
856
+ font-weight: 700;
857
+ letter-spacing: 0.03em;
858
+ color: var(--latte-overlay1);
859
+ }
860
+
836
861
  .meta-tags {
837
862
  color: var(--latte-lavender);
838
863
  }
@@ -914,7 +939,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
914
939
  border: 1px solid var(--latte-surface0);
915
940
  border-radius: 4px;
916
941
  padding: 2px 6px;
917
- font-family: "JetBrains Mono", monospace;
942
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
918
943
  font-size: 0.88em;
919
944
  color: var(--latte-mauve);
920
945
  }
@@ -955,7 +980,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
955
980
 
956
981
  .code-filename {
957
982
  flex: 1;
958
- font-family: "JetBrains Mono", monospace;
983
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
959
984
  font-size: 0.75rem;
960
985
  color: #aeb7c2;
961
986
  text-align: center;
@@ -1000,7 +1025,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
1000
1025
  display: block;
1001
1026
  padding: 18px 20px;
1002
1027
  overflow-x: auto;
1003
- font-family: "JetBrains Mono", monospace;
1028
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
1004
1029
  font-size: 0.95rem;
1005
1030
  font-weight: 500;
1006
1031
  line-height: 1.72;
@@ -1041,7 +1066,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
1041
1066
  display: block;
1042
1067
  padding: 16px;
1043
1068
  overflow-x: auto;
1044
- font-family: "JetBrains Mono", monospace;
1069
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
1045
1070
  font-size: 0.875rem;
1046
1071
  line-height: 1.6;
1047
1072
  }
@@ -82,6 +82,37 @@ function loadInitialViewData() {
82
82
  }
83
83
  }
84
84
 
85
+ function loadInitialManifestData() {
86
+ const script = document.getElementById("initial-manifest-data");
87
+ if (!(script instanceof HTMLScriptElement)) {
88
+ return null;
89
+ }
90
+
91
+ const raw = script.textContent;
92
+ if (!raw) {
93
+ return null;
94
+ }
95
+
96
+ try {
97
+ const parsed = JSON.parse(raw);
98
+ if (!parsed || typeof parsed !== "object") {
99
+ return null;
100
+ }
101
+
102
+ if (!Array.isArray(parsed.docs) || !Array.isArray(parsed.tree)) {
103
+ return null;
104
+ }
105
+
106
+ if (!parsed.routeMap || typeof parsed.routeMap !== "object") {
107
+ return null;
108
+ }
109
+
110
+ return parsed;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
85
116
  function resolveRouteFromLocation(routeMap) {
86
117
  const direct = normalizeRoute(location.pathname);
87
118
  if (routeMap[direct]) {
@@ -186,6 +217,13 @@ function buildBranchView(manifest, branch, defaultBranch) {
186
217
  };
187
218
  }
188
219
 
220
+ function pickHomeRoute(view) {
221
+ if (view.routeMap["/index/"]) {
222
+ return "/index/";
223
+ }
224
+ return view.docs[0]?.route || "/";
225
+ }
226
+
189
227
  function loadExpandedSet() {
190
228
  try {
191
229
  const raw = localStorage.getItem(EXPANDED_KEY);
@@ -583,8 +621,11 @@ function createFileNode(node, fileRowsById, depth = 0) {
583
621
  row.dataset.fileId = node.id;
584
622
  row.style.setProperty("--tree-depth", String(depth));
585
623
 
624
+ const prefix = typeof node.prefix === "string" ? node.prefix.trim() : "";
625
+ const prefixHtml = prefix ? `<span class="tree-prefix">${escapeHtmlAttr(prefix)}</span>` : "";
626
+ const label = escapeHtmlAttr(node.title || node.name);
586
627
  const newBadge = node.isNew ? `<span class="badge-new">NEW</span>` : "";
587
- row.innerHTML = `<span class="material-symbols-outlined">article</span><span class="tree-label">${node.title || node.name}</span>${newBadge}`;
628
+ row.innerHTML = `<span class="material-symbols-outlined">article</span>${prefixHtml}<span class="tree-label">${label}</span>${newBadge}`;
588
629
  fileRowsById.set(node.id, row);
589
630
 
590
631
  return row;
@@ -653,6 +694,10 @@ function renderBreadcrumb(route) {
653
694
  function renderMeta(doc) {
654
695
  const items = [];
655
696
 
697
+ if (typeof doc.prefix === "string" && doc.prefix.trim().length > 0) {
698
+ items.push(`<span class="meta-item meta-prefix">${escapeHtmlAttr(doc.prefix)}</span>`);
699
+ }
700
+
656
701
  const createdAt = formatMetaDateTime(doc.date);
657
702
  if (createdAt) {
658
703
  items.push(
@@ -660,13 +705,6 @@ function renderMeta(doc) {
660
705
  );
661
706
  }
662
707
 
663
- const updatedAt = formatMetaDateTime(doc.updatedDate);
664
- if (updatedAt) {
665
- items.push(
666
- `<span class="meta-item"><span class="material-symbols-outlined">schedule</span>updated ${escapeHtmlAttr(updatedAt)}</span>`,
667
- );
668
- }
669
-
670
708
  const tags = normalizeTags(doc.tags);
671
709
  if (tags.length > 0) {
672
710
  const tagsStr = tags.map((tag) => `#${escapeHtmlAttr(tag)}`).join(" ");
@@ -1077,12 +1115,14 @@ async function start() {
1077
1115
  }
1078
1116
  });
1079
1117
 
1080
- const manifestRes = await fetch("/manifest.json");
1081
- if (!manifestRes.ok) {
1082
- throw new Error(`Failed to load manifest: ${manifestRes.status}`);
1118
+ let manifest = loadInitialManifestData();
1119
+ if (!manifest) {
1120
+ const manifestRes = await fetch("/manifest.json");
1121
+ if (!manifestRes.ok) {
1122
+ throw new Error(`Failed to load manifest: ${manifestRes.status}`);
1123
+ }
1124
+ manifest = await manifestRes.json();
1083
1125
  }
1084
-
1085
- const manifest = await manifestRes.json();
1086
1126
  const defaultBranch = normalizeBranch(manifest.defaultBranch) || DEFAULT_BRANCH;
1087
1127
  const availableBranchSet = new Set([defaultBranch]);
1088
1128
  for (const doc of manifest.docs) {
@@ -1419,7 +1459,7 @@ async function start() {
1419
1459
  return;
1420
1460
  }
1421
1461
 
1422
- const fallbackRoute = view.docs[0]?.route || "/";
1462
+ const fallbackRoute = pickHomeRoute(view);
1423
1463
  await state.navigate(fallbackRoute, true);
1424
1464
  };
1425
1465
 
@@ -1428,7 +1468,7 @@ async function start() {
1428
1468
  renderTree(state);
1429
1469
 
1430
1470
  const currentRoute = resolveRouteFromLocation(view.routeMap);
1431
- const initialRoute = currentRoute === "/" ? view.docs[0]?.route || "/" : currentRoute;
1471
+ const initialRoute = currentRoute === "/" ? pickHomeRoute(view) : currentRoute;
1432
1472
  handleLayoutChange();
1433
1473
  await state.navigate(initialRoute, currentRoute === "/" && initialRoute !== "/");
1434
1474
 
package/src/template.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { escapeHtmlAttribute } from "./seo";
2
+ import type { Manifest } from "./types";
2
3
 
3
4
  const DEFAULT_TITLE = "File-System Blog";
4
5
  const DEFAULT_DESCRIPTION = "File-system style static blog with markdown explorer UI.";
@@ -44,6 +45,8 @@ interface AppShellInitialViewPayload {
44
45
  title: string;
45
46
  }
46
47
 
48
+ interface AppShellManifestPayload extends Manifest {}
49
+
47
50
  const DEFAULT_ASSETS: AppShellAssets = {
48
51
  cssHref: "/assets/app.css",
49
52
  jsSrc: "/assets/app.js",
@@ -137,14 +140,6 @@ function renderHeadMeta(meta: AppShellMeta): string {
137
140
  return headTags.join("\n");
138
141
  }
139
142
 
140
- function renderDeferredStylesheet(href: string): string {
141
- return [
142
- ` <link rel="preload" href="${escapeHtmlAttribute(href)}" as="style" />`,
143
- ` <link rel="stylesheet" href="${escapeHtmlAttribute(href)}" media="print" onload="this.media='all'" />`,
144
- ` <noscript><link rel="stylesheet" href="${escapeHtmlAttribute(href)}" /></noscript>`,
145
- ].join("\n");
146
- }
147
-
148
143
  function renderInitialViewScript(initialView: AppShellInitialView | null): string {
149
144
  if (!initialView) {
150
145
  return "";
@@ -164,17 +159,30 @@ function renderInitialViewScript(initialView: AppShellInitialView | null): strin
164
159
  return `\n <script id="initial-view-data" type="application/json">${payload}</script>`;
165
160
  }
166
161
 
162
+ function renderInitialManifestScript(manifest: AppShellManifestPayload | null): string {
163
+ if (!manifest) {
164
+ return "";
165
+ }
166
+
167
+ const payload = JSON.stringify(manifest)
168
+ .replaceAll("<", "\\u003c")
169
+ .replaceAll("\u2028", "\\u2028")
170
+ .replaceAll("\u2029", "\\u2029");
171
+
172
+ return `\n <script id="initial-manifest-data" type="application/json">${payload}</script>`;
173
+ }
174
+
167
175
  export function renderAppShellHtml(
168
176
  meta: AppShellMeta = {},
169
177
  assets: AppShellAssets = DEFAULT_ASSETS,
170
178
  initialView: AppShellInitialView | null = null,
179
+ manifest: AppShellManifestPayload | null = null,
171
180
  ): string {
172
181
  const headMeta = renderHeadMeta(meta);
173
182
  const initialViewScript = renderInitialViewScript(initialView);
174
- const textFontStylesheet =
175
- "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+KR:wght@400;500;700&display=optional";
183
+ const initialManifestScript = renderInitialManifestScript(manifest);
176
184
  const symbolFontStylesheet =
177
- "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=optional";
185
+ "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap";
178
186
  const initialTitle = initialView ? escapeHtmlAttribute(initialView.title) : "문서를 선택하세요";
179
187
  const initialBreadcrumb = initialView ? initialView.breadcrumbHtml : "";
180
188
  const initialMeta = initialView ? initialView.metaHtml : "";
@@ -191,8 +199,7 @@ export function renderAppShellHtml(
191
199
  ${headMeta}
192
200
  <link rel="preconnect" href="https://fonts.googleapis.com" />
193
201
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
194
- ${renderDeferredStylesheet(textFontStylesheet)}
195
- ${renderDeferredStylesheet(symbolFontStylesheet)}
202
+ <link rel="stylesheet" href="${escapeHtmlAttribute(symbolFontStylesheet)}" />
196
203
  <link rel="stylesheet" href="${escapeHtmlAttribute(assets.cssHref)}" />
197
204
  </head>
198
205
  <body>
@@ -298,12 +305,13 @@ ${renderDeferredStylesheet(symbolFontStylesheet)}
298
305
  <div id="viewer-meta" class="viewer-meta">${initialMeta}</div>
299
306
  </header>
300
307
  <article id="viewer-content" class="viewer-content">${initialContent}</article>
301
- <nav id="viewer-nav" class="viewer-nav">${initialNav}</nav>
308
+ <nav id="viewer-nav" class="viewer-nav" aria-label="문서 이전/다음 탐색">${initialNav}</nav>
302
309
  </div>
303
310
  </main>
304
311
  </div>
305
312
  <div id="tree-label-tooltip" class="tree-label-tooltip" role="tooltip" hidden></div>
306
313
  ${initialViewScript}
314
+ ${initialManifestScript}
307
315
  <script type="module" src="${escapeHtmlAttribute(assets.jsSrc)}"></script>
308
316
  </body>
309
317
  </html>
@@ -311,10 +319,8 @@ ${initialViewScript}
311
319
  }
312
320
 
313
321
  export function render404Html(assets: AppShellAssets = DEFAULT_ASSETS): string {
314
- const textFontStylesheet =
315
- "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+KR:wght@400;500;700&display=optional";
316
322
  const symbolFontStylesheet =
317
- "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=optional";
323
+ "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap";
318
324
 
319
325
  return `<!doctype html>
320
326
  <html lang="ko">
@@ -324,8 +330,7 @@ export function render404Html(assets: AppShellAssets = DEFAULT_ASSETS): string {
324
330
  <title>404 - File-System Blog</title>
325
331
  <link rel="preconnect" href="https://fonts.googleapis.com" />
326
332
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
327
- ${renderDeferredStylesheet(textFontStylesheet)}
328
- ${renderDeferredStylesheet(symbolFontStylesheet)}
333
+ <link rel="stylesheet" href="${escapeHtmlAttribute(symbolFontStylesheet)}" />
329
334
  <link rel="stylesheet" href="${escapeHtmlAttribute(assets.cssHref)}" />
330
335
  </head>
331
336
  <body>
package/src/types.ts CHANGED
@@ -82,6 +82,7 @@ export interface DocRecord {
82
82
  contentUrl: string;
83
83
  fileName: string;
84
84
  title: string;
85
+ prefix?: string;
85
86
  date?: string;
86
87
  updatedDate?: string;
87
88
  description?: string;
@@ -99,9 +100,9 @@ export interface FileNode {
99
100
  name: string;
100
101
  id: string;
101
102
  title: string;
103
+ prefix?: string;
102
104
  route: string;
103
105
  contentUrl: string;
104
- mtime: number;
105
106
  isNew: boolean;
106
107
  tags: string[];
107
108
  description?: string;
@@ -134,8 +135,8 @@ export interface Manifest {
134
135
  id: string;
135
136
  route: string;
136
137
  title: string;
138
+ prefix?: string;
137
139
  contentUrl: string;
138
- mtime: number;
139
140
  date?: string;
140
141
  updatedDate?: string;
141
142
  tags: string[];
@@ -156,6 +157,7 @@ export interface BuildCache {
156
157
  publish: boolean;
157
158
  draft: boolean;
158
159
  title?: string;
160
+ prefix?: string;
159
161
  date?: string;
160
162
  updatedDate?: string;
161
163
  description?: string;