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
|
@@ -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
|
-
*
|
|
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();
|
|
@@ -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 "@/
|
|
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,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 "@/
|
|
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:
|
|
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 "@/
|
|
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 {
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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,
|
|
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.
|
|
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 =
|
|
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, /
|
|
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 =
|
|
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(/
|
|
158
|
-
assert.doesNotMatch(
|
|
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 =
|
|
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(/
|
|
214
|
-
assert.equal([...html.matchAll(/
|
|
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(
|
|
383
|
+
assert.doesNotMatch(toNavigationHtml({site, docs}), /aria-current="page"|wg_navigationNavList__item--current/);
|
|
220
384
|
|
|
221
|
-
const app = createApp({
|
|
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({
|
|
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, /
|
|
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({
|
|
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({
|
|
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, /
|
|
461
|
-
assert.doesNotMatch(html, /wg_prose__num|
|
|
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) => {
|