@limcpf/everything-is-a-markdown 0.4.1 → 0.4.3

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.1",
3
+ "version": "0.4.3",
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(" ");
@@ -1047,7 +1078,7 @@ async function writeShellPages(
1047
1078
  runtimeAssets: RuntimeAssets,
1048
1079
  contentByDocId: Map<string, string>,
1049
1080
  ): Promise<void> {
1050
- const indexDoc = docs[0] ?? null;
1081
+ const indexDoc = pickHomeDoc(docs);
1051
1082
  const indexOutputPath = "index.html";
1052
1083
  const indexInitialView = indexDoc ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "") : null;
1053
1084
  const shell = renderAppShellHtml(
@@ -60,6 +60,8 @@
60
60
  --sidebar-mobile-shadow: 0 24px 48px rgba(76, 79, 105, 0.2);
61
61
  --accent-hover: #7030d0;
62
62
  --accent-strong: #6732ca;
63
+ --badge-new-bg: var(--latte-red);
64
+ --badge-new-fg: #ffffff;
63
65
  --desktop-sidebar-default: 420px;
64
66
  --desktop-sidebar-min: 320px;
65
67
  --desktop-viewer-min: 680px;
@@ -103,6 +105,8 @@
103
105
  --sidebar-mobile-shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
104
106
  --accent-hover: #a985f6;
105
107
  --accent-strong: #dfcbff;
108
+ --badge-new-bg: var(--mocha-red);
109
+ --badge-new-fg: var(--mocha-crust);
106
110
  }
107
111
 
108
112
  * {
@@ -478,6 +482,14 @@ a:hover {
478
482
  color: var(--latte-overlay0);
479
483
  }
480
484
 
485
+ .tree-prefix {
486
+ flex: 0 0 auto;
487
+ font-size: 0.68rem;
488
+ font-weight: 600;
489
+ letter-spacing: 0.02em;
490
+ color: var(--latte-subtext1);
491
+ }
492
+
481
493
  .tree-label {
482
494
  flex: 1;
483
495
  overflow: hidden;
@@ -526,8 +538,8 @@ a:hover {
526
538
  letter-spacing: 0.03em;
527
539
  padding: 2px 6px;
528
540
  border-radius: 999px;
529
- color: white;
530
- background: var(--latte-red);
541
+ color: var(--badge-new-fg);
542
+ background: var(--badge-new-bg);
531
543
  }
532
544
 
533
545
  /* Active badge */
@@ -582,7 +594,7 @@ a:hover {
582
594
 
583
595
  .status-encoding {
584
596
  font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
585
- color: var(--latte-overlay0);
597
+ color: var(--latte-subtext1);
586
598
  }
587
599
 
588
600
  .sidebar-footer-actions {
@@ -829,7 +841,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
829
841
  min-height: 22px;
830
842
  font-size: 0.85rem;
831
843
  font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
832
- color: var(--latte-subtext0);
844
+ color: var(--latte-subtext1);
833
845
  }
834
846
 
835
847
  .meta-item {
@@ -843,6 +855,13 @@ body.mobile-toggle-left .mobile-menu-toggle {
843
855
  color: var(--latte-overlay0);
844
856
  }
845
857
 
858
+ .meta-prefix {
859
+ font-size: 0.74rem;
860
+ font-weight: 700;
861
+ letter-spacing: 0.03em;
862
+ color: var(--latte-subtext1);
863
+ }
864
+
846
865
  .meta-tags {
847
866
  color: var(--latte-lavender);
848
867
  }
@@ -858,7 +877,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
858
877
  max-width: 72ch;
859
878
  margin: 0 auto;
860
879
  text-wrap: pretty;
861
- color: var(--latte-subtext0);
880
+ color: var(--latte-subtext1);
862
881
  }
863
882
 
864
883
  .viewer-content h1,
@@ -915,7 +934,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
915
934
  background: rgba(230, 233, 239, 0.5);
916
935
  border-radius: 0 8px 8px 0;
917
936
  font-style: normal;
918
- color: var(--latte-subtext0);
937
+ color: var(--latte-subtext1);
919
938
  }
920
939
 
921
940
  /* Inline code */
@@ -217,6 +217,13 @@ function buildBranchView(manifest, branch, defaultBranch) {
217
217
  };
218
218
  }
219
219
 
220
+ function pickHomeRoute(view) {
221
+ if (view.routeMap["/index/"]) {
222
+ return "/index/";
223
+ }
224
+ return view.docs[0]?.route || "/";
225
+ }
226
+
220
227
  function loadExpandedSet() {
221
228
  try {
222
229
  const raw = localStorage.getItem(EXPANDED_KEY);
@@ -614,8 +621,11 @@ function createFileNode(node, fileRowsById, depth = 0) {
614
621
  row.dataset.fileId = node.id;
615
622
  row.style.setProperty("--tree-depth", String(depth));
616
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);
617
627
  const newBadge = node.isNew ? `<span class="badge-new">NEW</span>` : "";
618
- 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}`;
619
629
  fileRowsById.set(node.id, row);
620
630
 
621
631
  return row;
@@ -684,6 +694,10 @@ function renderBreadcrumb(route) {
684
694
  function renderMeta(doc) {
685
695
  const items = [];
686
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
+
687
701
  const createdAt = formatMetaDateTime(doc.date);
688
702
  if (createdAt) {
689
703
  items.push(
@@ -691,13 +705,6 @@ function renderMeta(doc) {
691
705
  );
692
706
  }
693
707
 
694
- const updatedAt = formatMetaDateTime(doc.updatedDate);
695
- if (updatedAt) {
696
- items.push(
697
- `<span class="meta-item"><span class="material-symbols-outlined">schedule</span>updated ${escapeHtmlAttr(updatedAt)}</span>`,
698
- );
699
- }
700
-
701
708
  const tags = normalizeTags(doc.tags);
702
709
  if (tags.length > 0) {
703
710
  const tagsStr = tags.map((tag) => `#${escapeHtmlAttr(tag)}`).join(" ");
@@ -882,11 +889,15 @@ async function start() {
882
889
  }
883
890
 
884
891
  if (!isCompactLayout()) {
885
- sidebar.removeAttribute("aria-hidden");
892
+ sidebar.removeAttribute("inert");
886
893
  return;
887
894
  }
888
895
 
889
- sidebar.setAttribute("aria-hidden", String(!isOpen));
896
+ if (isOpen) {
897
+ sidebar.removeAttribute("inert");
898
+ } else {
899
+ sidebar.setAttribute("inert", "");
900
+ }
890
901
  };
891
902
 
892
903
  const openSidebar = () => {
@@ -1452,7 +1463,7 @@ async function start() {
1452
1463
  return;
1453
1464
  }
1454
1465
 
1455
- const fallbackRoute = view.docs[0]?.route || "/";
1466
+ const fallbackRoute = pickHomeRoute(view);
1456
1467
  await state.navigate(fallbackRoute, true);
1457
1468
  };
1458
1469
 
@@ -1461,7 +1472,7 @@ async function start() {
1461
1472
  renderTree(state);
1462
1473
 
1463
1474
  const currentRoute = resolveRouteFromLocation(view.routeMap);
1464
- const initialRoute = currentRoute === "/" ? view.docs[0]?.route || "/" : currentRoute;
1475
+ const initialRoute = currentRoute === "/" ? pickHomeRoute(view) : currentRoute;
1465
1476
  handleLayoutChange();
1466
1477
  await state.navigate(initialRoute, currentRoute === "/" && initialRoute !== "/");
1467
1478
 
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;