for-humanity 0.4.3 → 0.5.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 +23 -0
- package/README.md +5 -5
- package/dist/browser.js +39 -0
- package/dist/cli.css +372 -299
- package/dist/cli.js +1125 -301
- 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} +43 -26
- 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 -309
- package/src/component/widget/shell/wg-shell.tsx +16 -75
- 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/entry/browser/browser.tsx +22 -0
- package/src/{app.tsx → entry/cli/_function/create-app.tsx} +6 -6
- package/src/{content → entry/cli/_function/create-processor}/create-processor.ts +3 -3
- package/src/{content → entry/cli/_function}/read-docs.ts +1 -1
- package/src/{content → entry/cli/_function}/read-home.ts +1 -1
- package/src/{content/read-docs.test.ts → entry/cli/cli.test.ts} +105 -30
- package/src/{cli.ts → entry/cli/cli.ts} +10 -11
- 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 +4 -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/dist/client.js +0 -6
- 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 -140
- package/src/component/widget/shell/_function/bind-navigation-scroll.ts +0 -123
- 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/{dev.ts → entry/cli/_function/create-dev-app.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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 브라우저 진입 · 서버 자료 검증과 저장소 생성 후 공통 셸 컨트롤을 한 번 마운트
|
|
3
|
+
*/
|
|
4
|
+
import {createRoot} from "hono/jsx/dom/client";
|
|
5
|
+
import {navigationDataSchema} from "@/component/widget/navigation/_constant/navigation-data-schema";
|
|
6
|
+
import {WgShellControls} from "@/component/widget/shell-controls/wg-shell-controls";
|
|
7
|
+
import {createNavigationStores} from "@/store/navigation/create-navigation-stores";
|
|
8
|
+
|
|
9
|
+
const root = document.querySelector<HTMLElement>("[data-shell-browser-root]");
|
|
10
|
+
const payload = document.getElementById("fh-navigation-data");
|
|
11
|
+
|
|
12
|
+
if (root !== null && payload !== null) {
|
|
13
|
+
try {
|
|
14
|
+
const value: unknown = JSON.parse(payload.textContent);
|
|
15
|
+
const data = navigationDataSchema.parse(value);
|
|
16
|
+
const stores = createNavigationStores({local: () => localStorage, session: () => sessionStorage});
|
|
17
|
+
createRoot(root).render(<WgShellControls data={data} stores={stores} reload={root.hasAttribute("data-shell-reload")} />);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
// 초기화 오류에서도 서버의 링크·본문과 네이티브 스크롤 유지
|
|
20
|
+
console.error("for-humanity browser initialization failed", error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {Hono} from "hono";
|
|
2
|
+
import {html} from "hono/html";
|
|
3
|
+
import type {Child} from "hono/jsx";
|
|
2
4
|
import {ssgParams} from "hono/ssg";
|
|
3
|
-
import type {ReactElement} from "react";
|
|
4
|
-
import {renderToStaticMarkup} from "react-dom/server";
|
|
5
5
|
import {PgDoc} from "@/page/doc/pg-doc";
|
|
6
6
|
import {PgHome} from "@/page/home/pg-home";
|
|
7
7
|
import type {Doc} from "@/type/doc";
|
|
@@ -28,16 +28,16 @@ export interface AppOptions {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Hono JSX 페이지에 HTML 문서 선언 추가 · 서버와 정적 출력에서 같은 렌더러 사용
|
|
32
32
|
*/
|
|
33
|
-
const toHtml = (page:
|
|
34
|
-
return `<!DOCTYPE html>${
|
|
33
|
+
const toHtml = (page: Child) => {
|
|
34
|
+
return html`<!DOCTYPE html>${page}`;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* 첫 화면 / · 문서 /:slug{.+}/ · 하위 폴더 id 지원
|
|
39
39
|
* 자원 wildcard와 정규식 매개변수 혼합 시 TrieRouter fallback으로 중첩 경로 불일치
|
|
40
|
-
* 자원·SSE는
|
|
40
|
+
* 자원·SSE는 createDevApp의 별도 앱 소유
|
|
41
41
|
*/
|
|
42
42
|
export const createApp = (options: AppOptions) => {
|
|
43
43
|
const app = new Hono();
|
|
@@ -19,9 +19,9 @@ import {remarkParts} from "@/component/widget/prose/_function/remark-parts";
|
|
|
19
19
|
import {remarkStatus} from "@/component/widget/prose/_function/remark-status";
|
|
20
20
|
import {remarkSwatch} from "@/component/widget/prose/_function/remark-swatch";
|
|
21
21
|
import {remarkUnknownDirectives} from "@/component/widget/prose/_function/remark-unknown-directives";
|
|
22
|
-
import {rehypeAssets} from "@/
|
|
23
|
-
import {rehypeHeadingIds} from "@/
|
|
24
|
-
import {remarkReport} from "@/
|
|
22
|
+
import {rehypeAssets} from "@/entry/cli/_function/create-processor/_rehype-assets";
|
|
23
|
+
import {rehypeHeadingIds} from "@/entry/cli/_function/create-processor/_rehype-heading-ids";
|
|
24
|
+
import {remarkReport} from "@/entry/cli/_function/create-processor/_remark-report";
|
|
25
25
|
import type {SiteConfig} from "@/type/site-config";
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
copy_error_doc_parent_missing,
|
|
12
12
|
copy_error_frontmatter,
|
|
13
13
|
} from "@/constant/copy";
|
|
14
|
-
import type {Processor} from "@/
|
|
14
|
+
import type {Processor} from "@/entry/cli/_function/create-processor/create-processor";
|
|
15
15
|
import type {Doc} from "@/type/doc";
|
|
16
16
|
import {docDataSchema} from "@/type/doc-data";
|
|
17
17
|
import type {DocFileData} from "@/type/doc-file-data";
|
|
@@ -2,7 +2,7 @@ import {readdir, readFile} from "node:fs/promises";
|
|
|
2
2
|
import {join} from "node:path";
|
|
3
3
|
import {VFile} from "vfile";
|
|
4
4
|
import {copy_error_home_clash} from "@/constant/copy";
|
|
5
|
-
import type {Processor} from "@/
|
|
5
|
+
import type {Processor} from "@/entry/cli/_function/create-processor/create-processor";
|
|
6
6
|
import type {DocContent} from "@/type/doc-content";
|
|
7
7
|
import type {DocFileData} from "@/type/doc-file-data";
|
|
8
8
|
|
|
@@ -3,28 +3,87 @@ import {mkdir, mkdtemp, rm, symlink, writeFile} from "node:fs/promises";
|
|
|
3
3
|
import {tmpdir} from "node:os";
|
|
4
4
|
import {join} from "node:path";
|
|
5
5
|
import {test} from "node:test";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
6
|
+
import {navigationDataSchema} from "@/component/widget/navigation/_constant/navigation-data-schema";
|
|
7
|
+
import {toDocGroups} from "@/component/widget/navigation/_function/to-doc-groups/to-doc-groups";
|
|
8
|
+
import {toNavigationData} from "@/component/widget/navigation/_function/to-navigation-data/to-navigation-data";
|
|
9
|
+
import {toNavigationJson} from "@/component/widget/navigation/_function/to-navigation-json";
|
|
10
|
+
import {WgNavigation} from "@/component/widget/navigation/wg-navigation";
|
|
11
|
+
import {asset_style_path} from "@/constant/asset";
|
|
12
|
+
import {createApp} from "@/entry/cli/_function/create-app";
|
|
13
|
+
import {createProcessor} from "@/entry/cli/_function/create-processor/create-processor";
|
|
14
|
+
import {readDocs} from "@/entry/cli/_function/read-docs";
|
|
15
|
+
import {readHome} from "@/entry/cli/_function/read-home";
|
|
14
16
|
import {docDataSchema} from "@/type/doc-data";
|
|
15
17
|
import {siteConfigSchema} from "@/type/site-config";
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 브라우저와 같은 공개 탐색 컴포넌트에서 문서 목록만 확인
|
|
21
|
+
*/
|
|
22
|
+
const toNavigationHtml = (options: Parameters<typeof toNavigationData>[0]) => {
|
|
23
|
+
const markup = WgNavigation({data: toNavigationData(options)}).toString();
|
|
24
|
+
const navigation = markup.match(/<nav[^>]*class="wg_navigationNav__root"[\s\S]*?<\/nav>/)?.[0];
|
|
25
|
+
assert.ok(navigation);
|
|
26
|
+
return navigation;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test("navigation payload excludes document bodies and safely embeds authored labels", async (t) => {
|
|
30
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-navigation-"));
|
|
31
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
32
|
+
const title = "</script><script>window.injected=true</script>\u2028\u2029";
|
|
33
|
+
const site = siteConfigSchema.parse({title});
|
|
34
|
+
await writeFile(join(root, "guide.md"), "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n## Read\n\nPrivate body marker.\n");
|
|
35
|
+
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
36
|
+
const data = toNavigationData({site, docs, current: "guide"});
|
|
37
|
+
const json = toNavigationJson(data);
|
|
38
|
+
|
|
39
|
+
assert.equal(data.title, title);
|
|
40
|
+
assert.equal(data.groups[0].docs[0].active, true);
|
|
41
|
+
assert.doesNotMatch(json, /Private body marker|<|\u2028|\u2029/);
|
|
42
|
+
assert.deepEqual(JSON.parse(json), data);
|
|
43
|
+
assert.deepEqual(navigationDataSchema.parse(JSON.parse(json)), data);
|
|
44
|
+
});
|
|
45
|
+
|
|
17
46
|
test("page resource URLs use the supplied content fingerprints instead of fixed cache keys", async () => {
|
|
18
47
|
const site = siteConfigSchema.parse({});
|
|
19
|
-
const assets = {fontCss: "", preload: [], reload: false,
|
|
48
|
+
const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: "/_fh/style.abc123.css"};
|
|
20
49
|
const app = createApp({site, store: {docs: []}, assets});
|
|
21
50
|
const html = await (await app.request("/")).text();
|
|
22
51
|
|
|
23
52
|
assert.match(html, /rel="stylesheet" href="\/_fh\/style\.abc123\.css"/);
|
|
24
|
-
assert.
|
|
53
|
+
assert.doesNotMatch(html, /<script\b[^>]*\bsrc=/, "browser behavior must share one inline JSX entry");
|
|
25
54
|
assert.doesNotMatch(html, /(?:href|src)="\/_fh\/(?:style\.css|client\.js)"/);
|
|
26
55
|
});
|
|
27
56
|
|
|
57
|
+
test("navigation initialization runs after its DOM and before the document body", async () => {
|
|
58
|
+
const site = siteConfigSchema.parse({});
|
|
59
|
+
const browserScript = "window.navigationFixture = true;";
|
|
60
|
+
const assets = {fontCss: "", preload: [], reload: false, stylePath: asset_style_path, browserScript};
|
|
61
|
+
const html = await (await createApp({site, store: {docs: []}, assets}).request("/")).text();
|
|
62
|
+
const initialization = html.indexOf(`<script>${browserScript}</script>`);
|
|
63
|
+
|
|
64
|
+
assert.doesNotMatch(html, /\sref="|\sonClick="/, "server HTML must not serialize browser refs or handlers");
|
|
65
|
+
assert.ok(initialization > html.indexOf("fh-navigation-data"), "authored payload must be available before mounting");
|
|
66
|
+
assert.ok(initialization > html.indexOf("data-navigation-drawer"), "navigation controls must exist before initialization");
|
|
67
|
+
assert.ok(initialization > html.indexOf("data-cursor-face"), "the JSX browser owner must also contain its cursor");
|
|
68
|
+
assert.ok(initialization < html.indexOf('<main class="wg_shell__main">'), "navigation must be ready before the main content is parsed");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the page outline belongs only to the right rail rather than mobile navigation", async (t) => {
|
|
72
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-outline-"));
|
|
73
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
74
|
+
await writeFile(join(root, "README.md"), "# Home\n\n## Read here\n\n### More context\n\nText.\n");
|
|
75
|
+
const site = siteConfigSchema.parse({});
|
|
76
|
+
const home = await readHome({root, processor: createProcessor({site, root})});
|
|
77
|
+
const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: asset_style_path};
|
|
78
|
+
const html = await (await createApp({site, store: {docs: [], home}, assets}).request("/")).text();
|
|
79
|
+
const rail = html.match(/<aside\b[^>]*data-navigation-rail=""[^>]*>([\s\S]*?)<\/aside>/)?.[1];
|
|
80
|
+
|
|
81
|
+
assert.ok(rail, "the right rail must exist");
|
|
82
|
+
assert.ok(rail.includes('data-navigation-scroll="outline"'), "the right rail must own the outline scroll area");
|
|
83
|
+
assert.match(rail, /Read here/);
|
|
84
|
+
assert.equal([...html.matchAll(/data-navigation-scroll="outline"/g)].length, 1);
|
|
85
|
+
});
|
|
86
|
+
|
|
28
87
|
test("documents with the same initial retain distinct URLs", async (t) => {
|
|
29
88
|
const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
|
|
30
89
|
const site = siteConfigSchema.parse({});
|
|
@@ -66,7 +125,7 @@ test("navigation shows every document group in reading order without document ic
|
|
|
66
125
|
]);
|
|
67
126
|
|
|
68
127
|
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
69
|
-
const html =
|
|
128
|
+
const html = toNavigationHtml({site, docs, current: "writing"});
|
|
70
129
|
const groups = [...html.matchAll(/<section\b[^>]*aria-label="([^"]+)"/g)];
|
|
71
130
|
|
|
72
131
|
assert.deepEqual(
|
|
@@ -74,7 +133,7 @@ test("navigation shows every document group in reading order without document ic
|
|
|
74
133
|
["Getting started", "Guide"],
|
|
75
134
|
);
|
|
76
135
|
assert.doesNotMatch(html, /<details\b/);
|
|
77
|
-
assert.doesNotMatch(html, /
|
|
136
|
+
assert.doesNotMatch(html, /wg_navigationNav__docIcon/);
|
|
78
137
|
assert.doesNotMatch(html, /href="\/"/);
|
|
79
138
|
assert.doesNotMatch(html, />Documents<|>Overview</);
|
|
80
139
|
assert.ok(html.indexOf('href="/writing/"') < html.indexOf('href="/parts/"'));
|
|
@@ -147,15 +206,15 @@ test("nested groups follow metadata paths and keep sibling branches and document
|
|
|
147
206
|
assert.equal(groups[1].groups[0].groups[0].groups[0].groups[0].name, "Drafts");
|
|
148
207
|
assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
|
|
149
208
|
|
|
150
|
-
const html =
|
|
209
|
+
const html = toNavigationHtml({site, docs, current: "authoring"});
|
|
151
210
|
|
|
152
211
|
assert.match(html, /href="\/authoring\/"[^>]*aria-current="page"/);
|
|
153
|
-
assert.match(html, /Atlas<\/span><ul\b/);
|
|
154
|
-
assert.match(html, /Research<\/span><ul\b/);
|
|
212
|
+
assert.match(html, /Atlas<\/span><button\b[^>]*aria-expanded="true"[^>]*>−<\/button><ul\b/);
|
|
213
|
+
assert.match(html, /Research<\/span><button\b[^>]*aria-expanded="true"[^>]*>−<\/button><ul\b/);
|
|
155
214
|
assert.equal([...html.matchAll(/href="\/authoring\/"/g)].length, 1);
|
|
156
215
|
assert.equal([...html.matchAll(/>Research<\/span>/g)].length, 2);
|
|
157
|
-
assert.equal([...html.matchAll(/
|
|
158
|
-
assert.doesNotMatch(
|
|
216
|
+
assert.equal([...html.matchAll(/wg_navigationNavList__item--current/g)].length, 3);
|
|
217
|
+
assert.doesNotMatch(toNavigationHtml({site, docs}), /aria-current="page"|wg_navigationNavList__item--current/);
|
|
159
218
|
assert.ok(html.indexOf('href="/parts/"') < html.indexOf('href="/authoring/"'));
|
|
160
219
|
assert.doesNotMatch(html, /<details\b/);
|
|
161
220
|
});
|
|
@@ -205,20 +264,24 @@ test("parent documents contain ordered descendants with independent URLs and one
|
|
|
205
264
|
assert.deepEqual(docs.find((doc) => doc.id === "details")?.ancestors, ["settings", "guide/site"]);
|
|
206
265
|
assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
|
|
207
266
|
|
|
208
|
-
const html =
|
|
209
|
-
assert.match(html, /href="\/settings\/"[^>]*>Settings<\/a><ul\b[^>]*aria-label="Settings"/);
|
|
210
|
-
assert.match(html, /href="\/guide\/site\/"[^>]*>Site<\/a><ul\b[^>]*aria-label="Site"/);
|
|
267
|
+
const html = toNavigationHtml({site, docs, current: "details"});
|
|
268
|
+
assert.match(html, /href="\/settings\/"[^>]*>Settings<\/a><button\b[^>]*aria-label="Collapse Settings"[^>]*>−<\/button><ul\b[^>]*aria-label="Settings"/);
|
|
269
|
+
assert.match(html, /href="\/guide\/site\/"[^>]*>Site<\/a><button\b[^>]*aria-label="Collapse Site"[^>]*>−<\/button><ul\b[^>]*aria-label="Site"/);
|
|
211
270
|
assert.match(html, /href="\/details\/"[^>]*aria-current="page"/);
|
|
212
271
|
assert.equal([...html.matchAll(/aria-current="page"/g)].length, 1);
|
|
213
|
-
assert.equal([...html.matchAll(/
|
|
214
|
-
assert.equal([...html.matchAll(/
|
|
272
|
+
assert.equal([...html.matchAll(/wg_navigationNavList__link--active/g)].length, 1);
|
|
273
|
+
assert.equal([...html.matchAll(/wg_navigationNavList__item--current/g)].length, 5);
|
|
215
274
|
for (const doc of docs) {
|
|
216
275
|
assert.equal(html.split(`href="/${doc.id}/"`).length - 1, 1);
|
|
217
276
|
}
|
|
218
277
|
assert.ok(html.indexOf('href="/themes/"') < html.indexOf('href="/guide/site/"'));
|
|
219
|
-
assert.doesNotMatch(
|
|
278
|
+
assert.doesNotMatch(toNavigationHtml({site, docs}), /aria-current="page"|wg_navigationNavList__item--current/);
|
|
220
279
|
|
|
221
|
-
const app = createApp({
|
|
280
|
+
const app = createApp({
|
|
281
|
+
site,
|
|
282
|
+
store: {docs},
|
|
283
|
+
assets: {stylePath: asset_style_path, browserScript: "", fontCss: "", preload: [], reload: false},
|
|
284
|
+
});
|
|
222
285
|
for (const doc of docs) {
|
|
223
286
|
assert.equal((await app.request(`/${doc.id}/`)).status, 200);
|
|
224
287
|
}
|
|
@@ -340,7 +403,11 @@ test("a plain root README renders at home and relative home links resolve from n
|
|
|
340
403
|
]);
|
|
341
404
|
|
|
342
405
|
const [docs, home] = await Promise.all([readDocs({root, processor}), readHome({root, processor})]);
|
|
343
|
-
const app = createApp({
|
|
406
|
+
const app = createApp({
|
|
407
|
+
site,
|
|
408
|
+
store: {docs, home},
|
|
409
|
+
assets: {stylePath: asset_style_path, browserScript: "", fontCss: "", preload: [], reload: false},
|
|
410
|
+
});
|
|
344
411
|
const response = await app.request("/");
|
|
345
412
|
const html = await response.text();
|
|
346
413
|
|
|
@@ -356,7 +423,7 @@ test("a plain root README renders at home and relative home links resolve from n
|
|
|
356
423
|
assert.match(html, /href="\/guide\/setup\/"/);
|
|
357
424
|
assert.match(html, /href="#start" data-toc-link/);
|
|
358
425
|
assert.match(html, /class="shiki/);
|
|
359
|
-
assert.match(html, /
|
|
426
|
+
assert.match(html, /wg_navigation__brand" href="\/" aria-current="page"/);
|
|
360
427
|
assert.equal([...html.matchAll(/href="\/"/g)].length, 1);
|
|
361
428
|
assert.match(docs[0].html, /href="\/#start"/);
|
|
362
429
|
assert.doesNotMatch(html, /wg_prose__eyebrow|pg_home__card/);
|
|
@@ -369,7 +436,11 @@ test("a missing README provides a creation hint without generated overview cards
|
|
|
369
436
|
|
|
370
437
|
t.after(() => rm(root, {recursive: true, force: true}));
|
|
371
438
|
const home = await readHome({root, processor: createProcessor({site, root})});
|
|
372
|
-
const app = createApp({
|
|
439
|
+
const app = createApp({
|
|
440
|
+
site,
|
|
441
|
+
store: {docs: [], home},
|
|
442
|
+
assets: {stylePath: asset_style_path, browserScript: "", fontCss: "", preload: [], reload: false},
|
|
443
|
+
});
|
|
373
444
|
const html = await (await app.request("/")).text();
|
|
374
445
|
|
|
375
446
|
assert.equal(home, undefined);
|
|
@@ -448,7 +519,11 @@ test("headings and contents preserve authored text without automatic numbering",
|
|
|
448
519
|
);
|
|
449
520
|
|
|
450
521
|
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
451
|
-
const app = createApp({
|
|
522
|
+
const app = createApp({
|
|
523
|
+
site,
|
|
524
|
+
store: {docs},
|
|
525
|
+
assets: {stylePath: asset_style_path, browserScript: "", fontCss: "", preload: [], reload: false},
|
|
526
|
+
});
|
|
452
527
|
const html = await (await app.request("/guide/")).text();
|
|
453
528
|
|
|
454
529
|
assert.match(html, /<h2[^>]*id="overview"[^>]*><span class="wg_prose__headingText">Overview<\/span><\/h2>/);
|
|
@@ -457,8 +532,8 @@ test("headings and contents preserve authored text without automatic numbering",
|
|
|
457
532
|
assert.match(html, /href="#01-keep-this-number"[^>]*>01\. Keep this number<\/a>/);
|
|
458
533
|
assert.match(html, /href="#nested-heading"[^>]*>Nested heading<\/a>/);
|
|
459
534
|
assert.match(html, /href="#overview-1"[^>]*>Overview<\/a>/);
|
|
460
|
-
assert.match(html, /
|
|
461
|
-
assert.doesNotMatch(html, /wg_prose__num|
|
|
535
|
+
assert.match(html, /wg_navigationToc__group">Setup<\/div>/);
|
|
536
|
+
assert.doesNotMatch(html, /wg_prose__num|wg_navigationToc__mark/);
|
|
462
537
|
});
|
|
463
538
|
|
|
464
539
|
test("local Markdown and raw HTML images share fingerprinted assets with attachments", async (t) => {
|
|
@@ -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,16 @@ 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 {
|
|
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 {createApp} from "@/entry/cli/_function/create-app";
|
|
23
|
+
import {createDevApp} from "@/entry/cli/_function/create-dev-app";
|
|
24
|
+
import {createProcessor} from "@/entry/cli/_function/create-processor/create-processor";
|
|
25
|
+
import {readDocs} from "@/entry/cli/_function/read-docs";
|
|
26
|
+
import {readHome} from "@/entry/cli/_function/read-home";
|
|
27
27
|
import {siteConfigSchema} from "@/type/site-config";
|
|
28
28
|
import {toChangeQueue} from "@/util/async/to-change-queue";
|
|
29
29
|
import {toErrorMessage} from "@/util/error/to-error-message";
|
|
@@ -86,10 +86,9 @@ const main = async () => {
|
|
|
86
86
|
|
|
87
87
|
const fontCss = [sans.css, mono.css, brand.css].join("\n");
|
|
88
88
|
const stylePath = toAssetPath({path: asset_style_path, file: join(kitRoot, "dist/cli.css")});
|
|
89
|
-
const
|
|
89
|
+
const browserScript = readFileSync(join(kitRoot, "dist/browser.js"), "utf8");
|
|
90
90
|
const kitFiles = new Map([
|
|
91
91
|
[stylePath, join(kitRoot, "dist/cli.css")],
|
|
92
|
-
[clientPath, join(kitRoot, "dist/client.js")],
|
|
93
92
|
[asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")],
|
|
94
93
|
...sans.files,
|
|
95
94
|
...mono.files,
|
|
@@ -102,7 +101,7 @@ const main = async () => {
|
|
|
102
101
|
const app = createApp({
|
|
103
102
|
site: siteConfig,
|
|
104
103
|
store,
|
|
105
|
-
assets: {stylePath,
|
|
104
|
+
assets: {stylePath, browserScript, fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev"},
|
|
106
105
|
});
|
|
107
106
|
|
|
108
107
|
if (command === "build") {
|
|
@@ -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";
|