for-humanity 0.4.4 → 0.6.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 +21 -0
- package/README.md +6 -5
- package/dist/browser.js +39 -0
- package/dist/cli.css +350 -262
- package/dist/cli.js +1174 -364
- package/package.json +8 -11
- package/src/component/widget/{shell → cursor-face}/_constant/cursor-face.ts +5 -0
- package/src/component/widget/cursor-face/wg-cursor-face.css +15 -0
- package/src/component/widget/cursor-face/wg-cursor-face.tsx +153 -0
- package/src/component/widget/navigation/_constant/navigation-data-schema.ts +35 -0
- package/src/component/widget/navigation/_constant/navigation.ts +9 -0
- package/src/component/widget/navigation/_function/restore-navigation-scroll.ts +59 -0
- package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-branches.ts +2 -2
- package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-group-branches.ts +3 -3
- package/src/{content → component/widget/navigation/_function}/to-doc-groups/to-doc-groups.ts +1 -1
- package/src/component/widget/navigation/_function/to-navigation-branch-id.ts +6 -0
- package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-document.ts +14 -0
- package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-group.ts +14 -0
- package/src/component/widget/navigation/_function/to-navigation-data/to-navigation-data.ts +25 -0
- package/src/component/widget/navigation/_function/to-navigation-json.ts +8 -0
- package/src/component/widget/{shell → navigation}/_function/to-toc-groups.ts +2 -2
- package/src/{type → component/widget/navigation/_type}/doc-group.ts +1 -1
- package/src/component/widget/navigation/_type/navigation-data.ts +83 -0
- package/src/component/widget/navigation/_type/navigation-focus-snapshot.ts +21 -0
- package/src/component/widget/navigation/_wg-navigation-branch-toggle.css +28 -0
- package/src/component/widget/navigation/_wg-navigation-branch-toggle.tsx +48 -0
- package/src/component/widget/{shell/_wg-shell-nav-list.css → navigation/_wg-navigation-nav-list.css} +24 -16
- package/src/component/widget/navigation/_wg-navigation-nav-list.tsx +106 -0
- package/src/component/widget/{shell/_wg-shell-nav.css → navigation/_wg-navigation-nav.css} +9 -3
- package/src/component/widget/navigation/_wg-navigation-nav.tsx +43 -0
- package/src/component/widget/navigation/_wg-navigation-scroll.tsx +117 -0
- package/src/component/widget/{shell/_wg-shell-toc.css → navigation/_wg-navigation-toc.css} +39 -24
- package/src/component/widget/navigation/_wg-navigation-toc.tsx +155 -0
- package/src/component/widget/navigation/wg-navigation.css +282 -0
- package/src/component/widget/navigation/wg-navigation.tsx +479 -0
- package/src/component/widget/prose/_function/rehype-sections/rehype-sections.ts +1 -1
- package/src/component/widget/shell/wg-shell.css +13 -269
- package/src/component/widget/shell/wg-shell.tsx +16 -97
- package/src/component/widget/shell-controls/wg-shell-controls.css +4 -0
- package/src/component/widget/shell-controls/wg-shell-controls.tsx +170 -0
- package/src/constant/asset.ts +0 -5
- package/src/constant/copy.ts +5 -0
- package/src/entry/browser/browser.tsx +22 -0
- package/src/entry/cli/_constant/crawling.ts +14 -0
- package/src/{app.tsx → entry/cli/_function/create-app.tsx} +6 -6
- package/src/{dev.ts → entry/cli/_function/create-dev-app.ts} +9 -1
- package/src/{content → entry/cli/_function/create-processor}/create-processor.ts +3 -3
- package/src/{content → entry/cli/_function}/read-docs.ts +21 -2
- package/src/{content → entry/cli/_function}/read-home.ts +1 -1
- package/src/entry/cli/_function/to-crawling-app.ts +42 -0
- package/src/{content/read-docs.test.ts → entry/cli/cli.test.ts} +211 -31
- package/src/{cli.ts → entry/cli/cli.ts} +31 -15
- package/src/store/navigation/_constant/tree.ts +6 -0
- package/src/store/navigation/create-navigation-stores.test.ts +92 -0
- package/src/store/navigation/create-navigation-stores.ts +73 -0
- package/src/store/navigation/navigation-state.ts +46 -0
- package/src/store/navigation/navigation-storage.ts +14 -0
- package/src/store/navigation/to-navigation-scroll-state.ts +42 -0
- package/src/store/navigation/to-navigation-tree-state.ts +19 -0
- package/src/store/navigation/use-navigation-tree-store.ts +12 -0
- package/src/style/token.css +3 -2
- package/src/type/doc-content.ts +1 -1
- package/src/type/doc-file-data.ts +1 -1
- package/src/{component/widget/shell/_type → type}/doc-outline.ts +1 -1
- package/src/type/site-assets.ts +2 -2
- package/src/type/site-config.ts +10 -1
- package/dist/client.js +0 -7
- package/src/client.ts +0 -335
- package/src/component/widget/shell/_constant/navigation.ts +0 -9
- package/src/component/widget/shell/_function/bind-mobile-navigation.ts +0 -147
- package/src/component/widget/shell/_function/bind-navigation-overflow.ts +0 -47
- package/src/component/widget/shell/_function/bind-navigation-scroll.ts +0 -84
- package/src/component/widget/shell/_function/restore-navigation-scroll.ts +0 -47
- package/src/component/widget/shell/_wg-shell-nav-list.tsx +0 -87
- package/src/component/widget/shell/_wg-shell-nav.tsx +0 -49
- package/src/component/widget/shell/_wg-shell-toc.tsx +0 -57
- /package/src/{constant → component/widget/navigation/_constant}/locale.ts +0 -0
- /package/src/component/widget/{shell → navigation}/_constant/reading-line.ts +0 -0
- /package/src/{type → component/widget/navigation/_type}/doc-branch.ts +0 -0
- /package/src/component/widget/{shell → navigation}/_type/toc-group.ts +0 -0
- /package/src/{constant → entry/cli/_constant}/cli.ts +0 -0
- /package/src/{content/rehype-assets.ts → entry/cli/_function/create-processor/_rehype-assets.ts} +0 -0
- /package/src/{content/rehype-heading-ids.ts → entry/cli/_function/create-processor/_rehype-heading-ids.ts} +0 -0
- /package/src/{content/remark-report.ts → entry/cli/_function/create-processor/_remark-report.ts} +0 -0
- /package/src/{component/widget/prose/_type → type}/doc-section.ts +0 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {EventEmitter} from "node:events";
|
|
8
|
-
import {existsSync, watch} from "node:fs";
|
|
8
|
+
import {existsSync, readFileSync, watch} from "node:fs";
|
|
9
9
|
import {copyFile, mkdir, rm, writeFile} from "node:fs/promises";
|
|
10
10
|
import {basename, dirname, extname, join, relative, resolve, sep} from "node:path";
|
|
11
11
|
import {fileURLToPath, pathToFileURL} from "node:url";
|
|
@@ -14,16 +14,18 @@ import {serveStatic} from "@hono/node-server/serve-static";
|
|
|
14
14
|
import {debounce} from "es-toolkit";
|
|
15
15
|
import {Hono} from "hono";
|
|
16
16
|
import {toSSG} from "hono/ssg";
|
|
17
|
-
import {
|
|
18
|
-
import {asset_client_path, asset_favicon_path, asset_font_dir, asset_media_extensions, asset_style_path} from "@/constant/asset";
|
|
19
|
-
import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port, cli_reload_delay_ms} from "@/constant/cli";
|
|
17
|
+
import {asset_favicon_path, asset_font_dir, asset_media_extensions, asset_style_path} from "@/constant/asset";
|
|
20
18
|
import {copy_error_config, copy_error_font_preload, copy_error_prefix, copy_error_unknown_command} from "@/constant/copy";
|
|
21
19
|
import {font_brand_css, font_cache_control, font_mono_css, font_sans_css, font_sans_preload_file} from "@/constant/font";
|
|
22
20
|
import {site_config_absent} from "@/constant/site";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {createDevApp} from "@/dev";
|
|
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";
|
|
23
|
+
import {createApp} from "@/entry/cli/_function/create-app";
|
|
24
|
+
import {createDevApp} from "@/entry/cli/_function/create-dev-app";
|
|
25
|
+
import {createProcessor} from "@/entry/cli/_function/create-processor/create-processor";
|
|
26
|
+
import {readDocs} from "@/entry/cli/_function/read-docs";
|
|
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";
|
|
@@ -86,10 +88,9 @@ const main = async () => {
|
|
|
86
88
|
|
|
87
89
|
const fontCss = [sans.css, mono.css, brand.css].join("\n");
|
|
88
90
|
const stylePath = toAssetPath({path: asset_style_path, file: join(kitRoot, "dist/cli.css")});
|
|
89
|
-
const
|
|
91
|
+
const browserScript = readFileSync(join(kitRoot, "dist/browser.js"), "utf8");
|
|
90
92
|
const kitFiles = new Map([
|
|
91
93
|
[stylePath, join(kitRoot, "dist/cli.css")],
|
|
92
|
-
[clientPath, join(kitRoot, "dist/client.js")],
|
|
93
94
|
[asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")],
|
|
94
95
|
...sans.files,
|
|
95
96
|
...mono.files,
|
|
@@ -97,12 +98,16 @@ const main = async () => {
|
|
|
97
98
|
]);
|
|
98
99
|
const files = new Map(kitFiles);
|
|
99
100
|
const processor = createProcessor({site: siteConfig, root: docsRoot, files});
|
|
100
|
-
const [docs, home] = await Promise.all([
|
|
101
|
+
const [docs, home] = await Promise.all([
|
|
102
|
+
readDocs({root: docsRoot, processor, reserved: siteConfig.url === undefined ? undefined : crawling_reserved_roots}),
|
|
103
|
+
readHome({root: docsRoot, processor}),
|
|
104
|
+
]);
|
|
101
105
|
const store = {docs, home};
|
|
106
|
+
const crawling = siteConfig.url === undefined ? undefined : toCrawlingApp({url: siteConfig.url, store});
|
|
102
107
|
const app = createApp({
|
|
103
108
|
site: siteConfig,
|
|
104
109
|
store,
|
|
105
|
-
assets: {stylePath,
|
|
110
|
+
assets: {stylePath, browserScript, fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev"},
|
|
106
111
|
});
|
|
107
112
|
|
|
108
113
|
if (command === "build") {
|
|
@@ -115,12 +120,20 @@ const main = async () => {
|
|
|
115
120
|
throw result.error;
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
const crawlerResult = crawling === undefined ? undefined : await toSSG(crawling, {writeFile, mkdir}, {dir: outDir});
|
|
124
|
+
|
|
125
|
+
if (crawlerResult?.success === false) {
|
|
126
|
+
throw crawlerResult.error;
|
|
127
|
+
}
|
|
128
|
+
|
|
118
129
|
for (const [url, file] of files) {
|
|
119
130
|
await mkdir(dirname(join(outDir, url)), {recursive: true});
|
|
120
131
|
await copyFile(file, join(outDir, url));
|
|
121
132
|
}
|
|
122
133
|
|
|
123
|
-
console.log(
|
|
134
|
+
console.log(
|
|
135
|
+
`${crawlerResult === undefined ? result.files.length : result.files.length + crawlerResult.files.length} files, ${files.size} assets → ${relative(process.cwd(), outDir)}`,
|
|
136
|
+
);
|
|
124
137
|
|
|
125
138
|
return;
|
|
126
139
|
}
|
|
@@ -128,7 +141,7 @@ const main = async () => {
|
|
|
128
141
|
const reload = new EventEmitter();
|
|
129
142
|
let generation = 0;
|
|
130
143
|
|
|
131
|
-
serve({fetch: createDevApp({pages: app, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
|
|
144
|
+
serve({fetch: createDevApp({pages: app, crawling, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
|
|
132
145
|
|
|
133
146
|
/**
|
|
134
147
|
* 갱신은 하나씩 실행 · 연속 저장 합치기 · 최신 변경만 적용
|
|
@@ -140,7 +153,10 @@ const main = async () => {
|
|
|
140
153
|
const currentGeneration = generation;
|
|
141
154
|
const nextFiles = new Map(kitFiles);
|
|
142
155
|
const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles});
|
|
143
|
-
const [docs, home] = await Promise.all([
|
|
156
|
+
const [docs, home] = await Promise.all([
|
|
157
|
+
readDocs({root: docsRoot, processor: nextProcessor, reserved: siteConfig.url === undefined ? undefined : crawling_reserved_roots}),
|
|
158
|
+
readHome({root: docsRoot, processor: nextProcessor}),
|
|
159
|
+
]);
|
|
144
160
|
|
|
145
161
|
if (currentGeneration !== generation) {
|
|
146
162
|
return;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {test} from "node:test";
|
|
3
|
+
import {createNavigationStores} from "@/store/navigation/create-navigation-stores";
|
|
4
|
+
import {navigation_scroll_storage_key, navigation_tree_storage_key} from "@/store/navigation/navigation-storage";
|
|
5
|
+
|
|
6
|
+
const fixture = () => {
|
|
7
|
+
const local = new Map<string, string>();
|
|
8
|
+
const session = new Map<string, string>();
|
|
9
|
+
const storage = (values: Map<string, string>) => ({
|
|
10
|
+
getItem: (key: string) => {
|
|
11
|
+
const value = values.get(key);
|
|
12
|
+
return value === undefined ? null : value;
|
|
13
|
+
},
|
|
14
|
+
setItem: (key: string, value: string) => {
|
|
15
|
+
values.set(key, value);
|
|
16
|
+
},
|
|
17
|
+
removeItem: (key: string) => {
|
|
18
|
+
values.delete(key);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
return {local, session, options: {local: () => storage(local), session: () => storage(session)}};
|
|
22
|
+
};
|
|
23
|
+
|
|
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
|
+
test("retains independent wide, desktop and drawer scroll positions within a tab", () => {
|
|
34
|
+
const setup = fixture();
|
|
35
|
+
const stores = createNavigationStores(setup.options);
|
|
36
|
+
stores.scroll.setState({
|
|
37
|
+
positions: {
|
|
38
|
+
wide: {navigation: "docs", pathname: "/one/", documents: 450, outline: 300},
|
|
39
|
+
desktop: {navigation: "docs", pathname: "/two/", documents: 750, outline: 100},
|
|
40
|
+
drawer: {navigation: "docs", pathname: "/one/", documents: 200, outline: 800},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
assert.deepEqual(createNavigationStores(setup.options).scroll.getState(), stores.scroll.getState());
|
|
44
|
+
assert.equal(setup.local.has(navigation_scroll_storage_key), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("rejects malformed and unknown-version persistence without losing defaults", () => {
|
|
48
|
+
for (const value of ["{", "null", '{"state":null,"version":1}', '{"state":{"collapsed":{"group:a":true}},"version":999}']) {
|
|
49
|
+
const setup = fixture();
|
|
50
|
+
setup.local.set(navigation_tree_storage_key, value);
|
|
51
|
+
setup.session.set(navigation_scroll_storage_key, value);
|
|
52
|
+
const stores = createNavigationStores(setup.options);
|
|
53
|
+
assert.deepEqual(stores.tree.getState(), {collapsed: {}});
|
|
54
|
+
assert.deepEqual(stores.scroll.getState(), {positions: {}});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("narrows persisted branch flags and rejects invalid scroll coordinates", () => {
|
|
59
|
+
const setup = fixture();
|
|
60
|
+
setup.local.set(navigation_tree_storage_key, JSON.stringify({version: 1, state: {collapsed: {good: true, bad: "true"}, unexpected: true}}));
|
|
61
|
+
setup.session.set(
|
|
62
|
+
navigation_scroll_storage_key,
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
version: 1,
|
|
65
|
+
state: {
|
|
66
|
+
positions: {
|
|
67
|
+
desktop: {navigation: "docs", pathname: "/one/", documents: -1, outline: 0},
|
|
68
|
+
wide: {navigation: "docs", pathname: "/one/", documents: 100, outline: 20},
|
|
69
|
+
drawer: {navigation: "docs", pathname: "/one/", documents: 100, outline: "20"},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
const stores = createNavigationStores(setup.options);
|
|
75
|
+
assert.deepEqual(stores.tree.getState(), {collapsed: {good: true}});
|
|
76
|
+
assert.deepEqual(stores.scroll.getState().positions.wide, {navigation: "docs", pathname: "/one/", documents: 100, outline: 20});
|
|
77
|
+
assert.equal(stores.scroll.getState().positions.desktop, undefined);
|
|
78
|
+
assert.equal(stores.scroll.getState().positions.drawer, undefined);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("keeps in-memory interaction working when storage reads and writes are blocked", () => {
|
|
82
|
+
const blocked = () => {
|
|
83
|
+
throw new Error("Storage blocked");
|
|
84
|
+
};
|
|
85
|
+
const stores = createNavigationStores({local: blocked, session: blocked});
|
|
86
|
+
stores.tree.setState({collapsed: {"document:one": true}});
|
|
87
|
+
stores.tree.persist.rehydrate();
|
|
88
|
+
assert.equal(stores.tree.getState().collapsed["document:one"], true);
|
|
89
|
+
stores.scroll.setState({positions: {desktop: {navigation: "docs", pathname: "/", documents: 100, outline: 200}}});
|
|
90
|
+
stores.scroll.persist.rehydrate();
|
|
91
|
+
assert.equal(stores.scroll.getState().positions.desktop?.documents, 100);
|
|
92
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {createJSONStorage, persist, type StateStorage} from "zustand/middleware";
|
|
2
|
+
import {createStore} from "zustand/vanilla";
|
|
3
|
+
import type {NavigationScrollState, NavigationTreeState} from "@/store/navigation/navigation-state";
|
|
4
|
+
import {navigation_scroll_storage_key, navigation_storage_version, navigation_tree_storage_key} from "@/store/navigation/navigation-storage";
|
|
5
|
+
import {toNavigationScrollState} from "@/store/navigation/to-navigation-scroll-state";
|
|
6
|
+
import {toNavigationTreeState} from "@/store/navigation/to-navigation-tree-state";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 브라우저 저장소를 늦게 읽는 경계 · 차단 환경과 테스트의 메모리 저장소 지원
|
|
10
|
+
*/
|
|
11
|
+
interface NavigationStorage {
|
|
12
|
+
/**
|
|
13
|
+
* 브라우저 재실행 후에도 유지할 접힘 선택
|
|
14
|
+
*/
|
|
15
|
+
local: () => StateStorage;
|
|
16
|
+
/**
|
|
17
|
+
* 같은 탭 안에서만 복원할 스크롤 위치
|
|
18
|
+
*/
|
|
19
|
+
session: () => StateStorage;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 브라우저 탐색의 vanilla 스토어 생성기 · Hono Hook이 아닌 생성기이므로 use- 접두사를 붙이지 않음
|
|
24
|
+
* 서버 요청은 이 생성기를 실행하지 않으며 저장 실패 시 메모리 상태 유지
|
|
25
|
+
*/
|
|
26
|
+
export const createNavigationStores = (options: NavigationStorage) => {
|
|
27
|
+
/**
|
|
28
|
+
* 저장소 접근이 막혀도 persist 갱신이 버튼·스크롤 동작을 중단하지 않도록 격리
|
|
29
|
+
*/
|
|
30
|
+
const toSafeStorage = (getStorage: NavigationStorage["local"]): StateStorage => {
|
|
31
|
+
return {
|
|
32
|
+
getItem: (key) => {
|
|
33
|
+
// persist의 읽기 오류 경로로 보내 기존 메모리 상태를 유지 · null은 실제 저장소 초기화에만 사용
|
|
34
|
+
return getStorage().getItem(key);
|
|
35
|
+
},
|
|
36
|
+
setItem: (key, value) => {
|
|
37
|
+
try {
|
|
38
|
+
return getStorage().setItem(key, value);
|
|
39
|
+
} catch {
|
|
40
|
+
/* 저장 실패 시에도 스토어의 메모리 상태는 유지 */
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
removeItem: (key) => {
|
|
44
|
+
try {
|
|
45
|
+
return getStorage().removeItem(key);
|
|
46
|
+
} catch {
|
|
47
|
+
/* 저장소 차단이 탐색을 막지 않음 */
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
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
|
+
scroll: createStore<NavigationScrollState>()(
|
|
64
|
+
persist(() => ({positions: {}}), {
|
|
65
|
+
name: navigation_scroll_storage_key,
|
|
66
|
+
version: navigation_storage_version,
|
|
67
|
+
storage: createJSONStorage(() => toSafeStorage(options.session)),
|
|
68
|
+
merge: (persisted) => toNavigationScrollState(persisted),
|
|
69
|
+
migrate: () => ({positions: {}}),
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 스크롤 영역의 배치 · 넓은 3열, 좁은 2열, 모바일 모달
|
|
3
|
+
*/
|
|
4
|
+
export type NavigationLayout = "wide" | "desktop" | "drawer";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 사용자가 명시적으로 바꾼 가지 선택 · 없는 키는 펼침
|
|
8
|
+
*/
|
|
9
|
+
export interface NavigationTreeState {
|
|
10
|
+
/**
|
|
11
|
+
* 문서 ID·그룹 전체 경로·페이지별 heading으로 구분한 접힘 상태
|
|
12
|
+
*/
|
|
13
|
+
collapsed: Record<string, boolean>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 한 화면 배치의 마지막 탐색 위치 · 목록 변경과 다른 페이지의 목차는 재사용하지 않음
|
|
18
|
+
*/
|
|
19
|
+
export interface NavigationPosition {
|
|
20
|
+
/**
|
|
21
|
+
* 현재 사이트의 공통 문서 URL·제목 목록
|
|
22
|
+
*/
|
|
23
|
+
navigation: string;
|
|
24
|
+
/**
|
|
25
|
+
* 목차가 속한 페이지
|
|
26
|
+
*/
|
|
27
|
+
pathname: string;
|
|
28
|
+
/**
|
|
29
|
+
* 공통 문서 목록의 세로 위치
|
|
30
|
+
*/
|
|
31
|
+
documents: number;
|
|
32
|
+
/**
|
|
33
|
+
* 현재 페이지 목차의 세로 위치
|
|
34
|
+
*/
|
|
35
|
+
outline: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 같은 탭 안에서 유지하는 배치별 탐색 위치
|
|
40
|
+
*/
|
|
41
|
+
export interface NavigationScrollState {
|
|
42
|
+
/**
|
|
43
|
+
* 아직 열지 않은 배치의 위치는 저장하지 않음
|
|
44
|
+
*/
|
|
45
|
+
positions: Partial<Record<NavigationLayout, NavigationPosition>>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 문서·현재 페이지 목차의 접힘 선택 · 기본은 전체 펼침
|
|
3
|
+
*/
|
|
4
|
+
export const navigation_tree_storage_key = "for-humanity:navigation-tree";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 저장 형태가 바뀌면 기존 자료를 기본 상태로 돌리는 persist 버전
|
|
8
|
+
*/
|
|
9
|
+
export const navigation_storage_version = 1;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 같은 탭에서 문서 이동 후 탐색 위치 복원 · 데스크톱과 드로어는 별도 저장
|
|
13
|
+
*/
|
|
14
|
+
export const navigation_scroll_storage_key = "for-humanity:navigation-scroll";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type {NavigationPosition, NavigationScrollState} from "@/store/navigation/navigation-state";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 알 수 없는 저장 형태·음수·무한 좌표 제외 · 초기 HTML과 Zustand에 같은 검증 적용
|
|
5
|
+
*/
|
|
6
|
+
export const toNavigationScrollState = (value: unknown): NavigationScrollState => {
|
|
7
|
+
/**
|
|
8
|
+
* 한 배치의 완전한 좌표만 복원 대상으로 수용
|
|
9
|
+
*/
|
|
10
|
+
const toPosition = (position: unknown): NavigationPosition | undefined => {
|
|
11
|
+
if (
|
|
12
|
+
typeof position !== "object" ||
|
|
13
|
+
position === null ||
|
|
14
|
+
!("navigation" in position) ||
|
|
15
|
+
typeof position.navigation !== "string" ||
|
|
16
|
+
!("pathname" in position) ||
|
|
17
|
+
typeof position.pathname !== "string" ||
|
|
18
|
+
!("documents" in position) ||
|
|
19
|
+
typeof position.documents !== "number" ||
|
|
20
|
+
!Number.isFinite(position.documents) ||
|
|
21
|
+
position.documents < 0 ||
|
|
22
|
+
!("outline" in position) ||
|
|
23
|
+
typeof position.outline !== "number" ||
|
|
24
|
+
!Number.isFinite(position.outline) ||
|
|
25
|
+
position.outline < 0
|
|
26
|
+
) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
return {navigation: position.navigation, pathname: position.pathname, documents: position.documents, outline: position.outline};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (typeof value !== "object" || value === null || !("positions" in value) || typeof value.positions !== "object" || value.positions === null) {
|
|
33
|
+
return {positions: {}};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const positions = {
|
|
37
|
+
wide: toPosition("wide" in value.positions ? value.positions.wide : undefined),
|
|
38
|
+
desktop: toPosition("desktop" in value.positions ? value.positions.desktop : undefined),
|
|
39
|
+
drawer: toPosition("drawer" in value.positions ? value.positions.drawer : undefined),
|
|
40
|
+
};
|
|
41
|
+
return {positions: Object.fromEntries(Object.entries(positions).filter((entry) => entry[1] !== undefined))};
|
|
42
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type {NavigationTreeState} from "@/store/navigation/navigation-state";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* persist 저장 자료에서 boolean 접힘 선택만 수용
|
|
5
|
+
*/
|
|
6
|
+
export const toNavigationTreeState = (value: unknown): NavigationTreeState => {
|
|
7
|
+
if (
|
|
8
|
+
typeof value !== "object" ||
|
|
9
|
+
value === null ||
|
|
10
|
+
!("collapsed" in value) ||
|
|
11
|
+
typeof value.collapsed !== "object" ||
|
|
12
|
+
value.collapsed === null ||
|
|
13
|
+
Array.isArray(value.collapsed)
|
|
14
|
+
) {
|
|
15
|
+
return {collapsed: {}};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {collapsed: Object.fromEntries(Object.entries(value.collapsed).filter((entry) => typeof entry[1] === "boolean"))};
|
|
19
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {useSyncExternalStore} from "hono/jsx";
|
|
2
|
+
import {tree_server_state} from "@/store/navigation/_constant/tree";
|
|
3
|
+
import type {createNavigationStores} from "@/store/navigation/create-navigation-stores";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hono 컴포넌트의 persist 구독 · 브라우저 최초 렌더도 저장된 값을 즉시 소비
|
|
7
|
+
*/
|
|
8
|
+
export const useNavigationTreeStore = (tree?: ReturnType<typeof createNavigationStores>["tree"]) => {
|
|
9
|
+
const subscribe = tree === undefined ? () => () => {} : tree.subscribe;
|
|
10
|
+
const getSnapshot = tree === undefined ? () => tree_server_state : tree.getState;
|
|
11
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
12
|
+
};
|
package/src/style/token.css
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* 공통 토큰의 단일 출처 · --app-종류-쓰임
|
|
3
3
|
* 사용 기준: templates/document/design.md · 테마 분기는 color-scheme에 한정
|
|
4
|
-
* 폭 기준 1024px·640px ·
|
|
4
|
+
* 폭 기준 1536px·1024px·640px · 양쪽 탐색과 본문 배치
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
:root {
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
--app-space-gutter: 28px;
|
|
103
103
|
--app-space-column: 32px;
|
|
104
104
|
--app-space-sidebar-gap: 32px;
|
|
105
|
-
--app-size-sidebar:
|
|
105
|
+
--app-size-sidebar: 240px;
|
|
106
106
|
--app-size-main: 900px;
|
|
107
107
|
--app-size-page: calc(var(--app-size-main) + (var(--app-size-sidebar) + var(--app-space-sidebar-gap) + var(--app-space-gutter)) * 2);
|
|
108
108
|
--app-size-brand: calc(var(--app-font-size-title) * var(--app-line-height-title) + var(--app-space-page-top) + var(--app-space-group));
|
|
@@ -110,6 +110,7 @@
|
|
|
110
110
|
--app-size-menu-button: 44px;
|
|
111
111
|
--app-size-drawer: 320px;
|
|
112
112
|
--app-size-navigation-fade: 28px;
|
|
113
|
+
--app-size-tree-toggle: 24px;
|
|
113
114
|
|
|
114
115
|
/* 화면 위쪽 제목 여백 · hash 도착점과 목차 읽는 선의 공통 기준 */
|
|
115
116
|
--app-space-anchor: 80px;
|
package/src/type/doc-content.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {DocSection} from "@/component/widget/prose/_type/doc-section";
|
|
2
1
|
import type {DocData} from "@/type/doc-data";
|
|
3
2
|
import type {DocHeading} from "@/type/doc-heading";
|
|
3
|
+
import type {DocSection} from "@/type/doc-section";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* 문서 처리 중간 계약 · read-docs의 머리말과 rehype의 제목·절 정보
|
package/src/type/site-assets.ts
CHANGED
package/src/type/site-config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {z} from "zod";
|
|
2
|
-
import {copy_error_navigation_clash, copy_site_title_default} from "@/constant/copy";
|
|
2
|
+
import {copy_error_navigation_clash, copy_error_site_url, copy_site_title_default} from "@/constant/copy";
|
|
3
3
|
import {site_navigation_default} from "@/constant/site";
|
|
4
4
|
import {status_default_phrases, status_kind} from "@/constant/status";
|
|
5
5
|
import {groupPathSchema} from "@/type/group-path";
|
|
@@ -17,6 +17,15 @@ export const siteConfigSchema = z.object({
|
|
|
17
17
|
* HTML 설명 메타데이터 · 선택
|
|
18
18
|
*/
|
|
19
19
|
description: z.string().optional(),
|
|
20
|
+
/**
|
|
21
|
+
* 공개 사이트 루트 URL · 지정할 때만 robots.txt와 sitemap.xml 생성
|
|
22
|
+
*/
|
|
23
|
+
url: z
|
|
24
|
+
.url({protocol: /^https?$/})
|
|
25
|
+
.transform((value) => new URL(value))
|
|
26
|
+
.refine((value) => value.username === "" && value.password === "" && value.pathname === "/" && value.search === "" && value.hash === "", copy_error_site_url)
|
|
27
|
+
.transform((value) => value.origin)
|
|
28
|
+
.optional(),
|
|
20
29
|
/**
|
|
21
30
|
* 탐색 묶음 경로의 읽는 순서 · 미지정 형제 묶음은 뒤에서 이름순
|
|
22
31
|
*/
|
package/dist/client.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
var E="fh-cursor-face";var b="(width < 1024px)",S="for-humanity:navigation-scroll";var v=e=>{if(!e)return null;try{return document.getElementById(decodeURIComponent(e.slice(1)))}catch(t){if(t instanceof URIError)return null;throw t}};var p=e=>{let t=v(e);for(let o=t?.closest("details");o;o=o.parentElement?.closest("details"))o.open=!0;return t};var q=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector("[data-navigation-drawer]"),o=document.querySelector("[data-navigation-content]"),r=document.querySelector("[data-navigation-open]"),c=document.querySelector("[data-navigation-close]"),n=e?.parentElement;if(!e||!t||!o||!r||!c||!n)return;let l=matchMedia(b),s=()=>{t.open||(document.body.classList.remove("wg_shell__root--drawerOpen"),r.setAttribute("aria-expanded","false"))},u=()=>{t.close(),s()},g=()=>{!l.matches||t.open||(t.showModal(),document.body.classList.add("wg_shell__root--drawerOpen"),r.setAttribute("aria-expanded","true"))},a=i=>{let d=t.getBoundingClientRect();i.target===t&&(i.clientX<d.left||i.clientX>d.right||i.clientY<d.top||i.clientY>d.bottom)&&u()},y=i=>{if(!t.open||i.defaultPrevented||i.button!==0||i.metaKey||i.ctrlKey||i.shiftKey||i.altKey||!(i.target instanceof Element))return;let d=i.target.closest("a[href]");if(!(!(d instanceof HTMLAnchorElement)||d.target!==""&&d.target!=="_self"||d.hasAttribute("download"))&&(u(),d.origin===location.origin&&d.pathname===location.pathname&&d.search===location.search&&d.hash)){let f=p(d.hash);f&&(f.tabIndex=-1,f.focus({preventScroll:!0}))}},m=()=>{let i=document.activeElement;if(l.matches){r.classList.add("wg_shell__menuToggle--visible"),e.parentElement!==o&&o.append(e),i instanceof HTMLElement&&e.contains(i)&&r.focus({preventScroll:!0});return}u(),e.parentElement!==n&&n.append(e),i instanceof HTMLElement&&e.contains(i)?i.focus({preventScroll:!0}):(i===r||i instanceof HTMLElement&&t.contains(i))&&n.querySelector(".wg_shell__brand")?.focus({preventScroll:!0}),r.classList.remove("wg_shell__menuToggle--visible")};r.addEventListener("click",g),c.addEventListener("click",u),t.addEventListener("close",s),t.addEventListener("click",a),e.addEventListener("click",y),l.addEventListener("change",m),m(),n.classList.add("wg_shell__sidebar--navigationReady")};var C=e=>{let t=document.querySelector("[data-navigation]"),o=document.querySelector('[data-navigation-scroll="documents"]'),r=document.querySelector('[data-navigation-scroll="outline"]');if(!(!t||!o||!r))try{let c=sessionStorage.getItem(e);if(c===null)return;let n=JSON.parse(c),l=[...t.querySelectorAll(".wg_shellNav__root a[href]")].map(s=>`${s.pathname}:${s.textContent}`).join(`
|
|
2
|
-
`);if(typeof n!="object"||n===null||!("navigation"in n)||n.navigation!==l)return;"documents"in n&&typeof n.documents=="number"&&Number.isFinite(n.documents)&&n.documents>=0&&(o.scrollTop=n.documents),"pathname"in n&&n.pathname===location.pathname&&"outline"in n&&typeof n.outline=="number"&&Number.isFinite(n.outline)&&n.outline>=0&&(r.scrollTop=n.outline)}catch{}};var N=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector('[data-navigation-scroll="documents"]'),o=document.querySelector('[data-navigation-scroll="outline"]'),r=document.querySelector("[data-navigation-drawer]"),c=document.querySelector("[data-navigation-open]");if(!e||!t||!o||!r||!c)return;let n=matchMedia(b),l={mobile:n.matches},s=[t,o],u=new Map,g=[...e.querySelectorAll(".wg_shellNav__root a[href]")].map(m=>`${m.pathname}:${m.textContent}`).join(`
|
|
3
|
-
`),a=()=>{if(l.mobile!==n.matches||n.matches&&!r.open)return;let m=`${S}:${n.matches?"drawer":"desktop"}`;u.set(m,new Map(s.map(i=>[i,i.scrollTop])));try{sessionStorage.setItem(m,JSON.stringify({navigation:g,pathname:location.pathname,documents:t.scrollTop,outline:o.scrollTop}))}catch{}},y=()=>{if(l.mobile=n.matches,n.matches&&!r.open)return;let m=`${S}:${n.matches?"drawer":"desktop"}`,i=u.get(m);if(i===void 0)C(m);else for(let d of s){let f=i.get(d);f!==void 0&&(d.scrollTop=f)}a()};for(let m of s)m.addEventListener("scroll",a,{passive:!0});e.addEventListener("click",a,{capture:!0}),r.addEventListener("cancel",a),r.querySelector("[data-navigation-close]")?.addEventListener("click",a,{capture:!0}),r.addEventListener("click",a,{capture:!0}),c.addEventListener("click",y),n.addEventListener("change",y),addEventListener("pagehide",a),a()};q();N();var h=document.querySelector("[data-cursor-face]");if(h){let e=matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"),t={x:0,y:0,frame:0},o=()=>{cancelAnimationFrame(t.frame),t.frame=0,h.classList.remove("wg_shell__cursorFace--visible")},r=()=>{e.matches||o()},c=()=>{t.frame===0&&(t.frame=requestAnimationFrame(()=>{t.frame=0,h.style.transform=`translate3d(
|
|
4
|
-
${Math.max(0,Math.min(t.x+16,document.documentElement.clientWidth-h.width-16))}px,
|
|
5
|
-
${Math.max(0,Math.min(t.y+16,document.documentElement.clientHeight-h.height-16))}px,
|
|
6
|
-
0
|
|
7
|
-
)`,h.classList.add("wg_shell__cursorFace--visible")}))},n=g=>{if(!(e.matches&&g.pointerType==="mouse")){o();return}t.x=g.clientX,t.y=g.clientY,c()},l=()=>{h.classList.contains("wg_shell__cursorFace--visible")&&c()},s=()=>{if(h.classList.contains("wg_shell__cursorFace--visible"))try{sessionStorage.setItem(E,JSON.stringify({x:t.x,y:t.y}))}catch{}},u=()=>{try{let g=sessionStorage.getItem(E);if(sessionStorage.removeItem(E),g!==null&&e.matches){let a=JSON.parse(g);typeof a=="object"&&a!==null&&"x"in a&&"y"in a&&typeof a.x=="number"&&typeof a.y=="number"&&Number.isFinite(a.x)&&Number.isFinite(a.y)&&(t.x=a.x,t.y=a.y,c())}}catch{}};u(),document.addEventListener("pointermove",n,{passive:!0}),document.addEventListener("pointerdown",n,{passive:!0}),document.documentElement.addEventListener("pointerleave",o),addEventListener("blur",o),addEventListener("resize",l),addEventListener("pagehide",s),addEventListener("pageshow",u),e.addEventListener("change",r)}var H=p(location.hash),L=null,w=0,_=null,k=()=>{clearTimeout(w),_?.abort(),_=null,L?.classList.remove("wg_prose__headingText--highlighted"),L=null},A=e=>{k(),L=e===null?null:e.querySelector(".wg_prose__headingText"),L!==null&&(L.classList.add("wg_prose__headingText--highlighted"),w=window.setTimeout(k,3e3),_=new AbortController,document.addEventListener("scrollend",O,{signal:_.signal}))},O=e=>{e.target===document&&(clearTimeout(w),_?.abort(),_=null,w=window.setTimeout(k,3e3))},K=e=>{if(!(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)&&e.currentTarget instanceof HTMLAnchorElement&&(e.currentTarget.target===""||e.currentTarget.target==="_self")&&!e.currentTarget.hasAttribute("download")&&e.currentTarget.origin===location.origin&&e.currentTarget.pathname===location.pathname&&e.currentTarget.search===location.search&&e.currentTarget.hash){let t=p(e.currentTarget.hash);e.currentTarget.hash===location.hash&&A(t)}},P=()=>{let e=p(location.hash);e?.scrollIntoView(),A(e)};for(let e of document.querySelectorAll("a[href]"))e.addEventListener("click",K);addEventListener("hashchange",P);var M=[...document.querySelectorAll("[data-toc-link]")].flatMap(e=>{let t=v(e.hash),o=e.parentElement;return!t||!o?[]:[{heading:t,link:e,item:o,list:o.parentElement,subs:[...o.querySelectorAll("[data-toc-sub-link]")].flatMap(r=>{let c=v(r.hash),n=r.parentElement;return c&&n?[{heading:c,link:r,item:n}]:[]})}]}),x=M.at(0);if(x){let e=()=>{let t=Number.parseFloat(getComputedStyle(x.heading).scrollMarginTop)+1,o=M.findLast(l=>l.heading.checkVisibility()&&l.heading.getBoundingClientRect().top<=t)??x,r=o.subs.findLast(l=>l.heading.checkVisibility()&&l.heading.getBoundingClientRect().top<=t),c=M.indexOf(o),n=r===void 0?-1:o.subs.indexOf(r);for(let[l,s]of M.entries()){let u=s===o&&r===void 0;s.link.classList.toggle("wg_shellToc__link--active",u),u?s.link.setAttribute("aria-current","location"):s.link.removeAttribute("aria-current"),s.item.classList.toggle("wg_shellToc__item--trail",s.list===o.list&&l<c),s.item.classList.toggle("wg_shellToc__item--current",s===o);for(let[g,a]of s.subs.entries())a.link.classList.toggle("wg_shellToc__subLink--active",a===r),a===r?a.link.setAttribute("aria-current","location"):a.link.removeAttribute("aria-current"),a.item.classList.toggle("wg_shellToc__subItem--trail",s===o&&g<n),a.item.classList.toggle("wg_shellToc__subItem--current",a===r)}};addEventListener("scroll",e,{passive:!0}),addEventListener("resize",e),addEventListener("toggle",e,!0),e()}var B=new AbortController;for(let e of["wheel","touchmove","keydown","mousedown"])addEventListener(e,()=>B.abort(),{once:!0,passive:!0});var I=async()=>{await document.fonts.ready,H&&!B.signal.aborted&&(H.scrollIntoView({behavior:"instant"}),A(H)),document.documentElement.style.removeProperty("scroll-behavior")};document.readyState==="complete"?I():addEventListener("load",I,{once:!0});
|