for-humanity 0.1.2 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-10-04
4
+
5
+ - Support nested navigation with `group` name arrays while keeping single-name groups compatible.
6
+ - Order nested sibling groups with paths in `navigation`, independently of document folders.
7
+ - Extend active, hover and keyboard focus tree paths through ancestor groups.
8
+
3
9
  ## 0.1.2 — 2026-10-03
4
10
 
5
11
  Documentation and release automation update. The CLI commands and Node.js requirement are unchanged.
package/README.md CHANGED
@@ -33,7 +33,7 @@ Write Markdown. Keep the context. Share a static site.
33
33
  Node.js 22 or later and pnpm. Install the CLI in your document project:
34
34
 
35
35
  ```sh
36
- pnpm add -D --save-exact for-humanity@0.1.2
36
+ pnpm add -D --save-exact for-humanity@0.2.0
37
37
  mkdir docs
38
38
  ```
39
39
 
@@ -52,7 +52,7 @@ pnpm exec for-humanity preview docs
52
52
 
53
53
  If your Markdown files are in the project root, use `.` instead of `docs`. The root `README.md` becomes the home page; root `AGENTS.md` and `CLAUDE.md` stay out of the site.
54
54
 
55
- Version `0.1.2` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
55
+ Version `0.2.0` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
56
56
  Cloudflare Pages can host the generated site. See [Deployment](https://github.com/minu-ha/for-humanity/blob/main/templates/document/deployment.md) for the build settings.
57
57
 
58
58
  ### Develop the kit
@@ -74,7 +74,7 @@ pnpm preview # Preview the static site
74
74
  pnpm build:pages # Add Cloudflare Pages headers and a 404 page
75
75
  ```
76
76
 
77
- Changes pushed to `main` update the documentation site. A stable version tag such as `v0.1.2` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
77
+ Changes pushed to `main` update the documentation site. A stable version tag such as `v0.2.0` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
78
78
  The npm README updates with each package release. See [Maintenance](https://github.com/minu-ha/for-humanity/blob/main/templates/document/maintenance.md#packaging-and-release) for the release steps.
79
79
 
80
80
  ## Make it yours
@@ -94,6 +94,7 @@ export default {
94
94
 
95
95
  See [Commands](https://github.com/minu-ha/for-humanity/blob/main/templates/document/commands.md) for using another document folder, and [Settings](https://github.com/minu-ha/for-humanity/blob/main/templates/document/settings.md) for configuration.
96
96
  Each document other than the root README chooses a `group` and optional `order`. Pages stay independent; groups do not need parent documents or nested folders. Document names may share the same first letter.
97
+ Use `group: Guide` for one group or `group: [Projects, Example project, Research]` for nested navigation. Group paths do not change document URLs.
97
98
 
98
99
  ## Write with context
99
100
 
package/dist/cli.css CHANGED
@@ -295,22 +295,32 @@
295
295
  }
296
296
  }
297
297
 
298
- /* src/component/widget/shell/_wg-shell-nav.css */
299
- .wg_shellNav__root {
300
- font-size: var(--app-font-size-dense);
301
- line-height: var(--app-line-height-tight);
302
- }
303
- .wg_shellNav__docs {
304
- display: flex;
305
- flex-direction: column;
306
- gap: var(--app-space-stack);
307
- }
308
- .wg_shellNav__list {
298
+ /* src/component/widget/shell/_wg-shell-nav-list.css */
299
+ .wg_shellNavList__root {
309
300
  margin: 0 0 0 var(--app-space-tree-inset);
310
301
  padding: 0;
311
302
  list-style: none;
312
303
  }
313
- .wg_shellNav__item {
304
+ .wg_shellNavList__branch {
305
+ position: relative;
306
+ display: block;
307
+ padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
308
+ color: var(--app-color-text-muted);
309
+ font-size: var(--app-font-size-label);
310
+ line-height: var(--app-line-height-tight);
311
+ overflow-wrap: anywhere;
312
+ &::before {
313
+ position: absolute;
314
+ top: calc(0.5lh + var(--app-space-tree-row));
315
+ left: 0;
316
+ width: var(--app-space-tree-indent);
317
+ border-top: 1px solid var(--app-color-border-soft);
318
+ content: "";
319
+ pointer-events: none;
320
+ }
321
+ }
322
+ .wg_shellNavList__item {
323
+ display: flow-root;
314
324
  position: relative;
315
325
  &::before {
316
326
  position: absolute;
@@ -340,24 +350,36 @@
340
350
  &:where(:has(:focus-visible))::after {
341
351
  border-left-color: var(--app-color-text-strong);
342
352
  }
343
- &:where(:has(~ .wg_shellNav__item:hover))::before {
353
+ &:where(:has(~ .wg_shellNavList__item:hover))::before {
344
354
  border-left-color: var(--app-color-text-strong);
345
355
  }
346
- &:where(:has(~ .wg_shellNav__item :focus-visible))::before {
356
+ &:where(:has(~ .wg_shellNavList__item :focus-visible))::before {
347
357
  border-left-color: var(--app-color-text-strong);
348
358
  }
359
+ & > .wg_shellNavList__root {
360
+ margin-left: var(--app-space-tree-indent);
361
+ }
362
+ &:where(:hover) > .wg_shellNavList__branch::before {
363
+ border-top-color: var(--app-color-text-strong);
364
+ }
365
+ &:where(:has(:focus-visible)) > .wg_shellNavList__branch::before {
366
+ border-top-color: var(--app-color-text-strong);
367
+ }
349
368
  }
350
- .wg_shellNav__item--trail {
369
+ .wg_shellNavList__item--trail {
351
370
  &::before {
352
371
  border-left-color: var(--app-color-text-strong);
353
372
  }
354
373
  }
355
- .wg_shellNav__item--current {
374
+ .wg_shellNavList__item--current {
356
375
  &::after {
357
376
  border-left-color: var(--app-color-text-strong);
358
377
  }
378
+ & > .wg_shellNavList__branch::before {
379
+ border-top-color: var(--app-color-text-strong);
380
+ }
359
381
  }
360
- .wg_shellNav__link {
382
+ .wg_shellNavList__link {
361
383
  position: relative;
362
384
  display: block;
363
385
  padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
@@ -365,6 +387,7 @@
365
387
  font-size: var(--app-font-size-dense);
366
388
  line-height: var(--app-line-height-tight);
367
389
  text-decoration: none;
390
+ overflow-wrap: anywhere;
368
391
  &::before {
369
392
  position: absolute;
370
393
  top: calc(0.5lh + var(--app-space-tree-row));
@@ -388,13 +411,24 @@
388
411
  border-top-color: currentcolor;
389
412
  }
390
413
  }
391
- .wg_shellNav__link--active {
414
+ .wg_shellNavList__link--active {
392
415
  color: var(--app-color-text-strong);
393
416
  font-weight: var(--app-font-weight-active);
394
417
  &::before {
395
418
  border-top-color: currentcolor;
396
419
  }
397
420
  }
421
+
422
+ /* src/component/widget/shell/_wg-shell-nav.css */
423
+ .wg_shellNav__root {
424
+ font-size: var(--app-font-size-dense);
425
+ line-height: var(--app-line-height-tight);
426
+ }
427
+ .wg_shellNav__docs {
428
+ display: flex;
429
+ flex-direction: column;
430
+ gap: var(--app-space-stack);
431
+ }
398
432
  .wg_shellNav__group {
399
433
  margin: 0 0 4px;
400
434
  color: var(--app-color-text-muted);
package/dist/cli.js CHANGED
@@ -30,7 +30,7 @@ var WgProse = (props) => {
30
30
  };
31
31
 
32
32
  // src/component/widget/shell/wg-shell.tsx
33
- import clsx4 from "clsx";
33
+ import clsx5 from "clsx";
34
34
 
35
35
  // src/component/widget/shell/_function/to-toc-groups.ts
36
36
  import { takeWhile } from "es-toolkit/array";
@@ -66,7 +66,50 @@ var toTocGroups = (outline) => {
66
66
  };
67
67
 
68
68
  // src/component/widget/shell/_wg-shell-nav.tsx
69
+ import clsx3 from "clsx";
70
+
71
+ // src/component/widget/shell/_wg-shell-nav-list.tsx
69
72
  import clsx2 from "clsx";
73
+ import { Fragment } from "react";
74
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
75
+ var WgShellNavList = (props) => {
76
+ const entries = [
77
+ ...props.group.docs.map((doc) => ({ kind: "document", key: `doc:${doc.id}`, doc, current: doc.id === props.currentDoc?.id })),
78
+ ...props.group.groups.map((group) => ({
79
+ kind: "group",
80
+ key: `group:${JSON.stringify(group.path)}`,
81
+ group,
82
+ current: group.path.every((name, index) => name === props.currentDoc?.data.group[index])
83
+ }))
84
+ ];
85
+ const currentIndex = entries.findIndex((entry) => entry.current);
86
+ return /* @__PURE__ */ jsx2("ul", { className: clsx2("wg_shellNavList__root"), "aria-label": props.group.name, children: entries.map((entry, index) => /* @__PURE__ */ jsxs2(
87
+ "li",
88
+ {
89
+ className: clsx2("wg_shellNavList__item", {
90
+ "wg_shellNavList__item--trail": index < currentIndex,
91
+ "wg_shellNavList__item--current": entry.current
92
+ }),
93
+ children: [
94
+ entry.kind === "document" && /* @__PURE__ */ jsx2(
95
+ "a",
96
+ {
97
+ className: clsx2("wg_shellNavList__link", { "wg_shellNavList__link--active": entry.current }),
98
+ href: `/${entry.doc.id}/`,
99
+ title: entry.doc.data.label,
100
+ "aria-current": entry.current ? "page" : void 0,
101
+ children: entry.doc.data.name
102
+ }
103
+ ),
104
+ entry.kind === "group" && /* @__PURE__ */ jsxs2(Fragment, { children: [
105
+ /* @__PURE__ */ jsx2("span", { className: clsx2("wg_shellNavList__branch"), children: entry.group.name }),
106
+ /* @__PURE__ */ jsx2(WgShellNavList, { group: entry.group, currentDoc: props.currentDoc })
107
+ ] })
108
+ ]
109
+ },
110
+ entry.key
111
+ )) });
112
+ };
70
113
 
71
114
  // src/constant/copy.ts
72
115
  var copy_site_title_default = "for humanity";
@@ -95,61 +138,60 @@ var copy_warn_unknown_status = "\uC124\uC815\uC5D0 \uC5C6\uB294 \uC0C1\uD0DC \uB
95
138
  var locale_doc_name = "en";
96
139
  var locale_doc_group = "ko";
97
140
 
98
- // src/content/to-doc-groups.ts
141
+ // src/content/to-doc-groups/_to-doc-group-branches.ts
142
+ var toDocGroupBranches = (options) => {
143
+ return [
144
+ ...Map.groupBy(
145
+ options.docs.filter((doc) => doc.data.group.length > options.path.length),
146
+ (doc) => doc.data.group[options.path.length]
147
+ )
148
+ ].map(([name, docs]) => {
149
+ const path = [...options.path, name];
150
+ return {
151
+ name,
152
+ path,
153
+ docs,
154
+ position: options.positions.get(JSON.stringify(path)) ?? options.unlistedPosition
155
+ };
156
+ }).toSorted((a, b) => a.position - b.position || a.name.localeCompare(b.name, locale_doc_group)).map((group) => {
157
+ return {
158
+ name: group.name,
159
+ path: group.path,
160
+ docs: group.docs.filter((doc) => doc.data.group.length === group.path.length).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)),
161
+ groups: toDocGroupBranches({ docs: group.docs, path: group.path, positions: options.positions, unlistedPosition: options.unlistedPosition })
162
+ };
163
+ });
164
+ };
165
+
166
+ // src/content/to-doc-groups/to-doc-groups.ts
99
167
  var toDocGroups = (options) => {
100
- const positions = new Map(options.navigation.map((name, index) => [name, index]));
101
- return [...Map.groupBy(options.docs, (doc) => doc.data.group)].toSorted((a, b) => (positions.get(a[0]) ?? options.navigation.length) - (positions.get(b[0]) ?? options.navigation.length) || a[0].localeCompare(b[0], locale_doc_group)).map(([name, docs]) => ({
102
- name,
103
- docs: docs.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))
104
- }));
168
+ const positions = new Map(options.navigation.flatMap((path, index) => path.map((_name, depth) => [JSON.stringify(path.slice(0, depth + 1)), index])).toReversed());
169
+ return toDocGroupBranches({ docs: options.docs, path: [], positions, unlistedPosition: options.navigation.length });
105
170
  };
106
171
 
107
172
  // src/component/widget/shell/_wg-shell-nav.tsx
108
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
173
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
109
174
  var WgShellNav = (props) => {
110
175
  const docGroups = toDocGroups({ docs: props.docs, navigation: props.site.navigation });
111
- return /* @__PURE__ */ jsx2("nav", { className: clsx2("wg_shellNav__root"), "aria-label": copy_nav_aria_label, children: /* @__PURE__ */ jsx2("div", { className: clsx2("wg_shellNav__docs"), children: docGroups.map((group) => {
112
- const currentIndex = group.docs.findIndex((doc) => doc.id === props.current);
113
- return /* @__PURE__ */ jsxs2("section", { className: clsx2("wg_shellNav__docGroup"), "aria-label": group.name, children: [
114
- /* @__PURE__ */ jsx2("div", { className: clsx2("wg_shellNav__group"), children: group.name }),
115
- /* @__PURE__ */ jsx2("ul", { className: clsx2("wg_shellNav__list"), children: group.docs.map((doc, index) => /* @__PURE__ */ jsx2(
116
- "li",
117
- {
118
- className: clsx2("wg_shellNav__item", {
119
- "wg_shellNav__item--trail": index < currentIndex,
120
- "wg_shellNav__item--current": index === currentIndex
121
- }),
122
- children: /* @__PURE__ */ jsx2(
123
- "a",
124
- {
125
- className: clsx2("wg_shellNav__link", {
126
- "wg_shellNav__link--active": index === currentIndex
127
- }),
128
- href: `/${doc.id}/`,
129
- title: doc.data.label,
130
- "aria-current": index === currentIndex ? "page" : void 0,
131
- children: doc.data.name
132
- }
133
- )
134
- },
135
- doc.id
136
- )) })
137
- ] }, group.name);
138
- }) }) });
176
+ const currentDoc = props.docs.find((doc) => doc.id === props.current);
177
+ return /* @__PURE__ */ jsx3("nav", { className: clsx3("wg_shellNav__root"), "aria-label": copy_nav_aria_label, children: /* @__PURE__ */ jsx3("div", { className: clsx3("wg_shellNav__docs"), children: docGroups.map((group) => /* @__PURE__ */ jsxs3("section", { className: clsx3("wg_shellNav__docGroup"), "aria-label": group.name, children: [
178
+ /* @__PURE__ */ jsx3("div", { className: clsx3("wg_shellNav__group"), children: group.name }),
179
+ /* @__PURE__ */ jsx3(WgShellNavList, { group, currentDoc })
180
+ ] }, group.name)) }) });
139
181
  };
140
182
 
141
183
  // src/component/widget/shell/_wg-shell-toc.tsx
142
- import clsx3 from "clsx";
143
- import { Fragment } from "react";
144
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
184
+ import clsx4 from "clsx";
185
+ import { Fragment as Fragment2 } from "react";
186
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
145
187
  var WgShellToc = (props) => {
146
- return /* @__PURE__ */ jsxs3("nav", { className: clsx3("wg_shellToc__root"), "aria-label": copy_nav_toc_label, children: [
147
- /* @__PURE__ */ jsx3("div", { className: clsx3("wg_shellToc__label"), children: copy_nav_toc_label }),
148
- props.groups.map((group) => /* @__PURE__ */ jsxs3(Fragment, { children: [
149
- group.part !== void 0 && /* @__PURE__ */ jsx3("div", { className: clsx3("wg_shellToc__group"), children: group.part }),
150
- /* @__PURE__ */ jsx3("ul", { className: clsx3("wg_shellToc__list"), children: group.sections.map((section) => /* @__PURE__ */ jsxs3("li", { className: clsx3("wg_shellToc__item"), children: [
151
- /* @__PURE__ */ jsx3("a", { className: clsx3("wg_shellToc__link"), href: `#${section.heading.slug}`, "data-toc-link": "", children: section.heading.text }),
152
- section.subs.length > 0 && /* @__PURE__ */ jsx3("ul", { className: clsx3("wg_shellToc__sub"), children: section.subs.map((sub) => /* @__PURE__ */ jsx3("li", { className: clsx3("wg_shellToc__subItem"), children: /* @__PURE__ */ jsx3("a", { className: clsx3("wg_shellToc__subLink"), href: `#${sub.heading.slug}`, "data-toc-sub-link": "", children: sub.heading.text }) }, sub.heading.slug)) })
188
+ return /* @__PURE__ */ jsxs4("nav", { className: clsx4("wg_shellToc__root"), "aria-label": copy_nav_toc_label, children: [
189
+ /* @__PURE__ */ jsx4("div", { className: clsx4("wg_shellToc__label"), children: copy_nav_toc_label }),
190
+ props.groups.map((group) => /* @__PURE__ */ jsxs4(Fragment2, { children: [
191
+ group.part !== void 0 && /* @__PURE__ */ jsx4("div", { className: clsx4("wg_shellToc__group"), children: group.part }),
192
+ /* @__PURE__ */ jsx4("ul", { className: clsx4("wg_shellToc__list"), children: group.sections.map((section) => /* @__PURE__ */ jsxs4("li", { className: clsx4("wg_shellToc__item"), children: [
193
+ /* @__PURE__ */ jsx4("a", { className: clsx4("wg_shellToc__link"), href: `#${section.heading.slug}`, "data-toc-link": "", children: section.heading.text }),
194
+ section.subs.length > 0 && /* @__PURE__ */ jsx4("ul", { className: clsx4("wg_shellToc__sub"), children: section.subs.map((sub) => /* @__PURE__ */ jsx4("li", { className: clsx4("wg_shellToc__subItem"), children: /* @__PURE__ */ jsx4("a", { className: clsx4("wg_shellToc__subLink"), href: `#${sub.heading.slug}`, "data-toc-sub-link": "", children: sub.heading.text }) }, sub.heading.slug)) })
153
195
  ] }, section.heading.slug)) })
154
196
  ] }, group.sections[0].heading.slug))
155
197
  ] });
@@ -171,85 +213,85 @@ var asset_content_types = {
171
213
  var asset_content_type_default = "application/octet-stream";
172
214
 
173
215
  // src/component/widget/shell/wg-shell.tsx
174
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
216
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
175
217
  var reloadScript = `new EventSource(${JSON.stringify(asset_reload_path)}).onmessage=function(){location.reload()}`;
176
218
  var WgShell = (props) => {
177
219
  const tocGroups = props.outline === void 0 ? [] : toTocGroups(props.outline);
178
- return /* @__PURE__ */ jsxs4("html", { lang: "ko", children: [
179
- /* @__PURE__ */ jsxs4("head", { children: [
180
- /* @__PURE__ */ jsx4("meta", { charSet: "utf-8" }),
181
- /* @__PURE__ */ jsx4("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }),
182
- props.site.description !== void 0 && /* @__PURE__ */ jsx4("meta", { name: "description", content: props.site.description }),
183
- /* @__PURE__ */ jsx4("title", { children: props.title === void 0 ? props.site.title : `${props.title} \xB7 ${props.site.title}` }),
184
- /* @__PURE__ */ jsx4("link", { rel: "icon", href: asset_favicon_path, type: "image/svg+xml" }),
185
- /* @__PURE__ */ jsx4("style", { dangerouslySetInnerHTML: { __html: props.assets.fontCss } }),
186
- props.assets.preload.map((href) => /* @__PURE__ */ jsx4("link", { rel: "preload", href, as: "font", type: "font/woff2", crossOrigin: "" }, href)),
187
- /* @__PURE__ */ jsx4("script", { dangerouslySetInnerHTML: { __html: 'document.documentElement.style.scrollBehavior="auto"' } }),
188
- /* @__PURE__ */ jsx4("link", { rel: "stylesheet", href: asset_style_path }),
189
- props.assets.reload && /* @__PURE__ */ jsx4("script", { dangerouslySetInnerHTML: { __html: reloadScript } })
220
+ return /* @__PURE__ */ jsxs5("html", { lang: "ko", children: [
221
+ /* @__PURE__ */ jsxs5("head", { children: [
222
+ /* @__PURE__ */ jsx5("meta", { charSet: "utf-8" }),
223
+ /* @__PURE__ */ jsx5("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }),
224
+ props.site.description !== void 0 && /* @__PURE__ */ jsx5("meta", { name: "description", content: props.site.description }),
225
+ /* @__PURE__ */ jsx5("title", { children: props.title === void 0 ? props.site.title : `${props.title} \xB7 ${props.site.title}` }),
226
+ /* @__PURE__ */ jsx5("link", { rel: "icon", href: asset_favicon_path, type: "image/svg+xml" }),
227
+ /* @__PURE__ */ jsx5("style", { dangerouslySetInnerHTML: { __html: props.assets.fontCss } }),
228
+ props.assets.preload.map((href) => /* @__PURE__ */ jsx5("link", { rel: "preload", href, as: "font", type: "font/woff2", crossOrigin: "" }, href)),
229
+ /* @__PURE__ */ jsx5("script", { dangerouslySetInnerHTML: { __html: 'document.documentElement.style.scrollBehavior="auto"' } }),
230
+ /* @__PURE__ */ jsx5("link", { rel: "stylesheet", href: asset_style_path }),
231
+ props.assets.reload && /* @__PURE__ */ jsx5("script", { dangerouslySetInnerHTML: { __html: reloadScript } })
190
232
  ] }),
191
- /* @__PURE__ */ jsxs4("body", { className: clsx4("wg_shell__root"), children: [
192
- /* @__PURE__ */ jsxs4("div", { className: clsx4("wg_shell__sidebar"), children: [
193
- /* @__PURE__ */ jsxs4("div", { className: clsx4("wg_shell__brandRow"), children: [
194
- /* @__PURE__ */ jsx4("a", { className: clsx4("wg_shell__brand"), href: "/", "aria-current": props.current === void 0 ? "page" : void 0, children: props.site.title }),
195
- /* @__PURE__ */ jsx4(
233
+ /* @__PURE__ */ jsxs5("body", { className: clsx5("wg_shell__root"), children: [
234
+ /* @__PURE__ */ jsxs5("div", { className: clsx5("wg_shell__sidebar"), children: [
235
+ /* @__PURE__ */ jsxs5("div", { className: clsx5("wg_shell__brandRow"), children: [
236
+ /* @__PURE__ */ jsx5("a", { className: clsx5("wg_shell__brand"), href: "/", "aria-current": props.current === void 0 ? "page" : void 0, children: props.site.title }),
237
+ /* @__PURE__ */ jsx5(
196
238
  "button",
197
239
  {
198
- className: clsx4("wg_shell__menuToggle"),
240
+ className: clsx5("wg_shell__menuToggle"),
199
241
  type: "button",
200
242
  "aria-label": copy_nav_open,
201
243
  "aria-controls": "fh-navigation-drawer",
202
244
  "aria-expanded": "false",
203
245
  "data-navigation-open": "",
204
- children: /* @__PURE__ */ jsx4("svg", { className: clsx4("wg_shell__menuIcon"), viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: /* @__PURE__ */ jsx4("path", { d: "M4 6h16M4 12h16M4 18h16" }) })
246
+ children: /* @__PURE__ */ jsx5("svg", { className: clsx5("wg_shell__menuIcon"), viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: /* @__PURE__ */ jsx5("path", { d: "M4 6h16M4 12h16M4 18h16" }) })
205
247
  }
206
248
  )
207
249
  ] }),
208
- /* @__PURE__ */ jsxs4("div", { className: clsx4("wg_shell__navigation"), "data-navigation": "", children: [
209
- /* @__PURE__ */ jsx4(WgShellNav, { site: props.site, docs: props.docs, current: props.current }),
210
- tocGroups.length > 0 && /* @__PURE__ */ jsx4(WgShellToc, { groups: tocGroups })
250
+ /* @__PURE__ */ jsxs5("div", { className: clsx5("wg_shell__navigation"), "data-navigation": "", children: [
251
+ /* @__PURE__ */ jsx5(WgShellNav, { site: props.site, docs: props.docs, current: props.current }),
252
+ tocGroups.length > 0 && /* @__PURE__ */ jsx5(WgShellToc, { groups: tocGroups })
211
253
  ] })
212
254
  ] }),
213
- /* @__PURE__ */ jsx4("main", { className: clsx4("wg_shell__main"), children: props.children }),
214
- /* @__PURE__ */ jsxs4("dialog", { className: clsx4("wg_shell__drawer"), id: "fh-navigation-drawer", "aria-labelledby": "fh-navigation-title", "data-navigation-drawer": "", children: [
215
- /* @__PURE__ */ jsxs4("div", { className: clsx4("wg_shell__drawerHeader"), children: [
216
- /* @__PURE__ */ jsx4("h2", { className: clsx4("wg_shell__drawerTitle"), id: "fh-navigation-title", children: copy_nav_title }),
217
- /* @__PURE__ */ jsx4("button", { className: clsx4("wg_shell__drawerClose"), type: "button", "aria-label": copy_nav_close, "data-navigation-close": "", children: /* @__PURE__ */ jsx4("svg", { className: clsx4("wg_shell__menuIcon"), viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: /* @__PURE__ */ jsx4("path", { d: "m6 6 12 12M18 6 6 18" }) }) })
255
+ /* @__PURE__ */ jsx5("main", { className: clsx5("wg_shell__main"), children: props.children }),
256
+ /* @__PURE__ */ jsxs5("dialog", { className: clsx5("wg_shell__drawer"), id: "fh-navigation-drawer", "aria-labelledby": "fh-navigation-title", "data-navigation-drawer": "", children: [
257
+ /* @__PURE__ */ jsxs5("div", { className: clsx5("wg_shell__drawerHeader"), children: [
258
+ /* @__PURE__ */ jsx5("h2", { className: clsx5("wg_shell__drawerTitle"), id: "fh-navigation-title", children: copy_nav_title }),
259
+ /* @__PURE__ */ jsx5("button", { className: clsx5("wg_shell__drawerClose"), type: "button", "aria-label": copy_nav_close, "data-navigation-close": "", children: /* @__PURE__ */ jsx5("svg", { className: clsx5("wg_shell__menuIcon"), viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: /* @__PURE__ */ jsx5("path", { d: "m6 6 12 12M18 6 6 18" }) }) })
218
260
  ] }),
219
- /* @__PURE__ */ jsx4("div", { className: clsx4("wg_shell__drawerContent"), "data-navigation-content": "" })
261
+ /* @__PURE__ */ jsx5("div", { className: clsx5("wg_shell__drawerContent"), "data-navigation-content": "" })
220
262
  ] }),
221
- /* @__PURE__ */ jsx4("img", { className: clsx4("wg_shell__cursorFace"), src: asset_favicon_path, width: "24", height: "24", alt: "", "aria-hidden": "true", draggable: false, "data-cursor-face": "" }),
222
- /* @__PURE__ */ jsx4("script", { type: "module", src: asset_client_path })
263
+ /* @__PURE__ */ jsx5("img", { className: clsx5("wg_shell__cursorFace"), src: asset_favicon_path, width: "24", height: "24", alt: "", "aria-hidden": "true", draggable: false, "data-cursor-face": "" }),
264
+ /* @__PURE__ */ jsx5("script", { type: "module", src: asset_client_path })
223
265
  ] })
224
266
  ] });
225
267
  };
226
268
 
227
269
  // src/page/doc/pg-doc.tsx
228
- import { jsx as jsx5 } from "react/jsx-runtime";
270
+ import { jsx as jsx6 } from "react/jsx-runtime";
229
271
  var PgDoc = (props) => {
230
- return /* @__PURE__ */ jsx5(WgShell, { site: props.site, docs: props.docs, assets: props.assets, title: props.doc.data.name, current: props.doc.id, outline: props.doc.outline, children: /* @__PURE__ */ jsx5(WgProse, { html: props.doc.html }) });
272
+ return /* @__PURE__ */ jsx6(WgShell, { site: props.site, docs: props.docs, assets: props.assets, title: props.doc.data.name, current: props.doc.id, outline: props.doc.outline, children: /* @__PURE__ */ jsx6(WgProse, { html: props.doc.html }) });
231
273
  };
232
274
 
233
275
  // src/page/home/pg-home.tsx
234
- import { jsx as jsx6 } from "react/jsx-runtime";
276
+ import { jsx as jsx7 } from "react/jsx-runtime";
235
277
  var PgHome = (props) => {
236
- return /* @__PURE__ */ jsx6(WgShell, { site: props.site, docs: props.docs, assets: props.assets, outline: props.home?.outline, children: props.home === void 0 ? /* @__PURE__ */ jsx6(WgProse, { head: { title: props.site.title, lead: copy_home_missing } }) : /* @__PURE__ */ jsx6(WgProse, { html: props.home.html }) });
278
+ return /* @__PURE__ */ jsx7(WgShell, { site: props.site, docs: props.docs, assets: props.assets, outline: props.home?.outline, children: props.home === void 0 ? /* @__PURE__ */ jsx7(WgProse, { head: { title: props.site.title, lead: copy_home_missing } }) : /* @__PURE__ */ jsx7(WgProse, { html: props.home.html }) });
237
279
  };
238
280
 
239
281
  // src/app.tsx
240
- import { jsx as jsx7 } from "react/jsx-runtime";
282
+ import { jsx as jsx8 } from "react/jsx-runtime";
241
283
  var toHtml = (page) => {
242
284
  return `<!DOCTYPE html>${renderToStaticMarkup(page)}`;
243
285
  };
244
286
  var createApp = (options) => {
245
287
  const app = new Hono();
246
- app.get("/", (c) => c.html(toHtml(/* @__PURE__ */ jsx7(PgHome, { site: options.site, docs: options.store.docs, assets: options.assets, home: options.store.home }))));
288
+ app.get("/", (c) => c.html(toHtml(/* @__PURE__ */ jsx8(PgHome, { site: options.site, docs: options.store.docs, assets: options.assets, home: options.store.home }))));
247
289
  app.get(
248
290
  "/:slug{.+}/",
249
291
  ssgParams(() => options.store.docs.map((doc) => ({ slug: doc.id }))),
250
292
  (c) => {
251
293
  const doc = options.store.docs.find((entry) => entry.id === c.req.param("slug"));
252
- return doc === void 0 ? c.notFound() : c.html(toHtml(/* @__PURE__ */ jsx7(PgDoc, { site: options.site, docs: options.store.docs, assets: options.assets, doc })));
294
+ return doc === void 0 ? c.notFound() : c.html(toHtml(/* @__PURE__ */ jsx8(PgDoc, { site: options.site, docs: options.store.docs, assets: options.assets, doc })));
253
295
  }
254
296
  );
255
297
  return app;
@@ -935,7 +977,7 @@ import { VFile } from "vfile";
935
977
  import { parse as parseYaml } from "yaml";
936
978
 
937
979
  // src/type/doc-data.ts
938
- import { z } from "zod";
980
+ import { z as z2 } from "zod";
939
981
 
940
982
  // src/constant/doc.ts
941
983
  var doc_type = {
@@ -944,28 +986,32 @@ var doc_type = {
944
986
  };
945
987
  var doc_order_default = Number.MAX_SAFE_INTEGER;
946
988
 
989
+ // src/type/group-path.ts
990
+ import { z } from "zod";
991
+ var groupPathSchema = z.union([z.string().trim().min(1), z.array(z.string().trim().min(1)).min(1)]).transform((path) => typeof path === "string" ? [path] : path);
992
+
947
993
  // src/type/doc-data.ts
948
- var docDataSchema = z.object({
994
+ var docDataSchema = z2.object({
949
995
  /**
950
996
  * 문서 이름 · 제목·목록 · 같은 첫 글자 허용
951
997
  */
952
- name: z.string().trim().min(1),
998
+ name: z2.string().trim().min(1),
953
999
  /**
954
1000
  * 짧은 문서 설명 · 사이드바 툴팁
955
1001
  */
956
- label: z.string(),
1002
+ label: z2.string(),
957
1003
  /**
958
1004
  * 문서 종류 · blueprint 전용 표현 미구현
959
1005
  */
960
- type: z.enum(doc_type).default(doc_type.document),
1006
+ type: z2.enum(doc_type).default(doc_type.document),
961
1007
  /**
962
- * 사이드바의 문서 묶음
1008
+ * 사이드바의 문서 묶음 경로 · 문자열도 배열로 정규화
963
1009
  */
964
- group: z.string().trim().min(1),
1010
+ group: groupPathSchema,
965
1011
  /**
966
1012
  * 묶음 안의 읽는 순서 · 미지정 문서는 뒤에서 이름순
967
1013
  */
968
- order: z.number().int().nonnegative().default(doc_order_default)
1014
+ order: z2.number().int().nonnegative().default(doc_order_default)
969
1015
  });
970
1016
 
971
1017
  // src/content/read-docs.ts
@@ -1068,7 +1114,7 @@ var createDevApp = (options) => {
1068
1114
  };
1069
1115
 
1070
1116
  // src/type/site-config.ts
1071
- import { z as z2 } from "zod";
1117
+ import { z as z3 } from "zod";
1072
1118
 
1073
1119
  // src/constant/status.ts
1074
1120
  var status_kind = {
@@ -1081,36 +1127,36 @@ var status_default_phrases = [
1081
1127
  ];
1082
1128
 
1083
1129
  // src/type/site-config.ts
1084
- var siteConfigSchema = z2.object({
1130
+ var siteConfigSchema = z3.object({
1085
1131
  /**
1086
1132
  * 사이드바 이름과 탭 제목
1087
1133
  */
1088
- title: z2.string().default(copy_site_title_default),
1134
+ title: z3.string().default(copy_site_title_default),
1089
1135
  /**
1090
1136
  * HTML 설명 메타데이터 · 선택
1091
1137
  */
1092
- description: z2.string().optional(),
1138
+ description: z3.string().optional(),
1093
1139
  /**
1094
- * 탐색 묶음의 읽는 순서 · 미지정 묶음은 뒤에서 이름순
1140
+ * 탐색 묶음 경로의 읽는 순서 · 미지정 형제 묶음은 뒤에서 이름순
1095
1141
  */
1096
- navigation: z2.array(z2.string().trim().min(1)).refine((groups) => new Set(groups).size === groups.length, copy_error_navigation_clash).default([...site_navigation_default]),
1142
+ navigation: z3.array(groupPathSchema).refine((groups) => new Set(groups.map((path) => JSON.stringify(path))).size === groups.length, copy_error_navigation_clash).default(site_navigation_default.map((name) => [name])),
1097
1143
  /**
1098
1144
  * 본문에서 상태 표지로 표시할 문구
1099
1145
  */
1100
- status: z2.array(
1101
- z2.object({
1146
+ status: z3.array(
1147
+ z3.object({
1102
1148
  /**
1103
1149
  * 찾을 문구 원문
1104
1150
  */
1105
- phrase: z2.string().min(1).refine((phrase) => phrase.trim().length > 0),
1151
+ phrase: z3.string().min(1).refine((phrase) => phrase.trim().length > 0),
1106
1152
  /**
1107
1153
  * 상태 표지의 색 종류
1108
1154
  */
1109
- kind: z2.enum(status_kind),
1155
+ kind: z3.enum(status_kind),
1110
1156
  /**
1111
1157
  * 문구 뒤의 YYYY-MM-DD 날짜 포함 여부
1112
1158
  */
1113
- date: z2.boolean().optional()
1159
+ date: z3.boolean().optional()
1114
1160
  })
1115
1161
  ).default(status_default_phrases)
1116
1162
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.1.2",
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",
@@ -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));
@@ -2,6 +2,7 @@ import {z} from "zod";
2
2
  import {copy_error_navigation_clash, copy_site_title_default} from "@/constant/copy";
3
3
  import {site_navigation_default} from "@/constant/site";
4
4
  import {status_default_phrases, status_kind} from "@/constant/status";
5
+ import {groupPathSchema} from "@/type/group-path";
5
6
 
6
7
  /**
7
8
  * for-humanity.config.mjs 설정 스키마
@@ -17,12 +18,12 @@ export const siteConfigSchema = z.object({
17
18
  */
18
19
  description: z.string().optional(),
19
20
  /**
20
- * 탐색 묶음의 읽는 순서 · 미지정 묶음은 뒤에서 이름순
21
+ * 탐색 묶음 경로의 읽는 순서 · 미지정 형제 묶음은 뒤에서 이름순
21
22
  */
22
23
  navigation: z
23
- .array(z.string().trim().min(1))
24
- .refine((groups) => new Set(groups).size === groups.length, copy_error_navigation_clash)
25
- .default([...site_navigation_default]),
24
+ .array(groupPathSchema)
25
+ .refine((groups) => new Set(groups.map((path) => JSON.stringify(path))).size === groups.length, copy_error_navigation_clash)
26
+ .default(site_navigation_default.map((name) => [name])),
26
27
  /**
27
28
  * 본문에서 상태 표지로 표시할 문구
28
29
  */
@@ -1,18 +0,0 @@
1
- import {locale_doc_group, locale_doc_name} from "@/constant/locale";
2
- import type {Doc} from "@/type/doc";
3
- import type {SiteConfig} from "@/type/site-config";
4
-
5
- /**
6
- * 사이드바의 읽는 순서 · 설정 밖 묶음은 뒤에서 이름순
7
- * 같은 문서 이름·순서도 파일 id로 구분해 탐색 순서를 고정
8
- */
9
- export const toDocGroups = (options: {docs: readonly Doc[]; navigation: SiteConfig["navigation"]}) => {
10
- const positions = new Map(options.navigation.map((name, index) => [name, index]));
11
-
12
- return [...Map.groupBy(options.docs, (doc) => doc.data.group)]
13
- .toSorted((a, b) => (positions.get(a[0]) ?? options.navigation.length) - (positions.get(b[0]) ?? options.navigation.length) || a[0].localeCompare(b[0], locale_doc_group))
14
- .map(([name, docs]) => ({
15
- name,
16
- docs: docs.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)),
17
- }));
18
- };