for-humanity 0.1.2 → 0.2.1

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/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import {readHome} from "@/content/read-home";
25
25
  import {createDevApp} from "@/dev";
26
26
  import {siteConfigSchema} from "@/type/site-config";
27
27
  import {toErrorMessage} from "@/util/error/to-error-message";
28
+ import {toAssetPath} from "@/util/file/to-asset-path";
28
29
  import {toFontCss} from "@/util/font/to-font-css";
29
30
 
30
31
  const commands = ["dev", "build", "preview"];
@@ -82,9 +83,11 @@ const main = async () => {
82
83
  }
83
84
 
84
85
  const fontCss = [sans.css, mono.css, brand.css].join("\n");
86
+ const stylePath = toAssetPath({path: asset_style_path, file: join(kitRoot, "dist/cli.css")});
87
+ const clientPath = toAssetPath({path: asset_client_path, file: join(kitRoot, "dist/client.js")});
85
88
  const files = new Map([
86
- [asset_style_path, join(kitRoot, "dist/cli.css")],
87
- [asset_client_path, join(kitRoot, "dist/client.js")],
89
+ [stylePath, join(kitRoot, "dist/cli.css")],
90
+ [clientPath, join(kitRoot, "dist/client.js")],
88
91
  [asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")],
89
92
  ...sans.files,
90
93
  ...mono.files,
@@ -96,7 +99,7 @@ const main = async () => {
96
99
  const app = createApp({
97
100
  site: siteConfig,
98
101
  store,
99
- assets: {fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev"},
102
+ assets: {stylePath, clientPath, fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev"},
100
103
  });
101
104
 
102
105
  if (command === "build") {
@@ -0,0 +1,158 @@
1
+ /*
2
+ * 재귀 문서 목록 · 단계별 가지와 조상 경로의 1px 트리
3
+ */
4
+ .wg_shellNavList__root {
5
+ margin: 0 0 0 var(--app-space-tree-inset);
6
+ padding: 0;
7
+ list-style: none;
8
+ }
9
+
10
+ .wg_shellNavList__branch {
11
+ /* 하위 묶음도 문서와 같은 첫 줄 기준으로 가지 연결 */
12
+ position: relative;
13
+ display: block;
14
+ padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
15
+ color: var(--app-color-text-muted);
16
+ font-size: var(--app-font-size-label);
17
+ line-height: var(--app-line-height-tight);
18
+ overflow-wrap: anywhere;
19
+
20
+ &::before {
21
+ position: absolute;
22
+ top: calc(0.5lh + var(--app-space-tree-row));
23
+ left: 0;
24
+ width: var(--app-space-tree-indent);
25
+ border-top: 1px solid var(--app-color-border-soft);
26
+ content: "";
27
+ pointer-events: none;
28
+ }
29
+ }
30
+
31
+ .wg_shellNavList__item {
32
+ /* 하위 목록을 포함해 다음 형제까지 줄기 연결 */
33
+ display: flow-root;
34
+
35
+ /* 세로 줄기는 각 항목 기준 · 마지막 항목의 첫 줄 가운데에서 종료 */
36
+ position: relative;
37
+
38
+ &::before {
39
+ position: absolute;
40
+ top: 0;
41
+ bottom: 0;
42
+ left: 0;
43
+ border-left: 1px solid var(--app-color-border-soft);
44
+ content: "";
45
+ pointer-events: none;
46
+ }
47
+
48
+ &:where(:last-child)::before {
49
+ bottom: auto;
50
+ height: calc(0.5lh + var(--app-space-tree-row));
51
+ }
52
+
53
+ /* hover·키보드 포커스의 도착점 · 첫 줄 아래는 기본 줄기 유지 */
54
+ &::after {
55
+ position: absolute;
56
+ top: 0;
57
+ left: 0;
58
+ height: calc(0.5lh + var(--app-space-tree-row));
59
+ border-left: 1px solid transparent;
60
+ content: "";
61
+ pointer-events: none;
62
+ }
63
+
64
+ &:where(:hover)::after {
65
+ border-left-color: var(--app-color-text-strong);
66
+ }
67
+
68
+ &:where(:has(:focus-visible))::after {
69
+ border-left-color: var(--app-color-text-strong);
70
+ }
71
+
72
+ /* 뒤 항목까지의 세로 경로 · 같은 목록의 형제 안에서만 연결 */
73
+ &:where(:has(~ .wg_shellNavList__item:hover))::before {
74
+ border-left-color: var(--app-color-text-strong);
75
+ }
76
+
77
+ &:where(:has(~ .wg_shellNavList__item :focus-visible))::before {
78
+ border-left-color: var(--app-color-text-strong);
79
+ }
80
+
81
+ & > .wg_shellNavList__root {
82
+ margin-left: var(--app-space-tree-indent);
83
+ }
84
+
85
+ /* 하위 문서를 탐색할 때 조상 묶음의 가지도 함께 연결 */
86
+ &:where(:hover) > .wg_shellNavList__branch::before {
87
+ border-top-color: var(--app-color-text-strong);
88
+ }
89
+
90
+ &:where(:has(:focus-visible)) > .wg_shellNavList__branch::before {
91
+ border-top-color: var(--app-color-text-strong);
92
+ }
93
+ }
94
+
95
+ .wg_shellNavList__item--trail {
96
+ &::before {
97
+ border-left-color: var(--app-color-text-strong);
98
+ }
99
+ }
100
+
101
+ .wg_shellNavList__item--current {
102
+ &::after {
103
+ border-left-color: var(--app-color-text-strong);
104
+ }
105
+
106
+ & > .wg_shellNavList__branch::before {
107
+ border-top-color: var(--app-color-text-strong);
108
+ }
109
+ }
110
+
111
+ .wg_shellNavList__link {
112
+ /* 가로 가지는 링크 첫 줄 기준 · 여러 줄 제목에서도 연결 위치 유지 */
113
+ position: relative;
114
+ display: block;
115
+ padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
116
+ color: var(--app-color-text);
117
+ font-size: var(--app-font-size-dense);
118
+ line-height: var(--app-line-height-tight);
119
+ text-decoration: none;
120
+ overflow-wrap: anywhere;
121
+
122
+ &::before {
123
+ position: absolute;
124
+ top: calc(0.5lh + var(--app-space-tree-row));
125
+ left: 0;
126
+ width: var(--app-space-tree-indent);
127
+ border-top: 1px solid var(--app-color-border-soft);
128
+ content: "";
129
+ pointer-events: none;
130
+ }
131
+
132
+ &:where(:hover) {
133
+ text-decoration: underline;
134
+ }
135
+
136
+ &:where(:hover)::before {
137
+ border-top-color: currentcolor;
138
+ }
139
+
140
+ /* hover와 같은 밑줄·가지선으로 키보드 위치 표시 */
141
+ &:where(:focus-visible) {
142
+ outline: none;
143
+ text-decoration: underline;
144
+ }
145
+
146
+ &:where(:focus-visible)::before {
147
+ border-top-color: currentcolor;
148
+ }
149
+ }
150
+
151
+ .wg_shellNavList__link--active {
152
+ color: var(--app-color-text-strong);
153
+ font-weight: var(--app-font-weight-active);
154
+
155
+ &::before {
156
+ border-top-color: currentcolor;
157
+ }
158
+ }
@@ -0,0 +1,72 @@
1
+ import clsx from "clsx";
2
+ import {Fragment} from "react";
3
+ import type {Doc} from "@/type/doc";
4
+ import type {DocGroup} from "@/type/doc-group";
5
+ import "./_wg-shell-nav-list.css";
6
+
7
+ /**
8
+ * 한 단계의 문서 목록과 하위 묶음 · 재귀에서도 같은 탐색 표현 유지
9
+ */
10
+ export interface WgShellNavListProps {
11
+ /**
12
+ * 현재 단계의 문서와 하위 묶음
13
+ */
14
+ group: DocGroup;
15
+ /**
16
+ * 현재 문서 · 조상 묶음의 경로까지 표시
17
+ */
18
+ currentDoc?: Doc;
19
+ }
20
+
21
+ export const WgShellNavList = (props: WgShellNavListProps) => {
22
+ const entries = [
23
+ ...props.group.docs.map((doc) => ({kind: "document" as const, key: `doc:${doc.id}`, doc, current: doc.id === props.currentDoc?.id})),
24
+ ...props.group.groups.map((group) => ({
25
+ kind: "group" as const,
26
+ key: `group:${JSON.stringify(group.path)}`,
27
+ group,
28
+ current: group.path.every((name, index) => name === props.currentDoc?.data.group[index]),
29
+ })),
30
+ ];
31
+ const currentIndex = entries.findIndex((entry) => entry.current);
32
+
33
+ return (
34
+ <ul className={clsx("wg_shellNavList__root")} aria-label={props.group.name}>
35
+ {/**
36
+ * 현재 경로 앞의 줄기와 도착 가지 · 문서 링크는 원래 파일 URL 사용
37
+ */}
38
+ {entries.map((entry, index) => (
39
+ <li
40
+ className={clsx("wg_shellNavList__item", {
41
+ "wg_shellNavList__item--trail": index < currentIndex,
42
+ "wg_shellNavList__item--current": entry.current,
43
+ })}
44
+ key={entry.key}
45
+ >
46
+ {/**
47
+ * 독립 문서 · 키보드 포커스와 현재 페이지 표시는 링크가 담당
48
+ */}
49
+ {entry.kind === "document" && (
50
+ <a
51
+ className={clsx("wg_shellNavList__link", {"wg_shellNavList__link--active": entry.current})}
52
+ href={`/${entry.doc.id}/`}
53
+ title={entry.doc.data.label}
54
+ aria-current={entry.current ? "page" : undefined}
55
+ >
56
+ {entry.doc.data.name}
57
+ </a>
58
+ )}
59
+ {/**
60
+ * 하위 묶음 · 같은 목록을 재귀로 이어 깊이에 따라 들여쓰기
61
+ */}
62
+ {entry.kind === "group" && (
63
+ <Fragment>
64
+ <span className={clsx("wg_shellNavList__branch")}>{entry.group.name}</span>
65
+ <WgShellNavList group={entry.group} currentDoc={props.currentDoc} />
66
+ </Fragment>
67
+ )}
68
+ </li>
69
+ ))}
70
+ </ul>
71
+ );
72
+ };
@@ -12,120 +12,6 @@
12
12
  gap: var(--app-space-stack);
13
13
  }
14
14
 
15
- .wg_shellNav__list {
16
- margin: 0 0 0 var(--app-space-tree-inset);
17
- padding: 0;
18
- list-style: none;
19
- }
20
-
21
- .wg_shellNav__item {
22
- /* 세로 줄기는 각 항목 기준 · 마지막 항목의 첫 줄 가운데에서 종료 */
23
- position: relative;
24
-
25
- &::before {
26
- position: absolute;
27
- top: 0;
28
- bottom: 0;
29
- left: 0;
30
- border-left: 1px solid var(--app-color-border-soft);
31
- content: "";
32
- pointer-events: none;
33
- }
34
-
35
- &:where(:last-child)::before {
36
- bottom: auto;
37
- height: calc(0.5lh + var(--app-space-tree-row));
38
- }
39
-
40
- /* hover·키보드 포커스의 도착점 · 첫 줄 아래는 기본 줄기 유지 */
41
- &::after {
42
- position: absolute;
43
- top: 0;
44
- left: 0;
45
- height: calc(0.5lh + var(--app-space-tree-row));
46
- border-left: 1px solid transparent;
47
- content: "";
48
- pointer-events: none;
49
- }
50
-
51
- &:where(:hover)::after {
52
- border-left-color: var(--app-color-text-strong);
53
- }
54
-
55
- &:where(:has(:focus-visible))::after {
56
- border-left-color: var(--app-color-text-strong);
57
- }
58
-
59
- /* 뒤 항목까지의 세로 경로 · 같은 목록의 형제 안에서만 연결 */
60
- &:where(:has(~ .wg_shellNav__item:hover))::before {
61
- border-left-color: var(--app-color-text-strong);
62
- }
63
-
64
- &:where(:has(~ .wg_shellNav__item :focus-visible))::before {
65
- border-left-color: var(--app-color-text-strong);
66
- }
67
- }
68
-
69
- .wg_shellNav__item--trail {
70
- &::before {
71
- border-left-color: var(--app-color-text-strong);
72
- }
73
- }
74
-
75
- .wg_shellNav__item--current {
76
- &::after {
77
- border-left-color: var(--app-color-text-strong);
78
- }
79
- }
80
-
81
- .wg_shellNav__link {
82
- /* 가로 가지는 링크 첫 줄 기준 · 여러 줄 제목에서도 연결 위치 유지 */
83
- position: relative;
84
- display: block;
85
- padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
86
- color: var(--app-color-text);
87
- font-size: var(--app-font-size-dense);
88
- line-height: var(--app-line-height-tight);
89
- text-decoration: none;
90
-
91
- &::before {
92
- position: absolute;
93
- top: calc(0.5lh + var(--app-space-tree-row));
94
- left: 0;
95
- width: var(--app-space-tree-indent);
96
- border-top: 1px solid var(--app-color-border-soft);
97
- content: "";
98
- pointer-events: none;
99
- }
100
-
101
- &:where(:hover) {
102
- text-decoration: underline;
103
- }
104
-
105
- &:where(:hover)::before {
106
- border-top-color: currentcolor;
107
- }
108
-
109
- /* hover와 같은 밑줄·가지선으로 키보드 위치 표시 */
110
- &:where(:focus-visible) {
111
- outline: none;
112
- text-decoration: underline;
113
- }
114
-
115
- &:where(:focus-visible)::before {
116
- border-top-color: currentcolor;
117
- }
118
- }
119
-
120
- .wg_shellNav__link--active {
121
- color: var(--app-color-text-strong);
122
- font-weight: var(--app-font-weight-active);
123
-
124
- &::before {
125
- border-top-color: currentcolor;
126
- }
127
- }
128
-
129
15
  .wg_shellNav__group {
130
16
  margin: 0 0 4px;
131
17
  color: var(--app-color-text-muted);
@@ -1,6 +1,7 @@
1
1
  import clsx from "clsx";
2
+ import {WgShellNavList} from "@/component/widget/shell/_wg-shell-nav-list";
2
3
  import {copy_nav_aria_label} from "@/constant/copy";
3
- import {toDocGroups} from "@/content/to-doc-groups";
4
+ import {toDocGroups} from "@/content/to-doc-groups/to-doc-groups";
4
5
  import type {Doc} from "@/type/doc";
5
6
  import type {SiteConfig} from "@/type/site-config";
6
7
  import "./_wg-shell-nav.css";
@@ -25,6 +26,7 @@ export interface WgShellNavProps {
25
26
 
26
27
  export const WgShellNav = (props: WgShellNavProps) => {
27
28
  const docGroups = toDocGroups({docs: props.docs, navigation: props.site.navigation});
29
+ const currentDoc = props.docs.find((doc) => doc.id === props.current);
28
30
 
29
31
  return (
30
32
  <nav className={clsx("wg_shellNav__root")} aria-label={copy_nav_aria_label}>
@@ -35,40 +37,12 @@ export const WgShellNav = (props: WgShellNavProps) => {
35
37
  {/**
36
38
  * 묶음 이름과 독립 문서 · 제목이 링크의 접근 가능한 이름
37
39
  */}
38
- {docGroups.map((group) => {
39
- const currentIndex = group.docs.findIndex((doc) => doc.id === props.current);
40
-
41
- return (
42
- <section className={clsx("wg_shellNav__docGroup")} key={group.name} aria-label={group.name}>
43
- <div className={clsx("wg_shellNav__group")}>{group.name}</div>
44
- {/**
45
- * 현재 문서와 묶음 시작부터 이어지는 세로 경로 표시
46
- */}
47
- <ul className={clsx("wg_shellNav__list")}>
48
- {group.docs.map((doc, index) => (
49
- <li
50
- className={clsx("wg_shellNav__item", {
51
- "wg_shellNav__item--trail": index < currentIndex,
52
- "wg_shellNav__item--current": index === currentIndex,
53
- })}
54
- key={doc.id}
55
- >
56
- <a
57
- className={clsx("wg_shellNav__link", {
58
- "wg_shellNav__link--active": index === currentIndex,
59
- })}
60
- href={`/${doc.id}/`}
61
- title={doc.data.label}
62
- aria-current={index === currentIndex ? "page" : undefined}
63
- >
64
- {doc.data.name}
65
- </a>
66
- </li>
67
- ))}
68
- </ul>
69
- </section>
70
- );
71
- })}
40
+ {docGroups.map((group) => (
41
+ <section className={clsx("wg_shellNav__docGroup")} key={group.name} aria-label={group.name}>
42
+ <div className={clsx("wg_shellNav__group")}>{group.name}</div>
43
+ <WgShellNavList group={group} currentDoc={currentDoc} />
44
+ </section>
45
+ ))}
72
46
  </div>
73
47
  </nav>
74
48
  );
@@ -4,7 +4,7 @@ import {toTocGroups} from "@/component/widget/shell/_function/to-toc-groups";
4
4
  import type {DocOutline} from "@/component/widget/shell/_type/doc-outline";
5
5
  import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
6
6
  import {WgShellToc} from "@/component/widget/shell/_wg-shell-toc";
7
- import {asset_client_path, asset_favicon_path, asset_reload_path, asset_style_path} from "@/constant/asset";
7
+ import {asset_favicon_path, asset_reload_path} from "@/constant/asset";
8
8
  import {copy_nav_close, copy_nav_open, copy_nav_title} from "@/constant/copy";
9
9
  import type {Doc} from "@/type/doc";
10
10
  import type {SiteAssets} from "@/type/site-assets";
@@ -71,7 +71,7 @@ export const WgShell = (props: WgShellProps) => {
71
71
  <link key={href} rel="preload" href={href} as="font" type="font/woff2" crossOrigin="" />
72
72
  ))}
73
73
  <script dangerouslySetInnerHTML={{__html: 'document.documentElement.style.scrollBehavior="auto"'}} />
74
- <link rel="stylesheet" href={asset_style_path} />
74
+ <link rel="stylesheet" href={props.assets.stylePath} />
75
75
  {props.assets.reload && <script dangerouslySetInnerHTML={{__html: reloadScript}} />}
76
76
  </head>
77
77
  {/**
@@ -140,7 +140,7 @@ export const WgShell = (props: WgShellProps) => {
140
140
  <div className={clsx("wg_shell__drawerContent")} data-navigation-content="" />
141
141
  </dialog>
142
142
  <img className={clsx("wg_shell__cursorFace")} src={asset_favicon_path} width="24" height="24" alt="" aria-hidden="true" draggable={false} data-cursor-face="" />
143
- <script type="module" src={asset_client_path} />
143
+ <script type="module" src={props.assets.clientPath} />
144
144
  </body>
145
145
  </html>
146
146
  );
@@ -8,12 +8,12 @@
8
8
  export const asset_dir = "_fh";
9
9
 
10
10
  /**
11
- * esbuild의 컴포넌트 CSS 묶음
11
+ * 내용 지문을 붙일 esbuild CSS의 기본 URL
12
12
  */
13
13
  export const asset_style_path = `/${asset_dir}/style.css`;
14
14
 
15
15
  /**
16
- * 테마·목차·hash 보정 브라우저 스크립트
16
+ * 내용 지문을 붙일 테마·목차·hash 보정 스크립트의 기본 URL
17
17
  */
18
18
  export const asset_client_path = `/${asset_dir}/client.js`;
19
19
 
@@ -6,11 +6,25 @@ import {test} from "node:test";
6
6
  import {renderToStaticMarkup} from "react-dom/server";
7
7
  import {createApp} from "@/app";
8
8
  import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
9
+ import {asset_client_path, asset_style_path} from "@/constant/asset";
9
10
  import {createProcessor} from "@/content/create-processor";
10
11
  import {readDocs} from "@/content/read-docs";
11
12
  import {readHome} from "@/content/read-home";
13
+ import {toDocGroups} from "@/content/to-doc-groups/to-doc-groups";
14
+ import {docDataSchema} from "@/type/doc-data";
12
15
  import {siteConfigSchema} from "@/type/site-config";
13
16
 
17
+ test("page resource URLs use the supplied content fingerprints instead of fixed cache keys", async () => {
18
+ const site = siteConfigSchema.parse({});
19
+ const assets = {fontCss: "", preload: [], reload: false, stylePath: "/_fh/style.abc123.css", clientPath: "/_fh/client.def456.js"};
20
+ const app = createApp({site, store: {docs: []}, assets});
21
+ const html = await (await app.request("/")).text();
22
+
23
+ assert.match(html, /rel="stylesheet" href="\/_fh\/style\.abc123\.css"/);
24
+ assert.match(html, /type="module" src="\/_fh\/client\.def456\.js"/);
25
+ assert.doesNotMatch(html, /(?:href|src)="\/_fh\/(?:style\.css|client\.js)"/);
26
+ });
27
+
14
28
  test("documents with the same initial retain distinct URLs", async (t) => {
15
29
  const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
16
30
  const site = siteConfigSchema.parse({});
@@ -67,6 +81,85 @@ test("navigation shows every document group in reading order without document ic
67
81
  assert.match(html, /href="\/writing\/"[^>]*aria-current="page"/);
68
82
  });
69
83
 
84
+ test("group paths preserve literal names and reject empty or invalid segments", () => {
85
+ const data = {name: "Guide", label: "Guide"};
86
+
87
+ assert.deepEqual(docDataSchema.parse({...data, group: " Guide "}).group, ["Guide"]);
88
+ assert.deepEqual(docDataSchema.parse({...data, group: [" Projects ", "Example project", "Research"]}).group, ["Projects", "Example project", "Research"]);
89
+ assert.deepEqual(docDataSchema.parse({...data, group: "Research, decisions / notes"}).group, ["Research, decisions / notes"]);
90
+
91
+ for (const group of ["", " ", [], ["Projects", " "], ["Projects", 1], [["Projects"]], null]) {
92
+ assert.equal(docDataSchema.safeParse({...data, group}).success, false);
93
+ }
94
+
95
+ assert.equal(siteConfigSchema.safeParse({navigation: ["Guide", [" Guide "]]}).success, false);
96
+ assert.equal(siteConfigSchema.safeParse({navigation: [["Projects", ""]]}).success, false);
97
+ assert.equal(siteConfigSchema.safeParse({navigation: []}).success, true);
98
+ });
99
+
100
+ test("nested groups follow metadata paths and keep sibling branches and document URLs distinct", async (t) => {
101
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-groups-"));
102
+ const site = siteConfigSchema.parse({navigation: ["Guide", ["Projects", "Atlas", "Research"], ["Projects", "Atlas", "Decisions"]]});
103
+
104
+ t.after(() => rm(root, {recursive: true, force: true}));
105
+ await Promise.all([
106
+ writeFile(join(root, "guide.md"), "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\nText.\n"),
107
+ writeFile(join(root, "guide-two.md"), "---\nname: More guide\nlabel: Guide\ngroup: [Guide]\n---\n\nText.\n"),
108
+ writeFile(join(root, "overview.md"), "---\nname: Overview\nlabel: Overview\ngroup: [Projects, Atlas]\n---\n\nText.\n"),
109
+ writeFile(join(root, "parts.md"), "---\nname: Parts\nlabel: Parts\ngroup: [Projects, Atlas, Research]\norder: 1\n---\n\nText.\n"),
110
+ writeFile(join(root, "authoring.md"), "---\nname: Authoring\nlabel: Authoring\ngroup: [Projects, Atlas, Research]\norder: 2\n---\n\nText.\n"),
111
+ writeFile(join(root, "decision.md"), "---\nname: Decision\nlabel: Decision\ngroup: [Projects, Atlas, Decisions]\n---\n\nText.\n"),
112
+ writeFile(join(root, "other.md"), "---\nname: Other research\nlabel: Other research\ngroup: [Projects, Beacon, Research]\n---\n\nText.\n"),
113
+ writeFile(join(root, "deep.md"), "---\nname: Deep research\nlabel: Deep research\ngroup: [Projects, Atlas, Research, Archive, Drafts]\n---\n\nText.\n"),
114
+ ]);
115
+
116
+ const docs = await readDocs({root, processor: createProcessor({site, root})});
117
+ const groups = toDocGroups({docs, navigation: site.navigation});
118
+
119
+ assert.deepEqual(
120
+ groups.map((group) => group.name),
121
+ ["Guide", "Projects"],
122
+ );
123
+ assert.deepEqual(
124
+ groups[0].docs.map((doc) => doc.id),
125
+ ["guide", "guide-two"],
126
+ );
127
+ assert.deepEqual(
128
+ groups[1].groups.map((group) => group.name),
129
+ ["Atlas", "Beacon"],
130
+ );
131
+ assert.deepEqual(
132
+ groups[1].groups[0].docs.map((doc) => doc.id),
133
+ ["overview"],
134
+ );
135
+ assert.deepEqual(
136
+ groups[1].groups[0].groups.map((group) => group.name),
137
+ ["Research", "Decisions"],
138
+ );
139
+ assert.deepEqual(
140
+ groups[1].groups[0].groups[0].docs.map((doc) => doc.id),
141
+ ["parts", "authoring"],
142
+ );
143
+ assert.deepEqual(
144
+ groups[1].groups[1].groups[0].docs.map((doc) => doc.id),
145
+ ["other"],
146
+ );
147
+ assert.equal(groups[1].groups[0].groups[0].groups[0].groups[0].name, "Drafts");
148
+ assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
149
+
150
+ const html = renderToStaticMarkup(WgShellNav({site, docs, current: "authoring"}));
151
+
152
+ assert.match(html, /href="\/authoring\/"[^>]*aria-current="page"/);
153
+ assert.match(html, /Atlas<\/span><ul\b/);
154
+ assert.match(html, /Research<\/span><ul\b/);
155
+ assert.equal([...html.matchAll(/href="\/authoring\/"/g)].length, 1);
156
+ 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/);
159
+ assert.ok(html.indexOf('href="/parts/"') < html.indexOf('href="/authoring/"'));
160
+ assert.doesNotMatch(html, /<details\b/);
161
+ });
162
+
70
163
  test("frontmatter supports BOM, CRLF and a closing fence at EOF", async (t) => {
71
164
  const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
72
165
  const site = siteConfigSchema.parse({});
@@ -120,7 +213,7 @@ test("a plain root README renders at home and relative home links resolve from n
120
213
  ]);
121
214
 
122
215
  const [docs, home] = await Promise.all([readDocs({root, processor}), readHome({root, processor})]);
123
- const app = createApp({site, store: {docs, home}, assets: {fontCss: "", preload: [], reload: false}});
216
+ const app = createApp({site, store: {docs, home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
124
217
  const response = await app.request("/");
125
218
  const html = await response.text();
126
219
 
@@ -149,7 +242,7 @@ test("a missing README provides a creation hint without generated overview cards
149
242
 
150
243
  t.after(() => rm(root, {recursive: true, force: true}));
151
244
  const home = await readHome({root, processor: createProcessor({site, root})});
152
- const app = createApp({site, store: {docs: [], home}, assets: {fontCss: "", preload: [], reload: false}});
245
+ const app = createApp({site, store: {docs: [], home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
153
246
  const html = await (await app.request("/")).text();
154
247
 
155
248
  assert.equal(home, undefined);
@@ -228,7 +321,7 @@ test("headings and contents preserve authored text without automatic numbering",
228
321
  );
229
322
 
230
323
  const docs = await readDocs({root, processor: createProcessor({site, root})});
231
- const app = createApp({site, store: {docs}, assets: {fontCss: "", preload: [], reload: false}});
324
+ const app = createApp({site, store: {docs}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
232
325
  const html = await (await app.request("/guide/")).text();
233
326
 
234
327
  assert.match(html, /<h2[^>]*id="overview"[^>]*><span class="wg_prose__headingText">Overview<\/span><\/h2>/);