@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 +21 -0
- package/README.ko.md +128 -0
- package/README.md +84 -253
- package/package.json +2 -1
- package/src/build.ts +74 -40
- package/src/runtime/app.css +38 -13
- package/src/runtime/app.js +55 -15
- package/src/template.ts +24 -19
- package/src/types.ts +4 -2
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
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
24
|
+
## Usage
|
|
43
25
|
|
|
44
26
|
```bash
|
|
45
|
-
bun run dev
|
|
27
|
+
bun run blog [build|dev|clean] [options]
|
|
46
28
|
```
|
|
47
29
|
|
|
48
|
-
|
|
30
|
+
Commands:
|
|
49
31
|
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
bun run blog [build|dev|clean] [options]
|
|
55
|
-
```
|
|
36
|
+
Common options:
|
|
56
37
|
|
|
57
|
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
46
|
+
# Run with the sample vault
|
|
47
|
+
bun run dev -- --vault ./test-vault --out ./dist
|
|
93
48
|
|
|
94
|
-
#
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
```
|
|
59
|
+
- `publish: true`
|
|
60
|
+
Only documents with this value are included in build output.
|
|
112
61
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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:
|
|
190
|
-
date: "2024-
|
|
191
|
-
updatedDate: "2024-
|
|
192
|
-
description:
|
|
193
|
-
tags: ["
|
|
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
|
-
#
|
|
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
|
-
|
|
109
|
+
Draft document:
|
|
221
110
|
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
119
|
+
## bunx (Optional)
|
|
244
120
|
|
|
245
121
|
```bash
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
package/src/build.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
toRoute,
|
|
21
21
|
} from "./utils";
|
|
22
22
|
|
|
23
|
-
const CACHE_VERSION =
|
|
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.
|
|
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(
|
|
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 =
|
|
739
|
-
.
|
|
740
|
-
.
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
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);
|
package/src/runtime/app.css
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
--latte-crust: #dce0e8;
|
|
6
6
|
--latte-text: #4c4f69;
|
|
7
7
|
--latte-subtext0: #6c6f85;
|
|
8
|
-
--latte-subtext1: #
|
|
9
|
-
--latte-overlay0: #
|
|
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: #
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
}
|
package/src/runtime/app.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1081
|
-
if (!
|
|
1082
|
-
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
${
|
|
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=
|
|
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
|
-
${
|
|
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;
|