for-humanity 0.5.0 → 0.7.0
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/CHANGELOG.md +12 -0
- package/LICENSE +1 -1
- package/README.md +4 -3
- package/dist/browser.js +9 -9
- package/dist/cli.css +25 -310
- package/dist/cli.js +177 -321
- package/package.json +4 -3
- package/src/asset/font/{jetbrains-mono/LICENSE → monoplex-kr/LICENSE.md} +61 -8
- package/src/asset/font/monoplex-kr/MonoplexKR-Regular.woff2 +0 -0
- package/src/asset/font/monoplex-kr/MonoplexKR-SemiBold.woff2 +0 -0
- package/src/asset/font/monoplex-kr/monoplex-kr.css +20 -0
- package/src/component/widget/navigation/_constant/navigation-data-schema.ts +0 -3
- package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-document.ts +2 -3
- package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-group.ts +3 -4
- package/src/component/widget/navigation/_function/to-navigation-data/to-navigation-data.ts +6 -14
- package/src/component/widget/navigation/_type/navigation-data.ts +0 -12
- package/src/component/widget/navigation/_type/navigation-focus-snapshot.ts +0 -4
- package/src/component/widget/navigation/_wg-navigation-nav-list.css +5 -136
- package/src/component/widget/navigation/_wg-navigation-nav-list.tsx +10 -55
- package/src/component/widget/navigation/_wg-navigation-nav.css +2 -8
- package/src/component/widget/navigation/_wg-navigation-nav.tsx +1 -11
- package/src/component/widget/navigation/_wg-navigation-toc.css +12 -207
- package/src/component/widget/navigation/_wg-navigation-toc.tsx +39 -80
- package/src/component/widget/navigation/wg-navigation.tsx +10 -69
- package/src/component/widget/prose/wg-prose.css +0 -3
- package/src/component/widget/shell/wg-shell.css +1 -1
- package/src/constant/copy.ts +5 -5
- package/src/constant/font.ts +2 -12
- package/src/entry/browser/browser.tsx +1 -1
- package/src/entry/cli/_constant/crawling.ts +14 -0
- package/src/entry/cli/_function/create-dev-app.ts +9 -1
- package/src/entry/cli/_function/read-docs.ts +20 -1
- package/src/entry/cli/_function/to-crawling-app.ts +42 -0
- package/src/entry/cli/cli.test.ts +112 -9
- package/src/entry/cli/cli.ts +27 -22
- package/src/store/navigation/create-navigation-stores.test.ts +10 -28
- package/src/store/navigation/create-navigation-stores.ts +4 -18
- package/src/store/navigation/navigation-state.ts +0 -10
- package/src/store/navigation/navigation-storage.ts +0 -5
- package/src/style/token.css +11 -14
- package/src/type/site-config.ts +10 -1
- package/src/util/font/to-font-css.test.ts +2 -2
- package/src/util/mermaid/render-flow.js +2 -2
- package/src/asset/font/jetbrains-mono/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- package/src/asset/font/jetbrains-mono/jetbrains-mono.css +0 -11
- package/src/asset/font/pretendard/LICENSE.txt +0 -94
- package/src/asset/font/pretendard/pretendardvariable-dynamic-subset.css +0 -1008
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.0.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.1.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.10.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.11.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.12.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.13.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.14.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.15.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.16.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.17.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.18.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.19.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.2.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.20.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.21.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.22.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.23.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.24.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.25.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.26.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.27.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.28.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.29.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.3.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.30.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.31.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.32.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.33.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.34.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.35.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.36.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.37.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.38.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.39.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.4.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.40.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.41.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.42.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.43.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.44.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.45.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.46.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.47.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.48.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.49.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.5.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.50.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.51.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.52.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.53.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.54.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.55.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.56.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.57.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.58.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.59.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.6.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.60.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.61.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.62.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.63.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.64.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.65.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.66.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.67.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.68.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.69.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.7.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.70.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.71.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.72.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.73.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.74.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.75.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.76.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.77.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.78.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.79.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.8.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.80.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.81.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.82.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.83.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.84.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.85.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.86.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.87.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.88.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.89.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.9.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.90.woff2 +0 -0
- package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.91.woff2 +0 -0
- package/src/component/widget/navigation/_function/to-navigation-branch-id.ts +0 -6
- package/src/component/widget/navigation/_wg-navigation-branch-toggle.css +0 -28
- package/src/component/widget/navigation/_wg-navigation-branch-toggle.tsx +0 -48
- package/src/store/navigation/_constant/tree.ts +0 -6
- package/src/store/navigation/to-navigation-tree-state.ts +0 -19
- package/src/store/navigation/use-navigation-tree-store.ts +0 -12
package/src/constant/font.ts
CHANGED
|
@@ -4,25 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* 본문·코드 글꼴 CSS · 패키지 기준 경로 · Regular·SemiBold 두 파일
|
|
8
8
|
*/
|
|
9
|
-
export const
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* 코드 글꼴 CSS · 라틴 subset · 한글은 본문 글꼴
|
|
13
|
-
*/
|
|
14
|
-
export const font_mono_css = "src/asset/font/jetbrains-mono/jetbrains-mono.css";
|
|
9
|
+
export const font_mono_css = "src/asset/font/monoplex-kr/monoplex-kr.css";
|
|
15
10
|
|
|
16
11
|
/**
|
|
17
12
|
* 사이트 이름 글꼴 CSS · Architects Daughter Regular · 라틴 subset
|
|
18
13
|
*/
|
|
19
14
|
export const font_brand_css = "src/asset/font/architects-daughter/architects-daughter.css";
|
|
20
15
|
|
|
21
|
-
/**
|
|
22
|
-
* 탐색·라틴 문자에 필요한 본문 조각 · 92개 전체 선요청 방지
|
|
23
|
-
*/
|
|
24
|
-
export const font_sans_preload_file = "PretendardVariable.subset.91.woff2";
|
|
25
|
-
|
|
26
16
|
/**
|
|
27
17
|
* 늦게 받은 글꼴로 이미 보이는 대체 글꼴을 교체하지 않는 표시 정책
|
|
28
18
|
*/
|
|
@@ -13,7 +13,7 @@ if (root !== null && payload !== null) {
|
|
|
13
13
|
try {
|
|
14
14
|
const value: unknown = JSON.parse(payload.textContent);
|
|
15
15
|
const data = navigationDataSchema.parse(value);
|
|
16
|
-
const stores = createNavigationStores({
|
|
16
|
+
const stores = createNavigationStores({session: () => sessionStorage});
|
|
17
17
|
createRoot(root).render(<WgShellControls data={data} stores={stores} reload={root.hasAttribute("data-shell-reload")} />);
|
|
18
18
|
} catch (error) {
|
|
19
19
|
// 초기화 오류에서도 서버의 링크·본문과 네이티브 스크롤 유지
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 크롤러 안내 파일의 사이트 루트 경로
|
|
3
|
+
*/
|
|
4
|
+
export const crawling_robots_path = "/robots.txt";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 공개 페이지 목록의 사이트 루트 경로
|
|
8
|
+
*/
|
|
9
|
+
export const crawling_sitemap_path = "/sitemap.xml";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 공개 크롤러 파일과 정적 출력이 충돌하는 문서의 첫 경로 조각
|
|
13
|
+
*/
|
|
14
|
+
export const crawling_reserved_roots = [crawling_robots_path.slice(1), crawling_sitemap_path.slice(1)];
|
|
@@ -5,6 +5,7 @@ import {type Context, Hono} from "hono";
|
|
|
5
5
|
import {streamSSE} from "hono/streaming";
|
|
6
6
|
import {asset_content_type_default, asset_content_types, asset_dir, asset_favicon_path, asset_reload_path} from "@/constant/asset";
|
|
7
7
|
import {font_cache_control} from "@/constant/font";
|
|
8
|
+
import {crawling_robots_path, crawling_sitemap_path} from "@/entry/cli/_constant/crawling";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* 개발 서버 입력 계약
|
|
@@ -14,6 +15,10 @@ export interface DevOptions {
|
|
|
14
15
|
* 정적 자원 외 요청을 처리할 페이지 앱
|
|
15
16
|
*/
|
|
16
17
|
pages: Hono;
|
|
18
|
+
/**
|
|
19
|
+
* 공개 URL을 설정한 프로젝트의 크롤러 파일 앱
|
|
20
|
+
*/
|
|
21
|
+
crawling?: Hono;
|
|
17
22
|
/**
|
|
18
23
|
* 자원 URL → 파일 경로 · 정적 빌드와 동일 목록
|
|
19
24
|
*/
|
|
@@ -25,7 +30,7 @@ export interface DevOptions {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
|
-
* 개발 전용 자원·SSE
|
|
33
|
+
* 개발 전용 자원·SSE와 선택적 크롤러 응답 · 나머지 요청은 페이지 앱에 위임
|
|
29
34
|
* 정적 빌드의 페이지 라우트와 분리
|
|
30
35
|
*/
|
|
31
36
|
export const createDevApp = (options: DevOptions) => {
|
|
@@ -73,6 +78,9 @@ export const createDevApp = (options: DevOptions) => {
|
|
|
73
78
|
);
|
|
74
79
|
app.get(`/${asset_dir}/*`, handleAsset);
|
|
75
80
|
app.get(asset_favicon_path, handleAsset);
|
|
81
|
+
if (options.crawling !== undefined) {
|
|
82
|
+
app.on("GET", [crawling_robots_path, crawling_sitemap_path], (c) => options.crawling?.fetch(c.req.raw, c.env) ?? c.notFound());
|
|
83
|
+
}
|
|
76
84
|
app.all("/*", (c) => options.pages.fetch(c.req.raw, c.env));
|
|
77
85
|
|
|
78
86
|
return app;
|
|
@@ -16,12 +16,30 @@ import type {Doc} from "@/type/doc";
|
|
|
16
16
|
import {docDataSchema} from "@/type/doc-data";
|
|
17
17
|
import type {DocFileData} from "@/type/doc-file-data";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 문서 폴더를 읽고 명령별 출력 경로와 함께 검증하는 입력
|
|
21
|
+
*/
|
|
22
|
+
export interface ReadDocsParams {
|
|
23
|
+
/**
|
|
24
|
+
* CLI에 넘긴 문서 폴더의 절대 경로
|
|
25
|
+
*/
|
|
26
|
+
root: string;
|
|
27
|
+
/**
|
|
28
|
+
* 설정·자원 목록을 연결한 Markdown 처리기
|
|
29
|
+
*/
|
|
30
|
+
processor: Processor;
|
|
31
|
+
/**
|
|
32
|
+
* 명령이 생성할 파일과 충돌하는 첫 경로 조각 · 생략 시 추가 예약 없음
|
|
33
|
+
*/
|
|
34
|
+
reserved?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
/**
|
|
20
38
|
* Markdown 수집과 렌더링 · 루트 README는 홈, AGENTS·CLAUDE는 작성 지침으로 분리
|
|
21
39
|
* 문서 목록에서 루트 안내 파일·node_modules·dist 제외
|
|
22
40
|
* 머리말·문서 식별자 중복·플러그인 오류 시 실패
|
|
23
41
|
*/
|
|
24
|
-
export const readDocs = async (options:
|
|
42
|
+
export const readDocs = async (options: ReadDocsParams): Promise<Doc[]> => {
|
|
25
43
|
const paths = (await readdir(options.root, {recursive: true})).filter(
|
|
26
44
|
(path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(sep).includes("node_modules") && !path.startsWith(`dist${sep}`),
|
|
27
45
|
);
|
|
@@ -39,6 +57,7 @@ export const readDocs = async (options: {root: string; processor: Processor}): P
|
|
|
39
57
|
segments.some((segment) => segment === "" || segment === "." || segment === ".." || segment === "index.html") ||
|
|
40
58
|
/[#?%*:\\]/.test(id) ||
|
|
41
59
|
/[\p{Cc}]/u.test(id) ||
|
|
60
|
+
options.reserved?.includes(segments[0]) === true ||
|
|
42
61
|
segments[0] === asset_dir ||
|
|
43
62
|
segments[0] === asset_favicon_path.slice(1)
|
|
44
63
|
) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {Hono} from "hono";
|
|
2
|
+
import {html} from "hono/html";
|
|
3
|
+
import {crawling_robots_path, crawling_sitemap_path} from "@/entry/cli/_constant/crawling";
|
|
4
|
+
import type {AppOptions} from "@/entry/cli/_function/create-app";
|
|
5
|
+
import type {SiteConfig} from "@/type/site-config";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* URL을 명시한 공개 프로젝트의 크롤러 응답 입력
|
|
9
|
+
*/
|
|
10
|
+
export interface CrawlingParams {
|
|
11
|
+
/**
|
|
12
|
+
* 설정 검증에서 루트 origin으로 정규화한 공개 HTTP(S) URL
|
|
13
|
+
*/
|
|
14
|
+
url: NonNullable<SiteConfig["url"]>;
|
|
15
|
+
/**
|
|
16
|
+
* dev 파일 변경 시 교체되는 현재 문서 목록 · 본문은 사이트맵에 포함하지 않음
|
|
17
|
+
*/
|
|
18
|
+
store: AppOptions["store"];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* dev와 정적 빌드에서 같은 크롤러 파일 생성 · 정책·수정 시각은 추정하지 않음
|
|
23
|
+
*/
|
|
24
|
+
export const toCrawlingApp = (params: CrawlingParams) => {
|
|
25
|
+
const app = new Hono();
|
|
26
|
+
|
|
27
|
+
app.get(crawling_robots_path, (c) => c.text(`User-agent: *\nAllow: /\n\nSitemap: ${params.url}${crawling_sitemap_path}\n`));
|
|
28
|
+
app.get(
|
|
29
|
+
crawling_sitemap_path,
|
|
30
|
+
(_c) =>
|
|
31
|
+
new Response(
|
|
32
|
+
html`<?xml version="1.0" encoding="UTF-8"?>
|
|
33
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
34
|
+
<url><loc>${params.url}/</loc></url>
|
|
35
|
+
${params.store.docs.map((doc) => html`<url><loc>${params.url}/${doc.id.split("/").map(encodeURIComponent).join("/")}/</loc></url>`)}
|
|
36
|
+
</urlset>`.toString(),
|
|
37
|
+
{headers: {"Content-Type": "application/xml; charset=utf-8"}},
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return app;
|
|
42
|
+
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import {EventEmitter} from "node:events";
|
|
3
|
+
import {mkdir, mkdtemp, readFile, rm, symlink, writeFile} from "node:fs/promises";
|
|
3
4
|
import {tmpdir} from "node:os";
|
|
4
5
|
import {join} from "node:path";
|
|
5
6
|
import {test} from "node:test";
|
|
7
|
+
import {toSSG} from "hono/ssg";
|
|
6
8
|
import {navigationDataSchema} from "@/component/widget/navigation/_constant/navigation-data-schema";
|
|
7
9
|
import {toDocGroups} from "@/component/widget/navigation/_function/to-doc-groups/to-doc-groups";
|
|
8
10
|
import {toNavigationData} from "@/component/widget/navigation/_function/to-navigation-data/to-navigation-data";
|
|
@@ -10,9 +12,11 @@ import {toNavigationJson} from "@/component/widget/navigation/_function/to-navig
|
|
|
10
12
|
import {WgNavigation} from "@/component/widget/navigation/wg-navigation";
|
|
11
13
|
import {asset_style_path} from "@/constant/asset";
|
|
12
14
|
import {createApp} from "@/entry/cli/_function/create-app";
|
|
15
|
+
import {createDevApp} from "@/entry/cli/_function/create-dev-app";
|
|
13
16
|
import {createProcessor} from "@/entry/cli/_function/create-processor/create-processor";
|
|
14
17
|
import {readDocs} from "@/entry/cli/_function/read-docs";
|
|
15
18
|
import {readHome} from "@/entry/cli/_function/read-home";
|
|
19
|
+
import {toCrawlingApp} from "@/entry/cli/_function/to-crawling-app";
|
|
16
20
|
import {docDataSchema} from "@/type/doc-data";
|
|
17
21
|
import {siteConfigSchema} from "@/type/site-config";
|
|
18
22
|
|
|
@@ -54,6 +58,107 @@ test("page resource URLs use the supplied content fingerprints instead of fixed
|
|
|
54
58
|
assert.doesNotMatch(html, /(?:href|src)="\/_fh\/(?:style\.css|client\.js)"/);
|
|
55
59
|
});
|
|
56
60
|
|
|
61
|
+
test("public site URL is opt-in and normalizes an HTTP(S) root URL", () => {
|
|
62
|
+
assert.deepEqual(siteConfigSchema.parse({url: "https://docs.example.org/"}), {...siteConfigSchema.parse({}), url: "https://docs.example.org"});
|
|
63
|
+
assert.deepEqual(siteConfigSchema.parse({url: "http://localhost:4321"}), {...siteConfigSchema.parse({}), url: "http://localhost:4321"});
|
|
64
|
+
assert.equal("url" in siteConfigSchema.parse({}), false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("public site URL rejects unsupported protocols, credentials and non-root URLs", () => {
|
|
68
|
+
for (const url of [
|
|
69
|
+
"",
|
|
70
|
+
"/",
|
|
71
|
+
"file:///docs",
|
|
72
|
+
"ftp://docs.example.org",
|
|
73
|
+
"https://user:password@docs.example.org",
|
|
74
|
+
"https://docs.example.org/docs/",
|
|
75
|
+
"https://docs.example.org/?q=guide",
|
|
76
|
+
"https://docs.example.org/#guide",
|
|
77
|
+
]) {
|
|
78
|
+
assert.equal(siteConfigSchema.safeParse({url}).success, false, url);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("crawler files are absent when the public site URL is omitted", async () => {
|
|
83
|
+
const site = siteConfigSchema.parse({});
|
|
84
|
+
const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: asset_style_path};
|
|
85
|
+
const app = createDevApp({pages: createApp({site, store: {docs: []}, assets}), files: new Map(), reload: new EventEmitter()});
|
|
86
|
+
|
|
87
|
+
assert.equal((await app.request("/robots.txt")).status, 404);
|
|
88
|
+
assert.equal((await app.request("/sitemap.xml")).status, 404);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("crawler files list home and encoded nested document URLs with the current document store", async (t) => {
|
|
92
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-crawling-"));
|
|
93
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
94
|
+
await mkdir(join(root, "guide"));
|
|
95
|
+
await writeFile(join(root, "guide.md"), "---\nname: Start\nlabel: Start\ngroup: Guide\n---\n\nStart here.\n");
|
|
96
|
+
await writeFile(join(root, "guide/한국어 & tools.md"), "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n## Read\n\nText.\n");
|
|
97
|
+
const site = siteConfigSchema.parse({url: "https://docs.example.org/"});
|
|
98
|
+
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
99
|
+
const store = {docs};
|
|
100
|
+
const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: asset_style_path};
|
|
101
|
+
assert.ok(site.url);
|
|
102
|
+
const app = createDevApp({pages: createApp({site, store, assets}), crawling: toCrawlingApp({url: site.url, store}), files: new Map(), reload: new EventEmitter()});
|
|
103
|
+
const robots = await app.request("/robots.txt");
|
|
104
|
+
const sitemap = await app.request("/sitemap.xml");
|
|
105
|
+
|
|
106
|
+
assert.equal((await app.request("/guide/")).status, 200, "crawler routes must preserve ordinary document routes");
|
|
107
|
+
assert.equal((await app.request(`/guide/${encodeURIComponent("한국어 & tools")}/`)).status, 200, "crawler routes must preserve nested document routes");
|
|
108
|
+
assert.equal(robots.status, 200);
|
|
109
|
+
assert.match(String(robots.headers.get("content-type")), /^text\/plain/);
|
|
110
|
+
assert.equal(await robots.text(), "User-agent: *\nAllow: /\n\nSitemap: https://docs.example.org/sitemap.xml\n");
|
|
111
|
+
assert.equal(sitemap.status, 200);
|
|
112
|
+
assert.match(String(sitemap.headers.get("content-type")), /^application\/xml/);
|
|
113
|
+
const xml = await sitemap.text();
|
|
114
|
+
assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?>/);
|
|
115
|
+
assert.match(xml, /<urlset xmlns="http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9">/);
|
|
116
|
+
assert.match(xml, /<loc>https:\/\/docs\.example\.org\/<\/loc>/);
|
|
117
|
+
assert.ok(xml.includes(`<loc>https://docs.example.org/guide/${encodeURIComponent("한국어 & tools")}/</loc>`));
|
|
118
|
+
assert.equal([...xml.matchAll(/<loc>/g)].length, 3);
|
|
119
|
+
assert.doesNotMatch(xml, /<lastmod>|<changefreq>|<priority>|robots\.txt|sitemap\.xml|_fh|#read/);
|
|
120
|
+
|
|
121
|
+
store.docs = [];
|
|
122
|
+
assert.equal([...(await (await app.request("/sitemap.xml")).text()).matchAll(/<loc>/g)].length, 1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("SSG writes crawler files and preserves ordinary and nested document pages", async (t) => {
|
|
126
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-crawling-build-"));
|
|
127
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
128
|
+
await mkdir(join(root, "guide"));
|
|
129
|
+
await writeFile(join(root, "guide.md"), "---\nname: Start\nlabel: Start\ngroup: Guide\n---\n\nStart here.\n");
|
|
130
|
+
await writeFile(join(root, "guide/details.md"), "---\nname: Details\nlabel: Details\ngroup: Guide\n---\n\nNested details.\n");
|
|
131
|
+
const site = siteConfigSchema.parse({url: "https://docs.example.org"});
|
|
132
|
+
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
133
|
+
const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: asset_style_path};
|
|
134
|
+
const app = createApp({site, store: {docs}, assets});
|
|
135
|
+
const result = await toSSG(app, {writeFile, mkdir}, {dir: root});
|
|
136
|
+
assert.ok(site.url);
|
|
137
|
+
const crawlerResult = await toSSG(toCrawlingApp({url: site.url, store: {docs}}), {writeFile, mkdir}, {dir: root});
|
|
138
|
+
|
|
139
|
+
assert.equal(result.success, true);
|
|
140
|
+
assert.equal(crawlerResult.success, true);
|
|
141
|
+
assert.equal(result.files.length + crawlerResult.files.length, 5, "home, both documents and both crawler files must be emitted");
|
|
142
|
+
assert.match(await readFile(join(root, "guide/index.html"), "utf8"), /Start here\./);
|
|
143
|
+
assert.match(await readFile(join(root, "guide/details/index.html"), "utf8"), /Nested details\./);
|
|
144
|
+
assert.match(await readFile(join(root, "robots.txt"), "utf8"), /Sitemap: https:\/\/docs\.example\.org\/sitemap\.xml/);
|
|
145
|
+
assert.match(await readFile(join(root, "sitemap.xml"), "utf8"), /<loc>https:\/\/docs\.example\.org\/<\/loc>/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("crawler filenames reject conflicting document roots only when reserved", async (t) => {
|
|
149
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-crawling-paths-"));
|
|
150
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
151
|
+
await mkdir(join(root, "sitemap.xml"));
|
|
152
|
+
await writeFile(join(root, "Robots.txt.md"), "---\nname: Robots\nlabel: Robots\ngroup: Guide\n---\n\nText.\n");
|
|
153
|
+
await writeFile(join(root, "sitemap.xml/guide.md"), "---\nname: Sitemap\nlabel: Sitemap\ngroup: Guide\n---\n\nText.\n");
|
|
154
|
+
const site = siteConfigSchema.parse({});
|
|
155
|
+
const processor = createProcessor({site, root});
|
|
156
|
+
const params = {root, processor, reserved: ["robots.txt", "sitemap.xml"]};
|
|
157
|
+
|
|
158
|
+
await assert.rejects(readDocs(params), /문서 id에 사용할 수 없는 경로/);
|
|
159
|
+
assert.equal((await readDocs({root, processor})).length, 2);
|
|
160
|
+
});
|
|
161
|
+
|
|
57
162
|
test("navigation initialization runs after its DOM and before the document body", async () => {
|
|
58
163
|
const site = siteConfigSchema.parse({});
|
|
59
164
|
const browserScript = "window.navigationFixture = true;";
|
|
@@ -209,12 +314,11 @@ test("nested groups follow metadata paths and keep sibling branches and document
|
|
|
209
314
|
const html = toNavigationHtml({site, docs, current: "authoring"});
|
|
210
315
|
|
|
211
316
|
assert.match(html, /href="\/authoring\/"[^>]*aria-current="page"/);
|
|
212
|
-
assert.match(html, /Atlas<\/span><
|
|
213
|
-
assert.match(html, /Research<\/span><
|
|
317
|
+
assert.match(html, /Atlas<\/span><ul\b/);
|
|
318
|
+
assert.match(html, /Research<\/span><ul\b/);
|
|
214
319
|
assert.equal([...html.matchAll(/href="\/authoring\/"/g)].length, 1);
|
|
215
320
|
assert.equal([...html.matchAll(/>Research<\/span>/g)].length, 2);
|
|
216
|
-
assert.
|
|
217
|
-
assert.doesNotMatch(toNavigationHtml({site, docs}), /aria-current="page"|wg_navigationNavList__item--current/);
|
|
321
|
+
assert.doesNotMatch(toNavigationHtml({site, docs}), /aria-current="page"/);
|
|
218
322
|
assert.ok(html.indexOf('href="/parts/"') < html.indexOf('href="/authoring/"'));
|
|
219
323
|
assert.doesNotMatch(html, /<details\b/);
|
|
220
324
|
});
|
|
@@ -265,17 +369,16 @@ test("parent documents contain ordered descendants with independent URLs and one
|
|
|
265
369
|
assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
|
|
266
370
|
|
|
267
371
|
const html = toNavigationHtml({site, docs, current: "details"});
|
|
268
|
-
assert.match(html, /href="\/settings\/"[^>]*>Settings<\/a><
|
|
269
|
-
assert.match(html, /href="\/guide\/site\/"[^>]*>Site<\/a><
|
|
372
|
+
assert.match(html, /href="\/settings\/"[^>]*>Settings<\/a><ul\b[^>]*aria-label="Settings"/);
|
|
373
|
+
assert.match(html, /href="\/guide\/site\/"[^>]*>Site<\/a><ul\b[^>]*aria-label="Site"/);
|
|
270
374
|
assert.match(html, /href="\/details\/"[^>]*aria-current="page"/);
|
|
271
375
|
assert.equal([...html.matchAll(/aria-current="page"/g)].length, 1);
|
|
272
376
|
assert.equal([...html.matchAll(/wg_navigationNavList__link--active/g)].length, 1);
|
|
273
|
-
assert.equal([...html.matchAll(/wg_navigationNavList__item--current/g)].length, 5);
|
|
274
377
|
for (const doc of docs) {
|
|
275
378
|
assert.equal(html.split(`href="/${doc.id}/"`).length - 1, 1);
|
|
276
379
|
}
|
|
277
380
|
assert.ok(html.indexOf('href="/themes/"') < html.indexOf('href="/guide/site/"'));
|
|
278
|
-
assert.doesNotMatch(toNavigationHtml({site, docs}), /aria-current="page"
|
|
381
|
+
assert.doesNotMatch(toNavigationHtml({site, docs}), /aria-current="page"/);
|
|
279
382
|
|
|
280
383
|
const app = createApp({
|
|
281
384
|
site,
|
package/src/entry/cli/cli.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import {EventEmitter} from "node:events";
|
|
8
8
|
import {existsSync, readFileSync, watch} from "node:fs";
|
|
9
9
|
import {copyFile, mkdir, rm, writeFile} from "node:fs/promises";
|
|
10
|
-
import {
|
|
10
|
+
import {dirname, extname, join, relative, resolve, sep} from "node:path";
|
|
11
11
|
import {fileURLToPath, pathToFileURL} from "node:url";
|
|
12
12
|
import {serve} from "@hono/node-server";
|
|
13
13
|
import {serveStatic} from "@hono/node-server/serve-static";
|
|
@@ -15,15 +15,17 @@ import {debounce} from "es-toolkit";
|
|
|
15
15
|
import {Hono} from "hono";
|
|
16
16
|
import {toSSG} from "hono/ssg";
|
|
17
17
|
import {asset_favicon_path, asset_font_dir, asset_media_extensions, asset_style_path} from "@/constant/asset";
|
|
18
|
-
import {copy_error_config,
|
|
19
|
-
import {font_brand_css, font_cache_control, font_mono_css
|
|
18
|
+
import {copy_error_config, copy_error_prefix, copy_error_unknown_command} from "@/constant/copy";
|
|
19
|
+
import {font_brand_css, font_cache_control, font_mono_css} from "@/constant/font";
|
|
20
20
|
import {site_config_absent} from "@/constant/site";
|
|
21
21
|
import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port, cli_reload_delay_ms} from "@/entry/cli/_constant/cli";
|
|
22
|
+
import {crawling_reserved_roots} from "@/entry/cli/_constant/crawling";
|
|
22
23
|
import {createApp} from "@/entry/cli/_function/create-app";
|
|
23
24
|
import {createDevApp} from "@/entry/cli/_function/create-dev-app";
|
|
24
25
|
import {createProcessor} from "@/entry/cli/_function/create-processor/create-processor";
|
|
25
26
|
import {readDocs} from "@/entry/cli/_function/read-docs";
|
|
26
27
|
import {readHome} from "@/entry/cli/_function/read-home";
|
|
28
|
+
import {toCrawlingApp} from "@/entry/cli/_function/to-crawling-app";
|
|
27
29
|
import {siteConfigSchema} from "@/type/site-config";
|
|
28
30
|
import {toChangeQueue} from "@/util/async/to-change-queue";
|
|
29
31
|
import {toErrorMessage} from "@/util/error/to-error-message";
|
|
@@ -75,33 +77,25 @@ const main = async () => {
|
|
|
75
77
|
|
|
76
78
|
const siteConfig = parsedConfig.data;
|
|
77
79
|
// 내장 @font-face와 파일 수집 · font-family는 token.css 소유
|
|
78
|
-
const sans = toFontCss({css: join(kitRoot, font_sans_css), fontDir: asset_font_dir});
|
|
79
80
|
const mono = toFontCss({css: join(kitRoot, font_mono_css), fontDir: asset_font_dir});
|
|
80
81
|
const brand = toFontCss({css: join(kitRoot, font_brand_css), fontDir: asset_font_dir});
|
|
81
|
-
const sansPreload = [...sans.files].find((entry) => basename(entry[1]) === font_sans_preload_file);
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
throw new Error(`${copy_error_font_preload}: ${font_sans_preload_file}`);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const fontCss = [sans.css, mono.css, brand.css].join("\n");
|
|
83
|
+
const fontCss = [mono.css, brand.css].join("\n");
|
|
88
84
|
const stylePath = toAssetPath({path: asset_style_path, file: join(kitRoot, "dist/cli.css")});
|
|
89
85
|
const browserScript = readFileSync(join(kitRoot, "dist/browser.js"), "utf8");
|
|
90
|
-
const kitFiles = new Map([
|
|
91
|
-
[stylePath, join(kitRoot, "dist/cli.css")],
|
|
92
|
-
[asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")],
|
|
93
|
-
...sans.files,
|
|
94
|
-
...mono.files,
|
|
95
|
-
...brand.files,
|
|
96
|
-
]);
|
|
86
|
+
const kitFiles = new Map([[stylePath, join(kitRoot, "dist/cli.css")], [asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")], ...mono.files, ...brand.files]);
|
|
97
87
|
const files = new Map(kitFiles);
|
|
98
88
|
const processor = createProcessor({site: siteConfig, root: docsRoot, files});
|
|
99
|
-
const [docs, home] = await Promise.all([
|
|
89
|
+
const [docs, home] = await Promise.all([
|
|
90
|
+
readDocs({root: docsRoot, processor, reserved: siteConfig.url === undefined ? undefined : crawling_reserved_roots}),
|
|
91
|
+
readHome({root: docsRoot, processor}),
|
|
92
|
+
]);
|
|
100
93
|
const store = {docs, home};
|
|
94
|
+
const crawling = siteConfig.url === undefined ? undefined : toCrawlingApp({url: siteConfig.url, store});
|
|
101
95
|
const app = createApp({
|
|
102
96
|
site: siteConfig,
|
|
103
97
|
store,
|
|
104
|
-
assets: {stylePath, browserScript, fontCss, preload: [...brand.files.keys(),
|
|
98
|
+
assets: {stylePath, browserScript, fontCss, preload: [...brand.files.keys(), ...mono.files.keys()], reload: command === "dev"},
|
|
105
99
|
});
|
|
106
100
|
|
|
107
101
|
if (command === "build") {
|
|
@@ -114,12 +108,20 @@ const main = async () => {
|
|
|
114
108
|
throw result.error;
|
|
115
109
|
}
|
|
116
110
|
|
|
111
|
+
const crawlerResult = crawling === undefined ? undefined : await toSSG(crawling, {writeFile, mkdir}, {dir: outDir});
|
|
112
|
+
|
|
113
|
+
if (crawlerResult?.success === false) {
|
|
114
|
+
throw crawlerResult.error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
117
|
for (const [url, file] of files) {
|
|
118
118
|
await mkdir(dirname(join(outDir, url)), {recursive: true});
|
|
119
119
|
await copyFile(file, join(outDir, url));
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
console.log(
|
|
122
|
+
console.log(
|
|
123
|
+
`${crawlerResult === undefined ? result.files.length : result.files.length + crawlerResult.files.length} files, ${files.size} assets → ${relative(process.cwd(), outDir)}`,
|
|
124
|
+
);
|
|
123
125
|
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
@@ -127,7 +129,7 @@ const main = async () => {
|
|
|
127
129
|
const reload = new EventEmitter();
|
|
128
130
|
let generation = 0;
|
|
129
131
|
|
|
130
|
-
serve({fetch: createDevApp({pages: app, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
|
|
132
|
+
serve({fetch: createDevApp({pages: app, crawling, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
|
|
131
133
|
|
|
132
134
|
/**
|
|
133
135
|
* 갱신은 하나씩 실행 · 연속 저장 합치기 · 최신 변경만 적용
|
|
@@ -139,7 +141,10 @@ const main = async () => {
|
|
|
139
141
|
const currentGeneration = generation;
|
|
140
142
|
const nextFiles = new Map(kitFiles);
|
|
141
143
|
const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles});
|
|
142
|
-
const [docs, home] = await Promise.all([
|
|
144
|
+
const [docs, home] = await Promise.all([
|
|
145
|
+
readDocs({root: docsRoot, processor: nextProcessor, reserved: siteConfig.url === undefined ? undefined : crawling_reserved_roots}),
|
|
146
|
+
readHome({root: docsRoot, processor: nextProcessor}),
|
|
147
|
+
]);
|
|
143
148
|
|
|
144
149
|
if (currentGeneration !== generation) {
|
|
145
150
|
return;
|
|
@@ -1,35 +1,25 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import {test} from "node:test";
|
|
3
3
|
import {createNavigationStores} from "@/store/navigation/create-navigation-stores";
|
|
4
|
-
import {navigation_scroll_storage_key
|
|
4
|
+
import {navigation_scroll_storage_key} from "@/store/navigation/navigation-storage";
|
|
5
5
|
|
|
6
6
|
const fixture = () => {
|
|
7
|
-
const local = new Map<string, string>();
|
|
8
7
|
const session = new Map<string, string>();
|
|
9
|
-
const storage =
|
|
8
|
+
const storage = {
|
|
10
9
|
getItem: (key: string) => {
|
|
11
|
-
const value =
|
|
10
|
+
const value = session.get(key);
|
|
12
11
|
return value === undefined ? null : value;
|
|
13
12
|
},
|
|
14
13
|
setItem: (key: string, value: string) => {
|
|
15
|
-
|
|
14
|
+
session.set(key, value);
|
|
16
15
|
},
|
|
17
16
|
removeItem: (key: string) => {
|
|
18
|
-
|
|
17
|
+
session.delete(key);
|
|
19
18
|
},
|
|
20
|
-
}
|
|
21
|
-
return {
|
|
19
|
+
};
|
|
20
|
+
return {session, options: {session: () => storage}};
|
|
22
21
|
};
|
|
23
22
|
|
|
24
|
-
test("starts with every branch expanded and persists explicit choices across pages", () => {
|
|
25
|
-
const setup = fixture();
|
|
26
|
-
const stores = createNavigationStores(setup.options);
|
|
27
|
-
assert.deepEqual(stores.tree.getState(), {collapsed: {}});
|
|
28
|
-
stores.tree.setState({collapsed: {'group:["Projects"]': true, "outline:one:heading:setup": false}});
|
|
29
|
-
assert.deepEqual(createNavigationStores(setup.options).tree.getState(), stores.tree.getState());
|
|
30
|
-
assert.equal(setup.session.has(navigation_tree_storage_key), false);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
23
|
test("retains independent wide, desktop and drawer scroll positions within a tab", () => {
|
|
34
24
|
const setup = fixture();
|
|
35
25
|
const stores = createNavigationStores(setup.options);
|
|
@@ -41,23 +31,19 @@ test("retains independent wide, desktop and drawer scroll positions within a tab
|
|
|
41
31
|
},
|
|
42
32
|
});
|
|
43
33
|
assert.deepEqual(createNavigationStores(setup.options).scroll.getState(), stores.scroll.getState());
|
|
44
|
-
assert.equal(setup.local.has(navigation_scroll_storage_key), false);
|
|
45
34
|
});
|
|
46
35
|
|
|
47
36
|
test("rejects malformed and unknown-version persistence without losing defaults", () => {
|
|
48
|
-
for (const value of ["{", "null", '{"state":null,"version":1}', '{"state":{"
|
|
37
|
+
for (const value of ["{", "null", '{"state":null,"version":1}', '{"state":{"positions":{}},"version":999}']) {
|
|
49
38
|
const setup = fixture();
|
|
50
|
-
setup.local.set(navigation_tree_storage_key, value);
|
|
51
39
|
setup.session.set(navigation_scroll_storage_key, value);
|
|
52
40
|
const stores = createNavigationStores(setup.options);
|
|
53
|
-
assert.deepEqual(stores.tree.getState(), {collapsed: {}});
|
|
54
41
|
assert.deepEqual(stores.scroll.getState(), {positions: {}});
|
|
55
42
|
}
|
|
56
43
|
});
|
|
57
44
|
|
|
58
|
-
test("
|
|
45
|
+
test("rejects invalid scroll coordinates", () => {
|
|
59
46
|
const setup = fixture();
|
|
60
|
-
setup.local.set(navigation_tree_storage_key, JSON.stringify({version: 1, state: {collapsed: {good: true, bad: "true"}, unexpected: true}}));
|
|
61
47
|
setup.session.set(
|
|
62
48
|
navigation_scroll_storage_key,
|
|
63
49
|
JSON.stringify({
|
|
@@ -72,7 +58,6 @@ test("narrows persisted branch flags and rejects invalid scroll coordinates", ()
|
|
|
72
58
|
}),
|
|
73
59
|
);
|
|
74
60
|
const stores = createNavigationStores(setup.options);
|
|
75
|
-
assert.deepEqual(stores.tree.getState(), {collapsed: {good: true}});
|
|
76
61
|
assert.deepEqual(stores.scroll.getState().positions.wide, {navigation: "docs", pathname: "/one/", documents: 100, outline: 20});
|
|
77
62
|
assert.equal(stores.scroll.getState().positions.desktop, undefined);
|
|
78
63
|
assert.equal(stores.scroll.getState().positions.drawer, undefined);
|
|
@@ -82,10 +67,7 @@ test("keeps in-memory interaction working when storage reads and writes are bloc
|
|
|
82
67
|
const blocked = () => {
|
|
83
68
|
throw new Error("Storage blocked");
|
|
84
69
|
};
|
|
85
|
-
const stores = createNavigationStores({
|
|
86
|
-
stores.tree.setState({collapsed: {"document:one": true}});
|
|
87
|
-
stores.tree.persist.rehydrate();
|
|
88
|
-
assert.equal(stores.tree.getState().collapsed["document:one"], true);
|
|
70
|
+
const stores = createNavigationStores({session: blocked});
|
|
89
71
|
stores.scroll.setState({positions: {desktop: {navigation: "docs", pathname: "/", documents: 100, outline: 200}}});
|
|
90
72
|
stores.scroll.persist.rehydrate();
|
|
91
73
|
assert.equal(stores.scroll.getState().positions.desktop?.documents, 100);
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
import {createJSONStorage, persist, type StateStorage} from "zustand/middleware";
|
|
2
2
|
import {createStore} from "zustand/vanilla";
|
|
3
|
-
import type {NavigationScrollState
|
|
4
|
-
import {navigation_scroll_storage_key, navigation_storage_version
|
|
3
|
+
import type {NavigationScrollState} from "@/store/navigation/navigation-state";
|
|
4
|
+
import {navigation_scroll_storage_key, navigation_storage_version} from "@/store/navigation/navigation-storage";
|
|
5
5
|
import {toNavigationScrollState} from "@/store/navigation/to-navigation-scroll-state";
|
|
6
|
-
import {toNavigationTreeState} from "@/store/navigation/to-navigation-tree-state";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* 브라우저 저장소를 늦게 읽는 경계 · 차단 환경과 테스트의 메모리 저장소 지원
|
|
10
9
|
*/
|
|
11
10
|
interface NavigationStorage {
|
|
12
|
-
/**
|
|
13
|
-
* 브라우저 재실행 후에도 유지할 접힘 선택
|
|
14
|
-
*/
|
|
15
|
-
local: () => StateStorage;
|
|
16
11
|
/**
|
|
17
12
|
* 같은 탭 안에서만 복원할 스크롤 위치
|
|
18
13
|
*/
|
|
@@ -25,9 +20,9 @@ interface NavigationStorage {
|
|
|
25
20
|
*/
|
|
26
21
|
export const createNavigationStores = (options: NavigationStorage) => {
|
|
27
22
|
/**
|
|
28
|
-
* 저장소 접근이 막혀도 persist 갱신이
|
|
23
|
+
* 저장소 접근이 막혀도 persist 갱신이 스크롤 동작을 중단하지 않도록 격리
|
|
29
24
|
*/
|
|
30
|
-
const toSafeStorage = (getStorage: NavigationStorage["
|
|
25
|
+
const toSafeStorage = (getStorage: NavigationStorage["session"]): StateStorage => {
|
|
31
26
|
return {
|
|
32
27
|
getItem: (key) => {
|
|
33
28
|
// persist의 읽기 오류 경로로 보내 기존 메모리 상태를 유지 · null은 실제 저장소 초기화에만 사용
|
|
@@ -51,15 +46,6 @@ export const createNavigationStores = (options: NavigationStorage) => {
|
|
|
51
46
|
};
|
|
52
47
|
|
|
53
48
|
return {
|
|
54
|
-
tree: createStore<NavigationTreeState>()(
|
|
55
|
-
persist(() => ({collapsed: {}}), {
|
|
56
|
-
name: navigation_tree_storage_key,
|
|
57
|
-
version: navigation_storage_version,
|
|
58
|
-
storage: createJSONStorage(() => toSafeStorage(options.local)),
|
|
59
|
-
merge: (persisted) => toNavigationTreeState(persisted),
|
|
60
|
-
migrate: () => ({collapsed: {}}),
|
|
61
|
-
}),
|
|
62
|
-
),
|
|
63
49
|
scroll: createStore<NavigationScrollState>()(
|
|
64
50
|
persist(() => ({positions: {}}), {
|
|
65
51
|
name: navigation_scroll_storage_key,
|
|
@@ -3,16 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export type NavigationLayout = "wide" | "desktop" | "drawer";
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* 사용자가 명시적으로 바꾼 가지 선택 · 없는 키는 펼침
|
|
8
|
-
*/
|
|
9
|
-
export interface NavigationTreeState {
|
|
10
|
-
/**
|
|
11
|
-
* 문서 ID·그룹 전체 경로·페이지별 heading으로 구분한 접힘 상태
|
|
12
|
-
*/
|
|
13
|
-
collapsed: Record<string, boolean>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
6
|
/**
|
|
17
7
|
* 한 화면 배치의 마지막 탐색 위치 · 목록 변경과 다른 페이지의 목차는 재사용하지 않음
|
|
18
8
|
*/
|