for-humanity 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Documents written for humans to read: a docs kit with a sidebar, parts, sections and flowcharts, built on Hono and React",
5
5
  "keywords": [
6
6
  "docs",
@@ -17,7 +17,7 @@
17
17
  "type": "git",
18
18
  "url": "git+https://github.com/minu-ha/for-humanity.git"
19
19
  },
20
- "homepage": "https://github.com/minu-ha/for-humanity#readme",
20
+ "homepage": "https://for-humanity.fyi",
21
21
  "bugs": {
22
22
  "url": "https://github.com/minu-ha/for-humanity/issues"
23
23
  },
@@ -36,11 +36,12 @@
36
36
  "scripts": {
37
37
  "build:kit": "esbuild src/cli.ts --bundle --platform=node --format=esm --packages=external --jsx=automatic --outfile=dist/cli.js && esbuild src/client.ts --bundle --platform=browser --format=esm --jsx=automatic --minify --define:process.env.NODE_ENV='\"production\"' --outdir=dist",
38
38
  "sync:readme": "node util/sync-readme.mjs",
39
- "dev": "pnpm sync:readme && pnpm build:kit && node dist/cli.js dev templates/document",
40
- "build": "pnpm sync:readme && pnpm build:kit && node dist/cli.js build templates/document",
39
+ "sync:docs": "pnpm sync:readme && node util/sync-changelog.mjs",
40
+ "dev": "pnpm sync:docs && pnpm build:kit && node dist/cli.js dev templates/document",
41
+ "build": "pnpm sync:docs && pnpm build:kit && node dist/cli.js build templates/document",
41
42
  "build:pages": "pnpm build && node util/prepare-pages.mjs",
42
43
  "preview": "node dist/cli.js preview templates/document",
43
- "test": "esbuild src/util/font/to-font-css.test.ts src/content/read-docs.test.ts --bundle --platform=node --format=esm --packages=external --outbase=src --outdir=node_modules/.cache/for-humanity-tests --out-extension:.js=.mjs && node --test node_modules/.cache/for-humanity-tests/content/*.test.mjs node_modules/.cache/for-humanity-tests/util/font/*.test.mjs",
44
+ "test": "esbuild src/util/font/to-font-css.test.ts src/content/read-docs.test.ts --bundle --platform=node --format=esm --packages=external --outbase=src --outdir=node_modules/.cache/for-humanity-tests --out-extension:.js=.mjs && node --test node_modules/.cache/for-humanity-tests/content/*.test.mjs node_modules/.cache/for-humanity-tests/util/font/*.test.mjs util/*.test.mjs",
44
45
  "lint": "biome check && stylelint \"src/**/*.css\"",
45
46
  "lint:fix": "biome check --write && stylelint \"src/**/*.css\" --fix",
46
47
  "typecheck": "tsc --noEmit",
@@ -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
  );
@@ -9,6 +9,8 @@ import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
9
9
  import {createProcessor} from "@/content/create-processor";
10
10
  import {readDocs} from "@/content/read-docs";
11
11
  import {readHome} from "@/content/read-home";
12
+ import {toDocGroups} from "@/content/to-doc-groups/to-doc-groups";
13
+ import {docDataSchema} from "@/type/doc-data";
12
14
  import {siteConfigSchema} from "@/type/site-config";
13
15
 
14
16
  test("documents with the same initial retain distinct URLs", async (t) => {
@@ -67,6 +69,85 @@ test("navigation shows every document group in reading order without document ic
67
69
  assert.match(html, /href="\/writing\/"[^>]*aria-current="page"/);
68
70
  });
69
71
 
72
+ test("group paths preserve literal names and reject empty or invalid segments", () => {
73
+ const data = {name: "Guide", label: "Guide"};
74
+
75
+ assert.deepEqual(docDataSchema.parse({...data, group: " Guide "}).group, ["Guide"]);
76
+ assert.deepEqual(docDataSchema.parse({...data, group: [" Projects ", "Example project", "Research"]}).group, ["Projects", "Example project", "Research"]);
77
+ assert.deepEqual(docDataSchema.parse({...data, group: "Research, decisions / notes"}).group, ["Research, decisions / notes"]);
78
+
79
+ for (const group of ["", " ", [], ["Projects", " "], ["Projects", 1], [["Projects"]], null]) {
80
+ assert.equal(docDataSchema.safeParse({...data, group}).success, false);
81
+ }
82
+
83
+ assert.equal(siteConfigSchema.safeParse({navigation: ["Guide", [" Guide "]]}).success, false);
84
+ assert.equal(siteConfigSchema.safeParse({navigation: [["Projects", ""]]}).success, false);
85
+ assert.equal(siteConfigSchema.safeParse({navigation: []}).success, true);
86
+ });
87
+
88
+ test("nested groups follow metadata paths and keep sibling branches and document URLs distinct", async (t) => {
89
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-groups-"));
90
+ const site = siteConfigSchema.parse({navigation: ["Guide", ["Projects", "Atlas", "Research"], ["Projects", "Atlas", "Decisions"]]});
91
+
92
+ t.after(() => rm(root, {recursive: true, force: true}));
93
+ await Promise.all([
94
+ writeFile(join(root, "guide.md"), "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\nText.\n"),
95
+ writeFile(join(root, "guide-two.md"), "---\nname: More guide\nlabel: Guide\ngroup: [Guide]\n---\n\nText.\n"),
96
+ writeFile(join(root, "overview.md"), "---\nname: Overview\nlabel: Overview\ngroup: [Projects, Atlas]\n---\n\nText.\n"),
97
+ writeFile(join(root, "parts.md"), "---\nname: Parts\nlabel: Parts\ngroup: [Projects, Atlas, Research]\norder: 1\n---\n\nText.\n"),
98
+ writeFile(join(root, "authoring.md"), "---\nname: Authoring\nlabel: Authoring\ngroup: [Projects, Atlas, Research]\norder: 2\n---\n\nText.\n"),
99
+ writeFile(join(root, "decision.md"), "---\nname: Decision\nlabel: Decision\ngroup: [Projects, Atlas, Decisions]\n---\n\nText.\n"),
100
+ writeFile(join(root, "other.md"), "---\nname: Other research\nlabel: Other research\ngroup: [Projects, Beacon, Research]\n---\n\nText.\n"),
101
+ writeFile(join(root, "deep.md"), "---\nname: Deep research\nlabel: Deep research\ngroup: [Projects, Atlas, Research, Archive, Drafts]\n---\n\nText.\n"),
102
+ ]);
103
+
104
+ const docs = await readDocs({root, processor: createProcessor({site, root})});
105
+ const groups = toDocGroups({docs, navigation: site.navigation});
106
+
107
+ assert.deepEqual(
108
+ groups.map((group) => group.name),
109
+ ["Guide", "Projects"],
110
+ );
111
+ assert.deepEqual(
112
+ groups[0].docs.map((doc) => doc.id),
113
+ ["guide", "guide-two"],
114
+ );
115
+ assert.deepEqual(
116
+ groups[1].groups.map((group) => group.name),
117
+ ["Atlas", "Beacon"],
118
+ );
119
+ assert.deepEqual(
120
+ groups[1].groups[0].docs.map((doc) => doc.id),
121
+ ["overview"],
122
+ );
123
+ assert.deepEqual(
124
+ groups[1].groups[0].groups.map((group) => group.name),
125
+ ["Research", "Decisions"],
126
+ );
127
+ assert.deepEqual(
128
+ groups[1].groups[0].groups[0].docs.map((doc) => doc.id),
129
+ ["parts", "authoring"],
130
+ );
131
+ assert.deepEqual(
132
+ groups[1].groups[1].groups[0].docs.map((doc) => doc.id),
133
+ ["other"],
134
+ );
135
+ assert.equal(groups[1].groups[0].groups[0].groups[0].groups[0].name, "Drafts");
136
+ assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
137
+
138
+ const html = renderToStaticMarkup(WgShellNav({site, docs, current: "authoring"}));
139
+
140
+ assert.match(html, /href="\/authoring\/"[^>]*aria-current="page"/);
141
+ assert.match(html, /Atlas<\/span><ul\b/);
142
+ assert.match(html, /Research<\/span><ul\b/);
143
+ assert.equal([...html.matchAll(/href="\/authoring\/"/g)].length, 1);
144
+ assert.equal([...html.matchAll(/>Research<\/span>/g)].length, 2);
145
+ assert.equal([...html.matchAll(/wg_shellNavList__item--current/g)].length, 3);
146
+ assert.doesNotMatch(renderToStaticMarkup(WgShellNav({site, docs})), /aria-current="page"|wg_shellNavList__item--current/);
147
+ assert.ok(html.indexOf('href="/parts/"') < html.indexOf('href="/authoring/"'));
148
+ assert.doesNotMatch(html, /<details\b/);
149
+ });
150
+
70
151
  test("frontmatter supports BOM, CRLF and a closing fence at EOF", async (t) => {
71
152
  const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
72
153
  const site = siteConfigSchema.parse({});
@@ -0,0 +1,58 @@
1
+ import {locale_doc_group, locale_doc_name} from "@/constant/locale";
2
+ import type {Doc} from "@/type/doc";
3
+ import type {DocGroup} from "@/type/doc-group";
4
+
5
+ /**
6
+ * 같은 부모의 문서를 다음 경로 단계로 묶는 재귀 입력
7
+ */
8
+ interface DocGroupBranchesOptions {
9
+ /**
10
+ * 부모 경로에 속한 문서
11
+ */
12
+ docs: readonly Doc[];
13
+ /**
14
+ * 부모까지의 경로 · 최상위는 빈 배열
15
+ */
16
+ path: DocGroup["path"];
17
+ /**
18
+ * 전체 경로의 JSON 키와 navigation 위치
19
+ */
20
+ positions: ReadonlyMap<string, number>;
21
+ /**
22
+ * 미지정 경로를 지정한 묶음 뒤에 놓는 위치
23
+ */
24
+ unlistedPosition: number;
25
+ }
26
+
27
+ /**
28
+ * 각 단계의 직접 문서와 하위 묶음을 분리 · 고정된 깊이 제한 없이 구성
29
+ */
30
+ export const toDocGroupBranches = (options: DocGroupBranchesOptions): DocGroup[] => {
31
+ return [
32
+ ...Map.groupBy(
33
+ options.docs.filter((doc) => doc.data.group.length > options.path.length),
34
+ (doc) => doc.data.group[options.path.length],
35
+ ),
36
+ ]
37
+ .map(([name, docs]) => {
38
+ const path = [...options.path, name];
39
+
40
+ return {
41
+ name,
42
+ path,
43
+ docs,
44
+ position: options.positions.get(JSON.stringify(path)) ?? options.unlistedPosition,
45
+ };
46
+ })
47
+ .toSorted((a, b) => a.position - b.position || a.name.localeCompare(b.name, locale_doc_group))
48
+ .map((group) => {
49
+ return {
50
+ name: group.name,
51
+ path: group.path,
52
+ docs: group.docs
53
+ .filter((doc) => doc.data.group.length === group.path.length)
54
+ .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)),
55
+ groups: toDocGroupBranches({docs: group.docs, path: group.path, positions: options.positions, unlistedPosition: options.unlistedPosition}),
56
+ };
57
+ });
58
+ };
@@ -0,0 +1,14 @@
1
+ import {toDocGroupBranches} from "@/content/to-doc-groups/_to-doc-group-branches";
2
+ import type {Doc} from "@/type/doc";
3
+ import type {SiteConfig} from "@/type/site-config";
4
+
5
+ /**
6
+ * 메타데이터 경로로 읽는 순서를 가진 문서 계층 구성
7
+ * navigation의 첫 지정을 조상에도 적용 · 미지정 형제 묶음은 이름순
8
+ */
9
+ export const toDocGroups = (options: {docs: readonly Doc[]; navigation: SiteConfig["navigation"]}) => {
10
+ // 같은 조상을 지정한 경로는 최초 navigation 위치를 우선
11
+ const positions = new Map(options.navigation.flatMap((path, index) => path.map((_name, depth) => [JSON.stringify(path.slice(0, depth + 1)), index] as const)).toReversed());
12
+
13
+ return toDocGroupBranches({docs: options.docs, path: [], positions, unlistedPosition: options.navigation.length});
14
+ };
@@ -1,5 +1,6 @@
1
1
  import {z} from "zod";
2
2
  import {doc_order_default, doc_type} from "@/constant/doc";
3
+ import {groupPathSchema} from "@/type/group-path";
3
4
 
4
5
  /**
5
6
  * 문서 frontmatter 스키마 · 검증 실패 시 빌드 중단
@@ -18,9 +19,9 @@ export const docDataSchema = z.object({
18
19
  */
19
20
  type: z.enum(doc_type).default(doc_type.document),
20
21
  /**
21
- * 사이드바의 문서 묶음
22
+ * 사이드바의 문서 묶음 경로 · 문자열도 배열로 정규화
22
23
  */
23
- group: z.string().trim().min(1),
24
+ group: groupPathSchema,
24
25
  /**
25
26
  * 묶음 안의 읽는 순서 · 미지정 문서는 뒤에서 이름순
26
27
  */
@@ -0,0 +1,23 @@
1
+ import type {Doc} from "@/type/doc";
2
+
3
+ /**
4
+ * 사이드바의 문서 묶음 · 같은 이름도 전체 경로로 구분
5
+ */
6
+ export interface DocGroup {
7
+ /**
8
+ * 현재 단계의 표시 이름
9
+ */
10
+ name: string;
11
+ /**
12
+ * 최상위부터 현재 단계까지의 경로
13
+ */
14
+ path: Doc["data"]["group"];
15
+ /**
16
+ * 현재 묶음에 직접 속한 문서 · order·제목·id 순
17
+ */
18
+ docs: Doc[];
19
+ /**
20
+ * 현재 묶음의 하위 묶음 · navigation·이름 순
21
+ */
22
+ groups: DocGroup[];
23
+ }
@@ -0,0 +1,7 @@
1
+ import {z} from "zod";
2
+
3
+ /**
4
+ * 탐색 묶음 경로 · 기존 단일 이름과 중첩 배열을 한 번만 정규화
5
+ * 쉼표·슬래시는 이름 그대로 유지하며 빈 경로·단계는 거부
6
+ */
7
+ export const groupPathSchema = z.union([z.string().trim().min(1), z.array(z.string().trim().min(1)).min(1)]).transform((path) => (typeof path === "string" ? [path] : path));