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.
Files changed (85) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +6 -5
  3. package/dist/browser.js +39 -0
  4. package/dist/cli.css +350 -262
  5. package/dist/cli.js +1174 -364
  6. package/package.json +8 -11
  7. package/src/component/widget/{shell → cursor-face}/_constant/cursor-face.ts +5 -0
  8. package/src/component/widget/cursor-face/wg-cursor-face.css +15 -0
  9. package/src/component/widget/cursor-face/wg-cursor-face.tsx +153 -0
  10. package/src/component/widget/navigation/_constant/navigation-data-schema.ts +35 -0
  11. package/src/component/widget/navigation/_constant/navigation.ts +9 -0
  12. package/src/component/widget/navigation/_function/restore-navigation-scroll.ts +59 -0
  13. package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-branches.ts +2 -2
  14. package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-group-branches.ts +3 -3
  15. package/src/{content → component/widget/navigation/_function}/to-doc-groups/to-doc-groups.ts +1 -1
  16. package/src/component/widget/navigation/_function/to-navigation-branch-id.ts +6 -0
  17. package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-document.ts +14 -0
  18. package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-group.ts +14 -0
  19. package/src/component/widget/navigation/_function/to-navigation-data/to-navigation-data.ts +25 -0
  20. package/src/component/widget/navigation/_function/to-navigation-json.ts +8 -0
  21. package/src/component/widget/{shell → navigation}/_function/to-toc-groups.ts +2 -2
  22. package/src/{type → component/widget/navigation/_type}/doc-group.ts +1 -1
  23. package/src/component/widget/navigation/_type/navigation-data.ts +83 -0
  24. package/src/component/widget/navigation/_type/navigation-focus-snapshot.ts +21 -0
  25. package/src/component/widget/navigation/_wg-navigation-branch-toggle.css +28 -0
  26. package/src/component/widget/navigation/_wg-navigation-branch-toggle.tsx +48 -0
  27. package/src/component/widget/{shell/_wg-shell-nav-list.css → navigation/_wg-navigation-nav-list.css} +24 -16
  28. package/src/component/widget/navigation/_wg-navigation-nav-list.tsx +106 -0
  29. package/src/component/widget/{shell/_wg-shell-nav.css → navigation/_wg-navigation-nav.css} +9 -3
  30. package/src/component/widget/navigation/_wg-navigation-nav.tsx +43 -0
  31. package/src/component/widget/navigation/_wg-navigation-scroll.tsx +117 -0
  32. package/src/component/widget/{shell/_wg-shell-toc.css → navigation/_wg-navigation-toc.css} +39 -24
  33. package/src/component/widget/navigation/_wg-navigation-toc.tsx +155 -0
  34. package/src/component/widget/navigation/wg-navigation.css +282 -0
  35. package/src/component/widget/navigation/wg-navigation.tsx +479 -0
  36. package/src/component/widget/prose/_function/rehype-sections/rehype-sections.ts +1 -1
  37. package/src/component/widget/shell/wg-shell.css +13 -269
  38. package/src/component/widget/shell/wg-shell.tsx +16 -97
  39. package/src/component/widget/shell-controls/wg-shell-controls.css +4 -0
  40. package/src/component/widget/shell-controls/wg-shell-controls.tsx +170 -0
  41. package/src/constant/asset.ts +0 -5
  42. package/src/constant/copy.ts +5 -0
  43. package/src/entry/browser/browser.tsx +22 -0
  44. package/src/entry/cli/_constant/crawling.ts +14 -0
  45. package/src/{app.tsx → entry/cli/_function/create-app.tsx} +6 -6
  46. package/src/{dev.ts → entry/cli/_function/create-dev-app.ts} +9 -1
  47. package/src/{content → entry/cli/_function/create-processor}/create-processor.ts +3 -3
  48. package/src/{content → entry/cli/_function}/read-docs.ts +21 -2
  49. package/src/{content → entry/cli/_function}/read-home.ts +1 -1
  50. package/src/entry/cli/_function/to-crawling-app.ts +42 -0
  51. package/src/{content/read-docs.test.ts → entry/cli/cli.test.ts} +211 -31
  52. package/src/{cli.ts → entry/cli/cli.ts} +31 -15
  53. package/src/store/navigation/_constant/tree.ts +6 -0
  54. package/src/store/navigation/create-navigation-stores.test.ts +92 -0
  55. package/src/store/navigation/create-navigation-stores.ts +73 -0
  56. package/src/store/navigation/navigation-state.ts +46 -0
  57. package/src/store/navigation/navigation-storage.ts +14 -0
  58. package/src/store/navigation/to-navigation-scroll-state.ts +42 -0
  59. package/src/store/navigation/to-navigation-tree-state.ts +19 -0
  60. package/src/store/navigation/use-navigation-tree-store.ts +12 -0
  61. package/src/style/token.css +3 -2
  62. package/src/type/doc-content.ts +1 -1
  63. package/src/type/doc-file-data.ts +1 -1
  64. package/src/{component/widget/shell/_type → type}/doc-outline.ts +1 -1
  65. package/src/type/site-assets.ts +2 -2
  66. package/src/type/site-config.ts +10 -1
  67. package/dist/client.js +0 -7
  68. package/src/client.ts +0 -335
  69. package/src/component/widget/shell/_constant/navigation.ts +0 -9
  70. package/src/component/widget/shell/_function/bind-mobile-navigation.ts +0 -147
  71. package/src/component/widget/shell/_function/bind-navigation-overflow.ts +0 -47
  72. package/src/component/widget/shell/_function/bind-navigation-scroll.ts +0 -84
  73. package/src/component/widget/shell/_function/restore-navigation-scroll.ts +0 -47
  74. package/src/component/widget/shell/_wg-shell-nav-list.tsx +0 -87
  75. package/src/component/widget/shell/_wg-shell-nav.tsx +0 -49
  76. package/src/component/widget/shell/_wg-shell-toc.tsx +0 -57
  77. /package/src/{constant → component/widget/navigation/_constant}/locale.ts +0 -0
  78. /package/src/component/widget/{shell → navigation}/_constant/reading-line.ts +0 -0
  79. /package/src/{type → component/widget/navigation/_type}/doc-branch.ts +0 -0
  80. /package/src/component/widget/{shell → navigation}/_type/toc-group.ts +0 -0
  81. /package/src/{constant → entry/cli/_constant}/cli.ts +0 -0
  82. /package/src/{content/rehype-assets.ts → entry/cli/_function/create-processor/_rehype-assets.ts} +0 -0
  83. /package/src/{content/rehype-heading-ids.ts → entry/cli/_function/create-processor/_rehype-heading-ids.ts} +0 -0
  84. /package/src/{content/remark-report.ts → entry/cli/_function/create-processor/_remark-report.ts} +0 -0
  85. /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
+ }
@@ -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)];
@@ -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
- * React 서버 렌더링 · 브라우저 React 번들 없음
31
+ * Hono JSX 페이지에 HTML 문서 선언 추가 · 서버와 정적 출력에서 같은 렌더러 사용
32
32
  */
33
- const toHtml = (page: ReactElement) => {
34
- return `<!DOCTYPE html>${renderToStaticMarkup(page)}`;
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는 dev.ts의 별도 앱 소유
40
+ * 자원·SSE는 createDevApp의 별도 앱 소유
41
41
  */
42
42
  export const createApp = (options: AppOptions) => {
43
43
  const app = new Hono();
@@ -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;
@@ -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 "@/content/rehype-assets";
23
- import {rehypeHeadingIds} from "@/content/rehype-heading-ids";
24
- import {remarkReport} from "@/content/remark-report";
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,17 +11,35 @@ import {
11
11
  copy_error_doc_parent_missing,
12
12
  copy_error_frontmatter,
13
13
  } from "@/constant/copy";
14
- import type {Processor} from "@/content/create-processor";
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";
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: {root: string; processor: Processor}): Promise<Doc[]> => {
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
  ) {
@@ -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 "@/content/create-processor";
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
 
@@ -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,30 +1,194 @@
1
1
  import assert from "node:assert/strict";
2
- import {mkdir, mkdtemp, rm, symlink, writeFile} from "node:fs/promises";
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";
6
- import {renderToStaticMarkup} from "react-dom/server";
7
- import {createApp} from "@/app";
8
- import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
9
- import {asset_client_path, asset_style_path} from "@/constant/asset";
10
- import {createProcessor} from "@/content/create-processor";
11
- import {readDocs} from "@/content/read-docs";
12
- import {readHome} from "@/content/read-home";
13
- import {toDocGroups} from "@/content/to-doc-groups/to-doc-groups";
7
+ import {toSSG} from "hono/ssg";
8
+ import {navigationDataSchema} from "@/component/widget/navigation/_constant/navigation-data-schema";
9
+ import {toDocGroups} from "@/component/widget/navigation/_function/to-doc-groups/to-doc-groups";
10
+ import {toNavigationData} from "@/component/widget/navigation/_function/to-navigation-data/to-navigation-data";
11
+ import {toNavigationJson} from "@/component/widget/navigation/_function/to-navigation-json";
12
+ import {WgNavigation} from "@/component/widget/navigation/wg-navigation";
13
+ import {asset_style_path} from "@/constant/asset";
14
+ import {createApp} from "@/entry/cli/_function/create-app";
15
+ import {createDevApp} from "@/entry/cli/_function/create-dev-app";
16
+ import {createProcessor} from "@/entry/cli/_function/create-processor/create-processor";
17
+ import {readDocs} from "@/entry/cli/_function/read-docs";
18
+ import {readHome} from "@/entry/cli/_function/read-home";
19
+ import {toCrawlingApp} from "@/entry/cli/_function/to-crawling-app";
14
20
  import {docDataSchema} from "@/type/doc-data";
15
21
  import {siteConfigSchema} from "@/type/site-config";
16
22
 
23
+ /**
24
+ * 브라우저와 같은 공개 탐색 컴포넌트에서 문서 목록만 확인
25
+ */
26
+ const toNavigationHtml = (options: Parameters<typeof toNavigationData>[0]) => {
27
+ const markup = WgNavigation({data: toNavigationData(options)}).toString();
28
+ const navigation = markup.match(/<nav[^>]*class="wg_navigationNav__root"[\s\S]*?<\/nav>/)?.[0];
29
+ assert.ok(navigation);
30
+ return navigation;
31
+ };
32
+
33
+ test("navigation payload excludes document bodies and safely embeds authored labels", async (t) => {
34
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-navigation-"));
35
+ t.after(() => rm(root, {recursive: true, force: true}));
36
+ const title = "</script><script>window.injected=true</script>\u2028\u2029";
37
+ const site = siteConfigSchema.parse({title});
38
+ await writeFile(join(root, "guide.md"), "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n## Read\n\nPrivate body marker.\n");
39
+ const docs = await readDocs({root, processor: createProcessor({site, root})});
40
+ const data = toNavigationData({site, docs, current: "guide"});
41
+ const json = toNavigationJson(data);
42
+
43
+ assert.equal(data.title, title);
44
+ assert.equal(data.groups[0].docs[0].active, true);
45
+ assert.doesNotMatch(json, /Private body marker|<|\u2028|\u2029/);
46
+ assert.deepEqual(JSON.parse(json), data);
47
+ assert.deepEqual(navigationDataSchema.parse(JSON.parse(json)), data);
48
+ });
49
+
17
50
  test("page resource URLs use the supplied content fingerprints instead of fixed cache keys", async () => {
18
51
  const site = siteConfigSchema.parse({});
19
- const assets = {fontCss: "", preload: [], reload: false, stylePath: "/_fh/style.abc123.css", clientPath: "/_fh/client.def456.js"};
52
+ const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: "/_fh/style.abc123.css"};
20
53
  const app = createApp({site, store: {docs: []}, assets});
21
54
  const html = await (await app.request("/")).text();
22
55
 
23
56
  assert.match(html, /rel="stylesheet" href="\/_fh\/style\.abc123\.css"/);
24
- assert.match(html, /type="module" src="\/_fh\/client\.def456\.js"/);
57
+ assert.doesNotMatch(html, /<script\b[^>]*\bsrc=/, "browser behavior must share one inline JSX entry");
25
58
  assert.doesNotMatch(html, /(?:href|src)="\/_fh\/(?:style\.css|client\.js)"/);
26
59
  });
27
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
+
162
+ test("navigation initialization runs after its DOM and before the document body", async () => {
163
+ const site = siteConfigSchema.parse({});
164
+ const browserScript = "window.navigationFixture = true;";
165
+ const assets = {fontCss: "", preload: [], reload: false, stylePath: asset_style_path, browserScript};
166
+ const html = await (await createApp({site, store: {docs: []}, assets}).request("/")).text();
167
+ const initialization = html.indexOf(`<script>${browserScript}</script>`);
168
+
169
+ assert.doesNotMatch(html, /\sref="|\sonClick="/, "server HTML must not serialize browser refs or handlers");
170
+ assert.ok(initialization > html.indexOf("fh-navigation-data"), "authored payload must be available before mounting");
171
+ assert.ok(initialization > html.indexOf("data-navigation-drawer"), "navigation controls must exist before initialization");
172
+ assert.ok(initialization > html.indexOf("data-cursor-face"), "the JSX browser owner must also contain its cursor");
173
+ assert.ok(initialization < html.indexOf('<main class="wg_shell__main">'), "navigation must be ready before the main content is parsed");
174
+ });
175
+
176
+ test("the page outline belongs only to the right rail rather than mobile navigation", async (t) => {
177
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-outline-"));
178
+ t.after(() => rm(root, {recursive: true, force: true}));
179
+ await writeFile(join(root, "README.md"), "# Home\n\n## Read here\n\n### More context\n\nText.\n");
180
+ const site = siteConfigSchema.parse({});
181
+ const home = await readHome({root, processor: createProcessor({site, root})});
182
+ const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: asset_style_path};
183
+ const html = await (await createApp({site, store: {docs: [], home}, assets}).request("/")).text();
184
+ const rail = html.match(/<aside\b[^>]*data-navigation-rail=""[^>]*>([\s\S]*?)<\/aside>/)?.[1];
185
+
186
+ assert.ok(rail, "the right rail must exist");
187
+ assert.ok(rail.includes('data-navigation-scroll="outline"'), "the right rail must own the outline scroll area");
188
+ assert.match(rail, /Read here/);
189
+ assert.equal([...html.matchAll(/data-navigation-scroll="outline"/g)].length, 1);
190
+ });
191
+
28
192
  test("documents with the same initial retain distinct URLs", async (t) => {
29
193
  const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
30
194
  const site = siteConfigSchema.parse({});
@@ -66,7 +230,7 @@ test("navigation shows every document group in reading order without document ic
66
230
  ]);
67
231
 
68
232
  const docs = await readDocs({root, processor: createProcessor({site, root})});
69
- const html = renderToStaticMarkup(WgShellNav({site, docs, current: "writing"}));
233
+ const html = toNavigationHtml({site, docs, current: "writing"});
70
234
  const groups = [...html.matchAll(/<section\b[^>]*aria-label="([^"]+)"/g)];
71
235
 
72
236
  assert.deepEqual(
@@ -74,7 +238,7 @@ test("navigation shows every document group in reading order without document ic
74
238
  ["Getting started", "Guide"],
75
239
  );
76
240
  assert.doesNotMatch(html, /<details\b/);
77
- assert.doesNotMatch(html, /wg_shellNav__docIcon/);
241
+ assert.doesNotMatch(html, /wg_navigationNav__docIcon/);
78
242
  assert.doesNotMatch(html, /href="\/"/);
79
243
  assert.doesNotMatch(html, />Documents<|>Overview</);
80
244
  assert.ok(html.indexOf('href="/writing/"') < html.indexOf('href="/parts/"'));
@@ -147,15 +311,15 @@ test("nested groups follow metadata paths and keep sibling branches and document
147
311
  assert.equal(groups[1].groups[0].groups[0].groups[0].groups[0].name, "Drafts");
148
312
  assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
149
313
 
150
- const html = renderToStaticMarkup(WgShellNav({site, docs, current: "authoring"}));
314
+ const html = toNavigationHtml({site, docs, current: "authoring"});
151
315
 
152
316
  assert.match(html, /href="\/authoring\/"[^>]*aria-current="page"/);
153
- assert.match(html, /Atlas<\/span><ul\b/);
154
- assert.match(html, /Research<\/span><ul\b/);
317
+ assert.match(html, /Atlas<\/span><button\b[^>]*aria-expanded="true"[^>]*>−<\/button><ul\b/);
318
+ assert.match(html, /Research<\/span><button\b[^>]*aria-expanded="true"[^>]*>−<\/button><ul\b/);
155
319
  assert.equal([...html.matchAll(/href="\/authoring\/"/g)].length, 1);
156
320
  assert.equal([...html.matchAll(/>Research<\/span>/g)].length, 2);
157
- assert.equal([...html.matchAll(/wg_shellNavList__item--current/g)].length, 3);
158
- assert.doesNotMatch(renderToStaticMarkup(WgShellNav({site, docs})), /aria-current="page"|wg_shellNavList__item--current/);
321
+ assert.equal([...html.matchAll(/wg_navigationNavList__item--current/g)].length, 3);
322
+ assert.doesNotMatch(toNavigationHtml({site, docs}), /aria-current="page"|wg_navigationNavList__item--current/);
159
323
  assert.ok(html.indexOf('href="/parts/"') < html.indexOf('href="/authoring/"'));
160
324
  assert.doesNotMatch(html, /<details\b/);
161
325
  });
@@ -205,20 +369,24 @@ test("parent documents contain ordered descendants with independent URLs and one
205
369
  assert.deepEqual(docs.find((doc) => doc.id === "details")?.ancestors, ["settings", "guide/site"]);
206
370
  assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
207
371
 
208
- const html = renderToStaticMarkup(WgShellNav({site, docs, current: "details"}));
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"/);
372
+ const html = toNavigationHtml({site, docs, current: "details"});
373
+ assert.match(html, /href="\/settings\/"[^>]*>Settings<\/a><button\b[^>]*aria-label="Collapse Settings"[^>]*>−<\/button><ul\b[^>]*aria-label="Settings"/);
374
+ assert.match(html, /href="\/guide\/site\/"[^>]*>Site<\/a><button\b[^>]*aria-label="Collapse Site"[^>]*>−<\/button><ul\b[^>]*aria-label="Site"/);
211
375
  assert.match(html, /href="\/details\/"[^>]*aria-current="page"/);
212
376
  assert.equal([...html.matchAll(/aria-current="page"/g)].length, 1);
213
- assert.equal([...html.matchAll(/wg_shellNavList__link--active/g)].length, 1);
214
- assert.equal([...html.matchAll(/wg_shellNavList__item--current/g)].length, 5);
377
+ assert.equal([...html.matchAll(/wg_navigationNavList__link--active/g)].length, 1);
378
+ assert.equal([...html.matchAll(/wg_navigationNavList__item--current/g)].length, 5);
215
379
  for (const doc of docs) {
216
380
  assert.equal(html.split(`href="/${doc.id}/"`).length - 1, 1);
217
381
  }
218
382
  assert.ok(html.indexOf('href="/themes/"') < html.indexOf('href="/guide/site/"'));
219
- assert.doesNotMatch(renderToStaticMarkup(WgShellNav({site, docs})), /aria-current="page"|wg_shellNavList__item--current/);
383
+ assert.doesNotMatch(toNavigationHtml({site, docs}), /aria-current="page"|wg_navigationNavList__item--current/);
220
384
 
221
- const app = createApp({site, store: {docs}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
385
+ const app = createApp({
386
+ site,
387
+ store: {docs},
388
+ assets: {stylePath: asset_style_path, browserScript: "", fontCss: "", preload: [], reload: false},
389
+ });
222
390
  for (const doc of docs) {
223
391
  assert.equal((await app.request(`/${doc.id}/`)).status, 200);
224
392
  }
@@ -340,7 +508,11 @@ test("a plain root README renders at home and relative home links resolve from n
340
508
  ]);
341
509
 
342
510
  const [docs, home] = await Promise.all([readDocs({root, processor}), readHome({root, processor})]);
343
- const app = createApp({site, store: {docs, home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
511
+ const app = createApp({
512
+ site,
513
+ store: {docs, home},
514
+ assets: {stylePath: asset_style_path, browserScript: "", fontCss: "", preload: [], reload: false},
515
+ });
344
516
  const response = await app.request("/");
345
517
  const html = await response.text();
346
518
 
@@ -356,7 +528,7 @@ test("a plain root README renders at home and relative home links resolve from n
356
528
  assert.match(html, /href="\/guide\/setup\/"/);
357
529
  assert.match(html, /href="#start" data-toc-link/);
358
530
  assert.match(html, /class="shiki/);
359
- assert.match(html, /wg_shell__brand" href="\/" aria-current="page"/);
531
+ assert.match(html, /wg_navigation__brand" href="\/" aria-current="page"/);
360
532
  assert.equal([...html.matchAll(/href="\/"/g)].length, 1);
361
533
  assert.match(docs[0].html, /href="\/#start"/);
362
534
  assert.doesNotMatch(html, /wg_prose__eyebrow|pg_home__card/);
@@ -369,7 +541,11 @@ test("a missing README provides a creation hint without generated overview cards
369
541
 
370
542
  t.after(() => rm(root, {recursive: true, force: true}));
371
543
  const home = await readHome({root, processor: createProcessor({site, root})});
372
- const app = createApp({site, store: {docs: [], home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
544
+ const app = createApp({
545
+ site,
546
+ store: {docs: [], home},
547
+ assets: {stylePath: asset_style_path, browserScript: "", fontCss: "", preload: [], reload: false},
548
+ });
373
549
  const html = await (await app.request("/")).text();
374
550
 
375
551
  assert.equal(home, undefined);
@@ -448,7 +624,11 @@ test("headings and contents preserve authored text without automatic numbering",
448
624
  );
449
625
 
450
626
  const docs = await readDocs({root, processor: createProcessor({site, root})});
451
- const app = createApp({site, store: {docs}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
627
+ const app = createApp({
628
+ site,
629
+ store: {docs},
630
+ assets: {stylePath: asset_style_path, browserScript: "", fontCss: "", preload: [], reload: false},
631
+ });
452
632
  const html = await (await app.request("/guide/")).text();
453
633
 
454
634
  assert.match(html, /<h2[^>]*id="overview"[^>]*><span class="wg_prose__headingText">Overview<\/span><\/h2>/);
@@ -457,8 +637,8 @@ test("headings and contents preserve authored text without automatic numbering",
457
637
  assert.match(html, /href="#01-keep-this-number"[^>]*>01\. Keep this number<\/a>/);
458
638
  assert.match(html, /href="#nested-heading"[^>]*>Nested heading<\/a>/);
459
639
  assert.match(html, /href="#overview-1"[^>]*>Overview<\/a>/);
460
- assert.match(html, /wg_shellToc__group">Setup<\/div>/);
461
- assert.doesNotMatch(html, /wg_prose__num|wg_shellToc__mark/);
640
+ assert.match(html, /wg_navigationToc__group">Setup<\/div>/);
641
+ assert.doesNotMatch(html, /wg_prose__num|wg_navigationToc__mark/);
462
642
  });
463
643
 
464
644
  test("local Markdown and raw HTML images share fingerprinted assets with attachments", async (t) => {