for-humanity 0.4.0 → 0.4.2
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 +11 -0
- package/README.md +3 -3
- package/dist/cli.css +69 -0
- package/dist/cli.js +53 -19
- package/dist/client.js +5 -4
- package/package.json +2 -2
- package/src/cli.ts +36 -28
- package/src/client.ts +2 -0
- package/src/component/widget/shell/_constant/navigation.ts +5 -0
- package/src/component/widget/shell/_function/bind-navigation-scroll.ts +123 -0
- package/src/component/widget/shell/wg-shell.css +90 -0
- package/src/constant/cli.ts +5 -0
- package/src/util/async/to-change-queue.test.ts +58 -0
- package/src/util/async/to-change-queue.ts +43 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.2 — 2026-10-04
|
|
6
|
+
|
|
7
|
+
- Preserve sidebar and mobile drawer scroll positions across document navigation and reloads within a tab, independently of the main content.
|
|
8
|
+
- Show thin native navigation scrollbar thumbs on hover or keyboard focus, with a transparent track, Firefox thin fallback and system colors in forced-color environments.
|
|
9
|
+
- Explain when related sections belong in one page's headings and when a separate child document is appropriate.
|
|
10
|
+
|
|
11
|
+
## 0.4.1 — 2026-10-04
|
|
12
|
+
|
|
13
|
+
- Coalesce consecutive document and asset saves and serialize dev refreshes so bulk AI edits cannot start overlapping full-site renders and exhaust memory.
|
|
14
|
+
- Continue queued and later edits after failed processing, and apply only the latest valid generation.
|
|
15
|
+
|
|
5
16
|
## 0.4.0 — 2026-10-04
|
|
6
17
|
|
|
7
18
|
- Copy explicitly referenced local images and supported attachments to fingerprinted asset URLs, resolving paths relative to their Markdown files in both build and dev.
|
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.4.
|
|
36
|
+
pnpm add -D --save-exact for-humanity@0.4.2
|
|
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.4.
|
|
55
|
+
Version `0.4.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).
|
|
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.4.
|
|
77
|
+
Changes pushed to `main` update the documentation site. A stable version tag such as `v0.4.2` 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
|
package/dist/cli.css
CHANGED
|
@@ -888,7 +888,27 @@
|
|
|
888
888
|
max-height: 100vh;
|
|
889
889
|
margin-inline-end: var(--app-space-sidebar-gap);
|
|
890
890
|
overflow-y: auto;
|
|
891
|
+
overscroll-behavior-y: contain;
|
|
892
|
+
scrollbar-gutter: stable;
|
|
893
|
+
scrollbar-width: auto;
|
|
894
|
+
scrollbar-color: auto;
|
|
891
895
|
scroll-padding-block: var(--app-size-brand) var(--app-space-block);
|
|
896
|
+
&::-webkit-scrollbar {
|
|
897
|
+
width: 3px;
|
|
898
|
+
}
|
|
899
|
+
&::-webkit-scrollbar-track {
|
|
900
|
+
background: transparent;
|
|
901
|
+
}
|
|
902
|
+
&::-webkit-scrollbar-thumb {
|
|
903
|
+
border-radius: 3px;
|
|
904
|
+
background: transparent;
|
|
905
|
+
}
|
|
906
|
+
&:hover::-webkit-scrollbar-thumb {
|
|
907
|
+
background: var(--app-color-border-soft);
|
|
908
|
+
}
|
|
909
|
+
&:focus-within::-webkit-scrollbar-thumb {
|
|
910
|
+
background: var(--app-color-border-soft);
|
|
911
|
+
}
|
|
892
912
|
}
|
|
893
913
|
.wg_shell__navigation {
|
|
894
914
|
min-width: 0;
|
|
@@ -914,8 +934,27 @@
|
|
|
914
934
|
border-right: 1px solid var(--app-color-border-layout);
|
|
915
935
|
background: var(--app-color-ground);
|
|
916
936
|
color: var(--app-color-text);
|
|
937
|
+
scrollbar-gutter: stable;
|
|
938
|
+
scrollbar-width: auto;
|
|
939
|
+
scrollbar-color: auto;
|
|
917
940
|
overscroll-behavior: contain;
|
|
918
941
|
scroll-padding-block: var(--app-size-mobile-header) var(--app-space-group);
|
|
942
|
+
&::-webkit-scrollbar {
|
|
943
|
+
width: 3px;
|
|
944
|
+
}
|
|
945
|
+
&::-webkit-scrollbar-track {
|
|
946
|
+
background: transparent;
|
|
947
|
+
}
|
|
948
|
+
&::-webkit-scrollbar-thumb {
|
|
949
|
+
border-radius: 3px;
|
|
950
|
+
background: transparent;
|
|
951
|
+
}
|
|
952
|
+
&:hover::-webkit-scrollbar-thumb {
|
|
953
|
+
background: var(--app-color-border-soft);
|
|
954
|
+
}
|
|
955
|
+
&:focus-within::-webkit-scrollbar-thumb {
|
|
956
|
+
background: var(--app-color-border-soft);
|
|
957
|
+
}
|
|
919
958
|
&::backdrop {
|
|
920
959
|
background: var(--app-color-backdrop);
|
|
921
960
|
}
|
|
@@ -974,6 +1013,36 @@
|
|
|
974
1013
|
.wg_shell__cursorFace--visible {
|
|
975
1014
|
visibility: visible;
|
|
976
1015
|
}
|
|
1016
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
1017
|
+
.wg_shell__sidebar {
|
|
1018
|
+
scrollbar-width: thin;
|
|
1019
|
+
scrollbar-color: transparent transparent;
|
|
1020
|
+
&:hover {
|
|
1021
|
+
scrollbar-color: var(--app-color-border-soft) transparent;
|
|
1022
|
+
}
|
|
1023
|
+
&:focus-within {
|
|
1024
|
+
scrollbar-color: var(--app-color-border-soft) transparent;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
.wg_shell__drawer {
|
|
1028
|
+
scrollbar-width: thin;
|
|
1029
|
+
scrollbar-color: var(--app-color-border-soft) transparent;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
@media (forced-colors: active) {
|
|
1033
|
+
.wg_shell__sidebar {
|
|
1034
|
+
scrollbar-width: auto;
|
|
1035
|
+
scrollbar-color: auto;
|
|
1036
|
+
&::-webkit-scrollbar-thumb {
|
|
1037
|
+
background: CanvasText;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
.wg_shell__drawer {
|
|
1041
|
+
&::-webkit-scrollbar-thumb {
|
|
1042
|
+
background: CanvasText;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
977
1046
|
@media (width < 1024px) {
|
|
978
1047
|
.wg_shell__root {
|
|
979
1048
|
max-width: calc(var(--app-size-main) + var(--app-space-gutter) * 2);
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { basename as basename2, dirname as dirname4, extname as extname4, join a
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { serve } from "@hono/node-server";
|
|
10
10
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
11
|
+
import { debounce } from "es-toolkit";
|
|
11
12
|
import { Hono as Hono3 } from "hono";
|
|
12
13
|
import { toSSG } from "hono/ssg";
|
|
13
14
|
|
|
@@ -349,6 +350,7 @@ var cli_default_command = "dev";
|
|
|
349
350
|
var cli_default_docs_dir = ".";
|
|
350
351
|
var cli_config_file_name = "for-humanity.config.mjs";
|
|
351
352
|
var cli_dev_port = 4321;
|
|
353
|
+
var cli_reload_delay_ms = 150;
|
|
352
354
|
|
|
353
355
|
// src/constant/font.ts
|
|
354
356
|
var font_sans_css = "src/asset/font/pretendard/pretendardvariable-dynamic-subset.css";
|
|
@@ -1288,6 +1290,32 @@ var siteConfigSchema = z3.object({
|
|
|
1288
1290
|
).default(status_default_phrases)
|
|
1289
1291
|
});
|
|
1290
1292
|
|
|
1293
|
+
// src/util/async/to-change-queue.ts
|
|
1294
|
+
var toChangeQueue = (options) => {
|
|
1295
|
+
const state = { requested: 0, completed: 0, running: false };
|
|
1296
|
+
return async () => {
|
|
1297
|
+
state.requested += 1;
|
|
1298
|
+
if (state.running) {
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
state.running = true;
|
|
1302
|
+
try {
|
|
1303
|
+
while (state.completed !== state.requested) {
|
|
1304
|
+
const requested = state.requested;
|
|
1305
|
+
try {
|
|
1306
|
+
await options.run();
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
options.onError(error);
|
|
1309
|
+
} finally {
|
|
1310
|
+
state.completed = requested;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
} finally {
|
|
1314
|
+
state.running = false;
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1291
1319
|
// src/util/error/to-error-message.ts
|
|
1292
1320
|
var toErrorMessage = (error) => {
|
|
1293
1321
|
if (error instanceof Error && "file" in error && typeof error.file === "string" && "line" in error && typeof error.line === "number") {
|
|
@@ -1390,31 +1418,37 @@ var main = async () => {
|
|
|
1390
1418
|
const reload = new EventEmitter();
|
|
1391
1419
|
let generation = 0;
|
|
1392
1420
|
serve({ fetch: createDevApp({ pages: app, files, reload }).fetch, port: cli_dev_port }, (info) => console.log(`http://localhost:${info.port}/`));
|
|
1393
|
-
const
|
|
1421
|
+
const reloadDocs = debounce(
|
|
1422
|
+
toChangeQueue({
|
|
1423
|
+
run: async () => {
|
|
1424
|
+
const currentGeneration = generation;
|
|
1425
|
+
const nextFiles = new Map(kitFiles);
|
|
1426
|
+
const nextProcessor = createProcessor({ site: siteConfig, root: docsRoot, files: nextFiles });
|
|
1427
|
+
const [docs2, home2] = await Promise.all([readDocs({ root: docsRoot, processor: nextProcessor }), readHome({ root: docsRoot, processor: nextProcessor })]);
|
|
1428
|
+
if (currentGeneration !== generation) {
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
files.clear();
|
|
1432
|
+
for (const entry of nextFiles) {
|
|
1433
|
+
files.set(...entry);
|
|
1434
|
+
}
|
|
1435
|
+
store.docs = docs2;
|
|
1436
|
+
store.home = home2;
|
|
1437
|
+
reload.emit("change");
|
|
1438
|
+
},
|
|
1439
|
+
onError: (error) => console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`)
|
|
1440
|
+
}),
|
|
1441
|
+
cli_reload_delay_ms
|
|
1442
|
+
);
|
|
1443
|
+
const handleDocsChange = (_event, filename) => {
|
|
1394
1444
|
if (filename !== null) {
|
|
1395
1445
|
const segments = filename.split(sep3);
|
|
1396
1446
|
if (segments.includes("dist") || segments.includes("node_modules") || !/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname4(filename).toLowerCase())) {
|
|
1397
1447
|
return;
|
|
1398
1448
|
}
|
|
1399
1449
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const nextFiles = new Map(kitFiles);
|
|
1403
|
-
const nextProcessor = createProcessor({ site: siteConfig, root: docsRoot, files: nextFiles });
|
|
1404
|
-
const [docs2, home2] = await Promise.all([readDocs({ root: docsRoot, processor: nextProcessor }), readHome({ root: docsRoot, processor: nextProcessor })]);
|
|
1405
|
-
if (currentGeneration !== generation) {
|
|
1406
|
-
return;
|
|
1407
|
-
}
|
|
1408
|
-
files.clear();
|
|
1409
|
-
for (const entry of nextFiles) {
|
|
1410
|
-
files.set(...entry);
|
|
1411
|
-
}
|
|
1412
|
-
store.docs = docs2;
|
|
1413
|
-
store.home = home2;
|
|
1414
|
-
reload.emit("change");
|
|
1415
|
-
} catch (error) {
|
|
1416
|
-
console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`);
|
|
1417
|
-
}
|
|
1450
|
+
generation += 1;
|
|
1451
|
+
reloadDocs();
|
|
1418
1452
|
};
|
|
1419
1453
|
watch(docsRoot, { recursive: true }, handleDocsChange);
|
|
1420
1454
|
};
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
var
|
|
2
|
-
|
|
3
|
-
${Math.max(0,Math.min(t.
|
|
1
|
+
var T="fh-cursor-face";var w="(width < 1024px)",x="for-humanity:navigation-scroll";var b=e=>{if(!e)return null;try{return document.getElementById(decodeURIComponent(e.slice(1)))}catch(t){if(t instanceof URIError)return null;throw t}};var y=e=>{let t=b(e);for(let n=t?.closest("details");n;n=n.parentElement?.closest("details"))n.open=!0;return t};var I=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector("[data-navigation-drawer]"),n=document.querySelector("[data-navigation-content]"),a=document.querySelector("[data-navigation-open]"),s=document.querySelector("[data-navigation-close]"),u=e?.parentElement;if(!e||!t||!n||!a||!s||!u)return;let d=matchMedia(w),l=()=>{t.open||(document.body.classList.remove("wg_shell__root--drawerOpen"),a.setAttribute("aria-expanded","false"))},m=()=>{t.close(),l()},g=()=>{!d.matches||t.open||(t.showModal(),document.body.classList.add("wg_shell__root--drawerOpen"),a.setAttribute("aria-expanded","true"))},i=r=>{let o=t.getBoundingClientRect();r.target===t&&(r.clientX<o.left||r.clientX>o.right||r.clientY<o.top||r.clientY>o.bottom)&&m()},L=r=>{if(!t.open||r.defaultPrevented||r.button!==0||r.metaKey||r.ctrlKey||r.shiftKey||r.altKey||!(r.target instanceof Element))return;let o=r.target.closest("a[href]");if(!(!(o instanceof HTMLAnchorElement)||o.target!==""&&o.target!=="_self"||o.hasAttribute("download"))&&(m(),o.origin===location.origin&&o.pathname===location.pathname&&o.search===location.search&&o.hash)){let c=y(o.hash);c&&(c.tabIndex=-1,c.focus({preventScroll:!0}))}},p=()=>{let r=document.activeElement;if(d.matches){a.classList.add("wg_shell__menuToggle--visible"),n.append(e),r instanceof HTMLElement&&e.contains(r)&&a.focus({preventScroll:!0});return}m(),u.append(e),r instanceof HTMLElement&&e.contains(r)?r.focus({preventScroll:!0}):(r===a||r instanceof HTMLElement&&t.contains(r))&&u.querySelector(".wg_shell__brand")?.focus({preventScroll:!0}),a.classList.remove("wg_shell__menuToggle--visible")};a.addEventListener("click",g),s.addEventListener("click",m),t.addEventListener("close",l),t.addEventListener("click",i),e.addEventListener("click",L),d.addEventListener("change",p),p(),u.classList.add("wg_shell__sidebar--navigationReady")};var N=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector(".wg_shell__sidebar"),n=document.querySelector("[data-navigation-drawer]"),a=document.querySelector("[data-navigation-open]");if(!e||!t||!n||!a)return;let s=matchMedia(w),u={mobile:s.matches},d=[...e.querySelectorAll(".wg_shellNav__root a[href]")].map(o=>`${o.pathname}:${o.textContent}`).join(`
|
|
2
|
+
`),l=new Map,m=new Map([[t,`${x}:desktop`],[n,`${x}:drawer`]]),g=new AbortController;for(let[o,c]of m){try{let _=sessionStorage.getItem(c);if(_!==null){let h=JSON.parse(_);typeof h=="object"&&h!==null&&"navigation"in h&&h.navigation===d&&"top"in h&&typeof h.top=="number"&&Number.isFinite(h.top)&&h.top>=0&&l.set(o,h.top)}}catch{}for(let _ of["wheel","touchstart","pointerdown","keydown"])o.addEventListener(_,()=>g.abort(),{once:!0,passive:!0})}let i=new Map(l),L=()=>{u.mobile=s.matches;let o=s.matches?n:t,c=l.get(o);c===void 0||s.matches&&!n.open||(o.scrollTop=c)},p=o=>{let c=s.matches?n:t,_=m.get(c);if(!(u.mobile!==s.matches||o.type==="scroll"&&o.currentTarget!==c||_===void 0||s.matches&&!n.open)){l.set(c,c.scrollTop);try{sessionStorage.setItem(_,JSON.stringify({navigation:d,top:c.scrollTop}))}catch{}}},r=async()=>{if(await document.fonts.ready,!g.signal.aborted){let o=s.matches?n:t,c=i.get(o);c!==void 0&&(!s.matches||n.open)&&(o.scrollTop=c)}};t.addEventListener("scroll",p,{passive:!0}),n.addEventListener("scroll",p,{passive:!0}),e.addEventListener("click",p,{capture:!0}),a.addEventListener("click",L),s.addEventListener("change",L),addEventListener("pagehide",p),L(),document.readyState==="complete"?r():addEventListener("load",r,{once:!0})};I();N();var f=document.querySelector("[data-cursor-face]");if(f){let e=matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"),t={x:0,y:0,frame:0},n=()=>{cancelAnimationFrame(t.frame),t.frame=0,f.classList.remove("wg_shell__cursorFace--visible")},a=()=>{e.matches||n()},s=()=>{t.frame===0&&(t.frame=requestAnimationFrame(()=>{t.frame=0,f.style.transform=`translate3d(
|
|
3
|
+
${Math.max(0,Math.min(t.x+16,document.documentElement.clientWidth-f.width-16))}px,
|
|
4
|
+
${Math.max(0,Math.min(t.y+16,document.documentElement.clientHeight-f.height-16))}px,
|
|
4
5
|
0
|
|
5
|
-
)`,
|
|
6
|
+
)`,f.classList.add("wg_shell__cursorFace--visible")}))},u=g=>{if(!(e.matches&&g.pointerType==="mouse")){n();return}t.x=g.clientX,t.y=g.clientY,s()},d=()=>{f.classList.contains("wg_shell__cursorFace--visible")&&s()},l=()=>{if(f.classList.contains("wg_shell__cursorFace--visible"))try{sessionStorage.setItem(T,JSON.stringify({x:t.x,y:t.y}))}catch{}},m=()=>{try{let g=sessionStorage.getItem(T);if(sessionStorage.removeItem(T),g!==null&&e.matches){let i=JSON.parse(g);typeof i=="object"&&i!==null&&"x"in i&&"y"in i&&typeof i.x=="number"&&typeof i.y=="number"&&Number.isFinite(i.x)&&Number.isFinite(i.y)&&(t.x=i.x,t.y=i.y,s())}}catch{}};m(),document.addEventListener("pointermove",u,{passive:!0}),document.addEventListener("pointerdown",u,{passive:!0}),document.documentElement.addEventListener("pointerleave",n),addEventListener("blur",n),addEventListener("resize",d),addEventListener("pagehide",l),addEventListener("pageshow",m),e.addEventListener("change",a)}var k=y(location.hash),E=null,S=0,v=null,C=()=>{clearTimeout(S),v?.abort(),v=null,E?.classList.remove("wg_prose__headingText--highlighted"),E=null},q=e=>{C(),E=e===null?null:e.querySelector(".wg_prose__headingText"),E!==null&&(E.classList.add("wg_prose__headingText--highlighted"),S=window.setTimeout(C,3e3),v=new AbortController,document.addEventListener("scrollend",P,{signal:v.signal}))},P=e=>{e.target===document&&(clearTimeout(S),v?.abort(),v=null,S=window.setTimeout(C,3e3))},R=e=>{if(!(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)&&e.currentTarget instanceof HTMLAnchorElement&&(e.currentTarget.target===""||e.currentTarget.target==="_self")&&!e.currentTarget.hasAttribute("download")&&e.currentTarget.origin===location.origin&&e.currentTarget.pathname===location.pathname&&e.currentTarget.search===location.search&&e.currentTarget.hash){let t=y(e.currentTarget.hash);e.currentTarget.hash===location.hash&&q(t)}},$=()=>{let e=y(location.hash);e?.scrollIntoView(),q(e)};for(let e of document.querySelectorAll("a[href]"))e.addEventListener("click",R);addEventListener("hashchange",$);var H=[...document.querySelectorAll("[data-toc-link]")].flatMap(e=>{let t=b(e.hash),n=e.parentElement;return!t||!n?[]:[{heading:t,link:e,item:n,list:n.parentElement,subs:[...n.querySelectorAll("[data-toc-sub-link]")].flatMap(a=>{let s=b(a.hash),u=a.parentElement;return s&&u?[{heading:s,link:a,item:u}]:[]})}]}),A=H.at(0);if(A){let e=()=>{let t=Number.parseFloat(getComputedStyle(A.heading).scrollMarginTop)+1,n=H.findLast(d=>d.heading.checkVisibility()&&d.heading.getBoundingClientRect().top<=t)??A,a=n.subs.findLast(d=>d.heading.checkVisibility()&&d.heading.getBoundingClientRect().top<=t),s=H.indexOf(n),u=a===void 0?-1:n.subs.indexOf(a);for(let[d,l]of H.entries()){let m=l===n&&a===void 0;l.link.classList.toggle("wg_shellToc__link--active",m),m?l.link.setAttribute("aria-current","location"):l.link.removeAttribute("aria-current"),l.item.classList.toggle("wg_shellToc__item--trail",l.list===n.list&&d<s),l.item.classList.toggle("wg_shellToc__item--current",l===n);for(let[g,i]of l.subs.entries())i.link.classList.toggle("wg_shellToc__subLink--active",i===a),i===a?i.link.setAttribute("aria-current","location"):i.link.removeAttribute("aria-current"),i.item.classList.toggle("wg_shellToc__subItem--trail",l===n&&g<u),i.item.classList.toggle("wg_shellToc__subItem--current",i===a)}};addEventListener("scroll",e,{passive:!0}),addEventListener("resize",e),addEventListener("toggle",e,!0),e()}var K=new AbortController;for(let e of["wheel","touchmove","keydown","mousedown"])addEventListener(e,()=>K.abort(),{once:!0,passive:!0});var F=async()=>{await document.fonts.ready,k&&!K.signal.aborted&&(k.scrollIntoView({behavior:"instant"}),q(k)),document.documentElement.style.removeProperty("scroll-behavior")};document.readyState==="complete"?F():addEventListener("load",F,{once:!0});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "for-humanity",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"build": "pnpm sync:docs && pnpm build:kit && node dist/cli.js build templates/document",
|
|
42
42
|
"build:pages": "pnpm build && node util/prepare-pages.mjs",
|
|
43
43
|
"preview": "node dist/cli.js preview templates/document",
|
|
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
|
+
"test": "esbuild src/util/async/to-change-queue.test.ts 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/util/async/*.test.mjs node_modules/.cache/for-humanity-tests/content/*.test.mjs node_modules/.cache/for-humanity-tests/util/font/*.test.mjs util/*.test.mjs",
|
|
45
45
|
"lint": "biome check && stylelint \"src/**/*.css\"",
|
|
46
46
|
"lint:fix": "biome check --write && stylelint \"src/**/*.css\" --fix",
|
|
47
47
|
"typecheck": "tsc --noEmit",
|
package/src/cli.ts
CHANGED
|
@@ -11,11 +11,12 @@ import {basename, dirname, extname, join, relative, resolve, sep} from "node:pat
|
|
|
11
11
|
import {fileURLToPath, pathToFileURL} from "node:url";
|
|
12
12
|
import {serve} from "@hono/node-server";
|
|
13
13
|
import {serveStatic} from "@hono/node-server/serve-static";
|
|
14
|
+
import {debounce} from "es-toolkit";
|
|
14
15
|
import {Hono} from "hono";
|
|
15
16
|
import {toSSG} from "hono/ssg";
|
|
16
17
|
import {createApp} from "@/app";
|
|
17
18
|
import {asset_client_path, asset_favicon_path, asset_font_dir, asset_media_extensions, asset_style_path} from "@/constant/asset";
|
|
18
|
-
import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port} from "@/constant/cli";
|
|
19
|
+
import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port, cli_reload_delay_ms} from "@/constant/cli";
|
|
19
20
|
import {copy_error_config, copy_error_font_preload, copy_error_prefix, copy_error_unknown_command} from "@/constant/copy";
|
|
20
21
|
import {font_brand_css, font_cache_control, font_mono_css, font_sans_css, font_sans_preload_file} from "@/constant/font";
|
|
21
22
|
import {site_config_absent} from "@/constant/site";
|
|
@@ -24,6 +25,7 @@ import {readDocs} from "@/content/read-docs";
|
|
|
24
25
|
import {readHome} from "@/content/read-home";
|
|
25
26
|
import {createDevApp} from "@/dev";
|
|
26
27
|
import {siteConfigSchema} from "@/type/site-config";
|
|
28
|
+
import {toChangeQueue} from "@/util/async/to-change-queue";
|
|
27
29
|
import {toErrorMessage} from "@/util/error/to-error-message";
|
|
28
30
|
import {toAssetPath} from "@/util/file/to-asset-path";
|
|
29
31
|
import {toFontCss} from "@/util/font/to-font-css";
|
|
@@ -129,40 +131,46 @@ const main = async () => {
|
|
|
129
131
|
serve({fetch: createDevApp({pages: app, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
|
|
130
132
|
|
|
131
133
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
+
* 갱신은 하나씩 실행 · 연속 저장 합치기 · 최신 변경만 적용
|
|
135
|
+
* 문서 수가 많아도 파일 이벤트마다 전체 렌더링이 동시에 쌓이지 않음
|
|
134
136
|
*/
|
|
135
|
-
const
|
|
137
|
+
const reloadDocs = debounce(
|
|
138
|
+
toChangeQueue({
|
|
139
|
+
run: async () => {
|
|
140
|
+
const currentGeneration = generation;
|
|
141
|
+
const nextFiles = new Map(kitFiles);
|
|
142
|
+
const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles});
|
|
143
|
+
const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor: nextProcessor}), readHome({root: docsRoot, processor: nextProcessor})]);
|
|
144
|
+
|
|
145
|
+
if (currentGeneration !== generation) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
files.clear();
|
|
150
|
+
for (const entry of nextFiles) {
|
|
151
|
+
files.set(...entry);
|
|
152
|
+
}
|
|
153
|
+
store.docs = docs;
|
|
154
|
+
store.home = home;
|
|
155
|
+
reload.emit("change");
|
|
156
|
+
},
|
|
157
|
+
onError: (error) => console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`),
|
|
158
|
+
}),
|
|
159
|
+
cli_reload_delay_ms,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 입력 변경 감지 · 출력·설치 폴더 제외 · 누락 자원 추가도 다시 확인
|
|
164
|
+
*/
|
|
165
|
+
const handleDocsChange = (_event: string, filename: string | null) => {
|
|
136
166
|
if (filename !== null) {
|
|
137
167
|
const segments = filename.split(sep);
|
|
138
|
-
// 출력·설치 폴더는 제외 · 실패한 참조의 파일이 뒤늦게 생겨도 다시 처리
|
|
139
168
|
if (segments.includes("dist") || segments.includes("node_modules") || (!/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname(filename).toLowerCase()))) {
|
|
140
169
|
return;
|
|
141
170
|
}
|
|
142
171
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
const nextFiles = new Map(kitFiles);
|
|
148
|
-
const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles});
|
|
149
|
-
const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor: nextProcessor}), readHome({root: docsRoot, processor: nextProcessor})]);
|
|
150
|
-
|
|
151
|
-
// 최신 변경의 처리 결과만 반영
|
|
152
|
-
if (currentGeneration !== generation) {
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
files.clear();
|
|
157
|
-
for (const entry of nextFiles) {
|
|
158
|
-
files.set(...entry);
|
|
159
|
-
}
|
|
160
|
-
store.docs = docs;
|
|
161
|
-
store.home = home;
|
|
162
|
-
reload.emit("change");
|
|
163
|
-
} catch (error) {
|
|
164
|
-
console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`);
|
|
165
|
-
}
|
|
172
|
+
generation += 1;
|
|
173
|
+
reloadDocs();
|
|
166
174
|
};
|
|
167
175
|
|
|
168
176
|
watch(docsRoot, {recursive: true}, handleDocsChange);
|
package/src/client.ts
CHANGED
|
@@ -7,10 +7,12 @@ import {heading_highlight_hold_ms} from "@/component/widget/prose/_constant/head
|
|
|
7
7
|
import {cursor_face_offset_px, cursor_face_storage_key} from "@/component/widget/shell/_constant/cursor-face";
|
|
8
8
|
import {reading_line_slack_px} from "@/component/widget/shell/_constant/reading-line";
|
|
9
9
|
import {bindMobileNavigation} from "@/component/widget/shell/_function/bind-mobile-navigation";
|
|
10
|
+
import {bindNavigationScroll} from "@/component/widget/shell/_function/bind-navigation-scroll";
|
|
10
11
|
import {findHashTarget} from "@/util/dom/find-hash-target";
|
|
11
12
|
import {revealHashTarget} from "@/util/dom/reveal-hash-target";
|
|
12
13
|
|
|
13
14
|
bindMobileNavigation();
|
|
15
|
+
bindNavigationScroll();
|
|
14
16
|
|
|
15
17
|
const cursorFace = document.querySelector<HTMLImageElement>("[data-cursor-face]");
|
|
16
18
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {navigation_mobile_query, navigation_scroll_storage_key} from "@/component/widget/shell/_constant/navigation";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 문서 이동·새로고침에서 공유 탐색의 위치 유지 · 본문 스크롤과 독립
|
|
5
|
+
* 저장소 실패는 기본 탐색 유지, 문서 목록 변경은 이전 위치를 사용하지 않음
|
|
6
|
+
*/
|
|
7
|
+
export const bindNavigationScroll = () => {
|
|
8
|
+
const navigation = document.querySelector<HTMLElement>("[data-navigation]");
|
|
9
|
+
const sidebar = document.querySelector<HTMLElement>(".wg_shell__sidebar");
|
|
10
|
+
const dialog = document.querySelector<HTMLDialogElement>("[data-navigation-drawer]");
|
|
11
|
+
const openButton = document.querySelector<HTMLButtonElement>("[data-navigation-open]");
|
|
12
|
+
|
|
13
|
+
if (!navigation || !sidebar || !dialog || !openButton) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const mobile = matchMedia(navigation_mobile_query);
|
|
18
|
+
const layout = {mobile: mobile.matches};
|
|
19
|
+
// 목차와 현재 상태는 페이지마다 바뀜 · 공통 문서 링크의 순서로 저장 위치의 유효성 판단
|
|
20
|
+
const signature = [...navigation.querySelectorAll<HTMLAnchorElement>(".wg_shellNav__root a[href]")].map((link) => `${link.pathname}:${link.textContent}`).join("\n");
|
|
21
|
+
const positions = new Map<HTMLElement, number>();
|
|
22
|
+
const storageKeys = new Map<HTMLElement, string>([
|
|
23
|
+
[sidebar, `${navigation_scroll_storage_key}:desktop`],
|
|
24
|
+
[dialog, `${navigation_scroll_storage_key}:drawer`],
|
|
25
|
+
]);
|
|
26
|
+
const userInput = new AbortController();
|
|
27
|
+
|
|
28
|
+
for (const [port, key] of storageKeys) {
|
|
29
|
+
try {
|
|
30
|
+
const value = sessionStorage.getItem(key);
|
|
31
|
+
|
|
32
|
+
if (value !== null) {
|
|
33
|
+
const stored: unknown = JSON.parse(value);
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
typeof stored === "object" &&
|
|
37
|
+
stored !== null &&
|
|
38
|
+
"navigation" in stored &&
|
|
39
|
+
stored.navigation === signature &&
|
|
40
|
+
"top" in stored &&
|
|
41
|
+
typeof stored.top === "number" &&
|
|
42
|
+
Number.isFinite(stored.top) &&
|
|
43
|
+
stored.top >= 0
|
|
44
|
+
) {
|
|
45
|
+
positions.set(port, stored.top);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// 차단되거나 손상된 저장소는 복원만 생략 · 스크롤과 기본 링크 이동 유지
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const type of ["wheel", "touchstart", "pointerdown", "keydown"]) {
|
|
53
|
+
port.addEventListener(type, () => userInput.abort(), {once: true, passive: true});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const initialPositions = new Map(positions);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 네이티브 스크롤 범위로 복원 · 닫힌 드로어의 0 좌표는 복원하지 않음
|
|
60
|
+
*/
|
|
61
|
+
const restoreScroll = () => {
|
|
62
|
+
layout.mobile = mobile.matches;
|
|
63
|
+
const port = mobile.matches ? dialog : sidebar;
|
|
64
|
+
const top = positions.get(port);
|
|
65
|
+
|
|
66
|
+
if (top === undefined || (mobile.matches && !dialog.open)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
port.scrollTop = top;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 드로어 닫기 이전 클릭·현재 스크롤·페이지 이탈에서 마지막 탐색 위치 저장
|
|
75
|
+
*/
|
|
76
|
+
const saveScroll = (event: Event) => {
|
|
77
|
+
const port = mobile.matches ? dialog : sidebar;
|
|
78
|
+
const key = storageKeys.get(port);
|
|
79
|
+
|
|
80
|
+
// 반응형 전환의 숨김·닫기 스크롤은 사용자 위치가 아님 · 활성 포트만 저장
|
|
81
|
+
if (layout.mobile !== mobile.matches || (event.type === "scroll" && event.currentTarget !== port) || key === undefined || (mobile.matches && !dialog.open)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
positions.set(port, port.scrollTop);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
sessionStorage.setItem(key, JSON.stringify({navigation: signature, top: port.scrollTop}));
|
|
89
|
+
} catch {
|
|
90
|
+
// 저장 실패도 같은 페이지의 드로어 재열기와 반응형 위치 복원은 유지
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 글꼴로 탐색 높이가 달라진 경우 한 번만 재보정 · 사용자 입력이 먼저면 유지
|
|
96
|
+
*/
|
|
97
|
+
const settleScroll = async () => {
|
|
98
|
+
await document.fonts.ready;
|
|
99
|
+
|
|
100
|
+
if (!userInput.signal.aborted) {
|
|
101
|
+
const port = mobile.matches ? dialog : sidebar;
|
|
102
|
+
const top = initialPositions.get(port);
|
|
103
|
+
|
|
104
|
+
if (top !== undefined && (!mobile.matches || dialog.open)) {
|
|
105
|
+
port.scrollTop = top;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
sidebar.addEventListener("scroll", saveScroll, {passive: true});
|
|
111
|
+
dialog.addEventListener("scroll", saveScroll, {passive: true});
|
|
112
|
+
navigation.addEventListener("click", saveScroll, {capture: true});
|
|
113
|
+
openButton.addEventListener("click", restoreScroll);
|
|
114
|
+
mobile.addEventListener("change", restoreScroll);
|
|
115
|
+
addEventListener("pagehide", saveScroll);
|
|
116
|
+
restoreScroll();
|
|
117
|
+
|
|
118
|
+
if (document.readyState === "complete") {
|
|
119
|
+
settleScroll();
|
|
120
|
+
} else {
|
|
121
|
+
addEventListener("load", settleScroll, {once: true});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
@@ -115,7 +115,34 @@
|
|
|
115
115
|
max-height: 100vh;
|
|
116
116
|
margin-inline-end: var(--app-space-sidebar-gap);
|
|
117
117
|
overflow-y: auto;
|
|
118
|
+
overscroll-behavior-y: contain;
|
|
119
|
+
|
|
120
|
+
/* 얇은 네이티브 손잡이 · 트랙 없이 기존 본문 간격 안쪽에 표시 */
|
|
121
|
+
scrollbar-gutter: stable;
|
|
122
|
+
scrollbar-width: auto;
|
|
123
|
+
scrollbar-color: auto;
|
|
118
124
|
scroll-padding-block: var(--app-size-brand) var(--app-space-block);
|
|
125
|
+
|
|
126
|
+
&::-webkit-scrollbar {
|
|
127
|
+
width: 3px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
&::-webkit-scrollbar-track {
|
|
131
|
+
background: transparent;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
&::-webkit-scrollbar-thumb {
|
|
135
|
+
border-radius: 3px;
|
|
136
|
+
background: transparent;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
&:hover::-webkit-scrollbar-thumb {
|
|
140
|
+
background: var(--app-color-border-soft);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
&:focus-within::-webkit-scrollbar-thumb {
|
|
144
|
+
background: var(--app-color-border-soft);
|
|
145
|
+
}
|
|
119
146
|
}
|
|
120
147
|
|
|
121
148
|
.wg_shell__navigation {
|
|
@@ -147,9 +174,33 @@
|
|
|
147
174
|
border-right: 1px solid var(--app-color-border-layout);
|
|
148
175
|
background: var(--app-color-ground);
|
|
149
176
|
color: var(--app-color-text);
|
|
177
|
+
scrollbar-gutter: stable;
|
|
178
|
+
scrollbar-width: auto;
|
|
179
|
+
scrollbar-color: auto;
|
|
150
180
|
overscroll-behavior: contain;
|
|
151
181
|
scroll-padding-block: var(--app-size-mobile-header) var(--app-space-group);
|
|
152
182
|
|
|
183
|
+
&::-webkit-scrollbar {
|
|
184
|
+
width: 3px;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
&::-webkit-scrollbar-track {
|
|
188
|
+
background: transparent;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
&::-webkit-scrollbar-thumb {
|
|
192
|
+
border-radius: 3px;
|
|
193
|
+
background: transparent;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
&:hover::-webkit-scrollbar-thumb {
|
|
197
|
+
background: var(--app-color-border-soft);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
&:focus-within::-webkit-scrollbar-thumb {
|
|
201
|
+
background: var(--app-color-border-soft);
|
|
202
|
+
}
|
|
203
|
+
|
|
153
204
|
&::backdrop {
|
|
154
205
|
background: var(--app-color-backdrop);
|
|
155
206
|
}
|
|
@@ -219,6 +270,45 @@
|
|
|
219
270
|
visibility: visible;
|
|
220
271
|
}
|
|
221
272
|
|
|
273
|
+
/* Firefox는 표준 thin 사용 · 손잡이 폭은 브라우저가 결정 */
|
|
274
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
275
|
+
.wg_shell__sidebar {
|
|
276
|
+
scrollbar-width: thin;
|
|
277
|
+
scrollbar-color: transparent transparent;
|
|
278
|
+
|
|
279
|
+
&:hover {
|
|
280
|
+
scrollbar-color: var(--app-color-border-soft) transparent;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
&:focus-within {
|
|
284
|
+
scrollbar-color: var(--app-color-border-soft) transparent;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.wg_shell__drawer {
|
|
289
|
+
scrollbar-width: thin;
|
|
290
|
+
scrollbar-color: var(--app-color-border-soft) transparent;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* 색상 강제 환경은 시스템 손잡이 유지 · 대비 설정을 덮어쓰지 않음 */
|
|
295
|
+
@media (forced-colors: active) {
|
|
296
|
+
.wg_shell__sidebar {
|
|
297
|
+
scrollbar-width: auto;
|
|
298
|
+
scrollbar-color: auto;
|
|
299
|
+
|
|
300
|
+
&::-webkit-scrollbar-thumb {
|
|
301
|
+
background: CanvasText;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.wg_shell__drawer {
|
|
306
|
+
&::-webkit-scrollbar-thumb {
|
|
307
|
+
background: CanvasText;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
222
312
|
/* 좁은 화면 · 상단 브랜드와 메뉴 버튼 · 탐색 목록은 네이티브 모달 */
|
|
223
313
|
@media (width < 1024px) {
|
|
224
314
|
.wg_shell__root {
|
package/src/constant/cli.ts
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {test} from "node:test";
|
|
3
|
+
import {toChangeQueue} from "@/util/async/to-change-queue";
|
|
4
|
+
|
|
5
|
+
test("coalesces changes during an active render and never overlaps work", async () => {
|
|
6
|
+
const gate = Promise.withResolvers<void>();
|
|
7
|
+
const renders: number[] = [];
|
|
8
|
+
const errors: unknown[] = [];
|
|
9
|
+
let version = 1;
|
|
10
|
+
let running = 0;
|
|
11
|
+
const queue = toChangeQueue({
|
|
12
|
+
run: async () => {
|
|
13
|
+
running += 1;
|
|
14
|
+
assert.equal(running, 1);
|
|
15
|
+
const current = version;
|
|
16
|
+
if (current === 1) await gate.promise;
|
|
17
|
+
renders.push(current);
|
|
18
|
+
running -= 1;
|
|
19
|
+
},
|
|
20
|
+
onError: (error) => errors.push(error),
|
|
21
|
+
});
|
|
22
|
+
const first = queue();
|
|
23
|
+
for (const next of [2, 3, 4, 5]) {
|
|
24
|
+
version = next;
|
|
25
|
+
await queue();
|
|
26
|
+
}
|
|
27
|
+
gate.resolve();
|
|
28
|
+
await first;
|
|
29
|
+
assert.deepEqual(renders, [1, 5]);
|
|
30
|
+
assert.deepEqual(errors, []);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a failed render does not lose a queued recovery or block later edits", async () => {
|
|
34
|
+
const gate = Promise.withResolvers<void>();
|
|
35
|
+
const errors: unknown[] = [];
|
|
36
|
+
const renders: number[] = [];
|
|
37
|
+
let version = 1;
|
|
38
|
+
const queue = toChangeQueue({
|
|
39
|
+
run: async () => {
|
|
40
|
+
const current = version;
|
|
41
|
+
if (current === 1) {
|
|
42
|
+
await gate.promise;
|
|
43
|
+
throw new Error("Invalid source");
|
|
44
|
+
}
|
|
45
|
+
renders.push(current);
|
|
46
|
+
},
|
|
47
|
+
onError: (error) => errors.push(error),
|
|
48
|
+
});
|
|
49
|
+
const first = queue();
|
|
50
|
+
version = 2;
|
|
51
|
+
await queue();
|
|
52
|
+
gate.resolve();
|
|
53
|
+
await first;
|
|
54
|
+
version = 3;
|
|
55
|
+
await queue();
|
|
56
|
+
assert.deepEqual(renders, [2, 3]);
|
|
57
|
+
assert.equal(errors.length, 1);
|
|
58
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 변경 재처리의 실행과 오류 보고 계약
|
|
3
|
+
*/
|
|
4
|
+
export interface ChangeQueueOptions {
|
|
5
|
+
/**
|
|
6
|
+
* 한 번의 최신 입력 처리 · 이전 실행이 끝난 뒤 호출
|
|
7
|
+
*/
|
|
8
|
+
run: () => Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* 실패 보고 · 대기 중인 변경과 다음 실행은 유지
|
|
11
|
+
*/
|
|
12
|
+
onError: (error: unknown) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 동시에 하나만 실행하는 변경 요청 함수 · 실행 중 요청은 다음 한 번으로 합침
|
|
17
|
+
* 활성 실행의 반환 Promise는 대기 요청까지 끝나면 완료, 중첩 요청은 즉시 반환
|
|
18
|
+
*/
|
|
19
|
+
export const toChangeQueue = (options: ChangeQueueOptions) => {
|
|
20
|
+
const state = {requested: 0, completed: 0, running: false};
|
|
21
|
+
|
|
22
|
+
return async () => {
|
|
23
|
+
state.requested += 1;
|
|
24
|
+
if (state.running) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
state.running = true;
|
|
28
|
+
try {
|
|
29
|
+
while (state.completed !== state.requested) {
|
|
30
|
+
const requested = state.requested;
|
|
31
|
+
try {
|
|
32
|
+
await options.run();
|
|
33
|
+
} catch (error) {
|
|
34
|
+
options.onError(error);
|
|
35
|
+
} finally {
|
|
36
|
+
state.completed = requested;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
state.running = false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
};
|