for-humanity 0.2.0 → 0.3.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.
@@ -13,8 +13,30 @@
13
13
  display: block;
14
14
  padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
15
15
  color: var(--app-color-text-muted);
16
- font-size: var(--app-font-size-label);
16
+ font-size: var(--app-font-size-group);
17
+ line-height: calc(var(--app-font-size-dense) * 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__link {
32
+ /* 가로 가지는 링크 첫 줄 기준 · 여러 줄 제목에서도 연결 위치 유지 */
33
+ position: relative;
34
+ display: block;
35
+ padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
36
+ color: var(--app-color-link);
37
+ font-size: var(--app-font-size-dense);
17
38
  line-height: var(--app-line-height-tight);
39
+ text-decoration: none;
18
40
  overflow-wrap: anywhere;
19
41
 
20
42
  &::before {
@@ -26,6 +48,33 @@
26
48
  content: "";
27
49
  pointer-events: none;
28
50
  }
51
+
52
+ &:where(:hover) {
53
+ text-decoration: underline;
54
+ }
55
+
56
+ &:where(:hover)::before {
57
+ border-top-color: currentcolor;
58
+ }
59
+
60
+ /* hover와 같은 밑줄·가지선으로 키보드 위치 표시 */
61
+ &:where(:focus-visible) {
62
+ outline: none;
63
+ text-decoration: underline;
64
+ }
65
+
66
+ &:where(:focus-visible)::before {
67
+ border-top-color: currentcolor;
68
+ }
69
+ }
70
+
71
+ .wg_shellNavList__link--active {
72
+ color: var(--app-color-link);
73
+ font-weight: var(--app-font-weight-active);
74
+
75
+ &::before {
76
+ border-top-color: currentcolor;
77
+ }
29
78
  }
30
79
 
31
80
  .wg_shellNavList__item {
@@ -62,20 +111,20 @@
62
111
  }
63
112
 
64
113
  &:where(:hover)::after {
65
- border-left-color: var(--app-color-text-strong);
114
+ border-left-color: var(--app-color-link);
66
115
  }
67
116
 
68
117
  &:where(:has(:focus-visible))::after {
69
- border-left-color: var(--app-color-text-strong);
118
+ border-left-color: var(--app-color-link);
70
119
  }
71
120
 
72
121
  /* 뒤 항목까지의 세로 경로 · 같은 목록의 형제 안에서만 연결 */
73
122
  &:where(:has(~ .wg_shellNavList__item:hover))::before {
74
- border-left-color: var(--app-color-text-strong);
123
+ border-left-color: var(--app-color-link);
75
124
  }
76
125
 
77
126
  &:where(:has(~ .wg_shellNavList__item :focus-visible))::before {
78
- border-left-color: var(--app-color-text-strong);
127
+ border-left-color: var(--app-color-link);
79
128
  }
80
129
 
81
130
  & > .wg_shellNavList__root {
@@ -84,75 +133,39 @@
84
133
 
85
134
  /* 하위 문서를 탐색할 때 조상 묶음의 가지도 함께 연결 */
86
135
  &:where(:hover) > .wg_shellNavList__branch::before {
87
- border-top-color: var(--app-color-text-strong);
136
+ border-top-color: var(--app-color-link);
88
137
  }
89
138
 
90
139
  &:where(:has(:focus-visible)) > .wg_shellNavList__branch::before {
91
- border-top-color: var(--app-color-text-strong);
140
+ border-top-color: var(--app-color-link);
141
+ }
142
+
143
+ /* 자식 문서를 탐색할 때 클릭 가능한 부모 문서의 가지도 연결 */
144
+ &:where(:hover) > .wg_shellNavList__link::before {
145
+ border-top-color: var(--app-color-link);
146
+ }
147
+
148
+ &:where(:has(:focus-visible)) > .wg_shellNavList__link::before {
149
+ border-top-color: var(--app-color-link);
92
150
  }
93
151
  }
94
152
 
95
153
  .wg_shellNavList__item--trail {
96
154
  &::before {
97
- border-left-color: var(--app-color-text-strong);
155
+ border-left-color: var(--app-color-link);
98
156
  }
99
157
  }
100
158
 
101
159
  .wg_shellNavList__item--current {
102
160
  &::after {
103
- border-left-color: var(--app-color-text-strong);
161
+ border-left-color: var(--app-color-link);
104
162
  }
105
163
 
106
164
  & > .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;
165
+ border-top-color: var(--app-color-link);
130
166
  }
131
167
 
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;
168
+ & > .wg_shellNavList__link::before {
169
+ border-top-color: var(--app-color-link);
157
170
  }
158
171
  }
@@ -19,8 +19,12 @@ export interface WgShellNavListProps {
19
19
  }
20
20
 
21
21
  export const WgShellNavList = (props: WgShellNavListProps) => {
22
+ const currentAncestors = new Set(props.currentDoc?.ancestors);
22
23
  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.docs.map((doc) => {
25
+ const active = doc.id === props.currentDoc?.id;
26
+ return {kind: "document" as const, key: `doc:${doc.id}`, doc, active, current: active || currentAncestors.has(doc.id)};
27
+ }),
24
28
  ...props.group.groups.map((group) => ({
25
29
  kind: "group" as const,
26
30
  key: `group:${JSON.stringify(group.path)}`,
@@ -44,17 +48,28 @@ export const WgShellNavList = (props: WgShellNavListProps) => {
44
48
  key={entry.key}
45
49
  >
46
50
  {/**
47
- * 독립 문서 · 키보드 포커스와 현재 페이지 표시는 링크가 담당
51
+ * 문서 링크 · 부모도 독립 페이지, 현재 한 문서에만 페이지 상태 부여
48
52
  */}
49
53
  {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>
54
+ <Fragment>
55
+ <a
56
+ className={clsx("wg_shellNavList__link", {"wg_shellNavList__link--active": entry.active})}
57
+ href={`/${entry.doc.id}/`}
58
+ title={entry.doc.data.label}
59
+ aria-current={entry.active ? "page" : undefined}
60
+ >
61
+ {entry.doc.data.name}
62
+ </a>
63
+ {/**
64
+ * 하위 문서 · 부모 아래에서도 기존 목록의 들여쓰기와 키보드 이동 유지
65
+ */}
66
+ {entry.doc.children !== undefined && (
67
+ <WgShellNavList
68
+ group={{name: entry.doc.data.name, path: entry.doc.data.group, docs: entry.doc.children, groups: []}}
69
+ currentDoc={props.currentDoc}
70
+ />
71
+ )}
72
+ </Fragment>
58
73
  )}
59
74
  {/**
60
75
  * 하위 묶음 · 같은 목록을 재귀로 이어 깊이에 따라 들여쓰기
@@ -1,5 +1,5 @@
1
1
  /*
2
- * 읽는 순서의 문서 목록 · 중립색 1px 트리
2
+ * 읽는 순서의 문서 목록 · 작은 회색 묶음과 링크색 문서
3
3
  */
4
4
  .wg_shellNav__root {
5
5
  font-size: var(--app-font-size-dense);
@@ -15,6 +15,7 @@
15
15
  .wg_shellNav__group {
16
16
  margin: 0 0 4px;
17
17
  color: var(--app-color-text-muted);
18
- font-size: var(--app-font-size-label);
18
+ font-size: var(--app-font-size-group);
19
+ line-height: calc(var(--app-font-size-dense) * var(--app-line-height-tight));
19
20
  text-box: trim-start cap alphabetic;
20
21
  }
@@ -1,5 +1,5 @@
1
1
  /*
2
- * 문서 탐색 아래 목차 · 작성한 제목과 계층을 유지하는 중립색 트리
2
+ * 문서 탐색 아래 목차 · 회색 제목·묶음과 본문 글색의 이동 대상 구분
3
3
  */
4
4
  .wg_shellToc__root {
5
5
  margin-top: var(--app-space-group);
@@ -13,14 +13,15 @@
13
13
  margin-bottom: var(--app-space-inline);
14
14
  color: var(--app-color-text-muted);
15
15
  font-size: var(--app-font-size-label);
16
- font-weight: var(--app-font-weight-mark);
16
+ font-weight: var(--app-font-weight-strong);
17
17
  letter-spacing: var(--app-letter-spacing-label);
18
18
  }
19
19
 
20
20
  .wg_shellToc__group {
21
21
  margin: var(--app-space-stack) 0 4px;
22
22
  color: var(--app-color-text-muted);
23
- font-size: var(--app-font-size-label);
23
+ font-size: var(--app-font-size-group);
24
+ line-height: calc(var(--app-font-size-dense) * var(--app-line-height-tight));
24
25
  }
25
26
 
26
27
  .wg_shellToc__list {
@@ -34,7 +35,7 @@
34
35
  position: relative;
35
36
  display: block;
36
37
  padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
37
- color: var(--app-color-text-muted);
38
+ color: var(--app-color-text);
38
39
  font-size: var(--app-font-size-dense);
39
40
  line-height: var(--app-line-height-tight);
40
41
  text-decoration: none;
@@ -222,7 +223,7 @@
222
223
  position: relative;
223
224
  display: block;
224
225
  padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
225
- color: var(--app-color-text-muted);
226
+ color: var(--app-color-text);
226
227
  font-size: var(--app-font-size-detail);
227
228
  line-height: var(--app-line-height-tight);
228
229
  text-decoration: none;
@@ -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
 
@@ -35,7 +35,7 @@ export const copy_home_missing = "문서 폴더에 README.md를 추가하면 이
35
35
  /**
36
36
  * 현재 문서 목차 라벨
37
37
  */
38
- export const copy_nav_toc_label = "Contents";
38
+ export const copy_nav_toc_label = "On this page";
39
39
 
40
40
  /**
41
41
  * 대소문자만 다른 README 중복 오류
@@ -82,6 +82,21 @@ export const copy_error_doc_id = "문서 id에 사용할 수 없는 경로";
82
82
  */
83
83
  export const copy_error_doc_id_clash = "문서 id가 겹친다";
84
84
 
85
+ /**
86
+ * 누락되거나 홈을 가리키는 부모 문서 참조
87
+ */
88
+ export const copy_error_doc_parent_missing = "부모 문서가 없다";
89
+
90
+ /**
91
+ * 자기 참조 또는 순환하는 문서 부모 경로
92
+ */
93
+ export const copy_error_doc_parent_cycle = "문서 parent가 순환한다";
94
+
95
+ /**
96
+ * 분류 경로가 다른 문서 사이의 부모 참조
97
+ */
98
+ export const copy_error_doc_parent_group = "부모 문서와 group이 다르다";
99
+
85
100
  /**
86
101
  * 흐름도 렌더링 실패
87
102
  */
@@ -6,6 +6,7 @@ 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,6 +14,17 @@ import {toDocGroups} from "@/content/to-doc-groups/to-doc-groups";
13
14
  import {docDataSchema} from "@/type/doc-data";
14
15
  import {siteConfigSchema} from "@/type/site-config";
15
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
+
16
28
  test("documents with the same initial retain distinct URLs", async (t) => {
17
29
  const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
18
30
  const site = siteConfigSchema.parse({});
@@ -148,6 +160,133 @@ test("nested groups follow metadata paths and keep sibling branches and document
148
160
  assert.doesNotMatch(html, /<details\b/);
149
161
  });
150
162
 
163
+ test("parent is an optional nonempty document ID", () => {
164
+ const data = {name: "Child", label: "Child", group: "Guide"};
165
+
166
+ assert.equal(docDataSchema.parse(data).parent, undefined);
167
+ assert.equal(docDataSchema.parse({...data, parent: " guide/settings "}).parent, "guide/settings");
168
+ for (const parent of ["", " ", null, 1, [], ["guide"]]) {
169
+ assert.equal(docDataSchema.safeParse({...data, parent}).success, false);
170
+ }
171
+ });
172
+
173
+ test("parent documents contain ordered descendants with independent URLs and one current page", async (t) => {
174
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
175
+ const site = siteConfigSchema.parse({navigation: [["Projects", "Atlas", "Guide"]]});
176
+
177
+ t.after(() => rm(root, {recursive: true, force: true}));
178
+ await mkdir(join(root, "guide"));
179
+ await Promise.all([
180
+ writeFile(join(root, "settings.md"), "---\nname: Settings\nlabel: Settings\ngroup: [Projects, Atlas, Guide]\norder: 10\n---\n\nSettings introduction.\n"),
181
+ writeFile(join(root, "guide/site.md"), "---\nname: Site\nlabel: Site\ngroup: [Projects, Atlas, Guide]\nparent: settings\norder: 20\n---\n\nSite introduction.\n"),
182
+ writeFile(join(root, "themes.md"), "---\nname: Themes\nlabel: Themes\ngroup: [Projects, Atlas, Guide]\nparent: settings\norder: 10\n---\n\nThemes introduction.\n"),
183
+ writeFile(join(root, "details.md"), "---\nname: Details\nlabel: Details\ngroup: [Projects, Atlas, Guide]\nparent: guide/site\n---\n\nDeep child.\n"),
184
+ writeFile(join(root, "writing.md"), "---\nname: Writing\nlabel: Writing\ngroup: [Projects, Atlas, Guide]\norder: 20\n---\n\nIndependent guide.\n"),
185
+ ]);
186
+
187
+ const docs = await readDocs({root, processor: createProcessor({site, root})});
188
+ const groups = toDocGroups({docs, navigation: site.navigation});
189
+ const guide = groups[0].groups[0].groups[0];
190
+
191
+ assert.deepEqual(
192
+ guide.docs.map((doc) => doc.id),
193
+ ["settings", "writing"],
194
+ );
195
+ assert.ok(guide.docs[0].children);
196
+ assert.deepEqual(
197
+ guide.docs[0].children.map((doc) => doc.id),
198
+ ["themes", "guide/site"],
199
+ );
200
+ assert.ok(guide.docs[0].children[1].children);
201
+ assert.deepEqual(
202
+ guide.docs[0].children[1].children.map((doc) => doc.id),
203
+ ["details"],
204
+ );
205
+ assert.deepEqual(docs.find((doc) => doc.id === "details")?.ancestors, ["settings", "guide/site"]);
206
+ assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
207
+
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"/);
211
+ assert.match(html, /href="\/details\/"[^>]*aria-current="page"/);
212
+ 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);
215
+ for (const doc of docs) {
216
+ assert.equal(html.split(`href="/${doc.id}/"`).length - 1, 1);
217
+ }
218
+ 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/);
220
+
221
+ const app = createApp({site, store: {docs}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
222
+ for (const doc of docs) {
223
+ assert.equal((await app.request(`/${doc.id}/`)).status, 200);
224
+ }
225
+ });
226
+
227
+ test("relative parent IDs work with both the whole collection and a project document root", async (t) => {
228
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
229
+ const site = siteConfigSchema.parse({});
230
+ t.after(() => rm(root, {recursive: true, force: true}));
231
+
232
+ await mkdir(join(root, "project/research"), {recursive: true});
233
+ await mkdir(join(root, "project/guide"));
234
+ await Promise.all([
235
+ writeFile(join(root, "project/research/README.md"), "---\nname: Research\nlabel: Research\ngroup: Guide\n---\n\nResearch introduction.\n"),
236
+ writeFile(join(root, "project/research/parts.md"), "---\nname: Parts\nlabel: Parts\ngroup: Guide\nparent: ./readme\n---\n\nText.\n"),
237
+ writeFile(join(root, "project/guide/authoring.md"), "---\nname: Authoring\nlabel: Authoring\ngroup: Guide\nparent: ../research/readme\n---\n\nText.\n"),
238
+ ]);
239
+ for (const scope of [root, join(root, "project")]) {
240
+ const docs = await readDocs({root: scope, processor: createProcessor({site, root: scope})});
241
+ const parentId = scope === root ? "project/research/readme" : "research/readme";
242
+ const groups = toDocGroups({docs, navigation: site.navigation});
243
+ assert.deepEqual(
244
+ groups[0].docs.map((doc) => doc.id),
245
+ [parentId],
246
+ );
247
+ assert.ok(groups[0].docs[0].children);
248
+ assert.equal(groups[0].docs[0].children.length, 2);
249
+ assert.ok(groups[0].docs[0].children.every((doc) => doc.data.parent === parentId));
250
+ assert.ok(groups[0].docs[0].children.every((doc) => doc.ancestors.length === 1 && doc.ancestors[0] === parentId));
251
+ }
252
+ });
253
+
254
+ test("missing and home document parents fail when loading documents", async (t) => {
255
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
256
+ const site = siteConfigSchema.parse({});
257
+ t.after(() => rm(root, {recursive: true, force: true}));
258
+
259
+ await writeFile(join(root, "README.md"), "# Home\n");
260
+ for (const parent of ["missing", "readme", "guide.md", "/guide/"]) {
261
+ await writeFile(join(root, "child.md"), `---\nname: Child\nlabel: Child\ngroup: Guide\nparent: ${parent}\n---\n\nText.\n`);
262
+ await assert.rejects(readDocs({root, processor: createProcessor({site, root})}), /부모 문서가 없다: child/);
263
+ }
264
+ });
265
+
266
+ test("self references and cycles fail when loading documents", async (t) => {
267
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
268
+ const site = siteConfigSchema.parse({});
269
+ t.after(() => rm(root, {recursive: true, force: true}));
270
+
271
+ await writeFile(join(root, "first.md"), "---\nname: First\nlabel: First\ngroup: Guide\nparent: first\n---\n\nText.\n");
272
+ await assert.rejects(readDocs({root, processor: createProcessor({site, root})}), /문서 parent가 순환한다/);
273
+
274
+ await writeFile(join(root, "first.md"), "---\nname: First\nlabel: First\ngroup: Guide\nparent: second\n---\n\nText.\n");
275
+ await writeFile(join(root, "second.md"), "---\nname: Second\nlabel: Second\ngroup: Guide\nparent: third\n---\n\nText.\n");
276
+ await writeFile(join(root, "third.md"), "---\nname: Third\nlabel: Third\ngroup: Guide\nparent: first\n---\n\nText.\n");
277
+ await assert.rejects(readDocs({root, processor: createProcessor({site, root})}), /문서 parent가 순환한다/);
278
+ });
279
+
280
+ test("parent and child must use the same complete group path", async (t) => {
281
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
282
+ const site = siteConfigSchema.parse({});
283
+ t.after(() => rm(root, {recursive: true, force: true}));
284
+
285
+ await writeFile(join(root, "parent.md"), "---\nname: Parent\nlabel: Parent\ngroup: [Projects, Atlas, Research]\n---\n\nText.\n");
286
+ await writeFile(join(root, "child.md"), "---\nname: Child\nlabel: Child\ngroup: [Projects, Beacon, Research]\nparent: parent\n---\n\nText.\n");
287
+ await assert.rejects(readDocs({root, processor: createProcessor({site, root})}), /부모 문서와 group이 다르다: child/);
288
+ });
289
+
151
290
  test("frontmatter supports BOM, CRLF and a closing fence at EOF", async (t) => {
152
291
  const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
153
292
  const site = siteConfigSchema.parse({});
@@ -201,7 +340,7 @@ test("a plain root README renders at home and relative home links resolve from n
201
340
  ]);
202
341
 
203
342
  const [docs, home] = await Promise.all([readDocs({root, processor}), readHome({root, processor})]);
204
- const app = createApp({site, store: {docs, home}, assets: {fontCss: "", preload: [], reload: false}});
343
+ const app = createApp({site, store: {docs, home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
205
344
  const response = await app.request("/");
206
345
  const html = await response.text();
207
346
 
@@ -230,7 +369,7 @@ test("a missing README provides a creation hint without generated overview cards
230
369
 
231
370
  t.after(() => rm(root, {recursive: true, force: true}));
232
371
  const home = await readHome({root, processor: createProcessor({site, root})});
233
- const app = createApp({site, store: {docs: [], home}, assets: {fontCss: "", preload: [], reload: false}});
372
+ const app = createApp({site, store: {docs: [], home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
234
373
  const html = await (await app.request("/")).text();
235
374
 
236
375
  assert.equal(home, undefined);
@@ -309,7 +448,7 @@ test("headings and contents preserve authored text without automatic numbering",
309
448
  );
310
449
 
311
450
  const docs = await readDocs({root, processor: createProcessor({site, root})});
312
- const app = createApp({site, store: {docs}, assets: {fontCss: "", preload: [], reload: false}});
451
+ const app = createApp({site, store: {docs}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
313
452
  const html = await (await app.request("/guide/")).text();
314
453
 
315
454
  assert.match(html, /<h2[^>]*id="overview"[^>]*><span class="wg_prose__headingText">Overview<\/span><\/h2>/);
@@ -1,9 +1,16 @@
1
1
  import {readdir, readFile} from "node:fs/promises";
2
- import {join, sep} from "node:path";
2
+ import {join, posix, sep} from "node:path";
3
3
  import {VFile} from "vfile";
4
4
  import {parse as parseYaml} from "yaml";
5
5
  import {asset_dir, asset_favicon_path} from "@/constant/asset";
6
- import {copy_error_doc_id, copy_error_doc_id_clash, copy_error_frontmatter} from "@/constant/copy";
6
+ import {
7
+ copy_error_doc_id,
8
+ copy_error_doc_id_clash,
9
+ copy_error_doc_parent_cycle,
10
+ copy_error_doc_parent_group,
11
+ copy_error_doc_parent_missing,
12
+ copy_error_frontmatter,
13
+ } from "@/constant/copy";
7
14
  import type {Processor} from "@/content/create-processor";
8
15
  import type {Doc} from "@/type/doc";
9
16
  import {docDataSchema} from "@/type/doc-data";
@@ -49,8 +56,14 @@ export const readDocs = async (options: {root: string; processor: Processor}): P
49
56
  throw new Error(`${copy_error_frontmatter}: ${path}: ${parsed.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
50
57
  }
51
58
 
59
+ // 상대 부모 id는 파일 위치 기준으로 한 번 해소 · 문서 루트를 바꿔도 같은 관계 유지
60
+ const data = {
61
+ ...parsed.data,
62
+ parent: parsed.data.parent !== undefined && /^\.\.?\//.test(parsed.data.parent) ? posix.join(posix.dirname(id), parsed.data.parent) : parsed.data.parent,
63
+ };
64
+
52
65
  // 플러그인의 제목·절 정보 공유
53
- const fh: DocFileData = {frontmatter: parsed.data, headings: [], sections: []};
66
+ const fh: DocFileData = {frontmatter: data, headings: [], sections: []};
54
67
  file.data.fh = fh;
55
68
 
56
69
  // YAML 노드만 제외 · 본문 위치와 오류의 원본 줄·열·offset 유지
@@ -59,7 +72,8 @@ export const readDocs = async (options: {root: string; processor: Processor}): P
59
72
 
60
73
  return {
61
74
  id,
62
- data: parsed.data,
75
+ data,
76
+ ancestors: [],
63
77
  html: options.processor.stringify(rendered, file),
64
78
  outline: {headings: fh.headings, sections: fh.sections},
65
79
  };
@@ -71,5 +85,25 @@ export const readDocs = async (options: {root: string; processor: Processor}): P
71
85
  throw new Error(`${copy_error_doc_id_clash}: ${duplicateIds.map((entry) => entry[0]).join(", ")}`);
72
86
  }
73
87
 
74
- return docs;
88
+ // 모든 파일을 수집한 뒤 부모 참조 검증 · 파일 읽기 순서와 무관한 문서 경로
89
+ const docsById = new Map(docs.map((doc) => [doc.id, doc]));
90
+ return docs.map((doc) => {
91
+ const ancestors = new Set([doc.id]);
92
+ for (let parentId = doc.data.parent; parentId !== undefined; ) {
93
+ const parent = docsById.get(parentId);
94
+ if (parent === undefined) {
95
+ throw new Error(`${copy_error_doc_parent_missing}: ${doc.id} → ${parentId}`);
96
+ }
97
+ if (ancestors.has(parent.id)) {
98
+ throw new Error(`${copy_error_doc_parent_cycle}: ${[...ancestors, parent.id].join(" → ")}`);
99
+ }
100
+ if (JSON.stringify(parent.data.group) !== JSON.stringify(doc.data.group)) {
101
+ throw new Error(`${copy_error_doc_parent_group}: ${doc.id} → ${parent.id}`);
102
+ }
103
+ ancestors.add(parent.id);
104
+ parentId = parent.data.parent;
105
+ }
106
+
107
+ return {...doc, ancestors: [...ancestors].slice(1).toReversed()};
108
+ });
75
109
  };
@@ -0,0 +1,15 @@
1
+ import {locale_doc_name} from "@/constant/locale";
2
+ import type {Doc} from "@/type/doc";
3
+ import type {DocBranch} from "@/type/doc-branch";
4
+
5
+ /**
6
+ * 검증된 부모 관계로 문서 가지를 재귀 구성 · 형제 순서와 URL 유지
7
+ */
8
+ export const toDocBranches = (options: {docs: readonly Doc[]; children: ReadonlyMap<Doc["data"]["parent"], Doc[]>}): DocBranch[] => {
9
+ return options.docs
10
+ .toSorted((a, b) => a.data.order - b.data.order || a.data.name.localeCompare(b.data.name, locale_doc_name) || a.id.localeCompare(b.id, locale_doc_name))
11
+ .map((doc) => {
12
+ const children = options.children.get(doc.id);
13
+ return {...doc, children: children === undefined ? undefined : toDocBranches({docs: children, children: options.children})};
14
+ });
15
+ };